1 package FS::part_export::shellcommands;
3 use vars qw(@ISA %info);
6 use String::ShellQuote;
8 use FS::Record qw( qsearch qsearchs );
10 @ISA = qw(FS::part_export);
12 tie my %options, 'Tie::IxHash',
14 'user' => { label=>'Remote username', default=>'root' },
16 'useradd' => { label=>'Insert command',
17 default=>'useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username'
18 #default=>'cp -pr /etc/skel $dir; chown -R $uid.$gid $dir'
20 'useradd_no_queue' => { label=>'Run immediately',
23 'useradd_stdin' => { label=>'Insert command STDIN',
28 'userdel' => { label=>'Delete command',
29 default=>'userdel -r $username',
30 #default=>'rm -rf $dir',
32 'userdel_no_queue' => { label=>'Run immediately',
35 'userdel_stdin' => { label=>'Delete command STDIN',
40 'usermod' => { label=>'Modify command',
41 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',
42 #default=>'[ -d $old_dir ] && mv $old_dir $new_dir || ( '.
43 # 'chmod u+t $old_dir; mkdir $new_dir; cd $old_dir; '.
44 # 'find . -depth -print | cpio -pdm $new_dir; '.
45 # 'chmod u-t $new_dir; chown -R $uid.$gid $new_dir; '.
49 'usermod_no_queue' => { label=>'Run immediately',
52 'usermod_stdin' => { label=>'Modify command STDIN',
56 'usermod_pwonly' => { label=>'Disallow username, domain, uid, gid, and dir changes', #and RADIUS group changes',
59 'usermod_nousername' => { label=>'Disallow just username changes',
63 'suspend' => { label=>'Suspension command',
64 default=>'usermod -L $username',
66 'suspend_no_queue' => { label=>'Run immediately',
69 'suspend_stdin' => { label=>'Suspension command STDIN',
73 'unsuspend' => { label=>'Unsuspension command',
74 default=>'usermod -U $username',
76 'unsuspend_no_queue' => { label=>'Run immediately',
79 'unsuspend_stdin' => { label=>'Unsuspension command STDIN',
83 'pkg_change' => { label=>'Package changed command',
87 # run commands on package change for multiple services and roll back the
88 # package change transaciton if one fails? yuck. no.
89 # if this was really needed, would need to restrict to a single service with
90 # this kind of export configured.
91 #'pkg_change_no_queue' => { label=>'Run immediately',
94 'pkg_change_stdin' => { label=>'Package changed command STDIN',
98 'crypt' => { label => 'Default password encryption',
99 type=>'select', options=>[qw(crypt md5)],
102 'groups_susp_reason' => { label =>
103 'Radius group mapping to reason (via template user)',
106 'fail_on_output' => {
107 label => 'Treat any output from the command as an error',
110 'ignore_all_errors' => {
111 label => 'Ignore all errors from the command',
114 'ignored_errors' => { label => 'Regexes of specific errors to ignore, separated by newlines',
117 # 'no_queue' => { label => 'Run command immediately',
118 # type => 'checkbox',
125 'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
126 'options' => \%options,
129 Run remote commands via SSH. Usernames are considered unique (also see
130 shellcommands_withdomain). You probably want this if the commands you are
131 running will not accept a domain as a parameter. You will need to
132 <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Administration:SSH_Keys">setup SSH for unattended operation</a>.
134 <BR><BR>Use these buttons for some useful presets:
137 <INPUT TYPE="button" VALUE="Linux" onClick='
138 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
139 this.form.useradd_stdin.value = "";
140 this.form.userdel.value = "userdel -r $username";
141 this.form.userdel_stdin.value="";
142 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";
143 this.form.usermod_stdin.value = "";
144 this.form.suspend.value = "usermod -L $username";
145 this.form.suspend_stdin.value="";
146 this.form.unsuspend.value = "usermod -U $username";
147 this.form.unsuspend_stdin.value="";
150 <INPUT TYPE="button" VALUE="FreeBSD before 4.10 / 5.3" onClick='
151 this.form.useradd.value = "lockf /etc/passwd.lock pw useradd $username -d $dir -m -s $shell -u $uid -c $finger -h 0";
152 this.form.useradd_stdin.value = "$_password\n";
153 this.form.userdel.value = "lockf /etc/passwd.lock pw userdel $username -r"; this.form.userdel_stdin.value="";
154 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";
155 this.form.usermod_stdin.value = "$new__password\n"; this.form.suspend.value = "lockf /etc/passwd.lock pw lock $username";
156 this.form.suspend_stdin.value="";
157 this.form.unsuspend.value = "lockf /etc/passwd.lock pw unlock $username"; this.form.unsuspend_stdin.value="";
159 Note: On FreeBSD versions before 5.3 and 4.10 (4.10 is after 4.9, not
160 4.1!), due to deficient locking in pw(1), you must disable the chpass(1),
161 chsh(1), chfn(1), passwd(1), and vipw(1) commands, or replace them with
162 wrappers that prepend "lockf /etc/passwd.lock". Alternatively, apply the
164 <A HREF="http://www.freebsd.org/cgi/query-pr.cgi?pr=23501">FreeBSD PR#23501</A>
165 and use the "FreeBSD 4.10 / 5.3 or later" button below.
167 <INPUT TYPE="button" VALUE="FreeBSD 4.10 / 5.3 or later" onClick='
168 this.form.useradd.value = "pw useradd $username -d $dir -m -s $shell -u $uid -g $gid -c $finger -h 0";
169 this.form.useradd_stdin.value = "$_password\n";
170 this.form.userdel.value = "pw userdel $username -r";
171 this.form.userdel_stdin.value="";
172 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";
173 this.form.usermod_stdin.value = "$new__password\n";
174 this.form.suspend.value = "pw lock $username";
175 this.form.suspend_stdin.value="";
176 this.form.unsuspend.value = "pw unlock $username";
177 this.form.unsuspend_stdin.value="";
180 <INPUT TYPE="button" VALUE="NetBSD/OpenBSD" onClick='
181 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
182 this.form.useradd_stdin.value = "";
183 this.form.userdel.value = "userdel -r $username";
184 this.form.userdel_stdin.value="";
185 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";
186 this.form.usermod_stdin.value = "";
187 this.form.suspend.value = "";
188 this.form.suspend_stdin.value="";
189 this.form.unsuspend.value = "";
190 this.form.unsuspend_stdin.value="";
193 <INPUT TYPE="button" VALUE="Just maintain directories (use with sysvshell or bsdshell)" onClick='
194 this.form.useradd.value = "cp -pr /etc/skel $dir; chown -R $uid.$gid $dir"; this.form.useradd_stdin.value = "";
195 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 )";
196 this.form.usermod_stdin.value = "";
197 this.form.userdel.value = "rm -rf $dir";
198 this.form.userdel_stdin.value="";
199 this.form.suspend.value = "";
200 this.form.suspend_stdin.value="";
201 this.form.unsuspend.value = "";
202 this.form.unsuspend_stdin.value="";
206 The following variables are available for interpolation (prefixed with new_ or
207 old_ for replace operations):
209 <LI><code>$username</code>
210 <LI><code>$_password</code>
211 <LI><code>$quoted_password</code> - unencrypted password, already quoted for the shell (do not add additional quotes).
212 <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).
213 <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).
214 <LI><code>$uid</code>
215 <LI><code>$gid</code>
216 <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).
217 <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).
218 <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).
219 <LI><code>$dir</code> - home directory
220 <LI><code>$shell</code>
221 <LI><code>$quota</code>
222 <LI><code>@radius_groups</code>
223 <LI><code>$reasonnum (when suspending)</code>
224 <LI><code>$reasontext (when suspending)</code>
225 <LI><code>$reasontypenum (when suspending)</code>
226 <LI><code>$reasontypetext (when suspending)</code>
227 <LI><code>$pkgnum</code>
228 <LI><code>$custnum</code>
229 <LI>All other fields in <b>svc_acct</b> are also available.
230 <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).
232 For the package changed command only, the following fields are also available:
234 <LI>$old_pkgnum and $new_pkgnum
235 <LI>$old_pkgpart and $new_pkgpart
236 <LI>$old_agent_pkgid and $new_agent_pkgid
237 <LI>$old_order_date and $new_order_date
238 <LI>$old_start_date and $new_start_date
239 <LI>$old_setup and $new_setup
240 <LI>$old_bill and $new_bill
241 <LI>$old_last_bill and $new_last_bill
242 <LI>$old_susp and $new_susp
243 <LI>$old_adjourn and $new_adjourn
244 <LI>$old_resume and $new_resume
245 <LI>$old_cancel and $new_cancel
246 <LI>$old_unancel and $new_unancel
247 <LI>$old_expire and $new_expire
248 <LI>$old_contract_end and $new_contract_end
253 sub _groups_susp_reason_map { shift->_map('groups_susp_reason'); }
257 map { reverse(/^\s*(\S+)\s*(.*)\s*$/) } split("\n", $self->option(shift) );
260 sub rebless { shift; }
264 $self->_export_command('useradd', @_);
269 $self->_export_command('userdel', @_);
272 sub _export_suspend {
274 $self->_export_command_or_super('suspend', @_);
277 sub _export_unsuspend {
279 $self->_export_command_or_super('unsuspend', @_);
282 sub export_pkg_change {
283 my( $self, $svc_acct, $new_cust_pkg, $old_cust_pkg ) = @_;
285 my @fields = qw( pkgnum pkgpart agent_pkgid ); #others?
286 my @date_fields = qw( order_date start_date setup bill last_bill susp adjourn
287 resume cancel uncancel expore contract_end );
293 ${"old_$_"} = $old_cust_pkg->getfield($_);
294 ${"new_$_"} = $new_cust_pkg->getfield($_);
296 foreach (@date_fields) {
297 ${"old_$_"} = time2str('%Y-%m-%d', $old_cust_pkg->getfield($_));
298 ${"new_$_"} = time2str('%Y-%m-%d', $new_cust_pkg->getfield($_));
302 $self->_export_command('pkg_change', $svc_acct);
305 sub _export_command_or_super {
306 my($self, $action) = (shift, shift);
307 if ( $self->option($action) =~ /^\s*$/ ) {
308 my $method = "SUPER::_export_$action";
311 $self->_export_command($action, @_);
315 sub _export_command {
316 my ( $self, $action, $svc_acct) = (shift, shift, shift);
317 my $command = $self->option($action);
319 return '' if $command =~ /^\s*$/;
320 my $stdin = $self->option($action."_stdin");
325 ${$_} = $svc_acct->getfield($_) foreach $svc_acct->fields;
327 # snarfs are unused at this point?
329 # foreach my $acct_snarf ( $svc_acct->acct_snarf ) {
330 # ${"snarf_$_$count"} = shell_quote( $acct_snarf->get($_) )
331 # foreach qw( machine username _password );
336 my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
341 foreach my $custf (qw( company address1 address2 city state zip country
342 daytime night fax otaker agent_custid locale
345 ${$custf} = $cust_pkg->cust_main->$custf();
348 $email = ( grep { $_ !~ /^(POST|FAX)$/ } $cust_pkg->cust_main->invoicing_list )[0];
353 $finger =~ /^(.*)\s+(\S+)$/ or $finger =~ /^((.*))$/;
354 ($first, $last ) = ( $1, $2 );
355 $domain = $svc_acct->domain;
357 $quoted_password = shell_quote $_password;
359 $crypt_password = $svc_acct->crypt_password( $self->option('crypt') );
360 $ldap_password = $svc_acct->ldap_password( $self->option('crypt') );
362 @radius_groups = $svc_acct->radius_groups;
364 my ($reasonnum, $reasontext, $reasontypenum, $reasontypetext);
365 if ( $cust_pkg && $action eq 'suspend' &&
366 (my $r = $cust_pkg->last_reason('susp')) )
368 $reasonnum = $r->reasonnum;
369 $reasontext = $r->reason;
370 $reasontypenum = $r->reason_type;
371 $reasontypetext = $r->reasontype->type;
373 my %reasonmap = $self->_groups_susp_reason_map;
375 $userspec = $reasonmap{$reasonnum}
376 if exists($reasonmap{$reasonnum});
377 $userspec = $reasonmap{$reasontext}
378 if (!$userspec && exists($reasonmap{$reasontext}));
381 if ( $userspec =~ /^\d+$/ ) {
382 $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
383 } elsif ( $userspec =~ /^\S+\@\S+$/ ) {
384 my ($username,$domain) = split(/\@/, $userspec);
385 for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
386 $suspend_user = $user if $userspec eq $user->email;
388 } elsif ($userspec) {
389 $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
392 @radius_groups = $suspend_user->radius_groups
396 $reasonnum = $reasontext = $reasontypenum = $reasontypetext = '';
399 $pkgnum = $cust_pkg ? $cust_pkg->pkgnum : '';
400 $custnum = $cust_pkg ? $cust_pkg->custnum : '';
402 my $stdin_string = eval(qq("$stdin"));
403 return "error filling in STDIN: $@" if $@;
405 $first = shell_quote $first;
406 $last = shell_quote $last;
407 $finger = shell_quote $finger;
408 $crypt_password = shell_quote $crypt_password;
409 $ldap_password = shell_quote $ldap_password;
411 $company = shell_quote $company;
412 $address1 = shell_quote $address1;
413 $address2 = shell_quote $address2;
414 $city = shell_quote $city;
415 $state = shell_quote $state;
416 $zip = shell_quote $zip;
417 $country = shell_quote $country;
418 $daytime = shell_quote $daytime;
419 $night = shell_quote $night;
420 $fax = shell_quote $fax;
421 $otaker = shell_quote $otaker;
422 $agent_custid = shell_quote $agent_custid;
423 $locale = shell_quote $locale;
425 my $command_string = eval(qq("$command"));
426 return "error filling in command: $@" if $@;
429 user => $self->option('user') || 'root',
430 host => $self->machine,
431 command => $command_string,
432 stdin_string => $stdin_string,
433 ignored_errors => $self->option('ignored_errors') || '',
434 ignore_all_errors => $self->option('ignore_all_errors'),
435 fail_on_output => $self->option('fail_on_output'),
438 if ( $self->option($action. '_no_queue') ) {
439 # discard return value just like freeside-queued.
440 eval { ssh_cmd(@ssh_cmd_args) };
442 $error = $error->full_message if ref $error; # Exception::Class::Base
443 return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
446 $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
450 sub _export_replace {
451 my($self, $new, $old ) = (shift, shift, shift);
452 my $command = $self->option('usermod');
453 return '' if $command =~ /^\s*$/;
454 my $stdin = $self->option('usermod_stdin');
458 ${"old_$_"} = $old->getfield($_) foreach $old->fields;
459 ${"new_$_"} = $new->getfield($_) foreach $new->fields;
461 my $old_cust_pkg = $old->cust_svc->cust_pkg;
462 my $new_cust_pkg = $new->cust_svc->cust_pkg;
463 my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
465 $new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
466 ($new_first, $new_last ) = ( $1, $2 );
467 $quoted_new__password = shell_quote $new__password; #old, wrong?
468 $new_quoted_password = shell_quote $new__password; #new, better?
469 $old_domain = $old->domain;
470 $new_domain = $new->domain;
472 $new_crypt_password = $new->crypt_password( $self->option('crypt') );
473 $new_ldap_password = $new->ldap_password( $self->option('crypt') );
475 @old_radius_groups = $old->radius_groups;
476 @new_radius_groups = $new->radius_groups;
479 if ( $self->option('usermod_pwonly') || $self->option('usermod_nousername') ){
480 if ( $old_username ne $new_username ) {
481 $error ||= "can't change username";
484 if ( $self->option('usermod_pwonly') ) {
485 if ( $old_domain ne $new_domain ) {
486 $error ||= "can't change domain";
488 if ( $old_uid != $new_uid ) {
489 $error ||= "can't change uid";
491 if ( $old_gid != $new_gid ) {
492 $error ||= "can't change gid";
494 if ( $old_dir ne $new_dir ) {
495 $error ||= "can't change dir";
497 #if ( join("\n", sort @old_radius_groups) ne
498 # join("\n", sort @new_radius_groups) ) {
499 # $error ||= "can't change RADIUS groups";
502 return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
505 $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
506 $new_locale = $new_cust_main ? $new_cust_main->locale : '';
507 $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
508 $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
509 $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
510 $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
512 my $stdin_string = eval(qq("$stdin"));
514 $new_first = shell_quote $new_first;
515 $new_last = shell_quote $new_last;
516 $new_finger = shell_quote $new_finger;
517 $new_crypt_password = shell_quote $new_crypt_password;
518 $new_ldap_password = shell_quote $new_ldap_password;
519 $new_agent_custid = shell_quote $new_agent_custid;
520 $new_locale = shell_quote $new_locale;
522 my $command_string = eval(qq("$command"));
525 user => $self->option('user') || 'root',
526 host => $self->machine,
527 command => $command_string,
528 stdin_string => $stdin_string,
529 ignored_errors => $self->option('ignored_errors') || '',
530 ignore_all_errors => $self->option('ignore_all_errors'),
531 fail_on_output => $self->option('fail_on_output'),
534 if($self->option('usermod_no_queue')) {
535 # discard return value just like freeside-queued.
536 eval { ssh_cmd(@ssh_cmd_args) };
538 $error = $error->full_message if ref $error; # Exception::Class::Base
539 return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
543 $self->shellcommands_queue( $new->svcnum, @ssh_cmd_args );
547 #a good idea to queue anything that could fail or take any time
548 sub shellcommands_queue {
549 my( $self, $svcnum ) = (shift, shift);
550 my $queue = new FS::queue {
552 'job' => "FS::part_export::shellcommands::ssh_cmd",
554 $queue->insert( @_ );
557 sub ssh_cmd { #subroutine, not method
560 open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
561 my $ssh = Net::OpenSSH->new(
562 $opt->{'user'}.'@'.$opt->{'host'},
563 'default_stdin_fh' => $def_in
565 # ignore_all_errors doesn't override SSH connection/auth errors--
567 die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
570 $ssh_opt->{'stdin_data'} = $opt->{'stdin_string'}
571 if exists($opt->{'stdin_string'}) and length($opt->{'stdin_string'});
573 my ($output, $errput) = $ssh->capture2($ssh_opt, $opt->{'command'});
575 return if $opt->{'ignore_all_errors'};
576 die "Error running SSH command: ". $ssh->error if $ssh->error;
578 if ( ($output || $errput)
579 && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})
581 my @ignored_errors = split('\n',$opt->{'ignored_errors'});
582 foreach my $ignored_error ( @ignored_errors ) {
583 $output =~ s/$ignored_error//g;
584 $errput =~ s/$ignored_error//g;
586 $output =~ s/[\s\n]//g;
587 $errput =~ s/[\s\n]//g;
590 die "$errput\n" if $errput;
591 die "$output\n" if $output and $opt->{'fail_on_output'};
595 #sub shellcommands_insert { #subroutine, not method
597 #sub shellcommands_replace { #subroutine, not method
599 #sub shellcommands_delete { #subroutine, not method
602 sub _upgrade_exporttype {
604 $class =~ /^FS::part_export::(\w+)$/;
605 foreach my $self ( qsearch('part_export', { 'exporttype' => $1 }) ) {
606 my %options = $self->options;
608 # 2011-12-13 - 2012-02-16: ignore_all_output option
609 if ( $options{'ignore_all_output'} ) {
610 # ignoring STDOUT is now the default
611 $options{'ignore_all_errors'} = 1;
612 delete $options{'ignore_all_output'};
615 my $error = $self->replace(%options) if $changed;
616 die $error if $error;