1 package OpenILS::Utils::RemoteAccount;
3 # use OpenSRF::Utils::SettingsClient;
4 use OpenSRF::Utils::Logger qw/:logger/;
8 use Net::SSH2; # because uFTP doesn't handle SSH keys (yet?)
12 use Text::Glob qw( match_glob glob_to_regex );
15 $Data::Dumper::Indent = 0;
27 accound_object => undef,
30 remote_password => undef,
31 remote_account => undef,
33 remote_path => undef, # not really doing anything with this... yet.
34 ssh_privatekey => undef,
35 ssh_publickey => undef,
49 The Remote Account module attempts to transfer a file to/from a remote server.
50 Net::uFTP is used, encapsulating the available options of SCP, FTP and SFTP.
52 All information is expected to be gathered by the Event Definition through event parameters:
53 ~ remote_host (required)
59 ~ type (FTP, SFTP or SCP -- default FTP)
63 The latter three are optionally passed to the Net::uFTP constructor.
65 Note: none of the parameters are actually required, except remote_host.
66 That is because remote_user, remote_password and remote_account can all be
67 extrapolated from other sources, as the Net::FTP docs describe:
69 If no arguments are given then Net::FTP uses the Net::Netrc package
70 to lookup the login information for the connected host.
72 If no information is found then a login of anonymous is used.
74 If no password is given and the login is anonymous then anonymous@
75 will be used for password.
77 Note that specifying a password will require you to specify a user.
78 Similarly, specifying an account requires both user and password.
79 That is, there are no assumed defaults when the latter arguments are used.
83 The use of ssh keys is preferred.
85 We attempt to use SSH keys where they are specified or otherwise found
86 in the runtime environment. If only one key is specified, we attempt to derive
87 the corresponding filename based on the ssh-keygen defaults. If either key is
88 specified, but both are not found (and readable) then the result is failure. If
89 no key is specified, but keys are found, the key-based connections will be attempted,
90 but failure will be non-fatal.
95 # returns plausible locations of a .ssh subdir where SSH keys might be stashed
96 # NOTE: these would need to be properly genericized w/ Makefule vars
97 # in order to support Debian packaging and multiple EG's on one box.
98 # Until that happens, we just rely on $HOME
101 # '/openils/conf', # __EG_CONFIG_DIR__
103 ($ENV{HOME}) and unshift @bases, $ENV{HOME};
105 return grep {-d $_} map {"$_/.ssh"} @bases;
109 # populates %keyfiles hash
110 # %keyfiles maps SSH_PRIVATEKEY => SSH_PUBLICKEY
112 my $force = (@_ ? shift : 0);
113 return %keyfiles if (%keyfiles and not $force); # caching
114 $logger->info("Checking for SSH keyfiles" . ($force ? ' (ignoring cache)' : ''));
115 %keyfiles = (); # reset to empty
116 my @dirs = plausible_dirs();
117 $logger->debug(scalar(@dirs) . " plausible dirs: " . join(', ', @dirs));
118 foreach my $dir (@dirs) {
119 foreach my $key (qw/rsa dsa/) {
120 my $private = "$dir/id_$key";
121 my $public = "$dir/id_$key.pub";
122 unless (-r $private) {
123 $logger->debug("Key '$private' cannot be read: $!");
126 unless (-r $public) {
127 $logger->debug("Key '$public' cannot be read: $!");
130 $keyfiles{$private} = $public;
139 if ($self->ssh_publickey and not $self->ssh_privatekey) {
140 my $private = $self->ssh_publickey;
141 unless ($private and $private =~ s/\.pub$// and -r $self->ssh_privatekey) { # try to guess missing private key name
142 $logger->error("No ssh_privatekey specified or found to pair with " . $self->ssh_publickey);
145 $self->ssh_privatekey($private);
147 if ($self->ssh_privatekey and not $self->ssh_publickey) {
148 my $pub = $self->ssh_privatekey . '.pub'; # guess missing public key name
150 $logger->error("No ssh_publickey specified or found to pair with " . $self->ssh_privatekey);
153 $self->ssh_publickey($pub);
156 # so now, we have either both ssh_p*keys params or neither
157 foreach (qw/ssh_publickey ssh_privatekey/) {
158 unless (-r $self->{$_}) {
159 $logger->error("$_ '" . $self->{$_} . "' cannot be read: $!");
160 return; # quit w/ error if we fail on any user-specified key
163 $keys{$self->ssh_privatekey} = $self->ssh_publickey;
169 my $text = shift || $self->content || '';
170 my $tmp = File::Temp->new(); # magical self-destructing tempfile
171 # print $tmp "THIS IS TEXT\n";
172 print $tmp $text or $logger->error($self->_error("could not write to tempfile '$tmp'"));
174 $self->tempfile($tmp); # save the object
175 $self->local_file($tmp->filename); # save the filename
176 $logger->info(_pkg("using tempfile $tmp"));
177 return $self->local_file; # return the filename
184 unless (defined $self->content or $self->local_file) { # content can be emptystring
185 $logger->error($self->_error("No content or local_file specified -- nothing to send"));
189 # tricky subtlety: we want to use the most recently specified options
190 # with priority order: filename, content, old filename, old content.
192 # The $params->{x} will already match $self->x after the secondary init,
193 # so the checks using $params below are for whether the value was specified NOW (e.g. via put()) or not.
195 # if we got a new local_file value, we use it
196 # else if the content is new to this call, build a new tempfile w/ it,
197 # else use existing local_file,
198 # else build new tempfile w/ content already specified via new()
200 return $params->{local_file} || (
201 (defined $params->{content}) ?
202 $self->new_tempfile($self->content) : # $self->content is same value as $params->{content}
203 ($self->local_file || $self->new_tempfile($self->content))
211 return if ($params->{type} and $params->{type} eq 'FTP'); # Forget it, user specified regular FTP
212 return if ( $self->type and $self->type eq 'FTP'); # Forget it, user specified regular FTP
214 if ($self->ssh_publickey || $self->ssh_privatekey) {
216 return $self->param_keys(); # we got one or both params, but they didn't pan out
218 return local_keyfiles(); # optional "force" arg could be used here to empty cache
223 # TODO: delete for both uFTP and SSH2
224 # TODO: handle IO::Scalar and IO::File for uFTP
230 $params = {remote_file => $params} ;
233 $self->init($params); # secondary init
235 $self->{get_args} = [$self->remote_file]; # same for scp_put and uFTP put
236 push @{$self->{get_args}}, $self->local_file if defined $self->local_file;
238 # $self->content($content);
240 my %keys = $self->key_check($params);
242 my $try = $self->get_ssh2(\%keys, @{$self->{get_args}});
243 return $try if $try; # if we had keys and they worked, we're done
246 # Otherwise, try w/ non-key uFTP methods
247 return $self->get_uftp(@{$self->{get_args}});
254 $params = {local_file => $params} ;
257 $self->init($params); # secondary init
259 my $local_file = $self->outbound_file($params) or return;
261 $self->{put_args} = [$local_file]; # same for scp_put and uFTP put
262 if (defined $self->remote_path and not defined $self->remote_file) {
263 $self->remote_file($self->remote_path . '/' . basename($local_file)); # if we know just the dir
265 if (defined $self->remote_file) {
266 push @{$self->{put_args}}, $self->remote_file; # user can specify remote_file name, optionally
269 my %keys = $self->key_check($params);
271 $self->put_ssh2(\%keys, @{$self->{put_args}}) and return $self->remote_file;
272 # if we had keys and they worked, we're done
275 # Otherwise, try w/ non-key uFTP methods
276 return $self->put_uftp(@{$self->{put_args}});
284 unshift @targets, ($params || '.'); # If it was just a string, it's the first target, else default pwd
285 delete $self->{remote_file}; # overriding any target in the object previously.
286 $params = {}; # make params a normal hashref again
288 if ($params->{remote_file} and @_) {
289 $logger->warn($self->_error("Ignoring ls parameter remote_file for subsequent args"));
290 delete $params->{remote_file};
292 $self->init($params); # secondary init
293 $self->remote_file and (! @targets) and push @targets, $self->remote_file; # if remote_file is there, and there's nothing else, use it
294 delete $self->{remote_file};
297 $self->{ls_args} = \@targets;
299 my %keys = $self->key_check($params);
301 # $logger->info("*** calling ls_ssh2(keys, '" . join("', '", (scalar(@targets) ? map {defined $_ ? $_ : '' } @targets : ())) . "') with ssh keys");
302 my @try = $self->ls_ssh2(\%keys, @targets);
303 return @try if @try; # if we had keys and they worked, we're done
306 # Otherwise, try w/ non-key uFTP methods
307 return $self->ls_uftp(@targets);
310 # Checks if the filename part of a pathname has one or more glob characters
311 # We split out the filename portion of the path
312 # Detect glob or no glob.
313 # return: regex for matching filenames
316 my $path = shift or return;
317 my ($vol, $dir, $file) = File::Spec->splitpath($path); # we don't care about attempted globs in mid-filepath
318 $file =~ /\*/ and return (File::Spec->catdir($vol, $dir), glob_to_regex($file));
319 $file =~ /\?/ and return (File::Spec->catdir($vol, $dir), glob_to_regex($file));
320 $logger->debug("No glob detected in '$path'");
329 $self->{ssh2} and return $self->{ssh2}; # caching
332 my $ssh2 = Net::SSH2->new();
333 unless($ssh2->connect($self->remote_host)) {
334 $logger->warn($self->error("SSH2 connect FAILED: $! " . join(" ", $ssh2->error)));
335 return; # we cannot connect
339 my @privates = keys %$keys;
340 my $count = scalar @privates;
341 foreach (@privates) {
342 if ($self->auth_ssh2($ssh2, $self->auth_ssh2_args($_, $keys->{$_}))) {
348 $logger->error($self->error("All ($count) keypair(s) FAILED for " . $self->remote_host));
351 return $self->{ssh2} = $ssh2;
360 my $host = $auth_args{hostname} || 'UNKNOWN';
361 my $key = $auth_args{privatekey} || 'UNKNOWN';
362 my $msg = "ssh2->auth by keypair for $host using $key";
363 if ($ssh2->auth(%auth_args)) {
364 $logger->info("Successful $msg");
368 if ($self->specific) {
369 $logger->error($self->error("Aborting. FAILED $msg: " . ($ssh2->error || '')));
371 $logger->warn($self->error("Unsuccessful keypair: FAILED $msg: " . ($ssh2->error || '')));
381 rank => [qw/ publickey hostbased password /],
383 $self->remote_user and $auth_args{username} = $self->remote_user ;
384 $self->remote_password and $auth_args{password} = $self->remote_password;
385 $self->remote_host and $auth_args{hostname} = $self->remote_host ;
391 my $keys = shift; # could have many keypairs here
393 $logger->error($self->_error("put_ssh2 called without target: nothing to put!"));
397 $logger->info("*** attempting put (" . join(", ", @_) . ") with ssh keys");
398 my $ssh2 = $self->_ssh2($keys) or return;
400 if ($res = $ssh2->scp_put( @_ )) {
401 $logger->info(_pkg("successfully sent", $self->remote_host, join(' --> ', @_ )));
404 $logger->error($self->_error(sprintf "put with keys to %s failed with error: $!", $self->remote_host));
410 my $keys = shift; # could have many keypairs here
412 $logger->error($self->_error("get_ssh2 called without target: nothing to get!"));
416 $logger->info("*** get args: " . Dumper(\@_));
417 $logger->info("*** attempting get (" . join(", ", map {$_ =~ /\S/ ? $_ : '*Object'} map {defined($_) ? $_ : '*Object'} @_) . ") with ssh keys");
418 my $ssh2 = $self->_ssh2($keys) or return;
420 if ($res = $ssh2->scp_get( @_ )) {
421 $logger->info(_pkg("successfully got", $self->remote_host, join(' --> ', @_ )));
424 $logger->error($self->_error(sprintf "get with keys from %s failed with error: $!", $self->remote_host));
430 my @list = $self->ls_ssh2_full(@_);
431 @list and return sort map {$_->{slash_path}} @list;
432 # @list and return sort grep {$_->{name} !~ /./ and {$_->{name} !~ /./ } map {$_->{slash_path}} @list;
437 my $keys = shift; # could have many keypairs here
438 my @targets = grep {defined} @_;
440 $logger->info("*** attempting ls ('" . join("', '", @targets) . "') with ssh keys");
441 my $ssh2 = $self->_ssh2($keys) or return;
442 my $sftp = $ssh2->sftp or return;
445 foreach my $target (@targets) {
447 my ($dirpath, $regex) = $self->glob_parse($target);
448 $dir = $sftp->opendir($dirpath || $target); # Try to open it like a directory
450 $file = $sftp->stat($target); # Otherwise, check it like a file
452 $file->{slash_path} = $self->_slash_path($target, $file->{name}); # it was a file, not a dir. That's OK.
455 $logger->warn($self->_error("sftp->opendir($target) failed: " . $sftp->error));
460 while ($file = $dir->read()) {
461 $file->{slash_path} = $self->_slash_path($target, $file->{name});
465 my $count = scalar(@pool);
466 @pool = grep {$_->{name} =~ /$regex/} @pool;
467 $logger->info("Glob regex($regex) matches " . scalar(@pool) . " of $count files");
475 sub _slash_path { # not OO
477 my $dir = shift || '.';
478 my $file = shift || '';
479 return $dir . ($dir =~ /\/$/ ? '' : '/') . $file;
485 $self->{uftp} and return $self->{uftp}; # caching
486 foreach (qw/debug type port/) {
487 $options{$_} = $self->{$_} if $self->{$_};
490 my $ftp = Net::uFTP->new($self->remote_host, %options);
492 $logger->error($self->_error('Net::uFTP->new("' . $self->remote_host . ", ...) FAILED: $@"));
497 foreach (qw/remote_user remote_password remote_account/) {
499 push @login_args, $self->{$_};
502 eval { $login_ok = $ftp->login(@login_args) };
503 if ($@ or !$login_ok) {
504 $logger->error($self->_error("failed login to", $self->remote_host, "w/ args(" . join(',', @login_args) . ") : $@"));
507 return $self->{uftp} = $ftp;
512 my $ftp = $self->_uftp or return;
514 eval { $filename = $ftp->put(@{$self->{put_args}}) };
515 if ($@ or ! $filename) {
516 $logger->error($self->_error("put to", $self->remote_host, "failed with error: $@"));
519 $self->remote_file($filename);
520 $logger->info(_pkg("successfully sent", $self->remote_host, $self->local_file, '-->', $filename));
526 my $ftp = $self->_uftp or return;
528 eval { $filename = $ftp->get(@{$self->{get_args}}) };
529 if ($@ or ! $filename) {
530 $logger->error($self->_error("get from", $self->remote_host, "failed with error: $@"));
533 $self->local_file($filename);
534 $logger->info(_pkg("successfully retrieved $filename <--", $self->remote_host . '/' . $self->remote_file));
535 return $self->local_file;
540 my $ftp = $self->_uftp or return;
544 my ($dirpath, $regex) = $self->glob_parse($_);
545 eval { @part = $ftp->ls($dirpath || $_) };
547 $logger->error($self->_error("ls from", $self->remote_host, "failed with error: $@"));
551 my $count = scalar(@part);
552 @part = grep {/$regex/} @part;
553 $logger->info("Glob regex($regex) matches " . scalar(@part) . " of $count files");
562 my $ftp = $self->_uftp or return;
563 return $ftp->delete(shift);
567 return __PACKAGE__ . ' : ' unless @_;
568 return __PACKAGE__ . ' : ' . join(' ', @_);
573 return _pkg($self->error(join(' ',@_)));
579 my @required = @_; # qw(remote_host) ; # nothing required now
581 if ($params->{account_object}) { # if we got passed an object, we initialize off that first
582 $self->{remote_host } = $params->{account_object}->host;
583 $self->{remote_user } = $params->{account_object}->username;
584 $self->{remote_password} = $params->{account_object}->password;
585 $self->{remote_account } = $params->{account_object}->account;
586 $self->{remote_path } = $params->{account_object}->path; # not really the same as remote_file, maybe expand on this later
589 foreach (keys %{$self->{_permitted}}) {
590 $self->{$_} = $params->{$_} if defined $params->{$_}; # possibly override settings from object
593 foreach (@required) {
594 unless ($self->{$_}) {
595 $logger->error("Required parameter $_ not specified");
603 my ($class, %args) = @_;
604 my $self = { _permitted => \%fields, %fields };
608 $self->init(\%args); # or croak "Initialization error caused by bad args";
613 # in order to create, we must first ...
615 $self->{ssh2} and $self->{ssh2}->disconnect(); # let the other end know we're done.
616 $self->{uftp} and $self->{uftp}->quit(); # let the other end know we're done.
621 my $class = ref($self) or croak "AUTOLOAD error: $self is not an object";
622 my $name = $AUTOLOAD;
624 $name =~ s/.*://; # strip leading package stuff
626 unless (exists $self->{_permitted}->{$name}) {
627 croak "Cannot access '$name' field of class '$class'";
631 return $self->{$name} = shift;
633 return $self->{$name};