apply shellcommands ignored_errors regexen to STDOUT as well as STDERR, RT#15347
[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
357   my @ssh_cmd_args = (
358     user          => $self->option('user') || 'root',
359     host          => $self->machine,
360     command       => $command_string,
361     stdin_string  => $stdin_string,
362     ignore_all_output => $self->option('ignore_all_output'),
363     ignored_errors    => $self->option('ignored_errors') || '',
364   );
365
366   if($self->option($action . '_no_queue')) {
367     # discard return value just like freeside-queued.
368     eval { ssh_cmd(@ssh_cmd_args) };
369     $error = $@;
370     return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
371       if $error;
372   }
373   else {
374     $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
375   }
376 }
377
378 sub _export_replace {
379   my($self, $new, $old ) = (shift, shift, shift);
380   my $command = $self->option('usermod');
381   return '' if $command =~ /^\s*$/;
382   my $stdin = $self->option('usermod_stdin');
383   no strict 'vars';
384   {
385     no strict 'refs';
386     ${"old_$_"} = $old->getfield($_) foreach $old->fields;
387     ${"new_$_"} = $new->getfield($_) foreach $new->fields;
388   }
389   my $old_cust_pkg = $old->cust_svc->cust_pkg;
390   my $new_cust_pkg = $new->cust_svc->cust_pkg;
391   my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
392
393   $new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
394   ($new_first, $new_last ) = ( $1, $2 );
395   $quoted_new__password = shell_quote $new__password; #old, wrong?
396   $new_quoted_password = shell_quote $new__password; #new, better?
397   $old_domain = $old->domain;
398   $new_domain = $new->domain;
399
400   $new_crypt_password = $new->crypt_password( $self->option('crypt') );
401   $new_ldap_password  = $new->ldap_password(  $self->option('crypt') );
402
403   @old_radius_groups = $old->radius_groups;
404   @new_radius_groups = $new->radius_groups;
405
406   my $error = '';
407   if ( $self->option('usermod_pwonly') || $self->option('usermod_nousername') ){
408     if ( $old_username ne $new_username ) {
409       $error ||= "can't change username";
410     }
411   }
412   if ( $self->option('usermod_pwonly') ) {
413     if ( $old_domain ne $new_domain ) {
414       $error ||= "can't change domain";
415     }
416     if ( $old_uid != $new_uid ) {
417       $error ||= "can't change uid";
418     }
419     if ( $old_gid != $new_gid ) {
420       $error ||= "can't change gid";
421     }
422     if ( $old_dir ne $new_dir ) {
423       $error ||= "can't change dir";
424     }
425     #if ( join("\n", sort @old_radius_groups) ne
426     #     join("\n", sort @new_radius_groups)    ) {
427     #  $error ||= "can't change RADIUS groups";
428     #}
429   }
430   return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
431     if $error;
432
433   $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
434   $new_locale = $new_cust_main ? $new_cust_main->locale : '';
435   $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
436   $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
437   $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
438   $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
439
440   my $stdin_string = eval(qq("$stdin"));
441
442   $new_first = shell_quote $new_first;
443   $new_last = shell_quote $new_last;
444   $new_finger = shell_quote $new_finger;
445   $new_crypt_password = shell_quote $new_crypt_password;
446   $new_ldap_password  = shell_quote $new_ldap_password;
447   $new_agent_custid = shell_quote $new_agent_custid;
448   $new_locale = shell_quote $new_locale;
449
450   my $command_string = eval(qq("$command"));
451
452   my @ssh_cmd_args = (
453     user          => $self->option('user') || 'root',
454     host          => $self->machine,
455     command       => $command_string,
456     stdin_string  => $stdin_string,
457     ignore_all_output => $self->option('ignore_all_output'),
458     ignored_errors => $self->option('ignored_errors') || '',
459   );
460
461   if($self->option('usermod_no_queue')) {
462     # discard return value just like freeside-queued.
463     eval { ssh_cmd(@ssh_cmd_args) };
464     $error = $@;
465     return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
466       if $error;
467   }
468   else {
469     $self->shellcommands_queue( $new->svcnum, @ssh_cmd_args );
470   }
471 }
472
473 #a good idea to queue anything that could fail or take any time
474 sub shellcommands_queue {
475   my( $self, $svcnum ) = (shift, shift);
476   my $queue = new FS::queue {
477     'svcnum' => $svcnum,
478     'job'    => "FS::part_export::shellcommands::ssh_cmd",
479   };
480   $queue->insert( @_ );
481 }
482
483 sub ssh_cmd { #subroutine, not method
484   use Net::OpenSSH;
485   my $opt = { @_ };
486   open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
487   my $ssh = Net::OpenSSH->new(
488     $opt->{'user'}.'@'.$opt->{'host'},
489     'default_stdin_fh' => $def_in
490   );
491   # ignore_all_output doesn't override this
492   die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
493
494   my $ssh_opt = {};
495   $ssh_opt->{'stdin_data'} = $opt->{'stdin_string'}
496     if exists($opt->{'stdin_string'}) and length($opt->{'stdin_string'});
497   my ($output, $errput) = $ssh->capture2($ssh_opt, $opt->{'command'});
498   return if $opt->{'ignore_all_output'};
499   die "Error running SSH command: ". $ssh->error if $ssh->error;
500
501   if ( ($output || $errput)
502        && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})
503   ) {
504     my @ignored_errors = split('\n',$opt->{'ignored_errors'});
505     foreach my $ignored_error ( @ignored_errors ) {
506         $output =~ s/$ignored_error//g;
507         $errput =~ s/$ignored_error//g;
508     }
509     $output =~ s/[\s\n]//g;
510     $errput =~ s/[\s\n]//g;
511     chomp($output);
512     chomp($errput);
513   }
514
515   die $errput if $errput;
516   die $output if $output;
517   '';
518 }
519
520 #sub shellcommands_insert { #subroutine, not method
521 #}
522 #sub shellcommands_replace { #subroutine, not method
523 #}
524 #sub shellcommands_delete { #subroutine, not method
525 #}
526
527 1;
528