#!/usr/bin/perl -wT # # Copyright (c) 2000-2015 University of Utah and the Flux Group. # # {{{EMULAB-LICENSE # # This file is part of the Emulab network testbed software. # # This file is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or (at # your option) any later version. # # This file is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public # License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this file. If not, see . # # }}} # use English; use Getopt::Std; use XML::Simple; use File::Temp qw(tempfile :POSIX ); # # Parse ssh public keys and enter into the DB. The default format is # openssh, but if the key is not in that format, then use ssh-keygen # to see if it can be converted from either SSH2 or SECSH format into # openssh format. This gets called from the webpage to parse keys # uploaded by users. # sub usage() { print "Usage: addpubkeys [-d] [-k | -f] [-n | -u ] [ | ]\n"; print " addpubkeys [-d] -X \n"; print " addpubkeys [-d] [-i [-r] | -w] \n"; print "Options:\n"; print " -d Turn on debugging\n"; print " -k Indicates that key was passed in on the command line\n"; print " -f Indicates that key was passed in as a filename\n"; print " -n Verify key format only; do not enter into into DB\n"; print " -X Get args from an XML file: verify, user, keyfile.\n"; print " -w Generate new authkeys (protocol 1 and 2) file for user\n"; print " -i Initialize mode; generate initial key for user\n"; print " -r Force a regenerate of initial key for user\n"; exit(-1); } my $optlist = "dkniwfu:rX:sRNC:S:Ia"; my $iskey = 0; my $verify = 0; my $initmode = 0; my $force = 0; my $genmode = 0; my $nobody = 0; my $noemail = 0; my $remove = 0; my $nodelete = 0; my $internal = 0; my $isaptkey = 0; my $Comment; my $xmlfile; # # Configure variables # my $TB = "@prefix@"; my $TBOPS = "@TBOPSEMAIL@"; my $TBAUDIT = "@TBAUDITEMAIL@"; my $OURDOMAIN = "@OURDOMAIN@"; my $CONTROL = "@USERNODE@"; my $KEYGEN = "/usr/bin/ssh-keygen"; my $ACCOUNTPROXY= "$TB/sbin/accountsetup"; my $SSH = "$TB/bin/sshtb"; my $SAVEUID = $UID; my $USERUID; # Locals my $user; my $this_user; my $target_user; my $keyfile; my $keyline; my $key; my $comment; my $user_name; my $user_email; my $user_dbid; my $user_uid; my $user_gid; my $debug = 0; # # Testbed Support libraries # use lib "@prefix@/lib"; use libaudit; use libdb; use libtestbed; use User; # # Function prototypes # sub ParseKey($); sub InitUser(); sub GenerateKeyFile(); sub ParseXmlArgs($$$$$$); sub fatal($); my $HOMEDIR = USERROOT(); # # These are the fields that we allow to come in from the XMLfile. # my $SLOT_OPTIONAL = 0x1; # The field is not required. my $SLOT_REQUIRED = 0x2; # The field is required and must be non-null. my $SLOT_ADMINONLY = 0x4; # Only admins can set this field. my %xmlfields = # XML Field Name DB slot name Flags Default ("verify" => ["verify", $SLOT_OPTIONAL, 0], "user" => ["user", $SLOT_OPTIONAL], "keyfile" => ["keyfile", $SLOT_REQUIRED]); # # Turn off line buffering on output # $| = 1; # # Untaint the path # $ENV{'PATH'} = "/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin"; delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'}; # # We don't want to run this script unless its the real version. # if ($EUID != 0) { die("*** $0:\n". " Must be setuid! Maybe its a development version?\n"); } # # Please do not run it as root. Hard to track what has happened. # if ($UID == 0) { die("*** $0:\n". " Please do not run this as root!\n"); } # # Parse command arguments. Once we return from getopts, all that should be # left are the required arguments. # %options = (); if (! getopts($optlist, \%options)) { usage(); } if (defined($options{"d"})) { $debug = 1; } if (defined($options{"a"})) { $isaptkey = 1; } if (defined($options{"k"})) { $iskey = 1; } if (defined($options{"f"})) { $iskey = 0; } if (defined($options{"n"})) { $verify = 1; } if (defined($options{"i"})) { $initmode = 1; } if (defined($options{"N"})) { $nodelete = 1; } if (defined($options{"I"})) { $internal = 1; } if (defined($options{"r"})) { $force = 1; } if (defined($options{"R"})) { $remove = 1; } if (defined($options{"s"})) { $noemail = 1; } if (defined($options{"w"})) { $genmode = 1; } if (defined($options{"u"})) { $user = $options{"u"}; } if (defined($options{"C"})) { $Comment = $options{"C"}; } if (defined($options{"X"})) { $xmlfile = $options{"X"}; my %xmlargs = (); my %errors = (); ParseXmlArgs($xmlfile, "user_pubkeys", \%xmlfields, $debug, \%xmlargs, \%errors); if (keys(%errors)) { foreach my $key (keys(%errors)) { my $val = $errors{$key}; print "${key}: $val\n"; } fatal("XML arg error"); } $verify = $xmlargs{"verify"}; $user = $xmlargs{"user"} if (exists($xmlargs{"user"})); $ARGV[0] = $xmlargs{"keyfile"}; } if ($verify && $genmode) { usage(); } if ($initmode || $genmode) { usage() if (@ARGV != 1); $user = $ARGV[0]; } else { usage() if (@ARGV != 1); usage() if (!$verify && !defined($user)); $keyfile = $ARGV[0]; } # # Untaint the arguments. # if (defined($user)) { if ($user =~ /^([-\w]+)$/i) { $user = $1; } else { fatal("Tainted username: $user"); } # Map user to object. $target_user = User->Lookup($user); if (! defined($target_user)) { fatal("$user does not exist!") } $user_name = $target_user->name(); $user_email = $target_user->email(); $user_dbid = $target_user->dbid(); $user_uid = $target_user->uid(); $USERUID = $target_user->unix_uid(); my $firstproject; if ($target_user->FirstApprovedProject(\$firstproject) < 0) { fatal("Could not determine first approved project"); } if (defined($firstproject)) { $user_gid = $firstproject->unix_gid(); } else { $user_gid = "guest"; } } # # If invoked as "nobody" we came from the web interface. We have to have # a credential in the environment, unless its just a key verification # operation, which anyone can do. # if (getpwuid($UID) eq "nobody") { $this_user = User->ImpliedUser(); if (($initmode || $genmode || !$verify) && !defined($this_user)) { fatal("Bad usage from web interface"); } $nobody = 1; } else { # From the command line; map invoking user to object. $this_user = User->ThisUser(); if (! defined($this_user)) { fatal("You ($UID) do not exist!"); } } # # Initmode or genmode, do it and exit. # if ($initmode || $genmode) { if ($initmode) { exit(InitUser()); } if ($genmode) { exit(GenerateKeyFile()); } exit(1); } # Else, key parse mode ... if ($iskey) { if ($keyfile =~ /^([-\w\s\.\@\+\/\=]*)$/) { $keyfile = $1; } else { fatal("Tainted key: $keyfile"); } $keyline = $keyfile; } else { if ($keyfile =~ /^([-\w\.\/]+)$/) { $keyfile = $1; } else { fatal("Tainted filename: $keyfile"); } if (! -e $keyfile) { fatal("No such file: $keyfile\n"); } $keyline = `head -1 $keyfile`; } # # Check user # if (!$verify) { # If its the user himself, then we can generate a new authkeys file. # Assume nodelete option comes from internal script, so do not worry. if (!$target_user->SameUser($this_user) && !$this_user->IsAdmin() && !$nodelete) { fatal("You are not allowed to set pubkeys for $target_user\n"); } if (-d "$HOMEDIR/$user_uid/.ssh") { # Drop root privs, switch to target user. $EUID = $UID = $USERUID; $genmode = 1; } # # This script is audited when not in verify mode. Since all keys are first # checked with verify mode, this should not cause any extra email from bad # keys. # AuditStart(0); } # # Grab the first line of the file. Parse it to see if its in the # format we like (openssh), either protocol 1 or 2. # if (ParseKey($keyline)) { exit 0; } # If the key was entered on the command line, then nothing more to do. if ($iskey) { exit 1; } # # Run ssh-keygen over it and see if it can convert it. # if (! open(KEYGEN, "ssh-keygen -i -f $keyfile 2>/dev/null |")) { fatal("*** $0:\n". " Could not start ssh-keygen\n"); } $keyline = ; if (close(KEYGEN) && ParseKey($keyline)) { exit 0; } exit 1; sub ParseKey($) { my ($keyline) = @_; # Remove trailing newlines which screws the patterns below. # First convert dos newlines since many people upload from windoze. $keyline =~ s/\r/\n/g; $keyline =~ s/\n//g; # Enforce a reasonable length on the key. if (length($keyline) > 4096) { print "Key is too long!\n"; print "Key: $keyline\n"; return 0; } if ($keyline =~ /^(\d*\s\d*\s[0-9a-zA-Z]*) ([-\w\@\.:\ ]*)\s*$/) { # Protocol 1 $type = "ssh-rsa1"; $key = $1; $comment = $2; } elsif ($keyline =~ /^(\d*\s\d*\s[0-9a-zA-Z]*)\s*$/) { # Protocol 1 but no comment field. $type = "ssh-rsa1"; $key = $1; } elsif ($keyline =~ /^(ssh-rsa|ssh-dss) ([-\w\.\@\+\/\=]*) ([-\w\@\.:\ ]*)$/) { # Protocol 2 $type = $1; $key = "$1 $2"; $comment = $3; } elsif ($keyline =~ /^(ssh-rsa|ssh-dss) ([-\w\.\@\+\/\=:]*)$/) { # Protocol 2 but no comment field $type = $1; $key = "$1 $2"; } if (!defined($key)) { print STDERR "Key cannot be parsed!\n"; return 0; } # Do not enter into DB if in verify mode. if ($verify) { print "Key was good: $type\n"; return 1; } # # Make up a comment field for the DB. # if (!defined($comment)) { $comment = (defined($Comment) ? $Comment : "$type-${user_email}"); } $key = "$key $comment"; my $safe_key = DBQuoteSpecial($key); my $safe_comment = DBQuoteSpecial($comment); if ($remove) { DBQueryFatal("delete from user_pubkeys ". "where uid_idx='$user_dbid' and comment=$safe_comment"); } # Only one APT key allowed if ($isaptkey) { DBQueryFatal("delete from user_pubkeys ". "where uid_idx='$user_dbid' and isaptkey=1"); } DBQueryFatal("replace into user_pubkeys set ". " uid='$user_uid', uid_idx='$user_dbid', ". " internal='$internal', nodelete='$nodelete', ". " isaptkey='$isaptkey',idx=NULL, stamp=now(), ". " pubkey=$safe_key, comment=$safe_comment"); # # Mark user record as modified so nodes are updated. # TBNodeUpdateAccountsByUID($user_uid); my $chunked = ""; while (length($key)) { $chunked .= substr($key, 0, 65, ""); if (length($key)) { $chunked .= "\n"; } } print "SSH Public Key for '$user' added:\n"; print "$chunked\n"; # Generate new auth keys file. if ($genmode) { GenerateKeyFile(); } if (! $noemail) { SENDMAIL("$user_name <$user_email>", "SSH Public Key for '$user_uid' Added", "SSH Public Key for '$user_uid' added:\n". "\n". "$chunked\n", "$TBOPS"); } return 1; } # # Init function for new users. Generate the first key for the user (which # is loaded into the DB), and then generate the keyfiles. Note that the # user might have preloaded personal keys. # sub InitUser() { # # Want to delete existing keys from DB, but not the sslcert key. # DBQueryFatal("delete from user_pubkeys ". "where uid_idx='$user_dbid' and ". " (internal=1 or comment like '%\@${OURDOMAIN}' or ". " comment like '%\@boss.${OURDOMAIN}') and ". " isaptkey=0 and comment not like 'sslcert:%'"); # Redirect pub key to file, redirect STDERR to STDIN for display. my $outfile = tmpnam(); my $command = "$ACCOUNTPROXY createsshkey $user_uid $user_gid "; $UID = 0; open ERR, "$SSH -host $CONTROL '$command rsa1' 2>&1 > $outfile |"; $UID = $SAVEUID; my $errs = ""; while () { $errs .= $_; } close(ERR); print STDERR $errs; if ($?) { unlink($outfile); fatal("Could not create rsa1 key"); } my $pubkey = `cat $outfile`; chomp($pubkey); my $safe_pubkey = DBQuoteSpecial($pubkey); my $comment = "rsa\@${OURDOMAIN}"; if (! DBQueryWarn("replace into user_pubkeys set ". " uid='$user_uid', uid_idx='$user_dbid', ". " internal='1', nodelete='1', idx=NULL, stamp=now(), ". " pubkey=$safe_pubkey, comment='$comment'")) { unlink($outfile); fatal("Could not add rsa1 key to database"); } $UID = 0; open ERR, "$SSH -host $CONTROL '$command rsa' 2>&1 > $outfile |"; $UID = $SAVEUID; $errs = ""; while () { $errs .= $_; } close(ERR); print STDERR $errs; if ($?) { unlink($outfile); fatal("Could not create rsa key"); } $pubkey = `cat $outfile`; chomp($pubkey); $safe_pubkey = DBQuoteSpecial($pubkey); $comment = "rsa1\@${OURDOMAIN}"; if (! DBQueryWarn("replace into user_pubkeys set ". " uid='$user_uid', uid_idx='$user_dbid', ". " internal='1', nodelete='1', idx=NULL, stamp=now(), ". " pubkey=$safe_pubkey, comment='$comment'")) { unlink($outfile); fatal("Could not add rsa key to database"); } unlink($outfile); return GenerateKeyFile(); } # # Generate ssh authorized_keys files. Either protocol 1 or 2. # Returns 0 on success, -1 on failure. # sub GenerateKeyFile() { my @pkeys = (); my $outfile = tmpnam(); my $sshdir = "$HOMEDIR/$user_uid/.ssh"; my $query_result = DBQueryFatal("select pubkey from user_pubkeys ". "where uid_idx='$user_dbid'"); while (my ($key) = $query_result->fetchrow_array()) { push(@pkeys, $key); } print "Generating authorized_keys ...\n"; if (!open(AUTHKEYS, "> $outfile")) { warn("*** WARNING: Could not open $outfile: $!\n"); return -1; } print AUTHKEYS "#\n"; print AUTHKEYS "# DO NOT EDIT! This file auto generated by ". "Emulab account software.\n"; print AUTHKEYS "#\n"; print AUTHKEYS "# Please use the web interface to edit your ". "public key list.\n"; print AUTHKEYS "#\n"; foreach my $key (@pkeys) { print AUTHKEYS "$key\n"; } close(AUTHKEYS); $UID = 0; system("$SSH -host $CONTROL ". "'$ACCOUNTPROXY dropfile $user_uid $user_gid 0600 $sshdir ". "authorized_keys' < $outfile"); $UID = $SAVEUID; if ($?) { unlink($outfile); fatal("Could not copy authorized_keys file to $CONTROL"); } return 0; } sub ParseXmlArgs($$$$$$) { my ($xmlfile, $table_name, $fields_ref, $debug, $args_ref, $errs_ref) = @_; # # Input args: # $xmlfile - XML file path. # $table_name - table_regex table_name for low-level checking patterns. # $fields_ref - xmlfields specification (hash reference.) # $debug # # Output args: # $args_ref - Parsed argument values (hash reference.) # $errs_ref - Error messages on failure (hash reference.) $debug = 0; # # Must wrap the parser in eval since it exits on error. # my $xmlparse = eval { XMLin($xmlfile, VarAttr => 'name', ContentKey => '-content', SuppressEmpty => undef); }; if ($@) { $errs_ref->{"XML Parse Error"} = "Return code $@"; return; } # # Make sure all the required arguments were provided. # my $key; foreach $key (keys(%{ $fields_ref })) { my (undef, $required, undef) = @{$fields_ref->{$key}}; $errs_ref->{$key} = "Required value not provided" if ($required & $SLOT_REQUIRED && ! exists($xmlparse->{'attribute'}->{"$key"})); } return if (keys(%{ $errs_ref })); foreach $key (keys(%{ $xmlparse->{'attribute'} })) { my $value = $xmlparse->{'attribute'}->{"$key"}->{'value'}; if (!defined($value)) { # Empty string comes from XML as an undef value. $xmlparse->{'attribute'}->{"$key"}->{'value'} = $value = ""; } if ($debug) { print STDERR "User attribute: '$key' -> '$value'\n"; } $errs_ref->{$key} = "Unknown attribute" if (!exists($fields_ref->{$key})); my ($dbslot, $required, $default) = @{$fields_ref->{$key}}; if ($required & $SLOT_REQUIRED) { # A slot that must be provided, so do not allow a null value. if (!defined($value)) { $errs_ref->{$key} = "Must provide a non-null value"; next; } } if ($required & $SLOT_OPTIONAL) { # Optional slot. If value is null skip it. Might not be the correct # thing to do all the time? if (!defined($value)) { next if (!defined($default)); $value = $default; } } if ($required & $SLOT_ADMINONLY) { # Admin implies optional, but thats probably not correct approach. $errs_ref->{$key} = "Administrators only" if (! $this_user->IsAdmin()); } # Now check that the value is legal. if (! TBcheck_dbslot($value, $table_name, $dbslot, TBDB_CHECKDBSLOT_ERROR)) { $errs_ref->{$key} = TBFieldErrorString(); next; } $args_ref->{$key} = $value; } } sub fatal($) { my($mesg) = $_[0]; die("*** $0:\n". " $mesg\n"); }