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',
124 'desc' => 'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
125 '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" onClick='
151 this.form.useradd.value = "pw useradd $username -d $dir -m -s $shell -u $uid -g $gid -c $finger -h 0";
152 this.form.useradd_stdin.value = "$_password\n";
153 this.form.userdel.value = "pw userdel $username -r";
154 this.form.userdel_stdin.value="";
155 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";
156 this.form.usermod_stdin.value = "$new__password\n";
157 this.form.suspend.value = "pw lock $username";
158 this.form.suspend_stdin.value="";
159 this.form.unsuspend.value = "pw unlock $username";
160 this.form.unsuspend_stdin.value="";
163 <INPUT TYPE="button" VALUE="NetBSD/OpenBSD" onClick='
164 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
165 this.form.useradd_stdin.value = "";
166 this.form.userdel.value = "userdel -r $username";
167 this.form.userdel_stdin.value="";
168 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";
169 this.form.usermod_stdin.value = "";
170 this.form.suspend.value = "";
171 this.form.suspend_stdin.value="";
172 this.form.unsuspend.value = "";
173 this.form.unsuspend_stdin.value="";
176 <INPUT TYPE="button" VALUE="Just maintain directories (use with sysvshell or bsdshell)" onClick='
177 this.form.useradd.value = "cp -pr /etc/skel $dir; chown -R $uid.$gid $dir"; this.form.useradd_stdin.value = "";
178 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 )";
179 this.form.usermod_stdin.value = "";
180 this.form.userdel.value = "rm -rf $dir";
181 this.form.userdel_stdin.value="";
182 this.form.suspend.value = "";
183 this.form.suspend_stdin.value="";
184 this.form.unsuspend.value = "";
185 this.form.unsuspend_stdin.value="";
189 The following variables are available for interpolation (prefixed with new_ or
190 old_ for replace operations):
192 <LI><code>$username</code>
193 <LI><code>$_password</code>
194 <LI><code>$quoted_password</code> - unencrypted password, already quoted for the shell (do not add additional quotes).
195 <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).
196 <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).
197 <LI><code>$uid</code>
198 <LI><code>$gid</code>
199 <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).
200 <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).
201 <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).
202 <LI><code>$dir</code> - home directory
203 <LI><code>$shell</code>
204 <LI><code>$quota</code>
205 <LI><code>@radius_groups</code>
206 <LI><code>$reasonnum (when suspending)</code>
207 <LI><code>$reasontext (when suspending)</code>
208 <LI><code>$reasontypenum (when suspending)</code>
209 <LI><code>$reasontypetext (when suspending)</code>
210 <LI><code>$pkgnum</code>
211 <LI><code>$locationnum</code>
212 <LI><code>$custnum</code>
213 <LI>All other fields in <b>svc_acct</b> are also available.
214 <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).
216 For the package changed command only, the following fields are also available:
218 <LI>$old_pkgnum and $new_pkgnum
219 <LI>$old_pkgpart and $new_pkgpart
220 <LI>$old_agent_pkgid and $new_agent_pkgid
221 <LI>$old_order_date and $new_order_date
222 <LI>$old_start_date and $new_start_date
223 <LI>$old_setup and $new_setup
224 <LI>$old_bill and $new_bill
225 <LI>$old_last_bill and $new_last_bill
226 <LI>$old_susp and $new_susp
227 <LI>$old_adjourn and $new_adjourn
228 <LI>$old_resume and $new_resume
229 <LI>$old_cancel and $new_cancel
230 <LI>$old_unancel and $new_unancel
231 <LI>$old_expire and $new_expire
232 <LI>$old_contract_end and $new_contract_end
237 sub _groups_susp_reason_map { shift->_map('groups_susp_reason'); }
241 map { reverse(/^\s*(\S+)\s*(.*)\s*$/) } split("\n", $self->option(shift) );
244 sub rebless { shift; }
248 $self->_export_command('useradd', @_);
253 $self->_export_command('userdel', @_);
256 sub _export_suspend {
258 $self->_export_command_or_super('suspend', @_);
261 sub _export_unsuspend {
263 $self->_export_command_or_super('unsuspend', @_);
266 sub export_pkg_change {
267 my( $self, $svc_acct, $new_cust_pkg, $old_cust_pkg ) = @_;
269 my @fields = qw( pkgnum pkgpart agent_pkgid ); #others?
270 my @date_fields = qw( order_date start_date setup bill last_bill susp adjourn
271 resume cancel uncancel expire contract_end );
277 ${"old_$_"} = $old_cust_pkg ? $old_cust_pkg->getfield($_) : '';
278 ${"new_$_"} = $new_cust_pkg->getfield($_);
280 foreach (@date_fields) {
281 ${"old_$_"} = $old_cust_pkg
282 ? time2str('%Y-%m-%d', $old_cust_pkg->getfield($_))
284 ${"new_$_"} = time2str('%Y-%m-%d', $new_cust_pkg->getfield($_));
288 $self->_export_command('pkg_change', $svc_acct);
291 sub _export_command_or_super {
292 my($self, $action) = (shift, shift);
293 if ( $self->option($action) =~ /^\s*$/ ) {
294 my $method = "SUPER::_export_$action";
297 $self->_export_command($action, @_);
301 sub _export_command {
302 my ( $self, $action, $svc_acct) = (shift, shift, shift);
303 my $command = $self->option($action);
305 return '' if $command =~ /^\s*$/;
306 my $stdin = $self->option($action."_stdin");
311 ${$_} = $svc_acct->getfield($_) foreach $svc_acct->fields;
313 # snarfs are unused at this point?
315 # foreach my $acct_snarf ( $svc_acct->acct_snarf ) {
316 # ${"snarf_$_$count"} = shell_quote( $acct_snarf->get($_) )
317 # foreach qw( machine username _password );
322 my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
327 foreach my $custf (qw( company address1 address2 city state zip country
328 daytime night fax otaker agent_custid locale
331 ${$custf} = $cust_pkg->cust_main->$custf();
334 $email = ( grep { $_ !~ /^(POST|FAX)$/ } $cust_pkg->cust_main->invoicing_list )[0];
339 $finger =~ /^(.*)\s+(\S+)$/ or $finger =~ /^((.*))$/;
340 ($first, $last ) = ( $1, $2 );
341 $domain = $svc_acct->domain;
343 $quoted_password = shell_quote $_password;
345 $crypt_password = $svc_acct->crypt_password( $self->option('crypt') );
346 $ldap_password = $svc_acct->ldap_password( $self->option('crypt') );
348 @radius_groups = $svc_acct->radius_groups;
350 my ($reasonnum, $reasontext, $reasontypenum, $reasontypetext);
351 if ( $cust_pkg && $action eq 'suspend' &&
352 (my $r = $cust_pkg->last_reason('susp')) )
354 $reasonnum = $r->reasonnum;
355 $reasontext = $r->reason;
356 $reasontypenum = $r->reason_type;
357 $reasontypetext = $r->reasontype->type;
359 my %reasonmap = $self->_groups_susp_reason_map;
361 $userspec = $reasonmap{$reasonnum}
362 if exists($reasonmap{$reasonnum});
363 $userspec = $reasonmap{$reasontext}
364 if (!$userspec && exists($reasonmap{$reasontext}));
367 if ( $userspec =~ /^\d+$/ ) {
368 $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
369 } elsif ( $userspec =~ /^\S+\@\S+$/ ) {
370 my ($username,$domain) = split(/\@/, $userspec);
371 for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
372 $suspend_user = $user if $userspec eq $user->email;
374 } elsif ($userspec) {
375 $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
378 @radius_groups = $suspend_user->radius_groups
382 $reasonnum = $reasontext = $reasontypenum = $reasontypetext = '';
385 $pkgnum = $cust_pkg ? $cust_pkg->pkgnum : '';
386 $locationnum = $cust_pkg ? $cust_pkg->locationnum : '';
387 $custnum = $cust_pkg ? $cust_pkg->custnum : '';
389 my $stdin_string = eval(qq("$stdin"));
390 return "error filling in STDIN: $@" if $@;
392 $first = shell_quote $first;
393 $last = shell_quote $last;
394 $finger = shell_quote $finger;
395 $crypt_password = shell_quote $crypt_password;
396 $ldap_password = shell_quote $ldap_password;
398 $company = shell_quote $company;
399 $address1 = shell_quote $address1;
400 $address2 = shell_quote $address2;
401 $city = shell_quote $city;
402 $state = shell_quote $state;
403 $zip = shell_quote $zip;
404 $country = shell_quote $country;
405 $daytime = shell_quote $daytime;
406 $night = shell_quote $night;
407 $fax = shell_quote $fax;
408 $otaker = shell_quote $otaker;
409 $agent_custid = shell_quote $agent_custid;
410 $locale = shell_quote $locale;
412 my $command_string = eval(qq("$command"));
413 return "error filling in command: $@" if $@;
416 user => $self->option('user') || 'root',
417 host => $self->svc_machine($svc_acct),
418 command => $command_string,
419 stdin_string => $stdin_string,
420 ignored_errors => $self->option('ignored_errors') || '',
421 ignore_all_errors => $self->option('ignore_all_errors'),
422 fail_on_output => $self->option('fail_on_output'),
425 if ( $self->option($action. '_no_queue') ) {
426 # discard return value just like freeside-queued.
427 eval { ssh_cmd(@ssh_cmd_args) };
429 $error = $error->full_message if ref $error; # Exception::Class::Base
431 ' ('. $self->exporttype. ' to '. $self->svc_machine($svc_acct). ')'
434 $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
438 sub _export_replace {
439 my($self, $new, $old ) = (shift, shift, shift);
440 my $command = $self->option('usermod');
441 return '' if $command =~ /^\s*$/;
442 my $stdin = $self->option('usermod_stdin');
446 ${"old_$_"} = $old->getfield($_) foreach $old->fields;
447 ${"new_$_"} = $new->getfield($_) foreach $new->fields;
449 my $old_cust_pkg = $old->cust_svc->cust_pkg;
450 my $new_cust_pkg = $new->cust_svc->cust_pkg;
451 my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
453 $new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
454 ($new_first, $new_last ) = ( $1, $2 );
455 $quoted_new__password = shell_quote $new__password; #old, wrong?
456 $new_quoted_password = shell_quote $new__password; #new, better?
457 $old_domain = $old->domain;
458 $new_domain = $new->domain;
460 $new_crypt_password = $new->crypt_password( $self->option('crypt') );
461 $new_ldap_password = $new->ldap_password( $self->option('crypt') );
463 @old_radius_groups = $old->radius_groups;
464 @new_radius_groups = $new->radius_groups;
467 if ( $self->option('usermod_pwonly') || $self->option('usermod_nousername') ){
468 if ( $old_username ne $new_username ) {
469 $error ||= "can't change username";
472 if ( $self->option('usermod_pwonly') ) {
473 if ( $old_domain ne $new_domain ) {
474 $error ||= "can't change domain";
476 if ( $old_uid != $new_uid ) {
477 $error ||= "can't change uid";
479 if ( $old_gid != $new_gid ) {
480 $error ||= "can't change gid";
482 if ( $old_dir ne $new_dir ) {
483 $error ||= "can't change dir";
485 #if ( join("\n", sort @old_radius_groups) ne
486 # join("\n", sort @new_radius_groups) ) {
487 # $error ||= "can't change RADIUS groups";
490 return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($new). ')'
493 $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
494 $new_locale = $new_cust_main ? $new_cust_main->locale : '';
495 $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
496 $old_locationnum = $old_cust_pkg ? $old_cust_pkg->locationnum : '';
497 $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
498 $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
499 $new_locationnum = $new_cust_pkg ? $new_cust_pkg->locationnum : '';
500 $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
502 my $stdin_string = eval(qq("$stdin"));
504 $new_first = shell_quote $new_first;
505 $new_last = shell_quote $new_last;
506 $new_finger = shell_quote $new_finger;
507 $new_crypt_password = shell_quote $new_crypt_password;
508 $new_ldap_password = shell_quote $new_ldap_password;
509 $new_agent_custid = shell_quote $new_agent_custid;
510 $new_locale = shell_quote $new_locale;
512 my $command_string = eval(qq("$command"));
515 user => $self->option('user') || 'root',
516 host => $self->svc_machine($new),
517 command => $command_string,
518 stdin_string => $stdin_string,
519 ignored_errors => $self->option('ignored_errors') || '',
520 ignore_all_errors => $self->option('ignore_all_errors'),
521 fail_on_output => $self->option('fail_on_output'),
524 if($self->option('usermod_no_queue')) {
525 # discard return value just like freeside-queued.
526 eval { ssh_cmd(@ssh_cmd_args) };
528 $error = $error->full_message if ref $error; # Exception::Class::Base
529 return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($new). ')'
533 $self->shellcommands_queue( $new->svcnum, @ssh_cmd_args );
537 #a good idea to queue anything that could fail or take any time
538 sub shellcommands_queue {
539 my( $self, $svcnum ) = (shift, shift);
540 my $queue = new FS::queue {
542 'job' => "FS::part_export::shellcommands::ssh_cmd",
544 $queue->insert( @_ );
547 sub ssh_cmd { #subroutine, not method
550 open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
551 my $ssh = Net::OpenSSH->new(
552 $opt->{'user'}.'@'.$opt->{'host'},
553 'default_stdin_fh' => $def_in
555 # ignore_all_errors doesn't override SSH connection/auth errors--
557 die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
560 $ssh_opt->{'stdin_data'} = $opt->{'stdin_string'}
561 if exists($opt->{'stdin_string'}) and length($opt->{'stdin_string'});
563 my ($output, $errput) = $ssh->capture2($ssh_opt, $opt->{'command'});
565 return if $opt->{'ignore_all_errors'};
566 #die "Error running SSH command: ". $ssh->error if $ssh->error;
568 if ( ($output || $errput)
569 && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})
571 my @ignored_errors = split('\n',$opt->{'ignored_errors'});
572 foreach my $ignored_error ( @ignored_errors ) {
573 $output =~ s/$ignored_error//g;
574 $errput =~ s/$ignored_error//g;
576 $output =~ s/[\s\n]//g;
577 $errput =~ s/[\s\n]//g;
580 die (($errput || $ssh->error). "\n") if $errput || $ssh->error;
581 #die "$errput\n" if $errput;
583 die "$output\n" if $output and $opt->{'fail_on_output'};
587 #sub shellcommands_insert { #subroutine, not method
589 #sub shellcommands_replace { #subroutine, not method
591 #sub shellcommands_delete { #subroutine, not method
594 sub _upgrade_exporttype {
596 $class =~ /^FS::part_export::(\w+)$/;
597 foreach my $self ( qsearch('part_export', { 'exporttype' => $1 }) ) {
598 my %options = $self->options;
600 # 2011-12-13 - 2012-02-16: ignore_all_output option
601 if ( $options{'ignore_all_output'} ) {
602 # ignoring STDOUT is now the default
603 $options{'ignore_all_errors'} = 1;
604 delete $options{'ignore_all_output'};
607 my $error = $self->replace(%options) if $changed;
608 die $error if $error;