1 package FS::part_export::shellcommands;
3 use vars qw(@ISA %info);
6 use String::ShellQuote;
9 use FS::Record qw( qsearch qsearchs );
12 @ISA = qw(FS::part_export);
14 tie my %options, 'Tie::IxHash',
16 'user' => { label=>'Remote username', default=>'root' },
18 'useradd' => { label=>'Insert command',
19 default=>'useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username'
20 #default=>'cp -pr /etc/skel $dir; chown -R $uid.$gid $dir'
22 'useradd_no_queue' => { label=>'Run immediately',
25 'useradd_stdin' => { label=>'Insert command STDIN',
30 'userdel' => { label=>'Delete command',
31 default=>'userdel -r $username',
32 #default=>'rm -rf $dir',
34 'userdel_no_queue' => { label=>'Run immediately',
37 'userdel_stdin' => { label=>'Delete command STDIN',
42 'usermod' => { label=>'Modify command',
43 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',
44 #default=>'[ -d $old_dir ] && mv $old_dir $new_dir || ( '.
45 # 'chmod u+t $old_dir; mkdir $new_dir; cd $old_dir; '.
46 # 'find . -depth -print | cpio -pdm $new_dir; '.
47 # 'chmod u-t $new_dir; chown -R $uid.$gid $new_dir; '.
51 'usermod_no_queue' => { label=>'Run immediately',
54 'usermod_stdin' => { label=>'Modify command STDIN',
58 'usermod_pwonly' => { label=>'Disallow username, domain, uid, gid, and dir changes', #and RADIUS group changes',
61 'usermod_nousername' => { label=>'Disallow just username changes',
65 'suspend' => { label=>'Suspension command',
66 default=>'usermod -L $username',
68 'suspend_no_queue' => { label=>'Run immediately',
71 'suspend_stdin' => { label=>'Suspension command STDIN',
75 'unsuspend' => { label=>'Unsuspension command',
76 default=>'usermod -U $username',
78 'unsuspend_no_queue' => { label=>'Run immediately',
81 'unsuspend_stdin' => { label=>'Unsuspension command STDIN',
85 'pkg_change' => { label=>'Package changed command',
89 # run commands on package change for multiple services and roll back the
90 # package change transaciton if one fails? yuck. no.
91 # if this was really needed, would need to restrict to a single service with
92 # this kind of export configured.
93 #'pkg_change_no_queue' => { label=>'Run immediately',
96 'pkg_change_stdin' => { label=>'Package changed command STDIN',
100 'crypt' => { label => 'Default password encryption',
101 type=>'select', options=>[qw(crypt md5 sha512)],
104 'groups_susp_reason' => { label =>
105 'Radius group mapping to reason (via template user)',
108 'fail_on_output' => {
109 label => 'Treat any output from the command as an error',
112 'ignore_all_errors' => {
113 label => 'Ignore all errors from the command',
116 'ignored_errors' => { label => 'Regexes of specific errors to ignore, separated by newlines',
119 # 'no_queue' => { label => 'Run command immediately',
120 # type => 'checkbox',
126 'desc' => 'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
127 'options' => \%options,
131 Run remote commands via SSH. Usernames are considered unique (also see
132 shellcommands_withdomain). You probably want this if the commands you are
133 running will not accept a domain as a parameter. You will need to
134 <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Administration:SSH_Keys">setup SSH for unattended operation</a>.
136 <BR><BR>Use these buttons for some useful presets:
139 <INPUT TYPE="button" VALUE="Linux" onClick='
140 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
141 this.form.useradd_stdin.value = "";
142 this.form.userdel.value = "userdel -r $username";
143 this.form.userdel_stdin.value="";
144 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";
145 this.form.usermod_stdin.value = "";
146 this.form.suspend.value = "usermod -L $username";
147 this.form.suspend_stdin.value="";
148 this.form.unsuspend.value = "usermod -U $username";
149 this.form.unsuspend_stdin.value="";
152 <INPUT TYPE="button" VALUE="FreeBSD" onClick='
153 this.form.useradd.value = "pw useradd $username -d $dir -m -s $shell -u $uid -g $gid -c $finger -h 0";
154 this.form.useradd_stdin.value = "$_password\n";
155 this.form.userdel.value = "pw userdel $username -r";
156 this.form.userdel_stdin.value="";
157 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";
158 this.form.usermod_stdin.value = "$new__password\n";
159 this.form.suspend.value = "pw lock $username";
160 this.form.suspend_stdin.value="";
161 this.form.unsuspend.value = "pw unlock $username";
162 this.form.unsuspend_stdin.value="";
165 <INPUT TYPE="button" VALUE="NetBSD/OpenBSD" onClick='
166 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
167 this.form.useradd_stdin.value = "";
168 this.form.userdel.value = "userdel -r $username";
169 this.form.userdel_stdin.value="";
170 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";
171 this.form.usermod_stdin.value = "";
172 this.form.suspend.value = "";
173 this.form.suspend_stdin.value="";
174 this.form.unsuspend.value = "";
175 this.form.unsuspend_stdin.value="";
178 <INPUT TYPE="button" VALUE="Just maintain directories (use with sysvshell or bsdshell)" onClick='
179 this.form.useradd.value = "cp -pr /etc/skel $dir; chown -R $uid.$gid $dir"; this.form.useradd_stdin.value = "";
180 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 )";
181 this.form.usermod_stdin.value = "";
182 this.form.userdel.value = "rm -rf $dir";
183 this.form.userdel_stdin.value="";
184 this.form.suspend.value = "";
185 this.form.suspend_stdin.value="";
186 this.form.unsuspend.value = "";
187 this.form.unsuspend_stdin.value="";
191 The following variables are available for interpolation (prefixed with new_ or
192 old_ for replace operations):
194 <LI><code>$username</code>
195 <LI><code>$_password</code>
196 <LI><code>$quoted_password</code> - unencrypted password, already quoted for the shell (do not add additional quotes).
197 <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).
198 <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).
199 <LI><code>$uid</code>
200 <LI><code>$gid</code>
201 <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).
202 <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).
203 <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).
204 <LI><code>$dir</code> - home directory
205 <LI><code>$shell</code>
206 <LI><code>$quota</code>
207 <LI><code>@radius_groups</code>
208 <LI><code>$reasonnum (when suspending)</code>
209 <LI><code>$reasontext (when suspending)</code>
210 <LI><code>$reasontypenum (when suspending)</code>
211 <LI><code>$reasontypetext (when suspending)</code>
212 <LI><code>$pkgnum</code>
213 <LI><code>$locationnum</code>
214 <LI><code>$custnum</code>
215 <LI>All other fields in <b>svc_acct</b> are also available.
216 <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).
218 For the package changed command only, the following fields are also available:
220 <LI>$old_pkgnum and $new_pkgnum
221 <LI>$old_pkgpart and $new_pkgpart
222 <LI>$old_agent_pkgid and $new_agent_pkgid
223 <LI>$old_order_date and $new_order_date
224 <LI>$old_start_date and $new_start_date
225 <LI>$old_setup and $new_setup
226 <LI>$old_bill and $new_bill
227 <LI>$old_last_bill and $new_last_bill
228 <LI>$old_susp and $new_susp
229 <LI>$old_adjourn and $new_adjourn
230 <LI>$old_resume and $new_resume
231 <LI>$old_cancel and $new_cancel
232 <LI>$old_unancel and $new_unancel
233 <LI>$old_expire and $new_expire
234 <LI>$old_contract_end and $new_contract_end
239 sub _groups_susp_reason_map { shift->_map('groups_susp_reason'); }
243 map { reverse(/^\s*(\S+)\s*(.*)\s*$/) } split("\n", $self->option(shift) );
246 sub rebless { shift; }
250 $self->_export_command('useradd', @_);
255 $self->_export_command('userdel', @_);
258 sub _export_suspend {
260 $self->_export_command_or_super('suspend', @_);
263 sub _export_unsuspend {
265 $self->_export_command_or_super('unsuspend', @_);
268 sub export_pkg_change {
269 my( $self, $svc_acct, $new_cust_pkg, $old_cust_pkg ) = @_;
271 if ( $FS::svc_Common::noexport_hack ) {
272 carp 'export_pkg_change() suppressed by noexport_hack'
273 if $self->option('debug');
277 my @fields = qw( pkgnum pkgpart agent_pkgid ); #others?
278 my @date_fields = qw( order_date start_date setup bill last_bill susp adjourn
279 resume cancel uncancel expire contract_end );
285 ${"old_$_"} = $old_cust_pkg ? $old_cust_pkg->getfield($_) : '';
286 ${"new_$_"} = $new_cust_pkg->getfield($_);
288 foreach (@date_fields) {
289 ${"old_$_"} = $old_cust_pkg
290 ? time2str('%Y-%m-%d', $old_cust_pkg->getfield($_))
292 ${"new_$_"} = time2str('%Y-%m-%d', $new_cust_pkg->getfield($_));
296 $self->_export_command('pkg_change', $svc_acct);
299 sub _export_command_or_super {
300 my($self, $action) = (shift, shift);
302 if ( $FS::svc_Common::noexport_hack ) {
303 carp "_export_command_or_super($action) suppressed by noexport_hack"
304 if $self->option('debug');
308 if ( $self->option($action) =~ /^\s*$/ ) {
309 my $method = "SUPER::_export_$action";
312 $self->_export_command($action, @_);
316 sub _export_command {
317 my ( $self, $action, $svc_acct) = (shift, shift, shift);
318 my $command = $self->option($action);
320 if ( $FS::svc_Common::noexport_hack ) {
321 carp "_export_command($action) suppressed by noexport_hack"
322 if $self->option('debug');
326 return '' if $command =~ /^\s*$/;
327 my $stdin = $self->option($action."_stdin");
329 my( $command_string, $stdin_string ) =
330 $self->_export_subvars( $svc_acct, $command, $stdin );
332 $self->ssh_or_queue( $svc_acct, $command_string, $stdin_string );
336 my( $self, $svc_acct, $command_string, $stdin_string ) = @_;
339 user => $self->option('user') || 'root',
340 host => $self->svc_machine($svc_acct),
341 command => $command_string,
342 stdin_string => $stdin_string,
343 ignored_errors => $self->option('ignored_errors') || '',
344 ignore_all_errors => $self->option('ignore_all_errors'),
345 fail_on_output => $self->option('fail_on_output'),
348 if ( $self->option($action. '_no_queue') ) {
349 # discard return value just like freeside-queued.
350 eval { ssh_cmd(@ssh_cmd_args) };
352 $error = $error->full_message if ref $error; # Exception::Class::Base
354 ' ('. $self->exporttype. ' to '. $self->svc_machine($svc_acct). ')'
357 $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
361 sub _export_subvars {
362 my( $self, $svc_acct, $command, $stdin ) = @_;
367 ${$_} = $svc_acct->getfield($_) foreach $svc_acct->fields;
369 # snarfs are unused at this point?
371 # foreach my $acct_snarf ( $svc_acct->acct_snarf ) {
372 # ${"snarf_$_$count"} = shell_quote( $acct_snarf->get($_) )
373 # foreach qw( machine username _password );
378 my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
383 foreach my $custf (qw( company address1 address2 city state zip country
384 daytime night fax otaker agent_custid locale
387 ${$custf} = $cust_pkg->cust_main->$custf();
390 $email = ( grep { $_ !~ /^(POST|FAX)$/ } $cust_pkg->cust_main->invoicing_list )[0];
395 $finger =~ /^(.*)\s+(\S+)$/ or $finger =~ /^((.*))$/;
396 ($first, $last ) = ( $1, $2 );
397 $domain = $svc_acct->domain;
399 $quoted_password = shell_quote $_password;
401 $crypt_password = $svc_acct->crypt_password( $self->option('crypt') );
402 $ldap_password = $svc_acct->ldap_password( $self->option('crypt') );
404 @radius_groups = $svc_acct->radius_groups;
406 my ($reasonnum, $reasontext, $reasontypenum, $reasontypetext);
407 if ( $cust_pkg && $action eq 'suspend' &&
408 (my $r = $cust_pkg->last_reason('susp')) )
410 $reasonnum = $r->reasonnum;
411 $reasontext = $r->reason;
412 $reasontypenum = $r->reason_type;
413 $reasontypetext = $r->reasontype->type;
415 my %reasonmap = $self->_groups_susp_reason_map;
417 $userspec = $reasonmap{$reasonnum}
418 if exists($reasonmap{$reasonnum});
419 $userspec = $reasonmap{$reasontext}
420 if (!$userspec && exists($reasonmap{$reasontext}));
423 if ( $userspec =~ /^\d+$/ ) {
424 $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
425 } elsif ( $userspec =~ /^\S+\@\S+$/ ) {
426 my ($username,$domain) = split(/\@/, $userspec);
427 for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
428 $suspend_user = $user if $userspec eq $user->email;
430 } elsif ($userspec) {
431 $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
434 @radius_groups = $suspend_user->radius_groups
438 $reasonnum = $reasontext = $reasontypenum = $reasontypetext = '';
441 $pkgnum = $cust_pkg ? $cust_pkg->pkgnum : '';
442 $locationnum = $cust_pkg ? $cust_pkg->locationnum : '';
443 $custnum = $cust_pkg ? $cust_pkg->custnum : '';
445 my $stdin_string = eval(qq("$stdin"));
446 return "error filling in STDIN: $@" if $@;
448 $first = shell_quote $first;
449 $last = shell_quote $last;
450 $finger = shell_quote $finger;
451 $crypt_password = shell_quote $crypt_password;
452 $ldap_password = shell_quote $ldap_password;
454 $company = shell_quote $company;
455 $address1 = shell_quote $address1;
456 $address2 = shell_quote $address2;
457 $city = shell_quote $city;
458 $state = shell_quote $state;
459 $zip = shell_quote $zip;
460 $country = shell_quote $country;
461 $daytime = shell_quote $daytime;
462 $night = shell_quote $night;
463 $fax = shell_quote $fax;
464 $otaker = shell_quote $otaker;
465 $agent_custid = shell_quote $agent_custid;
466 $locale = shell_quote $locale;
468 my $command_string = eval(qq("$command"));
469 return "error filling in command: $@" if $@;
471 ( $command_string, $stdin_string );
474 sub _export_replace {
475 my($self, $new, $old ) = (shift, shift, shift);
476 my $command = $self->option('usermod');
477 return '' if $command =~ /^\s*$/;
478 my $stdin = $self->option('usermod_stdin');
480 my( $command_string, $stdin_string ) =
481 $self->_export_subvars_replace( $new, $old, $command, $stdin );
483 $self->ssh_or_queue( $new, $command_string, $stdin_string );
486 sub _export_subvars_replace {
487 my( $self, $new, $old, $command, $stdin ) = @_;
492 ${"old_$_"} = $old->getfield($_) foreach $old->fields;
493 ${"new_$_"} = $new->getfield($_) foreach $new->fields;
495 my $old_cust_pkg = $old->cust_svc->cust_pkg;
496 my $new_cust_pkg = $new->cust_svc->cust_pkg;
497 my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
499 $new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
500 ($new_first, $new_last ) = ( $1, $2 );
501 $quoted_new__password = shell_quote $new__password; #old, wrong?
502 $new_quoted_password = shell_quote $new__password; #new, better?
503 $old_domain = $old->domain;
504 $new_domain = $new->domain;
506 $new_crypt_password = $new->crypt_password( $self->option('crypt') );
507 $new_ldap_password = $new->ldap_password( $self->option('crypt') );
509 @old_radius_groups = $old->radius_groups;
510 @new_radius_groups = $new->radius_groups;
513 if ( $self->option('usermod_pwonly') || $self->option('usermod_nousername') ){
514 if ( $old_username ne $new_username ) {
515 $error ||= "can't change username";
518 if ( $self->option('usermod_pwonly') ) {
519 if ( $old_domain ne $new_domain ) {
520 $error ||= "can't change domain";
522 if ( $old_uid != $new_uid ) {
523 $error ||= "can't change uid";
525 if ( $old_gid != $new_gid ) {
526 $error ||= "can't change gid";
528 if ( $old_dir ne $new_dir ) {
529 $error ||= "can't change dir";
531 #if ( join("\n", sort @old_radius_groups) ne
532 # join("\n", sort @new_radius_groups) ) {
533 # $error ||= "can't change RADIUS groups";
536 return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($new). ')'
539 $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
540 $new_locale = $new_cust_main ? $new_cust_main->locale : '';
541 $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
542 $old_locationnum = $old_cust_pkg ? $old_cust_pkg->locationnum : '';
543 $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
544 $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
545 $new_locationnum = $new_cust_pkg ? $new_cust_pkg->locationnum : '';
546 $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
548 my $stdin_string = eval(qq("$stdin"));
550 $new_first = shell_quote $new_first;
551 $new_last = shell_quote $new_last;
552 $new_finger = shell_quote $new_finger;
553 $new_crypt_password = shell_quote $new_crypt_password;
554 $new_ldap_password = shell_quote $new_ldap_password;
555 $new_agent_custid = shell_quote $new_agent_custid;
556 $new_locale = shell_quote $new_locale;
558 my $command_string = eval(qq("$command"));
560 ( $command_string, $stdin_string );
563 #a good idea to queue anything that could fail or take any time
564 sub shellcommands_queue {
565 my( $self, $svcnum ) = (shift, shift);
566 my $queue = new FS::queue {
568 'job' => "FS::part_export::shellcommands::ssh_cmd",
570 $queue->insert( @_ );
573 sub ssh_cmd { #subroutine, not method
575 open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
576 my $ssh = Net::OpenSSH->new(
577 $opt->{'user'}.'@'.$opt->{'host'},
578 'default_stdin_fh' => $def_in
580 # ignore_all_errors doesn't override SSH connection/auth errors--
582 die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
585 $ssh_opt->{'stdin_data'} = $opt->{'stdin_string'}
586 if exists($opt->{'stdin_string'}) and length($opt->{'stdin_string'});
588 my ($output, $errput) = $ssh->capture2($ssh_opt, $opt->{'command'});
590 return if $opt->{'ignore_all_errors'};
591 #die "Error running SSH command: ". $ssh->error if $ssh->error;
593 if ( ($output || $errput)
594 && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})
596 my @ignored_errors = split('\n',$opt->{'ignored_errors'});
597 foreach my $ignored_error ( @ignored_errors ) {
598 $output =~ s/$ignored_error//g;
599 $errput =~ s/$ignored_error//g;
601 $output =~ s/[\s\n]//g;
602 $errput =~ s/[\s\n]//g;
605 die (($errput || $ssh->error). "\n") if $errput || $ssh->error;
606 #die "$errput\n" if $errput;
608 die "$output\n" if $output and $opt->{'fail_on_output'};
612 #sub shellcommands_insert { #subroutine, not method
614 #sub shellcommands_replace { #subroutine, not method
616 #sub shellcommands_delete { #subroutine, not method
619 sub _upgrade_exporttype {
621 $class =~ /^FS::part_export::(\w+)$/;
622 foreach my $self ( qsearch('part_export', { 'exporttype' => $1 }) ) {
623 my %options = $self->options;
625 # 2011-12-13 - 2012-02-16: ignore_all_output option
626 if ( $options{'ignore_all_output'} ) {
627 # ignoring STDOUT is now the default
628 $options{'ignore_all_errors'} = 1;
629 delete $options{'ignore_all_output'};
632 my $error = $self->replace(%options) if $changed;
633 die $error if $error;