#!/usr/bin/perl

##
## Note: POD is at the end-of-file -- the -h switch dumps POD
##

use strict;
use warnings;

use File::Spec::Functions qw(catdir catfile);
use File::Basename        qw(basename);
use Getopt::Long;
use Pod::Usage;

use constant REAL_PROCESS_NAME => 'remote-ssh-access';

our $VERSION = '1.0';

my $procname = basename($0);
my $realname = REAL_PROCESS_NAME;

my ( $opt_help, $opt_silent, $opt_no_defkey );

GetOptions(
    's|silent'    => \$opt_silent,
    'h|help'      => \$opt_help,
    'N|no-defkey' => \$opt_no_defkey,
);
usage() if $opt_help;
main(@ARGV);
exit(-1);

sub main {
    my @args = @_;

    die sprintf("This script (%s) is not meant to be run directly.\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 );

    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 = getlogin()
        || ( ( getpwuid $< )[ 0 ] )
        || $ENV{USER};

    return $user;
}

sub resolve_home {
    my $user     = resolve_user();
    my $home_dir = ( ( $user )
        ? ( ( getpwnam $user )[ 7 ] )
        : ( getpwuid $< )[ 7 ] )
            || $ENV{HOME};
    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 ) = @_;
    my %prios = ( 'id_rsa' => 0, 'id_dsa' => 1, 'identity' => 2 );
    my ( $candidate ) =
        sort { $prios{basename $a} <=> $prios{basename $b} }
        grep -f,
        map { /^(.*)\.pub$/ } 
        grep -f, glob(catfile($dir, '*.pub'));

    return unless $candidate;
    return basename( $candidate );
}

sub usage {
    pod2usage(
        {
            -exitval => -1,
            -verbose => 2,
         }
    );
}

__END__

=pod

=head1 NAME

remote-ssh-access - An application for creating handy SSH client shortcuts.

=head1 SYNOPSIS

    remote-ssh-access [-h] [-s|--silent] [-N|--no-defkey] [cmds...]

=head1 DESCRIPTION

This program is meant to be executed through a symlink to a hardlink.  The hardlink file is
called a C<parameter file>; the symlink is referred to as the C<shortcut>.  If you wish, the
hardlink may also serve as the shortcut, obviating the step of creating a symlink.

The C<parameter file> is created with the standard F<ln(1)> 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<host> is a fully qualified hostname or IP address

This parameter is the first and the only required argument.

=item B<user> is the remote login username

The default is the invoking user if not supplied in the parameter filename.

=item B<port> 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<key> 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<version> is the protocol version of SSH that the B<key> 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<ssh/-1>).

=item B<cmd> is an optional command argument list to run on the remote host (default is a login session)

=back

When forming the C<parameter filename> 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<-s E<verbar> --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<verbar> --help>

Help!

=item B<-N E<verbar> --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.

=item B<cmd...>

Multiple commands may be passed to a shortcut command.

=back

=head1 INSTALLATION AND USE

=head2 INSTALL THE SCRIPT

Install and use this script using the following steps

=over

=item   * Create a subdirectory under your home named ~/.hosts.

=item   * Add it to your PATH environment variable (and ideally, to your login profile)

=item   * Install this script inside the ~/.hosts directory

=back

=head2 CREATE HARD AND SOFT LINKS TO FORM SHORTCUTS

Hardlink the parameter file to this script given the syntax described earlier -- then create a symlink to the
hardlink and invoke the SSH session with the shortcut.

=over

=item * Example 1

You want a shortcut to remote to the host B<plethora> as the user B<joe>.  On B<plethora>, sshd runs on
port B<1234>.  In addition you would like to use the public key associated with F<id_dsa>.  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<duptime>

    % 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<pinyata> 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<domino>.  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 invokations of the shortcut
will have the passphrase fed by the agent.

    % ssh-agent bash
    % ssh-add ~/.ssh/identity
    % pinyata

=head2 OVERRIDING 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 parameter in the parameter filename.

    % 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

=head2 HOST 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<host:user:key:version>

Either B<host> or B<user> may be the wildcard (*) character, which means any host or user.  Note that the wildcard
does not "glob" usernames, for example C<jo*> will not pattern match all users prefixed with C<jo>.  The wildcard 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, echos are suppressed).

=over

=item Example

B<foo.bar.com:*:id_dsa:2>

This would force any shortcut, which, if symlinked to a file that has a host parameter of B<foo.bar.com> to have
its secret key overriden 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 targetted to the C<uploads> user to automatically
resort to using F<~/.ssh/identity> and SSH protocol version 1.

=back

=head1 AUTHOR

Lane Davis -- CPAN PAUSE id B<LDAVIS>

=cut
