File Coverage

File:blib/lib/App/FileSync/Transport/SSH.pm
Coverage:82.9%

linestmtbrancondsubtimecode
1package 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.
33our $PROTO = $PROTOCOL_VERSION;
34
35# Options every session uses: no prompts, keepalives so a dead link is
36# noticed, and a bounded connect.
37Readonly 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.
45Readonly my @HOST_KEY_CHECKING => qw(yes accept-new);
46
47# The highest TCP port.
48Readonly my $MAX_PORT => 65_535;
49
50# Characters that would end or split an argument.
51Readonly 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.
55Readonly 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 '-'.
60Readonly 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
155sub 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
266sub reader {
267
9
23
        my ($self) = @_;
268
269
9
39
        return $self->{reader};
270}
271
272sub writer {
273
9
20
        my ($self) = @_;
274
275
9
78
        return $self->{writer};
276}
277
278sub command {
279
0
0
        my ($self) = @_;
280
281
0
0
0
0
        return [ @{ $self->{command} } ];
282}
283
284sub 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
331sub 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
388sub 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
447sub valid_server {
448
25
1674
        my ($value) = @_;
449
450
25
78
        return (defined $value && $value =~ $SERVER) ? 1 : 0;
451}
452
453sub 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.
468sub _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.
487sub _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.
499sub _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).
511sub _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
5191;
520