1 package OpenILS::Utils::RemoteAccount;
3 # use OpenSRF::Utils::SettingsClient;
4 use OpenSRF::Utils::Logger qw/:logger/;
10 use Net::SFTP::Foreign;
14 use Text::Glob qw( match_glob glob_to_regex );
17 $Data::Dumper::Indent = 0;
29 account_object => undef,
32 remote_password => undef,
33 remote_account => undef,
35 remote_path => undef, # not really doing anything with this... yet.
36 ssh_privatekey => undef,
37 ssh_publickey => undef,
52 OpenILS::Utils::RemoteAccount - Encapsulate FTP, SFTP and SSH file transactions for Evergreen
56 The Remote Account module attempts to transfer a file to/from a remote server.
57 Net::FTP, Net::SSH2 or Net::SFTP::Foreign is used.
61 All information is expected to be supplied by the caller via parameters:
62 ~ remote_host (required)
68 ~ type (FTP, SFTP or SCP -- default FTP)
72 Note: none of the parameters are actually required, except remote_host.
73 That is because remote_user, remote_password and remote_account can all be
74 extrapolated from other sources, as the Net::FTP docs describe:
76 If no arguments are given then Net::FTP uses the Net::Netrc package
77 to lookup the login information for the connected host.
79 If no information is found then a login of anonymous is used.
81 If no password is given and the login is anonymous then anonymous@
82 will be used for password.
84 Note that specifying a password will require you to specify a user.
85 Similarly, specifying an account requires both user and password.
86 That is, there are no assumed defaults when the latter arguments are used.
90 The use of ssh keys is preferred. Explicit specification of connection type will prevent
91 multiple attempts to the same server. Therefore, using the type parameter is also recommended.
93 If the type is not explicit, we attempt to use SSH keys where they are specified or otherwise found
94 in the runtime environment. If only one key is specified, we attempt to derive
95 the corresponding filename based on the ssh-keygen defaults. If either key is
96 specified, but both are not found (and readable) then the result is failure. If
97 no key or type is specified, but keys are found, the key-based connections will be attempted,
98 but failure will be non-fatal.
103 # returns plausible locations of a .ssh subdir where SSH keys might be stashed
104 # NOTE: these would need to be properly genericized w/ Makefile vars
105 # in order to support Debian packaging and multiple EG's on one box.
106 # Until that happens, we just rely on $HOME
109 # '/openils/conf', # __EG_CONFIG_DIR__
111 ($ENV{HOME}) and unshift @bases, $ENV{HOME};
113 return grep {-d $_} map {"$_/.ssh"} @bases;
117 # populates %keyfiles hash
118 # %keyfiles maps SSH_PRIVATEKEY => SSH_PUBLICKEY
120 my $force = (@_ ? shift : 0);
121 return %keyfiles if (%keyfiles and not $force); # caching
122 $logger->info("Checking for SSH keyfiles" . ($force ? ' (ignoring cache)' : ''));
123 %keyfiles = (); # reset to empty
124 my @dirs = plausible_dirs();
125 $logger->debug(scalar(@dirs) . " plausible dirs: " . join(', ', @dirs));
126 foreach my $dir (@dirs) {
127 foreach my $key (qw/rsa dsa/) {
128 my $private = "$dir/id_$key";
129 my $public = "$dir/id_$key.pub";
130 unless (-r $private) {
131 $logger->debug("Key '$private' cannot be read: $!");
134 unless (-r $public) {
135 $logger->debug("Key '$public' cannot be read: $!");
138 $keyfiles{$private} = $public;
147 if ($self->ssh_publickey and not $self->ssh_privatekey) {
148 my $private = $self->ssh_publickey;
149 unless ($private and $private =~ s/\.pub$// and -r $self->ssh_privatekey) { # try to guess missing private key name
150 $logger->error("No ssh_privatekey specified or found to pair with " . $self->ssh_publickey);
153 $self->ssh_privatekey($private);
155 if ($self->ssh_privatekey and not $self->ssh_publickey) {
156 my $pub = $self->ssh_privatekey . '.pub'; # guess missing public key name
158 $logger->error("No ssh_publickey specified or found to pair with " . $self->ssh_privatekey);
161 $self->ssh_publickey($pub);
164 # so now, we have either both ssh_p*keys params or neither
165 foreach (qw/ssh_publickey ssh_privatekey/) {
166 unless (-r $self->{$_}) {
167 $logger->error("$_ '" . $self->{$_} . "' cannot be read: $!");
168 return; # quit w/ error if we fail on any user-specified key
171 $keys{$self->ssh_privatekey} = $self->ssh_publickey;
177 my $text = shift || $self->content || '';
178 my $tmp = File::Temp->new(); # magical self-destructing tempfile
179 # print $tmp "THIS IS TEXT\n";
180 print $tmp $text or $logger->error($self->_error("could not write to tempfile '$tmp'"));
182 $self->tempfile($tmp); # save the object
183 $self->local_file($tmp->filename); # save the filename
184 $logger->info(_pkg("using tempfile $tmp"));
185 return $self->local_file; # return the filename
192 unless (defined $self->content or $self->local_file) { # content can be emptystring
193 $logger->error($self->_error("No content or local_file specified -- nothing to send"));
197 # tricky subtlety: we want to use the most recently specified options
198 # with priority order: filename, content, old filename, old content.
200 # The $params->{x} will already match $self->x after the secondary init,
201 # so the checks using $params below are for whether the value was specified NOW (e.g. via put()) or not.
203 # if we got a new local_file value, we use it
204 # else if the content is new to this call, build a new tempfile w/ it,
205 # else use existing local_file,
206 # else build new tempfile w/ content already specified via new()
208 return $params->{local_file} || (
209 (defined $params->{content}) ?
210 $self->new_tempfile($self->content) : # $self->content is same value as $params->{content}
211 ($self->local_file || $self->new_tempfile($self->content))
219 return if ($params->{type} and $params->{type} eq 'FTP'); # Forget it, user specified regular FTP
220 return if ( $self->type and $self->type eq 'FTP'); # Forget it, user specified regular FTP
222 if ($self->ssh_publickey || $self->ssh_privatekey) {
224 return $self->param_keys(); # we got one or both params, but they didn't pan out
226 return local_keyfiles(); # optional "force" arg could be used here to empty cache
236 $params = {remote_file => $params} ;
239 $self->init($params); # secondary init
241 $self->{get_args} = [$self->remote_file]; # same for scp_put and FTP put
242 push @{$self->{get_args}}, $self->local_file if defined $self->local_file;
244 # $self->content($content);
246 if ($self->type eq "FTP") {
247 return $self->get_ftp(@{$self->{get_args}});
248 } elsif ($self->type eq "SFTP") {
249 return $self->get_sftp(@{$self->{get_args}});
251 my %keys = $self->key_check($params);
252 return $self->get_ssh2(\%keys, @{$self->{get_args}});
260 $params = {local_file => $params} ;
263 $self->init($params); # secondary init
265 my $local_file = $self->outbound_file($params) or return;
267 $self->{put_args} = [$local_file]; # same for scp_put and FTP put
268 if (defined $self->remote_path and not defined $self->remote_file) {
269 my $rpath = $self->remote_path;
270 my $fname = basename($local_file);
271 if ($rpath =~ /^(.*)\*+(.*)$/) { # if the path has an asterisk in it, like './incoming/*.tst'
275 $logger->warn($self->_error("remote path '$rpath' has dir slashes AFTER an asterisk. Cannot determine target dir"));
278 if ($self->single_ext) {
279 $tail =~ /\./ and $fname =~ s/\./_/g; # if dot in tail, replace dots in fname (w/ _)
281 $self->remote_file($head . $fname . $tail);
283 $self->remote_file($rpath . '/' . $fname); # if we know just the dir
287 if (defined $self->remote_file) {
288 push @{$self->{put_args}}, $self->remote_file; # user can specify remote_file name, optionally
291 if ($self->type eq "FTP") {
292 return $self->put_ftp(@{$self->{put_args}});
293 } elsif ($self->type eq "SFTP") {
294 return $self->put_sftp(@{$self->{put_args}});
296 my %keys = $self->key_check($params);
297 $self->put_ssh2(\%keys, @{$self->{put_args}}) and return $self->remote_file;
306 unshift @targets, ($params || '.'); # If it was just a string, it's the first target, else default pwd
307 delete $self->{remote_file}; # overriding any target in the object previously.
308 $params = {}; # make params a normal hashref again
310 if ($params->{remote_file} and @_) {
311 $logger->warn($self->_error("Ignoring ls parameter remote_file for subsequent args"));
312 delete $params->{remote_file};
314 $self->init($params); # secondary init
315 $self->remote_file and (! @targets) and push @targets, $self->remote_file; # if remote_file is there, and there's nothing else, use it
316 delete $self->{remote_file};
319 $self->{ls_args} = \@targets;
321 if ($self->type eq "FTP") {
322 return $self->ls_ftp(@targets);
323 } elsif ($self->type eq "SFTP") {
324 return $self->ls_sftp(@targets);
326 my %keys = $self->key_check($params);
327 # $logger->info("*** calling ls_ssh2(keys, '" . join("', '", (scalar(@targets) ? map {defined $_ ? $_ : '' } @targets : ())) . "') with ssh keys");
328 return $self->ls_ssh2(\%keys, @targets);
336 $params = {remote_file => $params} unless ref $params;
337 $self->init($params); # secondary init
339 my $file = $params->{remote_file};
342 $logger->warn("No file specified for deletion");
346 $logger->info("Deleting remote file '$file'");
348 if ($self->type eq "FTP") {
349 return $self->delete_ftp($file);
350 } elsif ($self->type eq "SFTP") {
351 return $self->delete_sftp($file);
353 my %keys = $self->key_check($params);
354 return $self->delete_ssh2(\%keys, $file);
359 # Checks if the filename part of a pathname has one or more glob characters
360 # We split out the filename portion of the path
361 # Detect glob or no glob.
362 # returns: directory, regex for matching filenames
365 my $path = shift or return;
366 my ($vol, $dir, $file) = File::Spec->splitpath($path); # we don't care about attempted globs in mid-filepath
367 my $front = $vol ? File::Spec->catdir($vol, $dir) : $dir;
368 $file =~ /\*/ and return ($front, glob_to_regex($file));
369 $file =~ /\?/ and return ($front, glob_to_regex($file));
370 $logger->debug("No glob detected in '$path'");
379 $self->{sftp} and return $self->{sftp}; # caching
380 my $sftp = Net::SFTP::Foreign->new($self->remote_host, user => $self->remote_user, password => $self->remote_password,
381 more => [-o => "StrictHostKeyChecking=no"]);
382 $sftp->error and $logger->error("SFTP connect FAILED: " . $sftp->error);
383 return $self->{sftp} = $sftp;
388 my $filename = $self->_sftp->put(@{$self->{put_args}});
389 if ($self->_sftp->error or not $filename) {
392 "SFTP put to", $self->remote_host, "failed with error: $self->_sftp->error"
398 $self->remote_file($filename);
401 "successfully sent", $self->remote_host, $self->local_file, "-->",
410 my $remote_filename = $self->{get_args}->[0];
411 my $filename = $self->{get_args}->[1];
412 my $success = $self->_sftp->get(@{$self->{get_args}});
413 if ($self->_sftp->error or not $success) {
416 "get from", $self->remote_host, "failed with error: $self->_sftp->error"
422 $self->local_file($filename);
425 "successfully retrieved $filename <--", $self->remote_host . '/' .
429 return $self->local_file;
433 #$sftp->ls($path) or die 'could not ls: ' . $sftp->error;
434 sub ls_sftp { # returns full path like: dir/path/file.ext
439 my ($dirpath, $regex) = $self->glob_parse($_);
440 my $dirtarget = $dirpath || $_;
441 $dirtarget =~ s/\/+$//;
442 my @part = @{$self->_sftp->ls($dirtarget, names_only=>1, no_wanted => qr/^\.+$/)};
443 if ($self->_sftp->error) {
446 "ls from", $self->remote_host, "failed with error: " . $self->_sftp->error
451 if ($dirtarget and $dirtarget ne '.' and $dirtarget ne './') {
452 foreach my $file (@part) { # we ensure full(er) path
453 $file =~ /^$dirtarget\// and next;
454 $logger->debug("ls_sftp: prepending $dirtarget/ to $file");
455 $file = File::Spec->catdir($dirtarget, $file);
459 my $count = scalar(@part);
460 # @part = grep {my @a = split('/',$_); scalar(@a) ? /$regex/ : ($a[-1] =~ /$regex/)} @part;
463 my ($vol, $dir, $file) = File::Spec->splitpath($_);
466 $logger->info("FTP ls: Glob regex($regex) matches " . scalar(@part) . " of $count files");
467 } # else {$logger->info("FTP ls: No Glob regex in '$_'. Just a regular ls");}
474 #$sftp->remove($putfile) or die "could not remove $putfile: " . $sftp->error;
480 $self->{ssh2} and return $self->{ssh2}; # caching
483 my $ssh2 = Net::SSH2->new();
484 unless($ssh2->connect($self->remote_host)) {
485 $logger->warn($self->error("SSH2 connect FAILED: $! " . join(" ", $ssh2->error)));
486 return; # we cannot connect
490 my @privates = keys %$keys;
491 my $count = scalar @privates;
494 foreach (@privates) {
495 if ($self->auth_ssh2($ssh2,$self->auth_ssh2_args($_,$keys->{$_}))) {
503 "All ($count) keypair(s) FAILED for " . $self->remote_host
510 $self->error("Login FAILED for " . $self->remote_host)
511 ) unless $self->auth_ssh2($ssh2, $self->auth_ssh2_args);
513 return $self->{ssh2} = $ssh2;
522 my $host = $auth_args{hostname} || 'UNKNOWN';
523 my $key = $auth_args{privatekey} || 'UNKNOWN';
524 my $msg = "ssh2->auth by keypair for $host using $key";
525 if ($ssh2->auth(%auth_args)) {
526 $logger->info("Successful $msg");
530 if ($self->specific) {
531 $logger->error($self->error("Aborting. FAILED $msg: " . ($ssh2->error || '')));
533 $logger->warn($self->error("Unsuccessful keypair: FAILED $msg: " . ($ssh2->error || '')));
543 rank => [qw/ publickey hostbased password /],
545 $self->remote_user and $auth_args{username} = $self->remote_user ;
546 $self->remote_password and $auth_args{password} = $self->remote_password;
547 $self->remote_host and $auth_args{hostname} = $self->remote_host ;
553 my $keys = shift; # could have many keypairs here
555 $logger->error($self->_error("put_ssh2 called without target: nothing to put!"));
559 $logger->info("*** attempting put (" . join(", ", @_) . ") with ssh keys");
560 my $ssh2 = $self->_ssh2($keys) or return;
562 if ($res = $ssh2->scp_put( @_ )) {
563 $logger->info(_pkg("successfully sent", $self->remote_host, join(' --> ', @_ )));
566 $logger->error($self->_error(sprintf "put with keys to %s failed with error: $!", $self->remote_host));
572 my $keys = shift; # could have many keypairs here
574 $logger->error($self->_error("get_ssh2 called without target: nothing to get!"));
578 $logger->info("*** get args: " . Dumper(\@_));
579 $logger->info("*** attempting get (" . join(", ", map {$_ =~ /\S/ ? $_ : '*Object'} map {defined($_) ? $_ : '*Object'} @_) . ") with ssh keys");
580 my $ssh2 = $self->_ssh2($keys) or return;
582 if ($res = $ssh2->scp_get( @_ )) {
583 $logger->info(_pkg("successfully got", $self->remote_host, join(' --> ', @_ )));
586 $logger->error($self->_error(sprintf "get with keys from %s failed with error: $!", $self->remote_host));
592 my @list = $self->ls_ssh2_full(@_);
593 @list and return sort map {$_->{slash_path}} @list;
594 # @list and return sort grep {$_->{name} !~ /./ and {$_->{name} !~ /./ } map {$_->{slash_path}} @list;
599 my $keys = shift; # could have many keypairs here
600 my @targets = grep {defined} @_;
602 $logger->info("*** attempting ls ('" . join("', '", @targets) . "') with ssh keys");
603 my $ssh2 = $self->_ssh2($keys) or return;
604 my $sftp = $ssh2->sftp or return;
607 foreach my $target (@targets) {
609 my ($dirpath, $regex) = $self->glob_parse($target);
610 $dir = $sftp->opendir($dirpath || $target); # Try to open it like a directory
612 $file = $sftp->stat($target); # Otherwise, check it like a file
614 $file->{slash_path} = $self->_slash_path($target, $file->{name}); # it was a file, not a dir. That's OK.
617 $logger->warn($self->_error("sftp->opendir($target) failed: " . $sftp->error));
622 while ($file = $dir->read()) {
623 $file->{slash_path} = $self->_slash_path($target, $file->{name});
627 my $count = scalar(@pool);
628 @pool = grep {$_->{name} =~ /$regex/} @pool;
629 $logger->info("SSH ls: Glob regex($regex) matches " . scalar(@pool) . " of $count files");
630 } # else { $logger->info("SSH ls: No Glob regex in '$target'. Just a regular ls"); }
641 my $sftp = $self->_ssh2($keys)->sftp;
642 return $sftp->unlink($file);
647 my $dir = shift || '.';
648 my $file = shift || '';
649 my ($dirpath, $regex) = $self->glob_parse($dir);
650 $dir = $dirpath if $dirpath;
651 return $dir . ($dir =~ /\/$/ ? '' : '/') . $file;
657 $self->{ftp} and return $self->{ftp}; # caching
658 foreach (qw/debug port/) {
659 $options{ucfirst($_)} = $self->{$_} if $self->{$_};
662 my $ftp = new Net::FTP($self->remote_host, %options);
666 "new Net::FTP('" . $self->remote_host . ", ...) FAILED: $@"
673 foreach (qw/remote_user remote_password remote_account/) {
675 push @login_args, $self->{$_};
678 eval { $login_ok = $ftp->login(@login_args) };
679 if ($@ or !$login_ok) {
682 "failed login to", $self->remote_host, "w/ args(" .
683 join(',', @login_args) . ") : $@"
685 ); # XXX later, maybe keep passwords out of the logs?
688 return $self->{ftp} = $ftp;
695 eval { $filename = $self->_ftp->put(@{$self->{put_args}}) };
696 if ($@ or not $filename) {
699 "put to", $self->remote_host, "failed with error: $@"
705 $self->remote_file($filename);
708 "successfully sent", $self->remote_host, $self->local_file, '-->',
719 my $remote_filename = $self->{get_args}->[0];
720 eval { $filename = $self->_ftp->get(@{$self->{get_args}}) };
721 if ($@ or not $filename) {
724 "get from", $self->remote_host, "failed with error: $@"
729 if (!defined(${$filename->sref})) {
730 # the underlying scalar is still undef, so Net::FTP must have
731 # successfully retrieved an empty file... which we should skip
734 "get $remote_filename from", $self->remote_host, ": remote file is zero-length"
740 $self->local_file($filename);
743 "successfully retrieved $filename <--", $self->remote_host . '/' .
747 return $self->local_file;
750 sub ls_ftp { # returns full path like: dir/path/file.ext
756 my ($dirpath, $regex) = $self->glob_parse($_);
757 my $dirtarget = $dirpath || $_;
758 $dirtarget =~ s/\/+$//;
759 eval { @part = $self->_ftp->ls($dirtarget) }; # this ls returns relative/path/filenames. defer filename glob filtering for below.
763 "ls from", $self->remote_host, "failed with error: $@"
768 if ($dirtarget and $dirtarget ne '.' and $dirtarget ne './' and
769 $self->_ftp->dir($dirtarget)) {
770 foreach my $file (@part) { # we ensure full(er) path
771 $file =~ /^$dirtarget\// and next;
772 $logger->debug("ls_ftp: prepending $dirtarget/ to $file");
773 $file = File::Spec->catdir($dirtarget, $file);
777 my $count = scalar(@part);
778 # @part = grep {my @a = split('/',$_); scalar(@a) ? /$regex/ : ($a[-1] =~ /$regex/)} @part;
781 my ($vol, $dir, $file) = File::Spec->splitpath($_);
784 $logger->info("FTP ls: Glob regex($regex) matches " . scalar(@part) . " of $count files");
785 } # else {$logger->info("FTP ls: No Glob regex in '$_'. Just a regular ls");}
794 return $self->_ftp->delete($file);
798 return __PACKAGE__ . ' : ' unless @_;
799 return __PACKAGE__ . ' : ' . join(' ', @_);
804 return _pkg($self->error(join(' ',@_)));
810 my @required = @_; # qw(remote_host) ; # nothing required now
812 if ($params->{account_object}) { # if we got passed an object, we initialize off that first
813 $self->{remote_host } = $params->{account_object}->host;
814 $self->{remote_user } = $params->{account_object}->username;
815 $self->{remote_password} = $params->{account_object}->password;
816 $self->{remote_account } = $params->{account_object}->account;
817 $self->{remote_path } = $params->{account_object}->path; # not really the same as remote_file, maybe expand on this later
820 foreach (keys %{$self->{_permitted}}) {
821 $self->{$_} = $params->{$_} if defined $params->{$_}; # possibly override settings from object
824 foreach (@required) {
825 unless ($self->{$_}) {
826 $logger->error("Required parameter $_ not specified");
834 my ($class, %args) = @_;
835 my $self = { _permitted => \%fields, %fields };
839 $self->init(\%args); # or croak "Initialization error caused by bad args";
844 # in order to create, we must first ...
846 # let the other end know we're done.
847 $self->{ssh2} and $self->{ssh2}->disconnect();
848 $self->{sftp} and $self->{sftp}->disconnect();
849 $self->{ftp} and $self->{ftp}->quit();
854 my $class = ref($self) or croak "AUTOLOAD error: $self is not an object";
855 my $name = $AUTOLOAD;
857 $name =~ s/.*://; # strip leading package stuff
859 unless (exists $self->{_permitted}->{$name}) {
860 croak "AUTOLOAD error: Cannot access '$name' field of class '$class'";
864 return $self->{$name} = shift;
866 return $self->{$name};