freebsd `toor' user
[freeside.git] / FS / FS / svc_acct.pm
1 package FS::svc_acct;
2
3 use strict;
4 use vars qw( @ISA $noexport_hack $conf
5              $dir_prefix @shells $usernamemin
6              $usernamemax $passwordmin $passwordmax
7              $username_ampersand $username_letter $username_letterfirst
8              $username_noperiod $username_nounderscore $username_nodash
9              $username_uppercase
10              $mydomain
11              $dirhash
12              @saltset @pw_set );
13 use Carp;
14 use Fcntl qw(:flock);
15 use FS::UID qw( datasrc );
16 use FS::Conf;
17 use FS::Record qw( qsearch qsearchs fields dbh );
18 use FS::svc_Common;
19 use Net::SSH;
20 use FS::part_svc;
21 use FS::svc_acct_pop;
22 use FS::svc_acct_sm;
23 use FS::cust_main_invoice;
24 use FS::svc_domain;
25 use FS::raddb;
26 use FS::queue;
27 use FS::radius_usergroup;
28 use FS::Msgcat qw(gettext);
29
30 @ISA = qw( FS::svc_Common );
31
32 #ask FS::UID to run this stuff for us later
33 $FS::UID::callback{'FS::svc_acct'} = sub { 
34   $conf = new FS::Conf;
35   $dir_prefix = $conf->config('home');
36   @shells = $conf->config('shells');
37   $usernamemin = $conf->config('usernamemin') || 2;
38   $usernamemax = $conf->config('usernamemax');
39   $passwordmin = $conf->config('passwordmin') || 6;
40   $passwordmax = $conf->config('passwordmax') || 8;
41   $username_letter = $conf->exists('username-letter');
42   $username_letterfirst = $conf->exists('username-letterfirst');
43   $username_noperiod = $conf->exists('username-noperiod');
44   $username_nounderscore = $conf->exists('username-nounderscore');
45   $username_nodash = $conf->exists('username-nodash');
46   $username_uppercase = $conf->exists('username-uppercase');
47   $username_ampersand = $conf->exists('username-ampersand');
48   $mydomain = $conf->config('domain');
49
50   $dirhash = $conf->config('dirhash') || 0;
51 };
52
53 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
54 @pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
55
56 sub _cache {
57   my $self = shift;
58   my ( $hashref, $cache ) = @_;
59   if ( $hashref->{'svc_acct_svcnum'} ) {
60     $self->{'_domsvc'} = FS::svc_domain->new( {
61       'svcnum'   => $hashref->{'domsvc'},
62       'domain'   => $hashref->{'svc_acct_domain'},
63       'catchall' => $hashref->{'svc_acct_catchall'},
64     } );
65   }
66 }
67
68 =head1 NAME
69
70 FS::svc_acct - Object methods for svc_acct records
71
72 =head1 SYNOPSIS
73
74   use FS::svc_acct;
75
76   $record = new FS::svc_acct \%hash;
77   $record = new FS::svc_acct { 'column' => 'value' };
78
79   $error = $record->insert;
80
81   $error = $new_record->replace($old_record);
82
83   $error = $record->delete;
84
85   $error = $record->check;
86
87   $error = $record->suspend;
88
89   $error = $record->unsuspend;
90
91   $error = $record->cancel;
92
93   %hash = $record->radius;
94
95   %hash = $record->radius_reply;
96
97   %hash = $record->radius_check;
98
99   $domain = $record->domain;
100
101   $svc_domain = $record->svc_domain;
102
103   $email = $record->email;
104
105   $seconds_since = $record->seconds_since($timestamp);
106
107 =head1 DESCRIPTION
108
109 An FS::svc_acct object represents an account.  FS::svc_acct inherits from
110 FS::svc_Common.  The following fields are currently supported:
111
112 =over 4
113
114 =item svcnum - primary key (assigned automatcially for new accounts)
115
116 =item username
117
118 =item _password - generated if blank
119
120 =item sec_phrase - security phrase
121
122 =item popnum - Point of presence (see L<FS::svc_acct_pop>)
123
124 =item uid
125
126 =item gid
127
128 =item finger - GECOS
129
130 =item dir - set automatically if blank (and uid is not)
131
132 =item shell
133
134 =item quota - (unimplementd)
135
136 =item slipip - IP address
137
138 =item seconds - 
139
140 =item domsvc - svcnum from svc_domain
141
142 =item radius_I<Radius_Attribute> - I<Radius-Attribute>
143
144 =back
145
146 =head1 METHODS
147
148 =over 4
149
150 =item new HASHREF
151
152 Creates a new account.  To add the account to the database, see L<"insert">.
153
154 =cut
155
156 sub table { 'svc_acct'; }
157
158 =item insert
159
160 Adds this account to the database.  If there is an error, returns the error,
161 otherwise returns false.
162
163 The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be 
164 defined.  An FS::cust_svc record will be created and inserted.
165
166 The additional field I<usergroup> can optionally be defined; if so it should
167 contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
168 sqlradius export only)
169
170 (TODOC: L<FS::queue> and L<freeside-queued>)
171
172 (TODOC: new exports! $noexport_hack)
173
174 =cut
175
176 sub insert {
177   my $self = shift;
178   my $error;
179
180   local $SIG{HUP} = 'IGNORE';
181   local $SIG{INT} = 'IGNORE';
182   local $SIG{QUIT} = 'IGNORE';
183   local $SIG{TERM} = 'IGNORE';
184   local $SIG{TSTP} = 'IGNORE';
185   local $SIG{PIPE} = 'IGNORE';
186
187   my $oldAutoCommit = $FS::UID::AutoCommit;
188   local $FS::UID::AutoCommit = 0;
189   my $dbh = dbh;
190
191   $error = $self->check;
192   return $error if $error;
193
194   return gettext('username_in_use'). ": ". $self->username
195     if qsearchs( 'svc_acct', { 'username' => $self->username,
196                                'domsvc'   => $self->domsvc,
197                              } );
198
199   if ( $self->svcnum ) {
200     my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
201     unless ( $cust_svc ) {
202       $dbh->rollback if $oldAutoCommit;
203       return "no cust_svc record found for svcnum ". $self->svcnum;
204     }
205     $self->pkgnum($cust_svc->pkgnum);
206     $self->svcpart($cust_svc->svcpart);
207   }
208
209   my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
210   return "Unknown svcpart" unless $part_svc;
211   return "uid in use"
212     if $part_svc->part_svc_column('uid')->columnflag ne 'F'
213       && qsearchs( 'svc_acct', { 'uid' => $self->uid } )
214       && $self->username !~ /^(hyla)?fax$/
215       && $self->username !~ /^toor$/ #FreeBSD
216     ;
217
218   $error = $self->SUPER::insert;
219   if ( $error ) {
220     $dbh->rollback if $oldAutoCommit;
221     return $error;
222   }
223
224   if ( $self->usergroup ) {
225     foreach my $groupname ( @{$self->usergroup} ) {
226       my $radius_usergroup = new FS::radius_usergroup ( {
227         svcnum    => $self->svcnum,
228         groupname => $groupname,
229       } );
230       my $error = $radius_usergroup->insert;
231       if ( $error ) {
232         $dbh->rollback if $oldAutoCommit;
233         return $error;
234       }
235     }
236   }
237
238   #new-style exports!
239   unless ( $noexport_hack ) {
240     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
241       my $error = $part_export->export_insert($self);
242       if ( $error ) {
243         $dbh->rollback if $oldAutoCommit;
244         return "exporting to ". $part_export->exporttype.
245                " (transaction rolled back): $error";
246       }
247     }
248   }
249
250   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
251   ''; #no error
252 }
253
254 =item delete
255
256 Deletes this account from the database.  If there is an error, returns the
257 error, otherwise returns false.
258
259 The corresponding FS::cust_svc record will be deleted as well.
260
261 (TODOC: new exports! $noexport_hack)
262
263 =cut
264
265 sub delete {
266   my $self = shift;
267
268   if ( defined( $FS::Record::dbdef->table('svc_acct_sm') ) ) {
269     return "Can't delete an account which has (svc_acct_sm) mail aliases!"
270       if $self->uid && qsearch( 'svc_acct_sm', { 'domuid' => $self->uid } );
271   }
272
273   return "Can't delete an account which is a (svc_forward) source!"
274     if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
275
276   return "Can't delete an account which is a (svc_forward) destination!"
277     if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
278
279   return "Can't delete an account with (svc_www) web service!"
280     if qsearch( 'svc_www', { 'usersvc' => $self->usersvc } );
281
282   # what about records in session ? (they should refer to history table)
283
284   local $SIG{HUP} = 'IGNORE';
285   local $SIG{INT} = 'IGNORE';
286   local $SIG{QUIT} = 'IGNORE';
287   local $SIG{TERM} = 'IGNORE';
288   local $SIG{TSTP} = 'IGNORE';
289   local $SIG{PIPE} = 'IGNORE';
290
291   my $oldAutoCommit = $FS::UID::AutoCommit;
292   local $FS::UID::AutoCommit = 0;
293   my $dbh = dbh;
294
295   foreach my $cust_main_invoice (
296     qsearch( 'cust_main_invoice', { 'dest' => $self->svcnum } )
297   ) {
298     unless ( defined($cust_main_invoice) ) {
299       warn "WARNING: something's wrong with qsearch";
300       next;
301     }
302     my %hash = $cust_main_invoice->hash;
303     $hash{'dest'} = $self->email;
304     my $new = new FS::cust_main_invoice \%hash;
305     my $error = $new->replace($cust_main_invoice);
306     if ( $error ) {
307       $dbh->rollback if $oldAutoCommit;
308       return $error;
309     }
310   }
311
312   foreach my $svc_domain (
313     qsearch( 'svc_domain', { 'catchall' => $self->svcnum } )
314   ) {
315     my %hash = new FS::svc_domain->hash;
316     $hash{'catchall'} = '';
317     my $new = new FS::svc_domain \%hash;
318     my $error = $new->replace($svc_domain);
319     if ( $error ) {
320       $dbh->rollback if $oldAutoCommit;
321       return $error;
322     }
323   }
324
325   foreach my $radius_usergroup (
326     qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } )
327   ) {
328     my $error = $radius_usergroup->delete;
329     if ( $error ) {
330       $dbh->rollback if $oldAutoCommit;
331       return $error;
332     }
333   }
334
335   my $part_svc = $self->cust_svc->part_svc;
336
337   my $error = $self->SUPER::delete;
338   if ( $error ) {
339     $dbh->rollback if $oldAutoCommit;
340     return $error;
341   }
342
343   #new-style exports!
344   unless ( $noexport_hack ) {
345     foreach my $part_export ( $part_svc->part_export ) {
346       my $error = $part_export->export_delete($self);
347       if ( $error ) {
348         $dbh->rollback if $oldAutoCommit;
349         return "exporting to ". $part_export->exporttype.
350                " (transaction rolled back): $error";
351       }
352     }
353   }
354
355   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
356   '';
357 }
358
359 =item replace OLD_RECORD
360
361 Replaces OLD_RECORD with this one in the database.  If there is an error,
362 returns the error, otherwise returns false.
363
364 The additional field I<usergroup> can optionally be defined; if so it should
365 contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
366 sqlradius export only)
367
368 =cut
369
370 sub replace {
371   my ( $new, $old ) = ( shift, shift );
372   my $error;
373
374   return "Username in use"
375     if $old->username ne $new->username &&
376       qsearchs( 'svc_acct', { 'username' => $new->username,
377                                'domsvc'   => $new->domsvc,
378                              } );
379   {
380     #no warnings 'numeric';  #alas, a 5.006-ism
381     local($^W) = 0;
382     return "Can't change uid!" if $old->uid != $new->uid;
383   }
384
385   #change homdir when we change username
386   $new->setfield('dir', '') if $old->username ne $new->username;
387
388   local $SIG{HUP} = 'IGNORE';
389   local $SIG{INT} = 'IGNORE';
390   local $SIG{QUIT} = 'IGNORE';
391   local $SIG{TERM} = 'IGNORE';
392   local $SIG{TSTP} = 'IGNORE';
393   local $SIG{PIPE} = 'IGNORE';
394
395   my $oldAutoCommit = $FS::UID::AutoCommit;
396   local $FS::UID::AutoCommit = 0;
397   my $dbh = dbh;
398
399   $error = $new->SUPER::replace($old);
400   if ( $error ) {
401     $dbh->rollback if $oldAutoCommit;
402     return $error if $error;
403   }
404
405   $old->usergroup( [ $old->radius_groups ] );
406   if ( $new->usergroup ) {
407     #(sorta) false laziness with FS::part_export::sqlradius::_export_replace
408     my @newgroups = @{$new->usergroup};
409     foreach my $oldgroup ( @{$old->usergroup} ) {
410       if ( grep { $oldgroup eq $_ } @newgroups ) {
411         @newgroups = grep { $oldgroup ne $_ } @newgroups;
412         next;
413       }
414       my $radius_usergroup = qsearchs('radius_usergroup', {
415         svcnum    => $old->svcnum,
416         groupname => $oldgroup,
417       } );
418       my $error = $radius_usergroup->delete;
419       if ( $error ) {
420         $dbh->rollback if $oldAutoCommit;
421         return "error deleting radius_usergroup $oldgroup: $error";
422       }
423     }
424
425     foreach my $newgroup ( @newgroups ) {
426       my $radius_usergroup = new FS::radius_usergroup ( {
427         svcnum    => $new->svcnum,
428         groupname => $newgroup,
429       } );
430       my $error = $radius_usergroup->insert;
431       if ( $error ) {
432         $dbh->rollback if $oldAutoCommit;
433         return "error adding radius_usergroup $newgroup: $error";
434       }
435     }
436
437   }
438
439   #new-style exports!
440   unless ( $noexport_hack ) {
441     foreach my $part_export ( $new->cust_svc->part_svc->part_export ) {
442       my $error = $part_export->export_replace($new,$old);
443       if ( $error ) {
444         $dbh->rollback if $oldAutoCommit;
445         return "exporting to ". $part_export->exporttype.
446                " (transaction rolled back): $error";
447       }
448     }
449   }
450
451   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
452   ''; #no error
453 }
454
455 =item suspend
456
457 Suspends this account by prefixing *SUSPENDED* to the password.  If there is an
458 error, returns the error, otherwise returns false.
459
460 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
461
462 =cut
463
464 sub suspend {
465   my $self = shift;
466   my %hash = $self->hash;
467   unless ( $hash{_password} =~ /^\*SUSPENDED\* /
468            || $hash{_password} eq '*'
469          ) {
470     $hash{_password} = '*SUSPENDED* '.$hash{_password};
471     my $new = new FS::svc_acct ( \%hash );
472     $new->replace($self);
473   } else {
474     ''; #no error (already suspended)
475   }
476 }
477
478 =item unsuspend
479
480 Unsuspends this account by removing *SUSPENDED* from the password.  If there is
481 an error, returns the error, otherwise returns false.
482
483 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
484
485 =cut
486
487 sub unsuspend {
488   my $self = shift;
489   my %hash = $self->hash;
490   if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
491     $hash{_password} = $1;
492     my $new = new FS::svc_acct ( \%hash );
493     $new->replace($self);
494   } else {
495     ''; #no error (already unsuspended)
496   }
497 }
498
499 =item cancel
500
501 Just returns false (no error) for now.
502
503 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
504
505 =item check
506
507 Checks all fields to make sure this is a valid service.  If there is an error,
508 returns the error, otherwise returns false.  Called by the insert and replace
509 methods.
510
511 Sets any fixed values; see L<FS::part_svc>.
512
513 =cut
514
515 sub check {
516   my $self = shift;
517
518   my($recref) = $self->hashref;
519
520   my $x = $self->setfixed;
521   return $x unless ref($x);
522   my $part_svc = $x;
523
524   if ( $part_svc->part_svc_column('usergroup')->columnflag eq "F" ) {
525     $self->usergroup(
526       [ split(',', $part_svc->part_svc_column('usergroup')->columnvalue) ] );
527   }
528
529   my $error = $self->ut_numbern('svcnum')
530               || $self->ut_number('domsvc')
531               || $self->ut_textn('sec_phrase')
532   ;
533   return $error if $error;
534
535   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
536   if ( $username_uppercase ) {
537     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/i
538       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
539     $recref->{username} = $1;
540   } else {
541     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/
542       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
543     $recref->{username} = $1;
544   }
545
546   if ( $username_letterfirst ) {
547     $recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
548   } elsif ( $username_letter ) {
549     $recref->{username} =~ /[a-z]/ or return gettext('illegal_username');
550   }
551   if ( $username_noperiod ) {
552     $recref->{username} =~ /\./ and return gettext('illegal_username');
553   }
554   if ( $username_nounderscore ) {
555     $recref->{username} =~ /_/ and return gettext('illegal_username');
556   }
557   if ( $username_nodash ) {
558     $recref->{username} =~ /\-/ and return gettext('illegal_username');
559   }
560   unless ( $username_ampersand ) {
561     $recref->{username} =~ /\&/ and return gettext('illegal_username');
562   }
563
564   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
565   $recref->{popnum} = $1;
566   return "Unknown popnum" unless
567     ! $recref->{popnum} ||
568     qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
569
570   unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
571
572     $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
573     $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
574
575     $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
576     $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
577     #not all systems use gid=uid
578     #you can set a fixed gid in part_svc
579
580     return "Only root can have uid 0"
581       if $recref->{uid} == 0
582          && $recref->{username} ne 'root'
583          && $recref->{username} ne 'toor';
584
585 #    $error = $self->ut_textn('finger');
586 #    return $error if $error;
587     $self->getfield('finger') =~
588       /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\*\<\>]*)$/
589         or return "Illegal finger: ". $self->getfield('finger');
590     $self->setfield('finger', $1);
591
592     $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
593       or return "Illegal directory";
594     $recref->{dir} = $1;
595     return "Illegal directory"
596       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
597     return "Illegal directory"
598       if $recref->{dir} =~ /\&/ && ! $username_ampersand;
599     unless ( $recref->{dir} ) {
600       $recref->{dir} = $dir_prefix . '/';
601       if ( $dirhash > 0 ) {
602         for my $h ( 1 .. $dirhash ) {
603           $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
604         }
605       } elsif ( $dirhash < 0 ) {
606         for my $h ( reverse $dirhash .. -1 ) {
607           $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
608         }
609       }
610       $recref->{dir} .= $recref->{username};
611     ;
612     }
613
614     unless ( $recref->{username} eq 'sync' ) {
615       if ( grep $_ eq $recref->{shell}, @shells ) {
616         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
617       } else {
618         return "Illegal shell \`". $self->shell. "\'; ".
619                $conf->dir. "/shells contains: @shells";
620       }
621     } else {
622       $recref->{shell} = '/bin/sync';
623     }
624
625     $recref->{quota} =~ /^(\d*)$/ or return "Illegal quota (unimplemented)";
626     $recref->{quota} = $1;
627
628   } else {
629     $recref->{gid} ne '' ? 
630       return "Can't have gid without uid" : ( $recref->{gid}='' );
631     $recref->{finger} ne '' ? 
632       return "Can't have finger-name without uid" : ( $recref->{finger}='' );
633     $recref->{dir} ne '' ? 
634       return "Can't have directory without uid" : ( $recref->{dir}='' );
635     $recref->{shell} ne '' ? 
636       return "Can't have shell without uid" : ( $recref->{shell}='' );
637     $recref->{quota} ne '' ? 
638       return "Can't have quota without uid" : ( $recref->{quota}='' );
639   }
640
641   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
642     unless ( $recref->{slipip} eq '0e0' ) {
643       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
644         or return "Illegal slipip". $self->slipip;
645       $recref->{slipip} = $1;
646     } else {
647       $recref->{slipip} = '0e0';
648     }
649
650   }
651
652   #arbitrary RADIUS stuff; allow ut_textn for now
653   foreach ( grep /^radius_/, fields('svc_acct') ) {
654     $self->ut_textn($_);
655   }
656
657   #generate a password if it is blank
658   $recref->{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
659     unless ( $recref->{_password} );
660
661   #if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{4,16})$/ ) {
662   if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
663     $recref->{_password} = $1.$3;
664     #uncomment this to encrypt password immediately upon entry, or run
665     #bin/crypt_pw in cron to give new users a window during which their
666     #password is available to techs, for faxing, etc.  (also be aware of 
667     #radius issues!)
668     #$recref->{password} = $1.
669     #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
670     #;
671   } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$]{13,34})$/ ) {
672     $recref->{_password} = $1.$3;
673   } elsif ( $recref->{_password} eq '*' ) {
674     $recref->{_password} = '*';
675   } elsif ( $recref->{_password} eq '!!' ) {
676     $recref->{_password} = '!!';
677   } else {
678     #return "Illegal password";
679     return gettext('illegal_password'). ": ". $recref->{_password};
680   }
681
682   ''; #no error
683 }
684
685 =item radius
686
687 Depriciated, use radius_reply instead.
688
689 =cut
690
691 sub radius {
692   carp "FS::svc_acct::radius depriciated, use radius_reply";
693   $_[0]->radius_reply;
694 }
695
696 =item radius_reply
697
698 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
699 reply attributes of this record.
700
701 Note that this is now the preferred method for reading RADIUS attributes - 
702 accessing the columns directly is discouraged, as the column names are
703 expected to change in the future.
704
705 =cut
706
707 sub radius_reply { 
708   my $self = shift;
709   my %reply =
710     map {
711       /^(radius_(.*))$/;
712       my($column, $attrib) = ($1, $2);
713       #$attrib =~ s/_/\-/g;
714       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
715     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
716   if ( $self->ip && $self->ip ne '0e0' ) {
717     $reply{'Framed-IP-Address'} = $self->ip;
718   }
719   %reply;
720 }
721
722 =item radius_check
723
724 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
725 check attributes of this record.
726
727 Note that this is now the preferred method for reading RADIUS attributes - 
728 accessing the columns directly is discouraged, as the column names are
729 expected to change in the future.
730
731 =cut
732
733 sub radius_check {
734   my $self = shift;
735   ( 'Password' => $self->_password,
736     map {
737       /^(rc_(.*))$/;
738       my($column, $attrib) = ($1, $2);
739       #$attrib =~ s/_/\-/g;
740       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
741     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table )
742   );
743 }
744
745 =item domain
746
747 Returns the domain associated with this account.
748
749 =cut
750
751 sub domain {
752   my $self = shift;
753   if ( $self->domsvc ) {
754     #$self->svc_domain->domain;
755     my $svc_domain = $self->svc_domain
756       or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
757     $svc_domain->domain;
758   } else {
759     $mydomain or die "svc_acct.domsvc is null and no legacy domain config file";
760   }
761 }
762
763 =item svc_domain
764
765 Returns the FS::svc_domain record for this account's domain (see
766 L<FS::svc_domain>.
767
768 =cut
769
770 sub svc_domain {
771   my $self = shift;
772   $self->{'_domsvc'}
773     ? $self->{'_domsvc'}
774     : qsearchs( 'svc_domain', { 'svcnum' => $self->domsvc } );
775 }
776
777 =item cust_svc
778
779 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
780
781 sub cust_svc {
782   my $self = shift;
783   qsearchs( 'cust_svc', { 'svcnum' => $self->svcnum } );
784 }
785
786 =item email
787
788 Returns an email address associated with the account.
789
790 =cut
791
792 sub email {
793   my $self = shift;
794   $self->username. '@'. $self->domain;
795 }
796
797 =item seconds_since TIMESTAMP
798
799 Returns the number of seconds this account has been online since TIMESTAMP.
800 See L<FS::session>
801
802 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
803 L<Time::Local> and L<Date::Parse> for conversion functions.
804
805 =cut
806
807 #note: POD here, implementation in FS::cust_svc
808 sub seconds_since {
809   my $self = shift;
810   $self->cust_svc->seconds_since(@_);
811 }
812
813 =item radius_groups
814
815 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
816
817 =cut
818
819 sub radius_groups {
820   my $self = shift;
821   map { $_->groupname }
822     qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
823 }
824
825 =back
826
827 =head1 SUBROUTINES
828
829 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
830
831 =cut
832
833 sub radius_usergroup_selector {
834   my $sel_groups = shift;
835   my %sel_groups = map { $_=>1 } @$sel_groups;
836
837   my $selectname = shift || 'radius_usergroup';
838
839   my $dbh = dbh;
840   my $sth = $dbh->prepare(
841     'SELECT DISTINCT(groupname) FROM radius_usergroup ORDER BY groupname'
842   ) or die $dbh->errstr;
843   $sth->execute() or die $sth->errstr;
844   my @all_groups = map { $_->[0] } @{$sth->fetchall_arrayref};
845
846   my $html = <<END;
847     <SCRIPT>
848     function ${selectname}_doadd(object) {
849       var myvalue = object.${selectname}_add.value;
850       var optionName = new Option(myvalue,myvalue,false,true);
851       var length = object.$selectname.length;
852       object.$selectname.options[length] = optionName;
853       object.${selectname}_add.value = "";
854     }
855     </SCRIPT>
856     <SELECT MULTIPLE NAME="$selectname">
857 END
858
859   foreach my $group ( @all_groups ) {
860     $html .= '<OPTION';
861     if ( $sel_groups{$group} ) {
862       $html .= ' SELECTED';
863       $sel_groups{$group} = 0;
864     }
865     $html .= ">$group</OPTION>\n";
866   }
867   foreach my $group ( grep { $sel_groups{$_} } keys %sel_groups ) {
868     $html .= "<OPTION SELECTED>$group</OPTION>\n";
869   };
870   $html .= '</SELECT>';
871
872   $html .= qq!<BR><INPUT TYPE="text" NAME="${selectname}_add">!.
873            qq!<INPUT TYPE="button" VALUE="Add new group" onClick="${selectname}_doadd(this.form)">!;
874
875   $html;
876 }
877
878 =head1 BUGS
879
880 The $recref stuff in sub check should be cleaned up.
881
882 The suspend, unsuspend and cancel methods update the database, but not the
883 current object.  This is probably a bug as it's unexpected and
884 counterintuitive.
885
886 radius_usergroup_selector?  putting web ui components in here?  they should
887 probably live somewhere else...
888
889 =head1 SEE ALSO
890
891 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
892 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
893 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
894 L<freeside-queued>), L<Net::SSH>, L<ssh>, L<FS::svc_acct_pop>,
895 schema.html from the base documentation.
896
897 =cut
898
899 1;
900