1 package FS::part_export::shellcommands;
3 use vars qw(@ISA %info);
6 use String::ShellQuote;
9 use FS::Record qw( qsearch qsearchs );
11 @ISA = qw(FS::part_export);
13 tie my %options, 'Tie::IxHash',
15 'user' => { label=>'Remote username', default=>'root' },
17 'useradd' => { label=>'Insert command',
18 default=>'useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username'
19 #default=>'cp -pr /etc/skel $dir; chown -R $uid.$gid $dir'
21 'useradd_no_queue' => { label=>'Run immediately',
24 'useradd_stdin' => { label=>'Insert command STDIN',
29 'userdel' => { label=>'Delete command',
30 default=>'userdel -r $username',
31 #default=>'rm -rf $dir',
33 'userdel_no_queue' => { label=>'Run immediately',
36 'userdel_stdin' => { label=>'Delete command STDIN',
41 'usermod' => { label=>'Modify command',
42 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',
43 #default=>'[ -d $old_dir ] && mv $old_dir $new_dir || ( '.
44 # 'chmod u+t $old_dir; mkdir $new_dir; cd $old_dir; '.
45 # 'find . -depth -print | cpio -pdm $new_dir; '.
46 # 'chmod u-t $new_dir; chown -R $uid.$gid $new_dir; '.
50 'usermod_no_queue' => { label=>'Run immediately',
53 'usermod_stdin' => { label=>'Modify command STDIN',
57 'usermod_pwonly' => { label=>'Disallow username, domain, uid, gid, and dir changes', #and RADIUS group changes',
60 'usermod_nousername' => { label=>'Disallow just username changes',
64 'suspend' => { label=>'Suspension command',
65 default=>'usermod -L $username',
67 'suspend_no_queue' => { label=>'Run immediately',
70 'suspend_stdin' => { label=>'Suspension command STDIN',
74 'unsuspend' => { label=>'Unsuspension command',
75 default=>'usermod -U $username',
77 'unsuspend_no_queue' => { label=>'Run immediately',
80 'unsuspend_stdin' => { label=>'Unsuspension command STDIN',
84 'pkg_change' => { label=>'Package changed command',
88 # run commands on package change for multiple services and roll back the
89 # package change transaciton if one fails? yuck. no.
90 # if this was really needed, would need to restrict to a single service with
91 # this kind of export configured.
92 #'pkg_change_no_queue' => { label=>'Run immediately',
95 'pkg_change_stdin' => { label=>'Package changed command STDIN',
99 'crypt' => { label => 'Default password encryption',
100 type=>'select', options=>[qw(crypt md5 sha512)],
103 'groups_susp_reason' => { label =>
104 'Radius group mapping to reason (via template user)',
107 'fail_on_output' => {
108 label => 'Treat any output from the command as an error',
111 'ignore_all_errors' => {
112 label => 'Ignore all errors from the command',
115 'ignored_errors' => { label => 'Regexes of specific errors to ignore, separated by newlines',
118 # 'no_queue' => { label => 'Run command immediately',
119 # type => 'checkbox',
125 'desc' => 'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
126 'options' => \%options,
130 Run remote commands via SSH. Usernames are considered unique (also see
131 shellcommands_withdomain). You probably want this if the commands you are
132 running will not accept a domain as a parameter. You will need to
133 <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Administration:SSH_Keys">setup SSH for unattended operation</a>.
135 <BR><BR>Use these buttons for some useful presets:
138 <INPUT TYPE="button" VALUE="Linux" onClick='
139 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
140 this.form.useradd_stdin.value = "";
141 this.form.userdel.value = "userdel -r $username";
142 this.form.userdel_stdin.value="";
143 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";
144 this.form.usermod_stdin.value = "";
145 this.form.suspend.value = "usermod -L $username";
146 this.form.suspend_stdin.value="";
147 this.form.unsuspend.value = "usermod -U $username";
148 this.form.unsuspend_stdin.value="";
151 <INPUT TYPE="button" VALUE="FreeBSD" onClick='
152 this.form.useradd.value = "pw useradd $username -d $dir -m -s $shell -u $uid -g $gid -c $finger -h 0";
153 this.form.useradd_stdin.value = "$_password\n";
154 this.form.userdel.value = "pw userdel $username -r";
155 this.form.userdel_stdin.value="";
156 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";
157 this.form.usermod_stdin.value = "$new__password\n";
158 this.form.suspend.value = "pw lock $username";
159 this.form.suspend_stdin.value="";
160 this.form.unsuspend.value = "pw unlock $username";
161 this.form.unsuspend_stdin.value="";
164 <INPUT TYPE="button" VALUE="NetBSD/OpenBSD" onClick='
165 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
166 this.form.useradd_stdin.value = "";
167 this.form.userdel.value = "userdel -r $username";
168 this.form.userdel_stdin.value="";
169 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";
170 this.form.usermod_stdin.value = "";
171 this.form.suspend.value = "";
172 this.form.suspend_stdin.value="";
173 this.form.unsuspend.value = "";
174 this.form.unsuspend_stdin.value="";
177 <INPUT TYPE="button" VALUE="Just maintain directories (use with sysvshell or bsdshell)" onClick='
178 this.form.useradd.value = "cp -pr /etc/skel $dir; chown -R $uid.$gid $dir"; this.form.useradd_stdin.value = "";
179 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 )";
180 this.form.usermod_stdin.value = "";
181 this.form.userdel.value = "rm -rf $dir";
182 this.form.userdel_stdin.value="";
183 this.form.suspend.value = "";
184 this.form.suspend_stdin.value="";
185 this.form.unsuspend.value = "";
186 this.form.unsuspend_stdin.value="";
190 The following variables are available for interpolation (prefixed with new_ or
191 old_ for replace operations):
193 <LI><code>$username</code>
194 <LI><code>$_password</code>
195 <LI><code>$quoted_password</code> - unencrypted password, already quoted for the shell (do not add additional quotes).
196 <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).
197 <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).
198 <LI><code>$uid</code>
199 <LI><code>$gid</code>
200 <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).
201 <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).
202 <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).
203 <LI><code>$dir</code> - home directory
204 <LI><code>$shell</code>
205 <LI><code>$quota</code>
206 <LI><code>@radius_groups</code>
207 <LI><code>$reasonnum (when suspending)</code>
208 <LI><code>$reasontext (when suspending)</code>
209 <LI><code>$reasontypenum (when suspending)</code>
210 <LI><code>$reasontypetext (when suspending)</code>
211 <LI><code>$pkgnum</code>
212 <LI><code>$locationnum</code>
213 <LI><code>$custnum</code>
214 <LI>All other fields in <b>svc_acct</b> are also available.
215 <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).
217 For the package changed command only, the following fields are also available:
219 <LI>$old_pkgnum and $new_pkgnum
220 <LI>$old_pkgpart and $new_pkgpart
221 <LI>$old_agent_pkgid and $new_agent_pkgid
222 <LI>$old_order_date and $new_order_date
223 <LI>$old_start_date and $new_start_date
224 <LI>$old_setup and $new_setup
225 <LI>$old_bill and $new_bill
226 <LI>$old_last_bill and $new_last_bill
227 <LI>$old_susp and $new_susp
228 <LI>$old_adjourn and $new_adjourn
229 <LI>$old_resume and $new_resume
230 <LI>$old_cancel and $new_cancel
231 <LI>$old_unancel and $new_unancel
232 <LI>$old_expire and $new_expire
233 <LI>$old_contract_end and $new_contract_end
238 sub _groups_susp_reason_map { shift->_map('groups_susp_reason'); }
242 map { reverse(/^\s*(\S+)\s*(.*)\s*$/) } split("\n", $self->option(shift) );
245 sub rebless { shift; }
249 $self->_export_command('useradd', @_);
254 $self->_export_command('userdel', @_);
257 sub _export_suspend {
259 $self->_export_command_or_super('suspend', @_);
262 sub _export_unsuspend {
264 $self->_export_command_or_super('unsuspend', @_);
267 sub export_pkg_change {
268 my( $self, $svc_acct, $new_cust_pkg, $old_cust_pkg ) = @_;
270 my @fields = qw( pkgnum pkgpart agent_pkgid ); #others?
271 my @date_fields = qw( order_date start_date setup bill last_bill susp adjourn
272 resume cancel uncancel expire contract_end );
278 ${"old_$_"} = $old_cust_pkg ? $old_cust_pkg->getfield($_) : '';
279 ${"new_$_"} = $new_cust_pkg->getfield($_);
281 foreach (@date_fields) {
282 ${"old_$_"} = $old_cust_pkg
283 ? time2str('%Y-%m-%d', $old_cust_pkg->getfield($_))
285 ${"new_$_"} = time2str('%Y-%m-%d', $new_cust_pkg->getfield($_));
289 $self->_export_command('pkg_change', $svc_acct);
292 sub _export_command_or_super {
293 my($self, $action) = (shift, shift);
294 if ( $self->option($action) =~ /^\s*$/ ) {
295 my $method = "SUPER::_export_$action";
298 $self->_export_command($action, @_);
302 sub _export_command {
303 my ( $self, $action, $svc_acct) = (shift, shift, shift);
304 my $command = $self->option($action);
306 return '' if $command =~ /^\s*$/;
307 my $stdin = $self->option($action."_stdin");
309 my( $command_string, $stdin_string ) =
310 $self->_export_subvars( $svc_acct, $command, $stdin );
312 $self->ssh_or_queue( $svc_acct, $command_string, $stdin_string );
316 my( $self, $svc_acct, $command_string, $stdin_string ) = @_;
319 user => $self->option('user') || 'root',
320 host => $self->svc_machine($svc_acct),
321 command => $command_string,
322 stdin_string => $stdin_string,
323 ignored_errors => $self->option('ignored_errors') || '',
324 ignore_all_errors => $self->option('ignore_all_errors'),
325 fail_on_output => $self->option('fail_on_output'),
328 if ( $self->option($action. '_no_queue') ) {
329 # discard return value just like freeside-queued.
330 eval { ssh_cmd(@ssh_cmd_args) };
332 $error = $error->full_message if ref $error; # Exception::Class::Base
334 ' ('. $self->exporttype. ' to '. $self->svc_machine($svc_acct). ')'
337 $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
341 sub _export_subvars {
342 my( $self, $svc_acct, $command, $stdin ) = @_;
347 ${$_} = $svc_acct->getfield($_) foreach $svc_acct->fields;
349 # snarfs are unused at this point?
351 # foreach my $acct_snarf ( $svc_acct->acct_snarf ) {
352 # ${"snarf_$_$count"} = shell_quote( $acct_snarf->get($_) )
353 # foreach qw( machine username _password );
358 my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
363 foreach my $custf (qw( company address1 address2 city state zip country
364 daytime night fax otaker agent_custid locale
367 ${$custf} = $cust_pkg->cust_main->$custf();
370 $email = ( grep { $_ !~ /^(POST|FAX)$/ } $cust_pkg->cust_main->invoicing_list )[0];
375 $finger =~ /^(.*)\s+(\S+)$/ or $finger =~ /^((.*))$/;
376 ($first, $last ) = ( $1, $2 );
377 $domain = $svc_acct->domain;
379 $quoted_password = shell_quote $_password;
381 $crypt_password = $svc_acct->crypt_password( $self->option('crypt') );
382 $ldap_password = $svc_acct->ldap_password( $self->option('crypt') );
384 @radius_groups = $svc_acct->radius_groups;
386 my ($reasonnum, $reasontext, $reasontypenum, $reasontypetext);
387 if ( $cust_pkg && $action eq 'suspend' &&
388 (my $r = $cust_pkg->last_reason('susp')) )
390 $reasonnum = $r->reasonnum;
391 $reasontext = $r->reason;
392 $reasontypenum = $r->reason_type;
393 $reasontypetext = $r->reasontype->type;
395 my %reasonmap = $self->_groups_susp_reason_map;
397 $userspec = $reasonmap{$reasonnum}
398 if exists($reasonmap{$reasonnum});
399 $userspec = $reasonmap{$reasontext}
400 if (!$userspec && exists($reasonmap{$reasontext}));
403 if ( $userspec =~ /^\d+$/ ) {
404 $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
405 } elsif ( $userspec =~ /^\S+\@\S+$/ ) {
406 my ($username,$domain) = split(/\@/, $userspec);
407 for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
408 $suspend_user = $user if $userspec eq $user->email;
410 } elsif ($userspec) {
411 $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
414 @radius_groups = $suspend_user->radius_groups
418 $reasonnum = $reasontext = $reasontypenum = $reasontypetext = '';
421 $pkgnum = $cust_pkg ? $cust_pkg->pkgnum : '';
422 $locationnum = $cust_pkg ? $cust_pkg->locationnum : '';
423 $custnum = $cust_pkg ? $cust_pkg->custnum : '';
425 my $stdin_string = eval(qq("$stdin"));
426 return "error filling in STDIN: $@" if $@;
428 $first = shell_quote $first;
429 $last = shell_quote $last;
430 $finger = shell_quote $finger;
431 $crypt_password = shell_quote $crypt_password;
432 $ldap_password = shell_quote $ldap_password;
434 $company = shell_quote $company;
435 $address1 = shell_quote $address1;
436 $address2 = shell_quote $address2;
437 $city = shell_quote $city;
438 $state = shell_quote $state;
439 $zip = shell_quote $zip;
440 $country = shell_quote $country;
441 $daytime = shell_quote $daytime;
442 $night = shell_quote $night;
443 $fax = shell_quote $fax;
444 $otaker = shell_quote $otaker;
445 $agent_custid = shell_quote $agent_custid;
446 $locale = shell_quote $locale;
448 my $command_string = eval(qq("$command"));
449 return "error filling in command: $@" if $@;
451 ( $command_string, $stdin_string );
454 sub _export_replace {
455 my($self, $new, $old ) = (shift, shift, shift);
456 my $command = $self->option('usermod');
457 return '' if $command =~ /^\s*$/;
458 my $stdin = $self->option('usermod_stdin');
460 my( $command_string, $stdin_string ) =
461 $self->_export_subvars_replace( $new, $old, $command, $stdin );
463 $self->ssh_or_queue( $new, $command_string, $stdin_string );
466 sub _export_subvars_replace {
467 my( $self, $new, $old, $command, $stdin ) = @_;
472 ${"old_$_"} = $old->getfield($_) foreach $old->fields;
473 ${"new_$_"} = $new->getfield($_) foreach $new->fields;
475 my $old_cust_pkg = $old->cust_svc->cust_pkg;
476 my $new_cust_pkg = $new->cust_svc->cust_pkg;
477 my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
479 $new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
480 ($new_first, $new_last ) = ( $1, $2 );
481 $quoted_new__password = shell_quote $new__password; #old, wrong?
482 $new_quoted_password = shell_quote $new__password; #new, better?
483 $old_domain = $old->domain;
484 $new_domain = $new->domain;
486 $new_crypt_password = $new->crypt_password( $self->option('crypt') );
487 $new_ldap_password = $new->ldap_password( $self->option('crypt') );
489 @old_radius_groups = $old->radius_groups;
490 @new_radius_groups = $new->radius_groups;
493 if ( $self->option('usermod_pwonly') || $self->option('usermod_nousername') ){
494 if ( $old_username ne $new_username ) {
495 $error ||= "can't change username";
498 if ( $self->option('usermod_pwonly') ) {
499 if ( $old_domain ne $new_domain ) {
500 $error ||= "can't change domain";
502 if ( $old_uid != $new_uid ) {
503 $error ||= "can't change uid";
505 if ( $old_gid != $new_gid ) {
506 $error ||= "can't change gid";
508 if ( $old_dir ne $new_dir ) {
509 $error ||= "can't change dir";
511 #if ( join("\n", sort @old_radius_groups) ne
512 # join("\n", sort @new_radius_groups) ) {
513 # $error ||= "can't change RADIUS groups";
516 return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($new). ')'
519 $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
520 $new_locale = $new_cust_main ? $new_cust_main->locale : '';
521 $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
522 $old_locationnum = $old_cust_pkg ? $old_cust_pkg->locationnum : '';
523 $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
524 $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
525 $new_locationnum = $new_cust_pkg ? $new_cust_pkg->locationnum : '';
526 $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
528 my $stdin_string = eval(qq("$stdin"));
530 $new_first = shell_quote $new_first;
531 $new_last = shell_quote $new_last;
532 $new_finger = shell_quote $new_finger;
533 $new_crypt_password = shell_quote $new_crypt_password;
534 $new_ldap_password = shell_quote $new_ldap_password;
535 $new_agent_custid = shell_quote $new_agent_custid;
536 $new_locale = shell_quote $new_locale;
538 my $command_string = eval(qq("$command"));
540 ( $command_string, $stdin_string );
543 #a good idea to queue anything that could fail or take any time
544 sub shellcommands_queue {
545 my( $self, $svcnum ) = (shift, shift);
546 my $queue = new FS::queue {
548 'job' => "FS::part_export::shellcommands::ssh_cmd",
550 $queue->insert( @_ );
553 sub ssh_cmd { #subroutine, not method
555 open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
556 my $ssh = Net::OpenSSH->new(
557 $opt->{'user'}.'@'.$opt->{'host'},
558 'default_stdin_fh' => $def_in
560 # ignore_all_errors doesn't override SSH connection/auth errors--
562 die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
565 $ssh_opt->{'stdin_data'} = $opt->{'stdin_string'}
566 if exists($opt->{'stdin_string'}) and length($opt->{'stdin_string'});
568 my ($output, $errput) = $ssh->capture2($ssh_opt, $opt->{'command'});
570 return if $opt->{'ignore_all_errors'};
571 #die "Error running SSH command: ". $ssh->error if $ssh->error;
573 if ( ($output || $errput)
574 && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})
576 my @ignored_errors = split('\n',$opt->{'ignored_errors'});
577 foreach my $ignored_error ( @ignored_errors ) {
578 $output =~ s/$ignored_error//g;
579 $errput =~ s/$ignored_error//g;
581 $output =~ s/[\s\n]//g;
582 $errput =~ s/[\s\n]//g;
585 die (($errput || $ssh->error). "\n") if $errput || $ssh->error;
586 #die "$errput\n" if $errput;
588 die "$output\n" if $output and $opt->{'fail_on_output'};
592 #sub shellcommands_insert { #subroutine, not method
594 #sub shellcommands_replace { #subroutine, not method
596 #sub shellcommands_delete { #subroutine, not method
599 sub _upgrade_exporttype {
601 $class =~ /^FS::part_export::(\w+)$/;
602 foreach my $self ( qsearch('part_export', { 'exporttype' => $1 }) ) {
603 my %options = $self->options;
605 # 2011-12-13 - 2012-02-16: ignore_all_output option
606 if ( $options{'ignore_all_output'} ) {
607 # ignoring STDOUT is now the default
608 $options{'ignore_all_errors'} = 1;
609 delete $options{'ignore_all_output'};
612 my $error = $self->replace(%options) if $changed;
613 die $error if $error;