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