shellcommands.pm: move to Net::OpenSSH and add ignored_errors option, RT831
[freeside.git] / FS / FS / part_export / shellcommands.pm
1 package FS::part_export::shellcommands;
2
3 use vars qw(@ISA %info);
4 use Tie::IxHash;
5 use String::ShellQuote;
6 use FS::part_export;
7 use FS::Record qw( qsearch qsearchs );
8
9 @ISA = qw(FS::part_export);
10
11 tie my %options, 'Tie::IxHash',
12   'user' => { label=>'Remote username', default=>'root' },
13   'useradd' => { label=>'Insert command',
14                  default=>'useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username'
15                 #default=>'cp -pr /etc/skel $dir; chown -R $uid.$gid $dir'
16                },
17   'useradd_no_queue' => { label=>'Run immediately',
18                           type => 'checkbox',
19                         },
20   'useradd_stdin' => { label=>'Insert command STDIN',
21                        type =>'textarea',
22                        default=>'',
23                      },
24   'userdel' => { label=>'Delete command',
25                  default=>'userdel -r $username',
26                  #default=>'rm -rf $dir',
27                },
28   'userdel_no_queue' => { label=>'Run immediately',
29                           type =>'checkbox',
30                         },
31   'userdel_stdin' => { label=>'Delete command STDIN',
32                        type =>'textarea',
33                        default=>'',
34                      },
35   'usermod' => { label=>'Modify command',
36                  default=>'usermod -c $new_finger -d $new_dir -m -l $new_username -s $new_shell -u $new_uid -g $new_gid -p $new_crypt_password $old_username',
37                 #default=>'[ -d $old_dir ] && mv $old_dir $new_dir || ( '.
38                  #  'chmod u+t $old_dir; mkdir $new_dir; cd $old_dir; '.
39                  #  'find . -depth -print | cpio -pdm $new_dir; '.
40                  #  'chmod u-t $new_dir; chown -R $uid.$gid $new_dir; '.
41                  #  'rm -rf $old_dir'.
42                  #')'
43                },
44   'usermod_no_queue' => { label=>'Run immediately',
45                           type =>'checkbox',
46                         },
47   'usermod_stdin' => { label=>'Modify command STDIN',
48                        type =>'textarea',
49                        default=>'',
50                      },
51   'usermod_pwonly' => { label=>'Disallow username, domain, uid, gid, and dir changes', #and RADIUS group changes',
52                         type =>'checkbox',
53                       },
54   'usermod_nousername' => { label=>'Disallow just username changes',
55                             type =>'checkbox',
56                           },
57   'suspend' => { label=>'Suspension command',
58                  default=>'usermod -L $username',
59                },
60   'suspend_no_queue' => { label=>'Run immediately',
61                           type =>'checkbox',
62                         },
63   'suspend_stdin' => { label=>'Suspension command STDIN',
64                        default=>'',
65                      },
66   'unsuspend' => { label=>'Unsuspension command',
67                    default=>'usermod -U $username',
68                  },
69   'unsuspend_no_queue' => { label=>'Run immediately',
70                             type =>'checkbox',
71                           },
72   'unsuspend_stdin' => { label=>'Unsuspension command STDIN',
73                          default=>'',
74                        },
75   'crypt' => { label   => 'Default password encryption',
76                type=>'select', options=>[qw(crypt md5)],
77                default => 'crypt',
78              },
79   'groups_susp_reason' => { label =>
80                              'Radius group mapping to reason (via template user)',
81                             type  => 'textarea',
82                           },
83   'ignored_errors' => { label   => 'Regexes of errors to ignore, separated by newlines',
84                         type    => 'textarea'
85                       },
86 #  'no_queue' => { label => 'Run command immediately',
87 #                   type  => 'checkbox',
88 #                },
89 ;
90
91 %info = (
92   'svc'      => 'svc_acct',
93   'desc'     =>
94     'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
95   'options'  => \%options,
96   'nodomain' => 'Y',
97   'notes' => <<'END'
98 Run remote commands via SSH.  Usernames are considered unique (also see
99 shellcommands_withdomain).  You probably want this if the commands you are
100 running will not accept a domain as a parameter.  You will need to
101 <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Administration:SSH_Keys">setup SSH for unattended operation</a>.
102
103 <BR><BR>Use these buttons for some useful presets:
104 <UL>
105   <LI>
106     <INPUT TYPE="button" VALUE="Linux" onClick='
107       this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
108       this.form.useradd_stdin.value = "";
109       this.form.userdel.value = "userdel -r $username";
110       this.form.userdel_stdin.value="";
111       this.form.usermod.value = "usermod -c $new_finger -d $new_dir -m -l $new_username -s $new_shell -u $new_uid -g $new_gid -p $new_crypt_password $old_username";
112       this.form.usermod_stdin.value = "";
113       this.form.suspend.value = "usermod -L $username";
114       this.form.suspend_stdin.value="";
115       this.form.unsuspend.value = "usermod -U $username";
116       this.form.unsuspend_stdin.value="";
117     '>
118   <LI>
119     <INPUT TYPE="button" VALUE="FreeBSD before 4.10 / 5.3" onClick='
120       this.form.useradd.value = "lockf /etc/passwd.lock pw useradd $username -d $dir -m -s $shell -u $uid -c $finger -h 0";
121       this.form.useradd_stdin.value = "$_password\n";
122       this.form.userdel.value = "lockf /etc/passwd.lock pw userdel $username -r"; this.form.userdel_stdin.value="";
123       this.form.usermod.value = "lockf /etc/passwd.lock pw usermod $old_username -d $new_dir -m -l $new_username -s $new_shell -u $new_uid -g $new_gid -c $new_finger -h 0";
124       this.form.usermod_stdin.value = "$new__password\n"; this.form.suspend.value = "lockf /etc/passwd.lock pw lock $username";
125       this.form.suspend_stdin.value="";
126       this.form.unsuspend.value = "lockf /etc/passwd.lock pw unlock $username"; this.form.unsuspend_stdin.value="";
127     '>
128     Note: On FreeBSD versions before 5.3 and 4.10 (4.10 is after 4.9, not
129     4.1!), due to deficient locking in pw(1), you must disable the chpass(1),
130     chsh(1), chfn(1), passwd(1), and vipw(1) commands, or replace them with
131     wrappers that prepend "lockf /etc/passwd.lock".  Alternatively, apply the
132     patch in
133     <A HREF="http://www.freebsd.org/cgi/query-pr.cgi?pr=23501">FreeBSD PR#23501</A>
134     and use the "FreeBSD 4.10 / 5.3 or later" button below.
135   <LI>
136     <INPUT TYPE="button" VALUE="FreeBSD 4.10 / 5.3 or later" onClick='
137       this.form.useradd.value = "pw useradd $username -d $dir -m -s $shell -u $uid -g $gid -c $finger -h 0";
138       this.form.useradd_stdin.value = "$_password\n";
139       this.form.userdel.value = "pw userdel $username -r";
140       this.form.userdel_stdin.value="";
141       this.form.usermod.value = "pw usermod $old_username -d $new_dir -m -l $new_username -s $new_shell -u $new_uid -g $new_gid -c $new_finger -h 0";
142       this.form.usermod_stdin.value = "$new__password\n";
143       this.form.suspend.value = "pw lock $username";
144       this.form.suspend_stdin.value="";
145       this.form.unsuspend.value = "pw unlock $username";
146       this.form.unsuspend_stdin.value="";
147     '>
148   <LI>
149     <INPUT TYPE="button" VALUE="NetBSD/OpenBSD" onClick='
150       this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
151       this.form.useradd_stdin.value = "";
152       this.form.userdel.value = "userdel -r $username";
153       this.form.userdel_stdin.value="";
154       this.form.usermod.value = "usermod -c $new_finger -d $new_dir -m -l $new_username -s $new_shell -u $new_uid -g $new_gid -p $new_crypt_password $old_username";
155       this.form.usermod_stdin.value = "";
156       this.form.suspend.value = "";
157       this.form.suspend_stdin.value="";
158       this.form.unsuspend.value = "";
159       this.form.unsuspend_stdin.value="";
160     '>
161   <LI>
162     <INPUT TYPE="button" VALUE="Just maintain directories (use with sysvshell or bsdshell)" onClick='
163       this.form.useradd.value = "cp -pr /etc/skel $dir; chown -R $uid.$gid $dir"; this.form.useradd_stdin.value = "";
164       this.form.usermod.value = "[ -d $old_dir ] && mv $old_dir $new_dir || ( chmod u+t $old_dir; mkdir $new_dir; cd $old_dir; find . -depth -print | cpio -pdm $new_dir; chmod u-t $new_dir; chown -R $new_uid.$new_gid $new_dir; rm -rf $old_dir )";
165       this.form.usermod_stdin.value = "";
166       this.form.userdel.value = "rm -rf $dir";
167       this.form.userdel_stdin.value="";
168       this.form.suspend.value = "";
169       this.form.suspend_stdin.value="";
170       this.form.unsuspend.value = "";
171       this.form.unsuspend_stdin.value="";
172     '>
173 </UL>
174
175 The following variables are available for interpolation (prefixed with new_ or
176 old_ for replace operations):
177 <UL>
178   <LI><code>$username</code>
179   <LI><code>$_password</code>
180   <LI><code>$quoted_password</code> - unencrypted password, already quoted for the shell (do not add additional quotes).
181   <LI><code>$crypt_password</code> - encrypted password.  When used on the command line (rather than STDIN), it will be quoted for the shell already (do not add additional quotes).
182   <LI><code>$ldap_password</code> - Password in LDAP/RFC2307 format (for example, "{PLAIN}himom", "{CRYPT}94pAVyK/4oIBk" or "{MD5}5426824942db4253f87a1009fd5d2d4").  When used on the command line (rather than STDIN), it will be quoted for the shell already (do not add additional quotes).
183   <LI><code>$uid</code>
184   <LI><code>$gid</code>
185   <LI><code>$finger</code> - GECOS.  When used on the command line (rather than STDIN), it will be quoted for the shell already (do not add additional quotes).
186   <LI><code>$first</code> - First name of GECOS.  When used on the command line (rather than STDIN), it will be quoted for the shell already (do not add additional quotes).
187   <LI><code>$last</code> - Last name of GECOS.  When used on the command line (rather than STDIN), it will be quoted for the shell already (do not add additional quotes).
188   <LI><code>$dir</code> - home directory
189   <LI><code>$shell</code>
190   <LI><code>$quota</code>
191   <LI><code>@radius_groups</code>
192   <LI><code>$reasonnum (when suspending)</code>
193   <LI><code>$reasontext (when suspending)</code>
194   <LI><code>$reasontypenum (when suspending)</code>
195   <LI><code>$reasontypetext (when suspending)</code>
196   <LI><code>$pkgnum</code>
197   <LI><code>$custnum</code>
198   <LI>All other fields in <b>svc_acct</b> are also available.
199   <LI>The following fields from <b>cust_main</b> are also available (except during replace): company, address1, address2, city, state, zip, county, daytime, night, fax, otaker, agent_custid.  When used on the command line (rather than STDIN), they will be quoted for the shell already (do not add additional quotes).
200 </UL>
201 END
202 );
203
204 sub _groups_susp_reason_map { shift->_map('groups_susp_reason'); }
205
206 sub _map {
207   my $self = shift;
208   map { reverse(/^\s*(\S+)\s*(.*)\s*$/) } split("\n", $self->option(shift) );
209 }
210
211 sub rebless { shift; }
212
213 sub _export_insert {
214   my($self) = shift;
215   $self->_export_command('useradd', @_);
216 }
217
218 sub _export_delete {
219   my($self) = shift;
220   $self->_export_command('userdel', @_);
221 }
222
223 sub _export_suspend {
224   my($self) = shift;
225   $self->_export_command_or_super('suspend', @_);
226 }
227
228 sub _export_unsuspend {
229   my($self) = shift;
230   $self->_export_command_or_super('unsuspend', @_);
231 }
232
233 sub _export_command_or_super {
234   my($self, $action) = (shift, shift);
235   if ( $self->option($action) =~ /^\s*$/ ) {
236     my $method = "SUPER::_export_$action";
237     $self->$method(@_);
238   } else {
239     $self->_export_command($action, @_);
240   }
241 };
242
243 sub _export_command {
244   my ( $self, $action, $svc_acct) = (shift, shift, shift);
245   my $command = $self->option($action);
246   return '' if $command =~ /^\s*$/;
247   my $stdin = $self->option($action."_stdin");
248
249   no strict 'vars';
250   {
251     no strict 'refs';
252     ${$_} = $svc_acct->getfield($_) foreach $svc_acct->fields;
253
254     # snarfs are unused at this point?
255     my $count = 1;
256     foreach my $acct_snarf ( $svc_acct->acct_snarf ) {
257       ${"snarf_$_$count"} = shell_quote( $acct_snarf->get($_) )
258         foreach qw( machine username _password );
259       $count++;
260     }
261   }
262
263   my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
264   if ( $cust_pkg ) {
265     no strict 'vars';
266     {
267       no strict 'refs';
268       foreach my $custf (qw( company address1 address2 city state zip country
269                              daytime night fax otaker agent_custid
270                         ))
271       {
272         ${$custf} = $cust_pkg->cust_main->$custf();
273       }
274     }
275     $email = ( grep { $_ !~ /^(POST|FAX)$/ } $cust_pkg->cust_main->invoicing_list )[0];
276   } else {
277     $email = '';
278   }
279
280   $finger =~ /^(.*)\s+(\S+)$/ or $finger =~ /^((.*))$/;
281   ($first, $last ) = ( $1, $2 );
282   $domain = $svc_acct->domain;
283
284   $quoted_password = shell_quote $_password;
285
286   $crypt_password = $svc_acct->crypt_password( $self->option('crypt') );
287   $ldap_password  = $svc_acct->ldap_password(  $self->option('crypt') );
288
289   @radius_groups = $svc_acct->radius_groups;
290
291   my ($reasonnum, $reasontext, $reasontypenum, $reasontypetext);
292   if ( $cust_pkg && $action eq 'suspend' &&
293        (my $r = $cust_pkg->last_reason('susp')) )
294   {
295     $reasonnum = $r->reasonnum;
296     $reasontext = $r->reason;
297     $reasontypenum = $r->reason_type;
298     $reasontypetext = $r->reasontype->type;
299
300     my %reasonmap = $self->_groups_susp_reason_map;
301     my $userspec = '';
302     $userspec = $reasonmap{$reasonnum}
303       if exists($reasonmap{$reasonnum});
304     $userspec = $reasonmap{$reasontext}
305       if (!$userspec && exists($reasonmap{$reasontext}));
306
307     my $suspend_user;
308     if ( $userspec =~ /^\d+$/ ) {
309       $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
310     } elsif ( $userspec =~ /^\S+\@\S+$/ ) {
311       my ($username,$domain) = split(/\@/, $userspec);
312       for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
313         $suspend_user = $user if $userspec eq $user->email;
314       }
315     } elsif ($userspec) {
316       $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
317     }
318
319     @radius_groups = $suspend_user->radius_groups
320       if $suspend_user;  
321
322   } else {
323     $reasonnum = $reasontext = $reasontypenum = $reasontypetext = '';
324   }
325
326   $pkgnum = $cust_pkg ? $cust_pkg->pkgnum : '';
327   $custnum = $cust_pkg ? $cust_pkg->custnum : '';
328
329   my $stdin_string = eval(qq("$stdin"));
330
331   $first = shell_quote $first;
332   $last = shell_quote $last;
333   $finger = shell_quote $finger;
334   $crypt_password = shell_quote $crypt_password;
335   $ldap_password  = shell_quote $ldap_password;
336
337   $company = shell_quote $company;
338   $address1 = shell_quote $address1;
339   $address2 = shell_quote $address2;
340   $city = shell_quote $city;
341   $state = shell_quote $state;
342   $zip = shell_quote $zip;
343   $country = shell_quote $country;
344   $daytime = shell_quote $daytime;
345   $night = shell_quote $night;
346   $fax = shell_quote $fax;
347   $otaker = shell_quote $otaker; 
348   $agent_custid = shell_quote $agent_custid;
349
350   my $command_string = eval(qq("$command"));
351   my @ssh_cmd_args = (
352     user          => $self->option('user') || 'root',
353     host          => $self->machine,
354     command       => $command_string,
355     stdin_string  => $stdin_string,
356     ignored_errors => $self->option('ignored_errors') || '',
357   );
358
359   if($self->option($action . '_no_queue')) {
360     # discard return value just like freeside-queued.
361     eval { ssh_cmd(@ssh_cmd_args) };
362     $error = $@;
363     return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
364       if $error;
365   }
366   else {
367     $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
368   }
369 }
370
371 sub _export_replace {
372   my($self, $new, $old ) = (shift, shift, shift);
373   my $command = $self->option('usermod');
374   my $stdin = $self->option('usermod_stdin');
375   no strict 'vars';
376   {
377     no strict 'refs';
378     ${"old_$_"} = $old->getfield($_) foreach $old->fields;
379     ${"new_$_"} = $new->getfield($_) foreach $new->fields;
380   }
381   my $old_cust_pkg = $old->cust_svc->cust_pkg;
382   my $new_cust_pkg = $new->cust_svc->cust_pkg;
383   my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
384
385   $new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
386   ($new_first, $new_last ) = ( $1, $2 );
387   $quoted_new__password = shell_quote $new__password; #old, wrong?
388   $new_quoted_password = shell_quote $new__password; #new, better?
389   $old_domain = $old->domain;
390   $new_domain = $new->domain;
391
392   $new_crypt_password = $new->crypt_password( $self->option('crypt') );
393   $new_ldap_password  = $new->ldap_password(  $self->option('crypt') );
394
395   @old_radius_groups = $old->radius_groups;
396   @new_radius_groups = $new->radius_groups;
397
398   my $error = '';
399   if ( $self->option('usermod_pwonly') || $self->option('usermod_nousername') ){
400     if ( $old_username ne $new_username ) {
401       $error ||= "can't change username";
402     }
403   }
404   if ( $self->option('usermod_pwonly') ) {
405     if ( $old_domain ne $new_domain ) {
406       $error ||= "can't change domain";
407     }
408     if ( $old_uid != $new_uid ) {
409       $error ||= "can't change uid";
410     }
411     if ( $old_gid != $new_gid ) {
412       $error ||= "can't change gid";
413     }
414     if ( $old_dir ne $new_dir ) {
415       $error ||= "can't change dir";
416     }
417     #if ( join("\n", sort @old_radius_groups) ne
418     #     join("\n", sort @new_radius_groups)    ) {
419     #  $error ||= "can't change RADIUS groups";
420     #}
421   }
422   return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
423     if $error;
424
425   $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
426   $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
427   $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
428   $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
429   $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
430
431   my $stdin_string = eval(qq("$stdin"));
432
433   $new_first = shell_quote $new_first;
434   $new_last = shell_quote $new_last;
435   $new_finger = shell_quote $new_finger;
436   $new_crypt_password = shell_quote $new_crypt_password;
437   $new_ldap_password  = shell_quote $new_ldap_password;
438   $new_agent_custid = shell_quote $new_agent_custid;
439
440   my $command_string = eval(qq("$command"));
441
442   my @ssh_cmd_args = (
443     user          => $self->option('user') || 'root',
444     host          => $self->machine,
445     command       => $command_string,
446     stdin_string  => $stdin_string,
447     ignored_errors => $self->option('ignored_errors') || '',
448   );
449
450   if($self->option('usermod_no_queue')) {
451     # discard return value just like freeside-queued.
452     eval { ssh_cmd(@ssh_cmd_args) };
453     $error = $@;
454     return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
455       if $error;
456   }
457   else {
458     $self->shellcommands_queue( $new->svcnum, @ssh_cmd_args );
459   }
460 }
461
462 #a good idea to queue anything that could fail or take any time
463 sub shellcommands_queue {
464   my( $self, $svcnum ) = (shift, shift);
465   my $queue = new FS::queue {
466     'svcnum' => $svcnum,
467     'job'    => "FS::part_export::shellcommands::ssh_cmd",
468   };
469   $queue->insert( @_ );
470 }
471
472 sub ssh_cmd { #subroutine, not method
473   use Net::OpenSSH;
474   my $opt = { @_ };
475   my $ssh = Net::OpenSSH->new($opt->{'user'}.'@'.$opt->{'host'});
476   die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
477   my ($output, $errput) = $ssh->capture2($opt->{'command'});
478   die "Error running SSH command: ". $ssh->error if $ssh->error;
479   if ($errput && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})) {
480     my @ignored_errors = split('\n',$opt->{'ignored_errors'});
481     foreach my $ignored_error ( @ignored_errors ) {
482         $errput =~ s/$ignored_error//g;
483     }
484     chomp($errput);
485   }
486   die $errput if $errput;
487   die $output if $output;
488   '';
489 }
490
491 #sub shellcommands_insert { #subroutine, not method
492 #}
493 #sub shellcommands_replace { #subroutine, not method
494 #}
495 #sub shellcommands_delete { #subroutine, not method
496 #}
497
498 1;
499