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