#!/usr/bin/perl =pod =head1 NAME remote-ssh-access - An application for creating handy SSH client shortcuts. =head1 SYNOPSIS remote-ssh-access [-a|--add] [-h] [-s|--silent] [-N|--no-defkey] [cmds...] =head1 DESCRIPTION This script replaces the use of aliases or other small scripts for automating and managing SSH client commands. =head1 README This small script creates and uses shortcuts for launching SSH sessions. It's a useful tool when you have a lot of systems to manage. This program is meant to be executed through a symlink to a hard link. The hard link file is called a C; the symlink is referred to as the C. If you wish, the hard link may also serve as the shortcut, obviating the step of creating a symlink. The C is created with the standard F command. Its name forms the arguments that the shorcut file uses to launch SSH sessions. The syntax for a parameter file is defined given the following syntax: =over =item host[:user[:port[:key[:version[:cmd]]]]] =back Where: All parameters are delimited with a colon C<:> character unless all right-most parameters are permitted to default, in which case they may be omitted. =over =item B is a fully qualified hostname or IP address This parameter is the first and the only required argument. =item B is the remote login username The default is the invoking user if not supplied in the parameter filename. =item B is the destination SSH server port number This parameter file argument may be a number or an /etc/services name. The default is whatever the current hosts' services(5) entry for 'ssh/tcp' has configured. =item B is the name of a secret private key (rsa1, rsa2 or dsa) authentication file in your ~/.ssh directory A default key will be selected unless the command line switch B<-N> is used. The default secret key file selection process uses a prioritized selection criteria ( if the key file exists ) in ~/.ssh, which is: =over =item id_rsa ~/.ssh/id_rsa =item id_dsa ~/.ssh/id_dsa =item identity ~/.ssh/identity =item Any others Other keys are discovered by looking for key filenames ending with a F<.pub> extension, in which a secret keyfile with the same name (sans the F<.pub> extension) exists. =back =item B is the protocol version of SSH that the B argument uses (1 or 2). It's usually best to just leave this empty unless you're sure the SSH key is protocol version 1. If this is specified in the parameter filename just remember that supplying this means that you intend to force SSH to require this protocol version (see L). =item B is an optional command argument list to run on the remote host (default is a login session) =back When forming the C all right-justified parameters and any delimiters may be omitted if the default values are wanted. If a right-most parameter needs to be supplied then embed all left-to-right intermediary parameters with empty C<::> delimiters. In other words parameters are identified using a positional argument list delimited with colons. Supplying empty colons will use their default values. =head2 COMMAND LINE SWITCHES =over =item B<-a E --add> An input loop is used to gather all of the input necessary to automatically build the C and C links. No extra charge. =item B<-s E --silent> The SSH command being spawned is normally echoed to the terminal. The echo is suppressed if this command line switch is given, or the remote session has command line arguments given (either through the parameter file, or if passed to the shortcut). Since shortcuts might exist to launch automated processes the echo suppression for command arguments makes parsing command output easier. =item B<-h E --help> Help! =item B<-N E --no-defkey> Normally, if a private authentication key is not specified in the parameter filename, or in the F<~/.remote-ssh-access> file, a default private key is selected. Using this switch prevents the default identity file from being selected. A couple things are worth noting about this option: This switch does not prevent a secret key file from being used if it is given in the parameter file, or the host/user specific override -- it only prevents default key selection of a key. The SSH client itself may or may not decide to automatically use a key anyway. =item B Multiple commands may be passed to a shortcut command. =back =head2 INSTALLATION AND USE =head3 INSTALL THE SCRIPT Install and use this script using the following steps =over =item * Create a subdirectory under your home named ~/.hosts, or something similar. =item * Add it to your PATH environment variable (and ideally, to your login profile) =item * Install this script inside ~/.hosts, or whatever directory you used above. =item * The script must be named C. If another name is desired then you must modify the source code: change the constant C in the source code to reflect the new script name. =item * Do not install the script in a shared system-wide location. Do not install this script in a directory such as F. This is because: =over =item 1. Non-privileged users need write access to the same directory. =item 2. This script makes use of hard links. Often system installation directories live on their own file systems. Since this script makes use of hard links, and hard links do not span disparate file systems, it would not make sense to install the script in a system binary directory. =back =back =head3 CREATE SHORTCUTS THE EASY WAY Run this script with the C<--add> switch, you will be prompted for all of the necessary data. The hard and soft links will be created by this script. =head3 CREATE HARD AND SOFT LINKS TO FORM SHORTCUTS THE HARD WAY Hardlink the parameter file to this script given the syntax described earlier -- then create a symlink to the hard link and invoke the SSH session with the shortcut. =over =item * Example 1 You want a shortcut to remote to the host B as the user B. On B, sshd runs on port B<1234>. In addition you would like to use the public key associated with F. Furthermore you would like the 'uptime' command to be executed. Allow the SSH protocol version to default. % cd ~/.hosts % ln remote-ssh-access plethora:joe:1234:id_dsa::uptime Symlink the parameter file to a shortcut named B % ln -s plethora:joe:1234:id_dsa::uptime duptime Login and run uptime against plethora by invoking the shortcut % duptime =item * Example 2 You want to connect to the host B as your default user, default ssh port, using the SSH v1 public key file named 'identity' and have an interactive shell. % ln remote-ssh-access pinyata:::identity:1 % ln -s pinyata:::identity:1 pinyata % pinyata =item * Example 3 Using all defaults create a shortcut to the host B. Since the command is already sufficiently short you can use it as the shortcut (no symlink is required). % ln remote-ssh-access domino % domino =back =head2 OPTIONAL SSH GOODNESS The standard SSH suite includes tools for managing sessions. You can load your key via ssh-agent under a sub-process (e.g., shell or X11) then add the key via ssh-add. Subsequent invocations of the shortcut will have the passphrase fed by the agent. % ssh-agent bash % ssh-add ~/.ssh/identity % pinyata =head2 OVERRIDING PARAMETER-CONFIGURED REMOTE COMMANDS If any command argument list is passed to the shortcut it is passed as commands to run against the target host -- overriding any command argument in the C. % ln remote-ssh-access dilbert:root:1234:id_dsa:2:who % ln -s dilbert:root:1234:id_dsa:2:who dwho % dwho # runs who(1) on dilbert % dwho w # runs the w(1) command on dilbert instead =head2 OVERRIDING HOST AND USER SPECIFIC KEYS You can override keys on a per host/user basis. Create a file named ~/.remote-ssh-access. Other than comments (introduced with the standard C<#> sigil) the file takes the following syntax: B Either B or B may be the wild card (*) character, which means any host or user. Note that the wild card does not "glob" identifiers, for example C will not pattern match all users prefixed with C. The wild card is either '*' or a specific label. The key argument can be the name of a secret key file in your ~/.ssh directory, or it may be a fully qualified path to the file. Note that when an authentication key is overridden you are given a hint -- the command echo will prefix the SSH command with a B<[*]> noting that the key had been overridden (unless, as explained earlier, echoes are suppressed). =over =item Example B This would force any shortcut, which, if symlinked to a file that has a host parameter of B to have its secret key overridden with F<~/.ssh/id_dsa>, despite the key specified in the parameter file. =item Another nifty example B<*:uploads:identity:1> Would force all shortcuts that result in remote SSH sessions targeted to the C user to automatically resort to using F<~/.ssh/identity> and SSH protocol version 1. =back =head1 PREREQUISITES This script requires C, C, C, C, and C It should be easy to obtain all of these since they are all standard Perl core modules. =head1 OSNAMES C, C, C =head1 SCRIPT CATEGORIES Networking UNIX/System_administration =head1 AUTHOR Lane Davis =cut use strict; use warnings; use Cwd qw(abs_path); use File::Spec::Functions qw(catdir catfile); use File::Basename qw(dirname basename); use Getopt::Long; use Pod::Usage; use constant REAL_PROCESS_NAME => 'remote-ssh-access'; our $VERSION = '1.7'; my $procname = basename($0); my $realname = REAL_PROCESS_NAME; my ( $opt_help, $opt_silent, $opt_no_defkey, $opt_add ); GetOptions( 's|silent' => \$opt_silent, 'h|help' => \$opt_help, 'N|no-defkey' => \$opt_no_defkey, 'a|add' => \$opt_add, ); usage() if $opt_help; main(@ARGV); exit(-1); sub main { my @args = @_; add() if $opt_add; die sprintf( "This script (%s) is not meant to be run directly, unless called with --add.\n", $realname ) if ( $procname eq $realname ); my $settings = load_defaults(); load_link_settings( $settings ); override_preferences( $settings ); run_ssh( $settings, @args ); return; } sub run_ssh { my ( $settings, @args ) = @_; my $cmd = build_ssh_cmd( $settings, @args ); if ( !$opt_silent && !( $settings->{cmd} ) && !scalar(@args) ) { $|++; printf( "%s%s\n", $settings->{override} ? '[*] ' : "", join( ' ', @$cmd ) ); } exec @$cmd; } sub build_ssh_cmd { my ( $settings, @args ) = @_; my $ssh_exec = path_of("ssh"); my $cmd = [$ssh_exec]; push @$cmd, sprintf( '-%s' => $settings->{version} ) if $settings->{version}; push @$cmd, ( '-p' => $settings->{port} ) if $settings->{port}; push @$cmd, ( '-i' => $settings->{key} ) if $settings->{key}; push @$cmd, ( '-l' => $settings->{user} ) if $settings->{user}; push @$cmd, $settings->{host}; if (@args) { push @$cmd, @args; } else { push @$cmd, $settings->{cmd} if $settings->{cmd}; } return $cmd; } sub path_of { my ($cmd) = @_; my @dirs = split /:/, $ENV{PATH}; for my $dir (@dirs) { my $path = catfile( $dir, $cmd ); return $path if -x $path && -f _; } return; } sub load_link_settings { my ($settings) = @_; my $link = readlink($0) || $procname; $link =~ s|^[./]+||; my ( $host, $user, $port, $key, $version, $cmd ) = split( ':', $link, 6 ); $settings->{host} = $host; $settings->{user} = $user if $user; if ($port) { $port = ( getservbyname( $port, 'tcp' ) )[2] if ( $port =~ /\D/ ); $settings->{port} = $port if $port; } if ($key) { my $key_file = resolve_key($key); $settings->{key} = $key_file if $key_file; } else { $settings->{key} = resolve_key( $settings->{key} ) if defined $settings->{key}; } $settings->{version} = $version if $version && $version !~ /\D/; $settings->{cmd} = $cmd if $cmd; return; } sub override_preferences { my ($settings) = @_; my $home_dir = resolve_home(); my $pref = catfile( $home_dir, ".remote-ssh-access" ); return unless $pref && -f $pref; my ( $host, $user ) = @{$settings}{qw( host user )}; ## ## Normalize the match parameters a bit $host =~ s/\.$//; $host = lc($host); $user = lc($user); ## Perl::Critic for some reason thinks @ARGV isn't localized... *sigh* local (*ARGV); @ARGV = ($pref); my @settings = <>; ## ## First match, first exit for my $config (@settings) { chomp($config); next if ( $config =~ /^$|^\s*#/ ); my ( $mhost, $muser, $mkey, $mver ) = split( /:/, $config, 4 ); if ( ( lc($mhost) eq $host || $mhost eq '*' ) && ( lc($muser) eq $user || $muser eq '*' ) ) { if ($mkey) { $mkey = resolve_key($mkey) if ( $mkey !~ m|/| ); if ( -f $mkey ) { $settings->{override}++ if ( $settings->{key} ); $settings->{key} = $mkey; $settings->{version} = $mver if $mver && $mver !~ /\D/; } } } } return; } sub resolve_user { my $user = ( ( getpwuid $< )[0] ) || $ENV{USER} || getlogin(); die "Who are you?\n" unless $user; return $user; } sub resolve_home { my $user = resolve_user(); my $home_dir = ( ($user) ? ( ( getpwnam $user )[7] ) : ( getpwuid $< )[7] ) || $ENV{HOME}; die "I couldn't find your home directory.\n" unless $home_dir; return $home_dir; } sub resolve_ssh_dir { my $home_dir = resolve_home(); return ( catdir( $home_dir, '.ssh' ) ) if ( $home_dir && -d $home_dir ); return; } sub resolve_key { my ($key) = @_; return unless $key; $key =~ s/\.pub$//; my $key_dir = resolve_ssh_dir(); my $key_file = catfile( $key_dir, $key ); return $key_file if -f $key_file; return; } sub load_defaults { my ( $user, $key_dir, $version, $key_file, $ssh_port ); $user = resolve_user(); $key_dir = resolve_ssh_dir(); $key_file = default_key($key_dir) if ( defined $key_dir && !$opt_no_defkey ); $ssh_port = ( getservbyname qw(ssh tcp) )[2]; my $opts = { user => $user, key => $key_file, version => $version, port => $ssh_port, }; $opts->{version} = $version if $version; return $opts; } sub default_key { my ($dir) = @_; ## ## Don't start at 0 so we can use logicical or shortcut || for undef's my %prios = ( 'id_rsa' => 1, 'id_dsa' => 2, 'identity' => 3 ); my $pdef = scalar( keys %prios ) + 1; my ($candidate) = sort { $prios{ basename $a} || $pdef <=> $prios{ basename $b} || $pdef } grep -f, map { /^(.*)\.pub$/ } grep -f, glob( catfile( $dir, '*.pub' ) ); return unless $candidate; return basename($candidate); } sub usage { pod2usage( { -exitval => -1, -verbose => 2, } ); } sub add { my @fields = ( [ host => { prompt => "Hostname", required => 1, }, ], [ user => { prompt => "Username", blank => "All users", required => 0, valid => \&validate_user, }, ], [ port => { prompt => "Port", blank => ( getservbyname( 'ssh', 'tcp' ) )[2], required => 0, valid => qr/^\d+$/, }, ], [ key => { prompt => "Public Key", blank => "Default key", required => 0, valid => \&resolve_key, }, ], [ version => { prompt => "Version", required => 0, valid => qr/^\d(?:\.\d+)?$/, }, ], [ cmd => { prompt => "Command", blank => "Login shell", required => 0, }, ], [ short => { prompt => "Shortcut", required => 1, valid => \&validate_shortcut, }, ], ); my $realpath = abs_path $0; die("I can't figure out where $0 lives!\n") unless $realpath && -x $realpath; my $dirpath = dirname($realpath); die("The directory '${dirpath}' isn't writable by you -- I can't put shortcuts there.\n") unless -w $dirpath; my ( $procfile, $shortcut ) = populate_fields( \@fields ); if ( defined $procfile ) { chdir($dirpath) or die("Can't chdir to $dirpath: $!\n"); link $realpath, $procfile; symlink $procfile, $shortcut; printf("The shortcut '%s' has been created.\n", $shortcut); } exit(0); } # # This is a very cheap and crusty user-input loop. It needs some cleanup or replacement # by something more standard (e.g., some module that handles form input). # sub input_fields { my ($fields, $print_header) = @_; my $item = 0; my $iter; local $| = 1; print( q{*** Entering input loop: your valid non-data input commands are: `back', `exit' or `quit'}, "\n") if $print_header; USER_LOOP: while (1) { $item = 0 if $item < 0; $iter = ${$fields}[$item]; my $label = $iter->[0]; my %opts = %{ $iter->[1] }; my $blk = $opts{blank} && sprintf( 'EMPTY=%s', $opts{blank} ) || ''; my $prompt = sprintf( '%s ', $opts{prompt} ); $prompt .= sprintf( '[%s]', $blk ) if $blk; $prompt .= " --> "; print($prompt); my $field = ; print("*EOF*\n"), return unless defined $field; chomp($field); $item--, redo if $field eq 'back'; return if $field =~ /^(exit|quit)$/; my $len = length($field); if ( !$len && $opts{required} ) { printf( "***: field '%s' is required\n", $label ); redo USER_LOOP; } my $valid = 1; if ( $opts{valid} && $len ) { $valid = $field =~ /$opts{valid}/ if ( ref( $opts{valid} ) eq 'Regexp' ); $valid = $opts{valid}->($field) if ( ref( $opts{valid} ) eq 'CODE' ); } if ( !$valid && $len ) { my $nein = sprintf("***: User-input '%s' is malformed -- please re-enter", $field); $nein .= ', or press ENTER to accept default' unless $opts{required}; print($nein, "\n"); redo USER_LOOP; } $fields->[$item]->[1]->{value} = $field; $item++; last USER_LOOP if $item > $#{$fields}; } continue { $iter = ${$fields}[$item]; } return $fields; } sub populate_fields { my ($fields) = @_; my $rv = input_fields($fields, 1); return unless defined $rv; my $proc = sprintf( '%s:%s:%s:%s:%s:%s', map( $_->[1]->{value}, @{$fields} ) ); $proc =~ s/:+$//; return ( $proc, $fields->[-1]->[1]->{value} ); } sub validate_shortcut { my ( $shortcut ) = @_; my $cmd = path_of( $shortcut ); if ($cmd && -f $cmd && -x _) { my @query = ( [ verify => { prompt => sprintf("Your shortcut '%s' seems to already exist -- are you sure (Y/N)? ", $shortcut), valid => qr/^[yn]/i, required => 1, }, ], ); input_fields(\@query, 0); return $query[0]->[1]->{value} =~ /^y/i; } return 1; } sub validate_user { my ( $user ) = @_; unless(defined(getpwnam($user))) { my @query = ( [ verify => { prompt => sprintf("The user '%s' does not seem to exist -- are you sure (Y/N)? ", $user), valid => qr/^[yn]/i, required => 1, }, ], ); input_fields(\@query, 0); return $query[0]->[1]->{value} =~ /^y/i; } return 1; } __END__