1#!/usr/bin/env perl
2
3#   Mosh: the mobile shell
4#   Copyright 2012 Keith Winstein
5#
6#   This program is free software: you can redistribute it and/or modify
7#   it under the terms of the GNU General Public License as published by
8#   the Free Software Foundation, either version 3 of the License, or
9#   (at your option) any later version.
10#
11#   This program is distributed in the hope that it will be useful,
12#   but WITHOUT ANY WARRANTY; without even the implied warranty of
13#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14#   GNU General Public License for more details.
15#
16#   You should have received a copy of the GNU General Public License
17#   along with this program.  If not, see <http://www.gnu.org/licenses/>.
18#
19#   In addition, as a special exception, the copyright holders give
20#   permission to link the code of portions of this program with the
21#   OpenSSL library under certain conditions as described in each
22#   individual source file, and distribute linked combinations including
23#   the two.
24#
25#   You must obey the GNU General Public License in all respects for all
26#   of the code used other than OpenSSL. If you modify file(s) with this
27#   exception, you may extend this exception to your version of the
28#   file(s), but you are not obligated to do so. If you do not wish to do
29#   so, delete this exception statement from your version. If you delete
30#   this exception statement from all source files in the program, then
31#   also delete it here.
32
33use 5.8.8;
34
35use warnings;
36use strict;
37use Getopt::Long;
38use IO::Socket;
39use Text::ParseWords;
40use Socket qw(IPPROTO_TCP);
41use Errno qw(EINTR);
42use POSIX qw(_exit);
43
44BEGIN {
45  my @gai_reqs = qw( getaddrinfo getnameinfo AI_CANONNAME AI_NUMERICHOST NI_NUMERICHOST );
46  eval { Socket->import( @gai_reqs ); 1; }
47    || eval { require Socket::GetAddrInfo; Socket::GetAddrInfo->import( ':newapi', @gai_reqs ); 1; }
48    || eval { Socket::GetAddrInfo->import( '0.22', @gai_reqs ); 1; }
49    || die "$0 error: requires Perl 5.14 or Socket::GetAddrInfo.\n";
50}
51
52my $have_ipv6 = eval {
53  require IO::Socket::IP;
54  IO::Socket::IP->import('-register');
55  1;
56} || eval {
57  require IO::Socket::INET6;
58  1;
59};
60
61$|=1;
62
63my $client = 'mosh-client';
64my $server = 'mosh-server';
65
66my $predict = undef;
67
68my $bind_ip = undef;
69
70my $use_remote_ip = 'proxy';
71
72my $family = 'prefer-inet';
73my $port_request = undef;
74
75my @ssh = ('ssh');
76
77my $term_init = 1;
78
79my $localhost = undef;
80
81my $ssh_pty = 1;
82
83my $help = undef;
84my $version = undef;
85
86my @cmdline = @ARGV;
87
88my $usage =
89qq{Usage: $0 [options] [--] [user@]host [command...]
90        --client=PATH        mosh client on local machine
91                                (default: "mosh-client")
92        --server=COMMAND     mosh server on remote machine
93                                (default: "mosh-server")
94
95        --predict=adaptive      local echo for slower links [default]
96-a      --predict=always        use local echo even on fast links
97-n      --predict=never         never use local echo
98        --predict=experimental  aggressively echo even when incorrect
99
100-4      --family=inet        use IPv4 only
101-6      --family=inet6       use IPv6 only
102        --family=auto        autodetect network type for single-family hosts only
103        --family=all         try all network types
104        --family=prefer-inet use all network types, but try IPv4 first [default]
105        --family=prefer-inet6 use all network types, but try IPv6 first
106-p PORT[:PORT2]
107        --port=PORT[:PORT2]  server-side UDP port or range
108                                (No effect on server-side SSH port)
109        --bind-server={ssh|any|IP}  ask the server to reply from an IP address
110                                       (default: "ssh")
111
112        --ssh=COMMAND        ssh command to run when setting up session
113                                (example: "ssh -p 2222")
114                                (default: "ssh")
115
116        --no-ssh-pty         do not allocate a pseudo tty on ssh connection
117
118        --no-init            do not send terminal initialization string
119
120        --local              run mosh-server locally without using ssh
121
122        --experimental-remote-ip=(local|remote|proxy)  select the method for
123                             discovering the remote IP address to use for mosh
124                             (default: "proxy")
125
126        --help               this message
127        --version            version and copyright information
128
129Please report bugs to mosh-devel\@mit.edu.
130Mosh home page: https://mosh.org\n};
131
132my $version_message = '@PACKAGE_STRING@ [build @VERSION@]' . qq{
133Copyright 2012 Keith Winstein <mosh-devel\@mit.edu>
134License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
135This is free software: you are free to change and redistribute it.
136There is NO WARRANTY, to the extent permitted by law.\n};
137
138sub predict_check {
139  my ( $predict, $env_set ) = @_;
140
141  if ( not exists { adaptive => 0, always => 0,
142		    never => 0, experimental => 0 }->{ $predict } ) {
143    my $explanation = $env_set ? " (MOSH_PREDICTION_DISPLAY in environment)" : "";
144    print STDERR qq{$0: Unknown mode \"$predict\"$explanation.\n\n};
145
146    die $usage;
147  }
148}
149
150GetOptions( 'client=s' => \$client,
151	    'server=s' => \$server,
152	    'predict=s' => \$predict,
153	    'port=s' => \$port_request,
154	    'a' => sub { $predict = 'always' },
155	    'n' => sub { $predict = 'never' },
156	    'family=s' => \$family,
157	    '4' => sub { $family = 'inet' },
158	    '6' => sub { $family = 'inet6' },
159	    'p=s' => \$port_request,
160	    'ssh=s' => sub { @ssh = shellwords($_[1]); },
161	    'ssh-pty!' => \$ssh_pty,
162	    'init!' => \$term_init,
163	    'local' => \$localhost,
164	    'help' => \$help,
165	    'version' => \$version,
166	    'fake-proxy!' => \my $fake_proxy,
167	    'bind-server=s' => \$bind_ip,
168	    'experimental-remote-ip=s' => \$use_remote_ip) or die $usage;
169
170if ( defined $help ) {
171    print $usage;
172    exit;
173}
174if ( defined $version ) {
175    print $version_message;
176    exit;
177}
178
179if ( defined $predict ) {
180  predict_check( $predict, 0 );
181} elsif ( defined $ENV{ 'MOSH_PREDICTION_DISPLAY' } ) {
182  $predict = $ENV{ 'MOSH_PREDICTION_DISPLAY' };
183  predict_check( $predict, 1 );
184} else {
185  $predict = 'adaptive';
186  predict_check( $predict, 0 );
187}
188
189if ( not grep { $_ eq $use_remote_ip } qw { local remote proxy } ) {
190  die "Unknown parameter $use_remote_ip";
191}
192
193$family = lc( $family );
194# Handle IPv4-only Perl installs.
195if (!$have_ipv6) {
196  # Report failure if IPv6 needed and not available.
197  if (defined($family) && $family eq "inet6") {
198    die "$0: IPv6 sockets not available in this Perl install\n";
199  }
200  # Force IPv4.
201  $family = "inet";
202}
203
204if ( defined $port_request ) {
205  if ( $port_request =~ m{^(\d+)(:(\d+))?$} ) {
206    my ( $low, $clause, $high ) = ( $1, $2, $3 );
207    # good port or port-range
208    if ( $low < 0 or $low > 65535 ) {
209      die "$0: Server-side (low) port ($low) must be within valid range [0..65535].\n";
210    }
211    if ( defined $high ) {
212      if ( $high <= 0 or $high > 65535 ) {
213	die "$0: Server-side high port ($high) must be within valid range [1..65535].\n";
214      }
215      if ( $low == 0 ) {
216	die "$0: Server-side port ranges may not be used with starting port 0 ($port_request).\n";
217      }
218      if ( $low > $high ) {
219	die "$0: Server-side port range ($port_request): low port greater than high port.\n";
220      }
221    }
222  } else {
223    die "$0: Server-side port or range ($port_request) is not valid.\n";
224  }
225}
226
227delete $ENV{ 'MOSH_PREDICTION_DISPLAY' };
228
229my $userhost;
230my @command;
231my @bind_arguments;
232
233if ( ! defined $fake_proxy ) {
234  if ( scalar @ARGV < 1 ) {
235    die $usage;
236  }
237  $userhost = shift;
238  @command = @ARGV;
239  if ( not defined $bind_ip or $bind_ip =~ m{^ssh$}i ) {
240    if ( not defined $localhost ) {
241      push @bind_arguments, '-s';
242    } else {
243      push @bind_arguments, ('-i', "$userhost");
244    }
245  } elsif ( $bind_ip =~ m{^any$}i ) {
246    # do nothing
247  } else {
248    push @bind_arguments, ('-i', "$bind_ip");
249  }
250} else {
251  my ( $host, $port ) = @ARGV;
252
253  my @res = resolvename( $host, $port, $family );
254
255  # Now try and connect to something.
256  my $err;
257  my $sock;
258  my $addr_string;
259  my $service;
260  for my $ai ( @res ) {
261    ( $err, $addr_string, $service ) = getnameinfo( $ai->{addr}, NI_NUMERICHOST );
262    next if $err;
263    if ( $sock = IO::Socket->new( Domain => $ai->{family},
264				  Family => $ai->{family},
265				  PeerHost => $addr_string,
266				  PeerPort => $port,
267				  Proto => 'tcp' )) {
268      print STDERR 'MOSH IP ', $addr_string, "\n";
269      last;
270    } else {
271      $err = $@;
272    }
273  }
274  die "$0: Could not connect to ${host}, last tried ${addr_string}: ${err}\n" if !$sock;
275
276  # Act like netcat
277  binmode($sock);
278  binmode(STDIN);
279  binmode(STDOUT);
280
281  sub cat {
282    my ( $from, $to ) = @_;
283    while ( my $n = $from->sysread( my $buf, 4096 ) ) {
284      next if ( $n == -1 && $! == EINTR );
285      $n >= 0 or last;
286      $to->write( $buf ) or last;
287    }
288  }
289
290  defined( my $pid = fork ) or die "$0: fork: $!\n";
291  if ( $pid == 0 ) {
292    close STDIN;
293    cat $sock, \*STDOUT; $sock->shutdown( 0 );
294    _exit 0;
295  }
296  $SIG{ 'HUP' } = 'IGNORE';
297  close STDOUT;
298  cat \*STDIN, $sock; $sock->shutdown( 1 );
299  close STDIN;
300  waitpid $pid, 0;
301  exit;
302}
303
304# Count colors
305open COLORCOUNT, '-|', $client, ('-c') or die "Can't count colors: $!\n";
306my $colors = "";
307{
308  local $/ = undef;
309  $colors = <COLORCOUNT>;
310}
311close COLORCOUNT or die;
312
313chomp $colors;
314
315if ( (not defined $colors)
316    or $colors !~ m{^[0-9]+$}
317    or $colors < 0 ) {
318  $colors = 0;
319}
320
321$ENV{ 'MOSH_CLIENT_PID' } = $$; # We don't support this, but it's useful for test and debug.
322
323# If we are using a locally-resolved address, we have to get it before we fork,
324# so both parent and child get it.
325my $ip;
326if ( $use_remote_ip eq 'local' ) {
327  # "parse" the host from what the user gave us
328  my ($user, $host) = $userhost =~ /^((?:.*@)?)(.*)$/;
329  # get list of addresses
330  my @res = resolvename( $host, 22, $family );
331  # Use only the first address as the Mosh IP
332  my $hostaddr = $res[0];
333  if ( !defined $hostaddr ) {
334    die( "could not find address for $host" );
335  }
336  my ( $err, $addr_string, $service ) = getnameinfo( $hostaddr->{addr}, NI_NUMERICHOST );
337  if ( $err ) {
338    die( "could not use address for $host" );
339  }
340  $ip = $addr_string;
341  $userhost = "$user$ip";
342}
343
344my $pid = open(my $pipe, "-|");
345die "$0: fork: $!\n" unless ( defined $pid );
346if ( $pid == 0 ) { # child
347  open(STDERR, ">&STDOUT") or die;
348
349  my @sshopts = ( '-n' );
350  if ($ssh_pty) {
351      push @sshopts, '-tt';
352  }
353
354  my $ssh_connection = "";
355  if ( $use_remote_ip eq 'remote' ) {
356    # Ask the server for its IP.  The user's shell may not be
357    # Posix-compatible so invoke sh explicitly.
358    $ssh_connection = "sh -c " .
359      shell_quote ( '[ -n "$SSH_CONNECTION" ] && printf "\nMOSH SSH_CONNECTION %s\n" "$SSH_CONNECTION"' ) .
360      " ; ";
361    # Only with 'remote', we may need to tell SSH which protocol to use.
362    if ( $family eq 'inet' ) {
363      push @sshopts, '-4';
364    } elsif ( $family eq 'inet6' ) {
365      push @sshopts, '-6';
366    }
367  }
368  my @server = ( 'new' );
369
370  push @server, ( '-c', $colors );
371
372  push @server, @bind_arguments;
373
374  if ( defined $port_request ) {
375    push @server, ( '-p', $port_request );
376  }
377
378  for ( &locale_vars ) {
379    push @server, ( '-l', $_ );
380  }
381
382  if ( scalar @command > 0 ) {
383    push @server, '--', @command;
384  }
385
386  if ( defined( $localhost )) {
387    delete $ENV{ 'SSH_CONNECTION' };
388    chdir; # $HOME
389    print "MOSH IP ${userhost}\n";
390    exec( $server, @server );
391    die "Cannot exec $server: $!\n";
392  }
393  if ( $use_remote_ip eq 'proxy' ) {
394    # Non-standard shells and broken shrc files cause the ssh
395    # proxy to break mysteriously.
396    $ENV{ 'SHELL' } = '/bin/sh';
397    my $quoted_proxy_command = shell_quote( $0, "--family=$family" );
398    push @sshopts, ( '-S', 'none', '-o', "ProxyCommand=$quoted_proxy_command --fake-proxy -- %h %p" );
399  }
400  my @exec_argv = ( @ssh, @sshopts, $userhost, '--', $ssh_connection . "$server " . shell_quote( @server ) );
401  exec @exec_argv;
402  die "Cannot exec ssh: $!\n";
403} else { # parent
404  my ( $sship, $port, $key );
405  my $bad_udp_port_warning = 0;
406  LINE: while ( <$pipe> ) {
407    chomp;
408    if ( m{^MOSH IP } ) {
409      if ( defined $ip ) {
410	die "$0 error: detected attempt to redefine MOSH IP.\n";
411      }
412      ( $ip ) = m{^MOSH IP (\S+)\s*$} or die "Bad MOSH IP string: $_\n";
413    } elsif ( m{^MOSH SSH_CONNECTION } ) {
414      my @words = split;
415      if ( scalar @words == 6 ) {
416	$sship = $words[4];
417      } else {
418	die "Bad MOSH SSH_CONNECTION string: $_\n";
419      }
420    } elsif ( m{^MOSH CONNECT } ) {
421      if ( ( $port, $key ) = m{^MOSH CONNECT (\d+?) ([A-Za-z0-9/+]{22})\s*$} ) {
422	last LINE;
423      } else {
424	die "Bad MOSH CONNECT string: $_\n";
425      }
426    } else {
427      if ( defined $port_request and $port_request =~ m{:} and m{Bad UDP port} ) {
428	$bad_udp_port_warning = 1;
429      }
430      print "$_\n";
431    }
432  }
433  waitpid $pid, 0;
434  close $pipe;
435
436  if ( not defined $ip ) {
437    if ( defined $sship ) {
438      warn "$0: Using remote IP address ${sship} from \$SSH_CONNECTION for hostname ${userhost}\n";
439      $ip = $sship;
440    } else {
441      die "$0: Did not find remote IP address (is SSH ProxyCommand disabled?).\n";
442    }
443  }
444
445  if ( not defined $key or not defined $port ) {
446    if ( $bad_udp_port_warning ) {
447      die "$0: Server does not support UDP port range option.\n";
448    }
449    die "$0: Did not find mosh server startup message. (Have you installed mosh on your server?)\n";
450  }
451
452  # Now start real mosh client
453  $ENV{ 'MOSH_KEY' } = $key;
454  $ENV{ 'MOSH_PREDICTION_DISPLAY' } = $predict;
455  $ENV{ 'MOSH_NO_TERM_INIT' } = '1' if !$term_init;
456  exec {$client} ("$client", "-# @cmdline |", $ip, $port);
457}
458
459sub shell_quote { join ' ', map {(my $a = $_) =~ s/'/'\\''/g; "'$a'"} @_ }
460
461sub locale_vars {
462  my @names = qw[LANG LANGUAGE LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION LC_ALL];
463
464  my @assignments;
465
466  for ( @names ) {
467    if ( defined $ENV{ $_ } ) {
468      push @assignments, $_ . q{=} . $ENV{ $_ };
469    }
470  }
471
472  return @assignments;
473}
474
475sub resolvename {
476  my ( $host, $port, $family ) = @_;
477  my $err;
478  my @res;
479  my $af;
480
481  # If the user selected a specific family, parse it.
482  if ( defined( $family ) && ( $family eq 'inet' || $family eq 'inet6' )) {
483      # Choose an address family, or cause pain.
484      my $afstr = 'AF_' . uc( $family );
485      $af = eval { IO::Socket->$afstr } or die "$0: Invalid family $family\n";
486  }
487
488  # First try the address as a numeric.
489  my %hints = ( flags => AI_NUMERICHOST,
490		socktype => SOCK_STREAM,
491		protocol => IPPROTO_TCP );
492  if ( defined( $af )) {
493    $hints{family} = $af;
494  }
495  ( $err, @res ) = getaddrinfo( $host, $port, \%hints );
496  if ( $err ) {
497    # Get canonical name for this host.
498    $hints{flags} = AI_CANONNAME;
499    ( $err, @res ) = getaddrinfo( $host, $port, \%hints );
500    die "$0: could not get canonical name for $host: ${err}\n" if $err;
501    # Then get final resolution of the canonical name.
502    delete $hints{flags};
503    my @newres;
504    ( $err, @newres ) = getaddrinfo( $res[0]{canonname}, $port, \%hints );
505    die "$0: could not resolve canonical name ${res[0]{canonname}} for ${host}: ${err}\n" if $err;
506    @res = @newres;
507  }
508
509  if ( defined( $af )) {
510    # If v4 or v6 was specified, reduce the host list.
511    @res = grep {$_->{family} == $af} @res;
512  } elsif ( $family =~ /^prefer-/ ) {
513    # If prefer-* was specified, reorder the host list to put that family first.
514    my $prefer_afstr = 'AF_' . uc( ($family =~ /prefer-(.*)/)[0] );
515    my $prefer_af = eval { IO::Socket->$prefer_afstr } or die "$0: Invalid preferred family $family\n";
516    @res = (grep({$_->{family} == $prefer_af} @res), grep({$_->{family} != $prefer_af} @res));
517  } elsif ( $family ne 'all' ) {
518    # If v4/v6/all were not specified, verify that this host only has one address family available.
519    for my $ai ( @res ) {
520      if ( !defined( $af )) {
521	$af = $ai->{family};
522      } else {
523	die "$0: host has both IPv4 and IPv6 addresses, use --family to specify behavior\n"
524	    if $af != $ai->{family};
525      }
526    }
527  }
528  return @res;
529}
530