| File: | blib/lib/App/FileSync/Transport/SSH.pm |
| Coverage: | 82.9% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::FileSync::Transport::SSH; | |||||
| 2 | ||||||
| 3 | # Runs filesync-server on the server host over one ssh connection and speaks | |||||
| 4 | # the wire protocol on its pipes. The ssh argument list is built here and | |||||
| 5 | # never passed through a local shell, and every value that reaches the remote | |||||
| 6 | # shell is restricted to characters that are inert there, so a repo or client | |||||
| 7 | # id cannot become a command. | |||||
| 8 | ||||||
| 9 | 314 314 314 | 982 298 4300 | use App::FileSync::Platform; | |||
| 10 | ||||||
| 11 | 314 314 314 | 537 280 2496 | use strict; | |||
| 12 | 314 314 314 | 447 248 5939 | use warnings; | |||
| 13 | ||||||
| 14 | # Not :all - this class has a close method, and autodie replaces a package's | |||||
| 15 | # subs named after the builtins it wraps. | |||||
| 16 | 314 314 314 | 494 282 658 | use autodie qw(open); | |||
| 17 | ||||||
| 18 | 314 314 314 | 36861 287 2099 | use File::Temp (); | |||
| 19 | 314 314 314 | 658 1503 2614 | use IPC::Open3 (); | |||
| 20 | 314 314 314 | 485 291 8311 | use List::Util qw(any); | |||
| 21 | 314 314 314 | 578 314 6666 | use Readonly; | |||
| 22 | 314 314 314 | 594 296 907 | use Sub::Private { mode => 'enforce' }; | |||
| 23 | ||||||
| 24 | 314 314 314 | 29414 279 6411 | use App::FileSync qw(validate_client_id validate_repo_name); | |||
| 25 | 314 314 314 | 699 294 5001 | use App::FileSync::Args qw(named); | |||
| 26 | 314 314 314 | 530 273 23308 | use App::FileSync::Constants qw($PROTOCOL_VERSION $ROLE_CLIENT @ROLES $SERVER_PROGRAM); | |||
| 27 | 314 314 314 | 7528 2589 7003 | use App::FileSync::I18N qw(throw); | |||
| 28 | 314 314 314 | 1339 317 4328 | use App::FileSync::IO qw(read_text); | |||
| 29 | ||||||
| 30 | 314 314 314 | 469 230 656 | use version; our $VERSION = version->declare('0.001.1'); | |||
| 31 | ||||||
| 32 | # Kept for compatibility: the protocol version sent as --proto. | |||||
| 33 | our $PROTO = $PROTOCOL_VERSION; | |||||
| 34 | ||||||
| 35 | # Options every session uses: no prompts, keepalives so a dead link is | |||||
| 36 | # noticed, and a bounded connect. | |||||
| 37 | Readonly my @SSH_OPTIONS => ( | |||||
| 38 | '-T', | |||||
| 39 | '-o', 'BatchMode=yes', | |||||
| 40 | '-o', 'ServerAliveInterval=30', | |||||
| 41 | '-o', 'ConnectTimeout=10', | |||||
| 42 | ); | |||||
| 43 | ||||||
| 44 | # Host key policies a repo may choose. | |||||
| 45 | Readonly my @HOST_KEY_CHECKING => qw(yes accept-new); | |||||
| 46 | ||||||
| 47 | # The highest TCP port. | |||||
| 48 | Readonly my $MAX_PORT => 65_535; | |||||
| 49 | ||||||
| 50 | # Characters that would end or split an argument. | |||||
| 51 | Readonly my $UNSAFE_ARG => qr/[\0\n]/; | |||||
| 52 | ||||||
| 53 | # user@host, host, or a bracketed IPv6 literal. Both the user and the host | |||||
| 54 | # start with a letter or digit, so neither can be read as an ssh option. | |||||
| 55 | Readonly my $SERVER => qr/\A(?:[A-Za-z0-9][A-Za-z0-9._-]*@)? | |||||
| 56 | (?:[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:]+\])\z/x; | |||||
| 57 | ||||||
| 58 | # A remote program path: only characters inert in a POSIX shell, and not | |||||
| 59 | # starting with '-'. | |||||
| 60 | Readonly my $PROGRAM => qr{\A[A-Za-z0-9._/][A-Za-z0-9._/-]*\z}; | |||||
| 61 | ||||||
| 62 | =encoding utf8 | |||||
| 63 | ||||||
| 64 - 153 | =head1 NAME
App::FileSync::Transport::SSH - run the server over ssh
=head1 SYNOPSIS
my $transport = App::FileSync::Transport::SSH->new(
server => 'filesync@files.example.com', repo => 'docs', client => 'laptop',
identity => '~/.ssh/filesync_ed25519',
);
...
$transport->close or warn $transport->error_text;
=head1 METHODS
=head2 new
Purpose: start C<filesync-server> on the server host for one session.
Args: C<server> (C<user@host>, C<host> or C<user@[v6]>), C<repo>, C<client>,
optional C<role> (default C<client>), C<server_command> (default
C<filesync-server>), C<host_key_checking> (C<yes>, the default, or
C<accept-new>), C<known_hosts>, C<identity>, C<port>, C<command> (the local
ssh program), C<extra_args> (arrayref of extra ssh arguments).
Returns: the transport.
Side Effects: runs C<ssh ... SERVER PROGRAM --repo R --client C --role X
--proto 2>. ssh's standard error goes to a temporary file, never the
protocol pipe, and is available from L</error_text>. On Windows a C<.cmd> or
C<.bat> ssh wrapper is started through C<COMSPEC>, since CreateProcess cannot
start one directly.
Usage:
my $transport = App::FileSync::Transport::SSH->new(%args);
=head3 EXAMPLE
my $t = App::FileSync::Transport::SSH->new(
server => 'backup', repo => 'photos', client => 'nas', role => 'replica', port => 2222,
);
print join(' ', @{ $t->command }), "\n";
=head3 API SPECIFICATION
=head4 INPUT
{
server => { type => 'string', matches => $SERVER },
repo => { type => 'string', min => 1, max => 64 },
client => { type => 'string', min => 1, max => 64 },
role => { type => 'string', optional => 1, memberof => [ 'client', 'replica' ] },
server_command => { type => 'string', optional => 1, matches => $PROGRAM },
host_key_checking => { type => 'string', optional => 1, memberof => [ 'yes', 'accept-new' ] },
known_hosts => { type => 'string', optional => 1 },
identity => { type => 'string', optional => 1 },
port => { type => 'integer', optional => 1, min => 1, max => 65535 },
command => { type => 'string', optional => 1 },
extra_args => { type => 'arrayref', optional => 1 },
}
=head4 OUTPUT
{ type => 'object', isa => 'App::FileSync::Transport::SSH' }
=head3 MESSAGES
Key Meaning Resolution
transport.missing server, repo or client absent fix the repo config
transport.bad_value a value is unsafe or out of range fix the repo config or settings
transport.bad_id the repo or client is not a plain id fix the repo config
io.write the error file cannot be created check the temp directory
=head3 FORMAL SPECIFICATION
pre server â SERVER â§ repo â Id â§ client â Id â§ role â ROLES â§ program â PROGRAM
â§ (port = ⥠⨠0 < port ⤠65535) â§ â a : extra_args ⢠a â TEXT â§ '\n' â a
argv = â¨sshâ© â options â â¨server, program, --repo, repo, --client, client, --role, role, --proto, PROTOâ©
=head3 PSEUDOCODE
validate every argument
build ssh's options: fixed ones, host key policy, known hosts, identity,
port, extra arguments
append the server, then the remote command
wrap a .cmd/.bat ssh in COMSPEC on Windows
start it with standard error going to a temporary file
=cut | |||||
| 154 | ||||||
| 155 | sub new { | |||||
| 156 | 9 | 8 | my $class = shift; | |||
| 157 | my $args = named(\@_, { | |||||
| 158 | 9 90 | 23 132 | map({ $_ => { type => 'string', optional => 1 } } | |||
| 159 | qw(server repo client role server_command host_key_checking known_hosts identity port command)), | |||||
| 160 | extra_args => { type => 'arrayref', optional => 1 }, | |||||
| 161 | }, 'Transport::SSH->new'); | |||||
| 162 | ||||||
| 163 | 9 | 29 | for my $required (qw(server repo client)) { | |||
| 164 | throw('transport.missing', { transport => 'SSH', what => $required }) | |||||
| 165 | 27 | 45 | unless defined $args->{$required} && length $args->{$required}; | |||
| 166 | } | |||||
| 167 | 9 | 20 | _check('server', $args->{server}, valid_server($args->{server})); | |||
| 168 | ||||||
| 169 | 9 | 33 | for my $check ([ repo => \&validate_repo_name ], [ client => \&validate_client_id ]) { | |||
| 170 | 18 18 | 14 17 | my ($field, $validate) = @{$check}; | |||
| 171 | 18 | 28 | my ($ok, $reason) = $validate->($args->{$field}); | |||
| 172 | 18 | 24 | throw('transport.bad_id', { reason => $reason }) unless $ok; | |||
| 173 | } | |||||
| 174 | ||||||
| 175 | 9 | 22 | my $role = $args->{role} || $ROLE_CLIENT; | |||
| 176 | 9 9 | 67 46 | _check('role', $role, any { $_ eq $role } @ROLES); | |||
| 177 | ||||||
| 178 | 9 | 28 | my $program = _given($args->{server_command}) // $SERVER_PROGRAM; | |||
| 179 | 9 | 14 | _check('server_command', $program, valid_program($program)); | |||
| 180 | ||||||
| 181 | 9 | 16 | my $checking = _given($args->{host_key_checking}) // $HOST_KEY_CHECKING[0]; | |||
| 182 | 9 17 | 24 45 | _check('host_key_checking', $checking, any { $_ eq $checking } @HOST_KEY_CHECKING); | |||
| 183 | ||||||
| 184 | 9 | 25 | my @command = (_given($args->{command}) // 'ssh', @SSH_OPTIONS, '-o', "StrictHostKeyChecking=$checking"); | |||
| 185 | ||||||
| 186 | 9 | 156 | if (defined(my $known = _given($args->{known_hosts}))) { | |||
| 187 | 2 | 7 | _check('known_hosts', $known, $known !~ $UNSAFE_ARG); | |||
| 188 | 2 | 7 | push @command, '-o', "UserKnownHostsFile=$known"; | |||
| 189 | } | |||||
| 190 | 9 | 12 | if (defined(my $identity = _given($args->{identity}))) { | |||
| 191 | 8 | 17 | _check('identity', $identity, $identity !~ $UNSAFE_ARG); | |||
| 192 | 8 | 12 | push @command, '-i', $identity; | |||
| 193 | } | |||||
| 194 | 9 | 14 | if (defined(my $port = _given($args->{port}))) { | |||
| 195 | 8 | 41 | _check('port', $port, $port =~ /\A\d{1,5}\z/ && $port > 0 && $port <= $MAX_PORT); | |||
| 196 | 8 | 12 | push @command, '-p', $port; | |||
| 197 | } | |||||
| 198 | 9 9 | 9 19 | for my $extra (@{ $args->{extra_args} || [] }) { | |||
| 199 | 0 | 0 | _check('extra_args', $extra // '', defined $extra && !ref $extra && $extra !~ $UNSAFE_ARG); | |||
| 200 | 0 | 0 | push @command, $extra; | |||
| 201 | } | |||||
| 202 | ||||||
| 203 | # The remote shell splits this back into arguments; every value is | |||||
| 204 | # restricted (above) to characters that are inert in a POSIX shell. | |||||
| 205 | push @command, $args->{server}, $program, | |||||
| 206 | 9 | 31 | '--repo', $args->{repo}, '--client', $args->{client}, '--role', $role, '--proto', $PROTO; | |||
| 207 | ||||||
| 208 | 9 | 21 | if ($^O eq 'MSWin32' && $command[0] =~ /\.(?:cmd|bat)\z/i) { | |||
| 209 | 0 | 0 | unshift @command, _given($ENV{COMSPEC}) // 'cmd.exe', '/d', '/c'; | |||
| 210 | } | |||||
| 211 | ||||||
| 212 | 9 9 | 12 16 | return bless { command => \@command, status => undef, stderr => '', %{ _spawn(\@command) } }, $class; | |||
| 213 | } | |||||
| 214 | ||||||
| 215 - 264 | =head2 reader
Documented with L</exit_status> below.
=head2 writer
Documented with L</exit_status> below.
=head2 command
Documented with L</exit_status> below.
=head2 exit_status
Purpose: the session's pipes, the argv that was run, and ssh's exit status.
Args: none.
Returns: the read and write handles; a copy of the argv (for C<--verbose> and
tests); the exit status after L</close> (undef before).
Side Effects: none.
Usage:
my $argv = $transport->command;
=head3 EXAMPLE
print "ran: @{ $transport->command }\n";
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'self', type => 'object' } ]
=head4 OUTPUT
{ type => 'scalar' }
=head3 MESSAGES
None.
=head3 FORMAL SPECIFICATION
command = copy(self.command) ; exit_status = self.status
=cut | |||||
| 265 | ||||||
| 266 | sub reader { | |||||
| 267 | 9 | 23 | my ($self) = @_; | |||
| 268 | ||||||
| 269 | 9 | 39 | return $self->{reader}; | |||
| 270 | } | |||||
| 271 | ||||||
| 272 | sub writer { | |||||
| 273 | 9 | 20 | my ($self) = @_; | |||
| 274 | ||||||
| 275 | 9 | 78 | return $self->{writer}; | |||
| 276 | } | |||||
| 277 | ||||||
| 278 | sub command { | |||||
| 279 | 0 | 0 | my ($self) = @_; | |||
| 280 | ||||||
| 281 | 0 0 | 0 0 | return [ @{ $self->{command} } ]; | |||
| 282 | } | |||||
| 283 | ||||||
| 284 | sub exit_status { | |||||
| 285 | 0 | 0 | my ($self) = @_; | |||
| 286 | ||||||
| 287 | 0 | 0 | return $self->{status}; | |||
| 288 | } | |||||
| 289 | ||||||
| 290 - 329 | =head2 close
Purpose: end the ssh session.
Args: none.
Returns: 1 when ssh exited 0, else 0. Repeated calls return 1.
Side Effects: closes both pipes, reaps ssh, records its exit status and
signal, and reads what it wrote to standard error.
Usage:
$transport->close or warn $transport->error_text, "\n";
=head3 EXAMPLE
unless ($transport->close) {
printf "ssh exited %d: %s\n", $transport->exit_status, $transport->error_text;
}
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'self', type => 'object' } ]
=head4 OUTPUT
{ type => 'integer', memberof => [ 0, 1 ] }
=head3 MESSAGES
None.
=head3 FORMAL SPECIFICATION
close = 1 â closed ⨠exit(ssh) = 0
=cut | |||||
| 330 | ||||||
| 331 | sub close { ## no critic (Subroutines::ProhibitBuiltinHomonyms, Subroutines::ProhibitAmbiguousNames) -- transport interface | |||||
| 332 | 9 | 11 | my ($self) = @_; | |||
| 333 | ||||||
| 334 | 9 | 25 | return 1 if $self->{closed}++; | |||
| 335 | ||||||
| 336 | 9 18 9 | 13 30 20 | for my $handle (grep { defined } @{$self}{qw(writer reader)}) { | |||
| 337 | 18 | 99 | CORE::close($handle); | |||
| 338 | } | |||||
| 339 | ||||||
| 340 | 9 | 18 | if ($self->{pid}) { | |||
| 341 | 9 | 9290869 | waitpid($self->{pid}, 0); | |||
| 342 | 9 | 76 | my $status = $?; | |||
| 343 | 9 9 | 29 60 | @{$self}{qw(status signal pid)} = ($status >> 8, $status & 127, 0); | |||
| 344 | } | |||||
| 345 | 9 | 56 | $self->{stderr} = _slurp($self->{error_path}); | |||
| 346 | ||||||
| 347 | 9 | 67 | return ($self->{status} || 0) == 0 ? 1 : 0; | |||
| 348 | } | |||||
| 349 | ||||||
| 350 - 386 | =head2 error_text
Purpose: what ssh or the remote shell said, for when the protocol failed.
Args: none.
Returns: the text, trimmed, with blank lines collapsed.
Side Effects: reads the error file if L</close> has not yet.
Usage:
my $why = $transport->error_text;
=head3 EXAMPLE
print $transport->error_text, "\n"; # "Permission denied (publickey)."
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'self', type => 'object' } ]
=head4 OUTPUT
{ type => 'string' }
=head3 MESSAGES
None.
=head3 FORMAL SPECIFICATION
error_text = squeeze(trim(stderr))
=cut | |||||
| 387 | ||||||
| 388 | sub error_text { | |||||
| 389 | 2 | 5 | my ($self) = @_; | |||
| 390 | ||||||
| 391 | 2 | 5 | my $text = length $self->{stderr} ? $self->{stderr} : _slurp($self->{error_path}); | |||
| 392 | 2 | 19 | $text =~ s/\A\s+|\s+\z//g; | |||
| 393 | 2 | 6 | $text =~ s/\n+/\n/g; | |||
| 394 | 2 | 4 | return $text; | |||
| 395 | } | |||||
| 396 | ||||||
| 397 - 445 | =head2 valid_server
Documented with L</valid_program> below.
=head2 valid_program
Purpose: check a server address and a remote program path.
Args: the value.
Returns: 1 or 0.
A server is C<user@host>, C<host> or C<user@[ipv6]>; the user and host must
start with a letter or digit, so neither can be read as an ssh option (a
leading C<->) and nothing can split into another word. A program is a path of
letters, digits, C<.>, C<_>, C</> and C<->, not starting with C<->.
Side Effects: none.
Usage:
die "bad server\n" unless App::FileSync::Transport::SSH::valid_server($server);
=head3 EXAMPLE
App::FileSync::Transport::SSH::valid_server('filesync@files.example.com'); # 1
App::FileSync::Transport::SSH::valid_server('-oProxyCommand=sh'); # 0
App::FileSync::Transport::SSH::valid_server('-v@host'); # 0
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'value', type => 'string', optional => 1 } ]
=head4 OUTPUT
{ type => 'integer', memberof => [ 0, 1 ] }
=head3 MESSAGES
None.
=head3 FORMAL SPECIFICATION
valid_server(v) â v â SERVER
valid_program(v) â v â PROGRAM
=cut | |||||
| 446 | ||||||
| 447 | sub valid_server { | |||||
| 448 | 25 | 1674 | my ($value) = @_; | |||
| 449 | ||||||
| 450 | 25 | 78 | return (defined $value && $value =~ $SERVER) ? 1 : 0; | |||
| 451 | } | |||||
| 452 | ||||||
| 453 | sub valid_program { | |||||
| 454 | 14 | 113 | my ($value) = @_; | |||
| 455 | ||||||
| 456 | 14 | 44 | return (defined $value && $value =~ $PROGRAM) ? 1 : 0; | |||
| 457 | } | |||||
| 458 | ||||||
| 459 | # _spawn | |||||
| 460 | # Purpose: start ssh with stderr going to a temporary file. | |||||
| 461 | # Entry: $command is the full argv. | |||||
| 462 | # Exit: returns { pid, writer, reader, errors, error_path }. | |||||
| 463 | # Side effects: creates the error file (removed when the object goes) and | |||||
| 464 | # starts the process. IPC::Open3 rather than fork/exec: on | |||||
| 465 | # Windows fork() is a pseudo-process whose pipe handles are | |||||
| 466 | # not always duplicated, which leaves a full-duplex session | |||||
| 467 | # waiting on a pipe end that never closes. | |||||
| 468 | sub _spawn :Private { | |||||
| 469 | my ($command) = @_; | |||||
| 470 | ||||||
| 471 | my $errors = File::Temp->new(UNLINK => 1); | |||||
| 472 | my $error_path = $errors->filename; | |||||
| 473 | open my $err_fh, '>', $error_path; | |||||
| 474 | ||||||
| 475 | my ($writer, $reader); | |||||
| 476 | my $pid = IPC::Open3::open3($writer, $reader, '>&' . fileno($err_fh), @{$command}); | |||||
| 477 | CORE::close($err_fh); | |||||
| 478 | ||||||
| 479 | return { pid => $pid, writer => $writer, reader => $reader, errors => $errors, error_path => $error_path }; | |||||
| 480 | 314 314 314 | 250634 289 4254 | } | |||
| 481 | ||||||
| 482 | # _check | |||||
| 483 | # Purpose: refuse an unsafe or out-of-range argument. | |||||
| 484 | # Entry: $ok is the result of the check. | |||||
| 485 | # Exit: returns 1. | |||||
| 486 | # Side effects: raises transport.bad_value naming the field. | |||||
| 487 | sub _check :Private { | |||||
| 488 | my ($field, $value, $ok) = @_; | |||||
| 489 | ||||||
| 490 | throw('transport.bad_value', { field => $field, value => $value }) unless $ok; | |||||
| 491 | return 1; | |||||
| 492 | 314 314 314 | 56258 313 2879 | } | |||
| 493 | ||||||
| 494 | # _given | |||||
| 495 | # Purpose: treat an empty string like an absent value. | |||||
| 496 | # Entry: any scalar. | |||||
| 497 | # Exit: returns the value, or undef if undefined or empty. | |||||
| 498 | # Side effects: none. | |||||
| 499 | sub _given :Private { | |||||
| 500 | my ($value) = @_; | |||||
| 501 | ||||||
| 502 | return (defined $value && length $value) ? $value : undef; | |||||
| 503 | 314 314 314 | 50311 273 2533 | } | |||
| 504 | ||||||
| 505 | # _slurp | |||||
| 506 | # Purpose: the error file's contents. | |||||
| 507 | # Entry: $path may be undef or gone. | |||||
| 508 | # Exit: returns the text, or ''. | |||||
| 509 | # Side effects: reads the file; an unreadable one is treated as empty | |||||
| 510 | # (there is nothing better to report). | |||||
| 511 | sub _slurp :Private { | |||||
| 512 | my ($path) = @_; | |||||
| 513 | ||||||
| 514 | return '' unless defined $path && -e $path; | |||||
| 515 | my $text = eval { read_text($path) }; | |||||
| 516 | return defined $text ? $text : ''; | |||||
| 517 | 314 314 314 | 53697 271 2300 | } | |||
| 518 | ||||||
| 519 | 1; | |||||
| 520 | ||||||