1 package FS::part_export::shellcommands;
3 use vars qw(@ISA %info);
5 use String::ShellQuote;
7 use FS::Record qw( qsearch qsearchs );
9 @ISA = qw(FS::part_export);
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'
17 'useradd_no_queue' => { label=>'Run immediately',
20 'useradd_stdin' => { label=>'Insert command STDIN',
24 'userdel' => { label=>'Delete command',
25 default=>'userdel -r $username',
26 #default=>'rm -rf $dir',
28 'userdel_no_queue' => { label=>'Run immediately',
31 'userdel_stdin' => { label=>'Delete command STDIN',
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; '.
44 'usermod_no_queue' => { label=>'Run immediately',
47 'usermod_stdin' => { label=>'Modify command STDIN',
51 'usermod_pwonly' => { label=>'Disallow username, domain, uid, gid, and dir changes', #and RADIUS group changes',
54 'usermod_nousername' => { label=>'Disallow just username changes',
57 'suspend' => { label=>'Suspension command',
58 default=>'usermod -L $username',
60 'suspend_no_queue' => { label=>'Run immediately',
63 'suspend_stdin' => { label=>'Suspension command STDIN',
66 'unsuspend' => { label=>'Unsuspension command',
67 default=>'usermod -U $username',
69 'unsuspend_no_queue' => { label=>'Run immediately',
72 'unsuspend_stdin' => { label=>'Unsuspension command STDIN',
75 'crypt' => { label => 'Default password encryption',
76 type=>'select', options=>[qw(crypt md5)],
79 'groups_susp_reason' => { label =>
80 'Radius group mapping to reason (via template user)',
84 label => 'Treat any output from the command as an error',
87 'ignore_all_errors' => {
88 label => 'Ignore all errors from the command',
91 'ignored_errors' => { label => 'Regexes of specific errors to ignore, separated by newlines',
94 # 'no_queue' => { label => 'Run command immediately',
102 'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
103 'options' => \%options,
106 Run remote commands via SSH. Usernames are considered unique (also see
107 shellcommands_withdomain). You probably want this if the commands you are
108 running will not accept a domain as a parameter. You will need to
109 <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Administration:SSH_Keys">setup SSH for unattended operation</a>.
111 <BR><BR>Use these buttons for some useful presets:
114 <INPUT TYPE="button" VALUE="Linux" onClick='
115 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
116 this.form.useradd_stdin.value = "";
117 this.form.userdel.value = "userdel -r $username";
118 this.form.userdel_stdin.value="";
119 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";
120 this.form.usermod_stdin.value = "";
121 this.form.suspend.value = "usermod -L $username";
122 this.form.suspend_stdin.value="";
123 this.form.unsuspend.value = "usermod -U $username";
124 this.form.unsuspend_stdin.value="";
127 <INPUT TYPE="button" VALUE="FreeBSD before 4.10 / 5.3" onClick='
128 this.form.useradd.value = "lockf /etc/passwd.lock pw useradd $username -d $dir -m -s $shell -u $uid -c $finger -h 0";
129 this.form.useradd_stdin.value = "$_password\n";
130 this.form.userdel.value = "lockf /etc/passwd.lock pw userdel $username -r"; this.form.userdel_stdin.value="";
131 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";
132 this.form.usermod_stdin.value = "$new__password\n"; this.form.suspend.value = "lockf /etc/passwd.lock pw lock $username";
133 this.form.suspend_stdin.value="";
134 this.form.unsuspend.value = "lockf /etc/passwd.lock pw unlock $username"; this.form.unsuspend_stdin.value="";
136 Note: On FreeBSD versions before 5.3 and 4.10 (4.10 is after 4.9, not
137 4.1!), due to deficient locking in pw(1), you must disable the chpass(1),
138 chsh(1), chfn(1), passwd(1), and vipw(1) commands, or replace them with
139 wrappers that prepend "lockf /etc/passwd.lock". Alternatively, apply the
141 <A HREF="http://www.freebsd.org/cgi/query-pr.cgi?pr=23501">FreeBSD PR#23501</A>
142 and use the "FreeBSD 4.10 / 5.3 or later" button below.
144 <INPUT TYPE="button" VALUE="FreeBSD 4.10 / 5.3 or later" onClick='
145 this.form.useradd.value = "pw useradd $username -d $dir -m -s $shell -u $uid -g $gid -c $finger -h 0";
146 this.form.useradd_stdin.value = "$_password\n";
147 this.form.userdel.value = "pw userdel $username -r";
148 this.form.userdel_stdin.value="";
149 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";
150 this.form.usermod_stdin.value = "$new__password\n";
151 this.form.suspend.value = "pw lock $username";
152 this.form.suspend_stdin.value="";
153 this.form.unsuspend.value = "pw unlock $username";
154 this.form.unsuspend_stdin.value="";
157 <INPUT TYPE="button" VALUE="NetBSD/OpenBSD" onClick='
158 this.form.useradd.value = "useradd -c $finger -d $dir -m -s $shell -u $uid -p $crypt_password $username";
159 this.form.useradd_stdin.value = "";
160 this.form.userdel.value = "userdel -r $username";
161 this.form.userdel_stdin.value="";
162 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";
163 this.form.usermod_stdin.value = "";
164 this.form.suspend.value = "";
165 this.form.suspend_stdin.value="";
166 this.form.unsuspend.value = "";
167 this.form.unsuspend_stdin.value="";
170 <INPUT TYPE="button" VALUE="Just maintain directories (use with sysvshell or bsdshell)" onClick='
171 this.form.useradd.value = "cp -pr /etc/skel $dir; chown -R $uid.$gid $dir"; this.form.useradd_stdin.value = "";
172 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 )";
173 this.form.usermod_stdin.value = "";
174 this.form.userdel.value = "rm -rf $dir";
175 this.form.userdel_stdin.value="";
176 this.form.suspend.value = "";
177 this.form.suspend_stdin.value="";
178 this.form.unsuspend.value = "";
179 this.form.unsuspend_stdin.value="";
183 The following variables are available for interpolation (prefixed with new_ or
184 old_ for replace operations):
186 <LI><code>$username</code>
187 <LI><code>$_password</code>
188 <LI><code>$quoted_password</code> - unencrypted password, already quoted for the shell (do not add additional quotes).
189 <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).
190 <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).
191 <LI><code>$uid</code>
192 <LI><code>$gid</code>
193 <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).
194 <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).
195 <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).
196 <LI><code>$dir</code> - home directory
197 <LI><code>$shell</code>
198 <LI><code>$quota</code>
199 <LI><code>@radius_groups</code>
200 <LI><code>$reasonnum (when suspending)</code>
201 <LI><code>$reasontext (when suspending)</code>
202 <LI><code>$reasontypenum (when suspending)</code>
203 <LI><code>$reasontypetext (when suspending)</code>
204 <LI><code>$pkgnum</code>
205 <LI><code>$custnum</code>
206 <LI>All other fields in <b>svc_acct</b> are also available.
207 <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).
212 sub _groups_susp_reason_map { shift->_map('groups_susp_reason'); }
216 map { reverse(/^\s*(\S+)\s*(.*)\s*$/) } split("\n", $self->option(shift) );
219 sub rebless { shift; }
223 $self->_export_command('useradd', @_);
228 $self->_export_command('userdel', @_);
231 sub _export_suspend {
233 $self->_export_command_or_super('suspend', @_);
236 sub _export_unsuspend {
238 $self->_export_command_or_super('unsuspend', @_);
241 sub _export_command_or_super {
242 my($self, $action) = (shift, shift);
243 if ( $self->option($action) =~ /^\s*$/ ) {
244 my $method = "SUPER::_export_$action";
247 $self->_export_command($action, @_);
251 sub _export_command {
252 my ( $self, $action, $svc_acct) = (shift, shift, shift);
253 my $command = $self->option($action);
254 return '' if $command =~ /^\s*$/;
255 my $stdin = $self->option($action."_stdin");
260 ${$_} = $svc_acct->getfield($_) foreach $svc_acct->fields;
262 # snarfs are unused at this point?
264 foreach my $acct_snarf ( $svc_acct->acct_snarf ) {
265 ${"snarf_$_$count"} = shell_quote( $acct_snarf->get($_) )
266 foreach qw( machine username _password );
271 my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
276 foreach my $custf (qw( company address1 address2 city state zip country
277 daytime night fax otaker agent_custid locale
280 ${$custf} = $cust_pkg->cust_main->$custf();
283 $email = ( grep { $_ !~ /^(POST|FAX)$/ } $cust_pkg->cust_main->invoicing_list )[0];
288 $finger =~ /^(.*)\s+(\S+)$/ or $finger =~ /^((.*))$/;
289 ($first, $last ) = ( $1, $2 );
290 $domain = $svc_acct->domain;
292 $quoted_password = shell_quote $_password;
294 $crypt_password = $svc_acct->crypt_password( $self->option('crypt') );
295 $ldap_password = $svc_acct->ldap_password( $self->option('crypt') );
297 @radius_groups = $svc_acct->radius_groups;
299 my ($reasonnum, $reasontext, $reasontypenum, $reasontypetext);
300 if ( $cust_pkg && $action eq 'suspend' &&
301 (my $r = $cust_pkg->last_reason('susp')) )
303 $reasonnum = $r->reasonnum;
304 $reasontext = $r->reason;
305 $reasontypenum = $r->reason_type;
306 $reasontypetext = $r->reasontype->type;
308 my %reasonmap = $self->_groups_susp_reason_map;
310 $userspec = $reasonmap{$reasonnum}
311 if exists($reasonmap{$reasonnum});
312 $userspec = $reasonmap{$reasontext}
313 if (!$userspec && exists($reasonmap{$reasontext}));
316 if ( $userspec =~ /^\d+$/ ) {
317 $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
318 } elsif ( $userspec =~ /^\S+\@\S+$/ ) {
319 my ($username,$domain) = split(/\@/, $userspec);
320 for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
321 $suspend_user = $user if $userspec eq $user->email;
323 } elsif ($userspec) {
324 $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
327 @radius_groups = $suspend_user->radius_groups
331 $reasonnum = $reasontext = $reasontypenum = $reasontypetext = '';
334 $pkgnum = $cust_pkg ? $cust_pkg->pkgnum : '';
335 $custnum = $cust_pkg ? $cust_pkg->custnum : '';
337 my $stdin_string = eval(qq("$stdin"));
339 $first = shell_quote $first;
340 $last = shell_quote $last;
341 $finger = shell_quote $finger;
342 $crypt_password = shell_quote $crypt_password;
343 $ldap_password = shell_quote $ldap_password;
345 $company = shell_quote $company;
346 $address1 = shell_quote $address1;
347 $address2 = shell_quote $address2;
348 $city = shell_quote $city;
349 $state = shell_quote $state;
350 $zip = shell_quote $zip;
351 $country = shell_quote $country;
352 $daytime = shell_quote $daytime;
353 $night = shell_quote $night;
354 $fax = shell_quote $fax;
355 $otaker = shell_quote $otaker;
356 $agent_custid = shell_quote $agent_custid;
357 $locale = shell_quote $locale;
359 my $command_string = eval(qq("$command"));
362 user => $self->option('user') || 'root',
363 host => $self->machine,
364 command => $command_string,
365 stdin_string => $stdin_string,
366 ignored_errors => $self->option('ignored_errors') || '',
367 ignore_all_errors => $self->option('ignore_all_errors'),
368 fail_on_output => $self->option('fail_on_output'),
371 if($self->option($action . '_no_queue')) {
372 # discard return value just like freeside-queued.
373 eval { ssh_cmd(@ssh_cmd_args) };
375 $error = $error->full_message if ref $error; # Exception::Class::Base
376 return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
380 $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
384 sub _export_replace {
385 my($self, $new, $old ) = (shift, shift, shift);
386 my $command = $self->option('usermod');
387 return '' if $command =~ /^\s*$/;
388 my $stdin = $self->option('usermod_stdin');
392 ${"old_$_"} = $old->getfield($_) foreach $old->fields;
393 ${"new_$_"} = $new->getfield($_) foreach $new->fields;
395 my $old_cust_pkg = $old->cust_svc->cust_pkg;
396 my $new_cust_pkg = $new->cust_svc->cust_pkg;
397 my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
399 $new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
400 ($new_first, $new_last ) = ( $1, $2 );
401 $quoted_new__password = shell_quote $new__password; #old, wrong?
402 $new_quoted_password = shell_quote $new__password; #new, better?
403 $old_domain = $old->domain;
404 $new_domain = $new->domain;
406 $new_crypt_password = $new->crypt_password( $self->option('crypt') );
407 $new_ldap_password = $new->ldap_password( $self->option('crypt') );
409 @old_radius_groups = $old->radius_groups;
410 @new_radius_groups = $new->radius_groups;
413 if ( $self->option('usermod_pwonly') || $self->option('usermod_nousername') ){
414 if ( $old_username ne $new_username ) {
415 $error ||= "can't change username";
418 if ( $self->option('usermod_pwonly') ) {
419 if ( $old_domain ne $new_domain ) {
420 $error ||= "can't change domain";
422 if ( $old_uid != $new_uid ) {
423 $error ||= "can't change uid";
425 if ( $old_gid != $new_gid ) {
426 $error ||= "can't change gid";
428 if ( $old_dir ne $new_dir ) {
429 $error ||= "can't change dir";
431 #if ( join("\n", sort @old_radius_groups) ne
432 # join("\n", sort @new_radius_groups) ) {
433 # $error ||= "can't change RADIUS groups";
436 return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
439 $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
440 $new_locale = $new_cust_main ? $new_cust_main->locale : '';
441 $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
442 $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
443 $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
444 $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
446 my $stdin_string = eval(qq("$stdin"));
448 $new_first = shell_quote $new_first;
449 $new_last = shell_quote $new_last;
450 $new_finger = shell_quote $new_finger;
451 $new_crypt_password = shell_quote $new_crypt_password;
452 $new_ldap_password = shell_quote $new_ldap_password;
453 $new_agent_custid = shell_quote $new_agent_custid;
454 $new_locale = shell_quote $new_locale;
456 my $command_string = eval(qq("$command"));
459 user => $self->option('user') || 'root',
460 host => $self->machine,
461 command => $command_string,
462 stdin_string => $stdin_string,
463 ignored_errors => $self->option('ignored_errors') || '',
464 ignore_all_errors => $self->option('ignore_all_errors'),
465 fail_on_output => $self->option('fail_on_output'),
468 if($self->option('usermod_no_queue')) {
469 # discard return value just like freeside-queued.
470 eval { ssh_cmd(@ssh_cmd_args) };
472 $error = $error->full_message if ref $error; # Exception::Class::Base
473 return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
477 $self->shellcommands_queue( $new->svcnum, @ssh_cmd_args );
481 #a good idea to queue anything that could fail or take any time
482 sub shellcommands_queue {
483 my( $self, $svcnum ) = (shift, shift);
484 my $queue = new FS::queue {
486 'job' => "FS::part_export::shellcommands::ssh_cmd",
488 $queue->insert( @_ );
491 sub ssh_cmd { #subroutine, not method
494 open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
495 my $ssh = Net::OpenSSH->new(
496 $opt->{'user'}.'@'.$opt->{'host'},
497 'default_stdin_fh' => $def_in
499 # ignore_all_errors doesn't override SSH connection/auth errors--
501 die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
504 $ssh_opt->{'stdin_data'} = $opt->{'stdin_string'}
505 if exists($opt->{'stdin_string'}) and length($opt->{'stdin_string'});
507 my ($output, $errput) = $ssh->capture2($ssh_opt, $opt->{'command'});
509 return if $opt->{'ignore_all_errors'};
510 die "Error running SSH command: ". $ssh->error if $ssh->error;
512 if ( ($output || $errput)
513 && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})
515 my @ignored_errors = split('\n',$opt->{'ignored_errors'});
516 foreach my $ignored_error ( @ignored_errors ) {
517 $output =~ s/$ignored_error//g;
518 $errput =~ s/$ignored_error//g;
520 $output =~ s/[\s\n]//g;
521 $errput =~ s/[\s\n]//g;
524 die "$errput\n" if $errput;
525 die "$output\n" if $output and $opt->{'fail_on_output'};
529 #sub shellcommands_insert { #subroutine, not method
531 #sub shellcommands_replace { #subroutine, not method
533 #sub shellcommands_delete { #subroutine, not method
536 sub _upgrade_exporttype {
538 $class =~ /^FS::part_export::(\w+)$/;
539 foreach my $self ( qsearch('part_export', { 'exporttype' => $1 }) ) {
540 my %options = $self->options;
542 # 2011-12-13 - 2012-02-16: ignore_all_output option
543 if ( $options{'ignore_all_output'} ) {
544 # ignoring STDOUT is now the default
545 $options{'ignore_all_errors'} = 1;
546 delete $options{'ignore_all_output'};
549 my $error = $self->replace(%options) if $changed;
550 die $error if $error;