merging RT 4.0.6
[freeside.git] / FS / FS / part_export / sqlradius.pm
1 package FS::part_export::sqlradius;
2
3 use strict;
4 use vars qw(@ISA @EXPORT_OK $DEBUG %info %options $notes1 $notes2);
5 use Exporter;
6 use Tie::IxHash;
7 use FS::Record qw( dbh qsearch qsearchs str2time_sql );
8 use FS::part_export;
9 use FS::svc_acct;
10 use FS::export_svc;
11 use Carp qw( cluck );
12
13 @ISA = qw(FS::part_export);
14 @EXPORT_OK = qw( sqlradius_connect );
15
16 $DEBUG = 0;
17
18 my %groups;
19 tie %options, 'Tie::IxHash',
20   'datasrc'  => { label=>'DBI data source ' },
21   'username' => { label=>'Database username' },
22   'password' => { label=>'Database password' },
23   'usergroup' => { label   => 'Group table',
24                    type    => 'select',
25                    options => [qw( usergroup radusergroup ) ],
26                  },
27   'ignore_accounting' => {
28     type  => 'checkbox',
29     label => 'Ignore accounting records from this database'
30   },
31   'process_single_realm' => {
32     type  => 'checkbox',
33     label => 'Only process one realm of accounting records',
34   },
35   'realm' => { label => 'The realm of of accounting records to be processed' },
36   'ignore_long_sessions' => {
37     type  => 'checkbox',
38     label => 'Ignore sessions which span billing periods',
39   },
40   'hide_ip' => {
41     type  => 'checkbox',
42     label => 'Hide IP address information on session reports',
43   },
44   'hide_data' => {
45     type  => 'checkbox',
46     label => 'Hide download/upload information on session reports',
47   },
48   'show_called_station' => {
49     type  => 'checkbox',
50     label => 'Show the Called-Station-ID on session reports',
51   },
52   'overlimit_groups' => {
53       label => 'Radius groups to assign to svc_acct which has exceeded its bandwidth or time limit (if not overridden by overlimit_groups global or per-agent config)', 
54       type  => 'select',
55       multi => 1,
56       option_label  => sub {
57         $groups{$_[0]};
58       },
59       option_values => sub {
60         %groups = (
61               map { $_->groupnum, $_->long_description } 
62                   qsearch('radius_group', {}),
63             );
64             sort keys (%groups);
65       },
66    } ,
67   'groups_susp_reason' => { label =>
68                              'Radius group mapping to reason (via template user) (svcnum|username|username@domain  reasonnum|reason)',
69                             type  => 'textarea',
70                           },
71   'export_attrs' => {
72     type => 'checkbox',
73     label => 'Export RADIUS group attributes to this database',
74   },
75 ;
76
77 $notes1 = <<'END';
78 Real-time export of <b>radcheck</b>, <b>radreply</b> and <b>usergroup</b>/<b>radusergroup</b>
79 tables to any SQL database for
80 <a href="http://www.freeradius.org/">FreeRADIUS</a>
81 or <a href="http://radius.innercite.com/">ICRADIUS</a>.
82 END
83
84 $notes2 = <<'END';
85 An existing RADIUS database will be updated in realtime, but you can use
86 <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Developer/bin/freeside-sqlradius-reset">freeside-sqlradius-reset</a>
87 to delete the entire RADIUS database and repopulate the tables from the
88 Freeside database.  See the
89 <a href="http://search.cpan.org/dist/DBI/DBI.pm#connect">DBI documentation</a>
90 and the
91 <a href="http://search.cpan.org/search?mode=module&query=DBD%3A%3A">documentation for your DBD</a>
92 for the exact syntax of a DBI data source.
93 <ul>
94   <li>Using FreeRADIUS 0.9.0 with the PostgreSQL backend, the db_postgresql.sql schema and postgresql.conf queries contain incompatible changes.  This is fixed in 0.9.1.  Only new installs with 0.9.0 and PostgreSQL are affected - upgrades and other database backends and versions are unaffected.
95   <li>Using ICRADIUS, add a dummy "op" column to your database:
96     <blockquote><code>
97       ALTER&nbsp;TABLE&nbsp;radcheck&nbsp;ADD&nbsp;COLUMN&nbsp;op&nbsp;VARCHAR(2)&nbsp;NOT&nbsp;NULL&nbsp;DEFAULT&nbsp;'=='<br>
98       ALTER&nbsp;TABLE&nbsp;radreply&nbsp;ADD&nbsp;COLUMN&nbsp;op&nbsp;VARCHAR(2)&nbsp;NOT&nbsp;NULL&nbsp;DEFAULT&nbsp;'=='<br>
99       ALTER&nbsp;TABLE&nbsp;radgroupcheck&nbsp;ADD&nbsp;COLUMN&nbsp;op&nbsp;VARCHAR(2)&nbsp;NOT&nbsp;NULL&nbsp;DEFAULT&nbsp;'=='<br>
100       ALTER&nbsp;TABLE&nbsp;radgroupreply&nbsp;ADD&nbsp;COLUMN&nbsp;op&nbsp;VARCHAR(2)&nbsp;NOT&nbsp;NULL&nbsp;DEFAULT&nbsp;'=='
101     </code></blockquote>
102   <li>Using Radiator, see the
103     <a href="http://www.open.com.au/radiator/faq.html#38">Radiator FAQ</a>
104     for configuration information.
105 </ul>
106 END
107
108 %info = (
109   'svc'      => 'svc_acct',
110   'desc'     => 'Real-time export to SQL-backed RADIUS (FreeRADIUS, ICRADIUS)',
111   'options'  => \%options,
112   'nodomain' => 'Y',
113   'nas'      => 'Y', # show export_nas selection in UI
114   'default_svc_class' => 'Internet',
115   'notes'    => $notes1.
116                 'This export does not export RADIUS realms (see also '.
117                 'sqlradius_withdomain).  '.
118                 $notes2
119 );
120
121 sub _groups_susp_reason_map { map { reverse( /^\s*(\S+)\s*(.*)$/ ) } 
122                               split( "\n", shift->option('groups_susp_reason'));
123 }
124
125 sub rebless { shift; }
126
127 sub export_username { # override for other svcdb
128   my($self, $svc_acct) = (shift, shift);
129   warn "export_username called on $self with arg $svc_acct" if $DEBUG > 1;
130   $svc_acct->username;
131 }
132
133 sub radius_reply { #override for other svcdb
134   my($self, $svc_acct) = (shift, shift);
135   $svc_acct->radius_reply;
136 }
137
138 sub radius_check { #override for other svcdb
139   my($self, $svc_acct) = (shift, shift);
140   $svc_acct->radius_check;
141 }
142
143 sub _export_insert {
144   my($self, $svc_x) = (shift, shift);
145
146   foreach my $table (qw(reply check)) {
147     my $method = "radius_$table";
148     my %attrib = $self->$method($svc_x);
149     next unless keys %attrib;
150     my $err_or_queue = $self->sqlradius_queue( $svc_x->svcnum, 'insert',
151       $table, $self->export_username($svc_x), %attrib );
152     return $err_or_queue unless ref($err_or_queue);
153   }
154   my @groups = $svc_x->radius_groups('hashref');
155   if ( @groups ) {
156     cluck localtime(). ": queuing usergroup_insert for ". $svc_x->svcnum.
157           " (". $self->export_username($svc_x). " with ". join(", ", @groups)
158       if $DEBUG;
159     my $usergroup = $self->option('usergroup') || 'usergroup';
160     my $err_or_queue = $self->sqlradius_queue(
161       $svc_x->svcnum, 'usergroup_insert',
162       $self->export_username($svc_x), $usergroup, @groups );
163     return $err_or_queue unless ref($err_or_queue);
164   }
165   '';
166 }
167
168 sub _export_replace {
169   my( $self, $new, $old ) = (shift, shift, shift);
170
171   local $SIG{HUP} = 'IGNORE';
172   local $SIG{INT} = 'IGNORE';
173   local $SIG{QUIT} = 'IGNORE';
174   local $SIG{TERM} = 'IGNORE';
175   local $SIG{TSTP} = 'IGNORE';
176   local $SIG{PIPE} = 'IGNORE';
177
178   my $oldAutoCommit = $FS::UID::AutoCommit;
179   local $FS::UID::AutoCommit = 0;
180   my $dbh = dbh;
181
182   my $jobnum = '';
183   if ( $self->export_username($old) ne $self->export_username($new) ) {
184     my $usergroup = $self->option('usergroup') || 'usergroup';
185     my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'rename',
186       $self->export_username($new), $self->export_username($old), $usergroup );
187     unless ( ref($err_or_queue) ) {
188       $dbh->rollback if $oldAutoCommit;
189       return $err_or_queue;
190     }
191     $jobnum = $err_or_queue->jobnum;
192   }
193
194   foreach my $table (qw(reply check)) {
195     my $method = "radius_$table";
196     my %new = $new->$method();
197     my %old = $old->$method();
198     if ( grep { !exists $old{$_} #new attributes
199                 || $new{$_} ne $old{$_} #changed
200               } keys %new
201     ) {
202       my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'insert',
203         $table, $self->export_username($new), %new );
204       unless ( ref($err_or_queue) ) {
205         $dbh->rollback if $oldAutoCommit;
206         return $err_or_queue;
207       }
208       if ( $jobnum ) {
209         my $error = $err_or_queue->depend_insert( $jobnum );
210         if ( $error ) {
211           $dbh->rollback if $oldAutoCommit;
212           return $error;
213         }
214       }
215     }
216
217     my @del = grep { !exists $new{$_} } keys %old;
218     if ( @del ) {
219       my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'attrib_delete',
220         $table, $self->export_username($new), @del );
221       unless ( ref($err_or_queue) ) {
222         $dbh->rollback if $oldAutoCommit;
223         return $err_or_queue;
224       }
225       if ( $jobnum ) {
226         my $error = $err_or_queue->depend_insert( $jobnum );
227         if ( $error ) {
228           $dbh->rollback if $oldAutoCommit;
229           return $error;
230         }
231       }
232     }
233   }
234
235   my $error;
236   my (@oldgroups) = $old->radius_groups('hashref');
237   my (@newgroups) = $new->radius_groups('hashref');
238   $error = $self->sqlreplace_usergroups( $new->svcnum,
239                                          $self->export_username($new),
240                                          $jobnum ? $jobnum : '',
241                                          \@oldgroups,
242                                          \@newgroups,
243                                        );
244   if ( $error ) {
245     $dbh->rollback if $oldAutoCommit;
246     return $error;
247   }
248
249   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
250
251   '';
252 }
253
254 #false laziness w/broadband_sqlradius.pm
255 sub _export_suspend {
256   my( $self, $svc_acct ) = (shift, shift);
257
258   my $new = $svc_acct->clone_suspended;
259   
260   local $SIG{HUP} = 'IGNORE';
261   local $SIG{INT} = 'IGNORE';
262   local $SIG{QUIT} = 'IGNORE';
263   local $SIG{TERM} = 'IGNORE';
264   local $SIG{TSTP} = 'IGNORE';
265   local $SIG{PIPE} = 'IGNORE';
266
267   my $oldAutoCommit = $FS::UID::AutoCommit;
268   local $FS::UID::AutoCommit = 0;
269   my $dbh = dbh;
270
271   my @newgroups = $self->suspended_usergroups($svc_acct);
272
273   unless (@newgroups) { #don't change password if assigning to a suspended group
274
275     my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'insert',
276       'check', $self->export_username($new), $new->radius_check );
277     unless ( ref($err_or_queue) ) {
278       $dbh->rollback if $oldAutoCommit;
279       return $err_or_queue;
280     }
281
282   }
283
284   my $error =
285     $self->sqlreplace_usergroups(
286       $new->svcnum,
287       $self->export_username($new),
288       '',
289       [ $svc_acct->radius_groups('hashref') ],
290       \@newgroups,
291     );
292   if ( $error ) {
293     $dbh->rollback if $oldAutoCommit;
294     return $error;
295   }
296   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
297
298   '';
299 }
300
301 sub _export_unsuspend {
302   my( $self, $svc_x ) = (shift, shift);
303
304   local $SIG{HUP} = 'IGNORE';
305   local $SIG{INT} = 'IGNORE';
306   local $SIG{QUIT} = 'IGNORE';
307   local $SIG{TERM} = 'IGNORE';
308   local $SIG{TSTP} = 'IGNORE';
309   local $SIG{PIPE} = 'IGNORE';
310
311   my $oldAutoCommit = $FS::UID::AutoCommit;
312   local $FS::UID::AutoCommit = 0;
313   my $dbh = dbh;
314
315   my $err_or_queue = $self->sqlradius_queue( $svc_x->svcnum, 'insert',
316     'check', $self->export_username($svc_x), $self->radius_check($svc_x) );
317   unless ( ref($err_or_queue) ) {
318     $dbh->rollback if $oldAutoCommit;
319     return $err_or_queue;
320   }
321
322   my $error;
323   my (@oldgroups) = $self->suspended_usergroups($svc_x);
324   $error = $self->sqlreplace_usergroups(
325     $svc_x->svcnum,
326     $self->export_username($svc_x),
327     '',
328     \@oldgroups,
329     [ $svc_x->radius_groups('hashref') ],
330   );
331   if ( $error ) {
332     $dbh->rollback if $oldAutoCommit;
333     return $error;
334   }
335   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
336
337   '';
338 }
339
340 sub _export_delete {
341   my( $self, $svc_x ) = (shift, shift);
342   my $usergroup = $self->option('usergroup') || 'usergroup';
343   my $err_or_queue = $self->sqlradius_queue( $svc_x->svcnum, 'delete',
344     $self->export_username($svc_x), $usergroup );
345   ref($err_or_queue) ? '' : $err_or_queue;
346 }
347
348 sub sqlradius_queue {
349   my( $self, $svcnum, $method ) = (shift, shift, shift);
350   my $queue = new FS::queue {
351     'svcnum' => $svcnum,
352     'job'    => "FS::part_export::sqlradius::sqlradius_$method",
353   };
354   $queue->insert(
355     $self->option('datasrc'),
356     $self->option('username'),
357     $self->option('password'),
358     @_,
359   ) or $queue;
360 }
361
362 sub suspended_usergroups {
363   my ($self, $svc_x) = (shift, shift);
364
365   return () unless $svc_x;
366
367   my $svc_table = $svc_x->table;
368
369   #false laziness with FS::part_export::shellcommands
370   #subclass part_export?
371
372   my $r = $svc_x->cust_svc->cust_pkg->last_reason('susp');
373   my %reasonmap = $self->_groups_susp_reason_map;
374   my $userspec = '';
375   if ($r) {
376     $userspec = $reasonmap{$r->reasonnum}
377       if exists($reasonmap{$r->reasonnum});
378     $userspec = $reasonmap{$r->reason}
379       if (!$userspec && exists($reasonmap{$r->reason}));
380   }
381   my $suspend_svc;
382   if ( $userspec =~ /^\d+$/ ){
383     $suspend_svc = qsearchs( $svc_table, { 'svcnum' => $userspec } );
384   } elsif ( $userspec =~ /^\S+\@\S+$/ && $svc_table eq 'svc_acct' ){
385     my ($username,$domain) = split(/\@/, $userspec);
386     for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
387       $suspend_svc = $user if $userspec eq $user->email;
388     }
389   }elsif ( $userspec && $svc_table eq 'svc_acct'  ){
390     $suspend_svc = qsearchs( 'svc_acct', { 'username' => $userspec } );
391   }
392   #esalf
393   return $suspend_svc->radius_groups('hashref') if $suspend_svc;
394   ();
395 }
396
397 sub sqlradius_insert { #subroutine, not method
398   my $dbh = sqlradius_connect(shift, shift, shift);
399   my( $table, $username, %attributes ) = @_;
400
401   foreach my $attribute ( keys %attributes ) {
402   
403     my $s_sth = $dbh->prepare(
404       "SELECT COUNT(*) FROM rad$table WHERE UserName = ? AND Attribute = ?"
405     ) or die $dbh->errstr;
406     $s_sth->execute( $username, $attribute ) or die $s_sth->errstr;
407
408     if ( $s_sth->fetchrow_arrayref->[0] ) {
409
410       my $u_sth = $dbh->prepare(
411         "UPDATE rad$table SET Value = ? WHERE UserName = ? AND Attribute = ?"
412       ) or die $dbh->errstr;
413       $u_sth->execute($attributes{$attribute}, $username, $attribute)
414         or die $u_sth->errstr;
415
416     } else {
417
418       my $i_sth = $dbh->prepare(
419         "INSERT INTO rad$table ( UserName, Attribute, op, Value ) ".
420           "VALUES ( ?, ?, ?, ? )"
421       ) or die $dbh->errstr;
422       $i_sth->execute(
423         $username,
424         $attribute,
425         ( $attribute eq 'Password' ? '==' : ':=' ),
426         $attributes{$attribute},
427       ) or die $i_sth->errstr;
428
429     }
430
431   }
432   $dbh->disconnect;
433 }
434
435 sub sqlradius_usergroup_insert { #subroutine, not method
436   my $dbh = sqlradius_connect(shift, shift, shift);
437   my $username = shift;
438   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
439   my @groups = @_;
440
441   my $s_sth = $dbh->prepare(
442     "SELECT COUNT(*) FROM $usergroup WHERE UserName = ? AND GroupName = ?"
443   ) or die $dbh->errstr;
444
445   my $sth = $dbh->prepare( 
446     "INSERT INTO $usergroup ( UserName, GroupName, Priority ) VALUES ( ?, ?, ? )"
447   ) or die $dbh->errstr;
448
449   foreach ( @groups ) {
450     my $group = $_->{'groupname'};
451     my $priority = $_->{'priority'};
452     $s_sth->execute( $username, $group ) or die $s_sth->errstr;
453     if ($s_sth->fetchrow_arrayref->[0]) {
454       warn localtime() . ": sqlradius_usergroup_insert attempted to reinsert " .
455            "$group for $username\n"
456         if $DEBUG;
457       next;
458     }
459     $sth->execute( $username, $group, $priority )
460       or die "can't insert into groupname table: ". $sth->errstr;
461   }
462   if ( $s_sth->{Active} ) {
463     warn "sqlradius s_sth still active; calling ->finish()";
464     $s_sth->finish;
465   }
466   if ( $sth->{Active} ) {
467     warn "sqlradius sth still active; calling ->finish()";
468     $sth->finish;
469   }
470   $dbh->disconnect;
471 }
472
473 sub sqlradius_usergroup_delete { #subroutine, not method
474   my $dbh = sqlradius_connect(shift, shift, shift);
475   my $username = shift;
476   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
477   my @groups = @_;
478
479   my $sth = $dbh->prepare( 
480     "DELETE FROM $usergroup WHERE UserName = ? AND GroupName = ?"
481   ) or die $dbh->errstr;
482   foreach ( @groups ) {
483     my $group = $_->{'groupname'};
484     $sth->execute( $username, $group )
485       or die "can't delete from groupname table: ". $sth->errstr;
486   }
487   $dbh->disconnect;
488 }
489
490 sub sqlradius_rename { #subroutine, not method
491   my $dbh = sqlradius_connect(shift, shift, shift);
492   my($new_username, $old_username) = (shift, shift);
493   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
494   foreach my $table (qw(radreply radcheck), $usergroup ) {
495     my $sth = $dbh->prepare("UPDATE $table SET Username = ? WHERE UserName = ?")
496       or die $dbh->errstr;
497     $sth->execute($new_username, $old_username)
498       or die "can't update $table: ". $sth->errstr;
499   }
500   $dbh->disconnect;
501 }
502
503 sub sqlradius_attrib_delete { #subroutine, not method
504   my $dbh = sqlradius_connect(shift, shift, shift);
505   my( $table, $username, @attrib ) = @_;
506
507   foreach my $attribute ( @attrib ) {
508     my $sth = $dbh->prepare(
509         "DELETE FROM rad$table WHERE UserName = ? AND Attribute = ?" )
510       or die $dbh->errstr;
511     $sth->execute($username,$attribute)
512       or die "can't delete from rad$table table: ". $sth->errstr;
513   }
514   $dbh->disconnect;
515 }
516
517 sub sqlradius_delete { #subroutine, not method
518   my $dbh = sqlradius_connect(shift, shift, shift);
519   my $username = shift;
520   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
521
522   foreach my $table (qw( radcheck radreply), $usergroup ) {
523     my $sth = $dbh->prepare( "DELETE FROM $table WHERE UserName = ?" );
524     $sth->execute($username)
525       or die "can't delete from $table table: ". $sth->errstr;
526   }
527   $dbh->disconnect;
528 }
529
530 sub sqlradius_connect {
531   #my($datasrc, $username, $password) = @_;
532   #DBI->connect($datasrc, $username, $password) or die $DBI::errstr;
533   DBI->connect(@_) or die $DBI::errstr;
534 }
535
536 sub sqlreplace_usergroups {
537   my ($self, $svcnum, $username, $jobnum, $old, $new) = @_;
538
539   # (sorta) false laziness with FS::svc_acct::replace
540   my @oldgroups = @$old;
541   my @newgroups = @$new;
542   my @delgroups = ();
543   foreach my $oldgroup ( @oldgroups ) {
544     if ( grep { $oldgroup eq $_ } @newgroups ) {
545       @newgroups = grep { $oldgroup ne $_ } @newgroups;
546       next;
547     }
548     push @delgroups, $oldgroup;
549   }
550
551   my $usergroup = $self->option('usergroup') || 'usergroup';
552
553   if ( @delgroups ) {
554     my $err_or_queue = $self->sqlradius_queue( $svcnum, 'usergroup_delete',
555       $username, $usergroup, @delgroups );
556     return $err_or_queue
557       unless ref($err_or_queue);
558     if ( $jobnum ) {
559       my $error = $err_or_queue->depend_insert( $jobnum );
560       return $error if $error;
561     }
562   }
563
564   if ( @newgroups ) {
565     cluck localtime(). ": queuing usergroup_insert for $svcnum ($username) ".
566           "with ".  join(", ", @newgroups)
567       if $DEBUG;
568     my $err_or_queue = $self->sqlradius_queue( $svcnum, 'usergroup_insert',
569       $username, $usergroup, @newgroups );
570     return $err_or_queue
571       unless ref($err_or_queue);
572     if ( $jobnum ) {
573       my $error = $err_or_queue->depend_insert( $jobnum );
574       return $error if $error;
575     }
576   }
577   '';
578 }
579
580
581 #--
582
583 =item usage_sessions HASHREF
584
585 =item usage_sessions TIMESTAMP_START TIMESTAMP_END [ SVC_ACCT [ IP [ PREFIX [ SQL_SELECT ] ] ] ]
586
587 New-style: pass a hashref with the following keys:
588
589 =over 4
590
591 =item stoptime_start - Lower bound for AcctStopTime, as a UNIX timestamp
592
593 =item stoptime_end - Upper bound for AcctStopTime, as a UNIX timestamp
594
595 =item open_sessions - Only show records with no AcctStopTime (typically used without stoptime_* options and with starttime_* options instead)
596
597 =item starttime_start - Lower bound for AcctStartTime, as a UNIX timestamp
598
599 =item starttime_end - Upper bound for AcctStartTime, as a UNIX timestamp
600
601 =item svc_acct
602
603 =item ip
604
605 =item prefix
606
607 =back
608
609 Old-style: 
610
611 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
612 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
613 functions.
614
615 SVC_ACCT, if specified, limits the results to the specified account.
616
617 IP, if specified, limits the results to the specified IP address.
618
619 PREFIX, if specified, limits the results to records with a matching
620 Called-Station-ID.
621
622 #SQL_SELECT defaults to * if unspecified.  It can be useful to set it to 
623 #SUM(acctsessiontime) or SUM(AcctInputOctets), etc.
624
625 Returns an arrayref of hashrefs with the following fields:
626
627 =over 4
628
629 =item username
630
631 =item framedipaddress
632
633 =item acctstarttime
634
635 =item acctstoptime
636
637 =item acctsessiontime
638
639 =item acctinputoctets
640
641 =item acctoutputoctets
642
643 =item calledstationid
644
645 =back
646
647 =cut
648
649 #some false laziness w/cust_svc::seconds_since_sqlradacct
650
651 sub usage_sessions {
652   my( $self ) = shift;
653
654   my $opt = {};
655   my($start, $end, $svc_acct, $ip, $prefix) = ( '', '', '', '', '');
656   my $summarize = 0;
657   if ( ref($_[0]) ) {
658     $opt = shift;
659     $start    = $opt->{stoptime_start};
660     $end      = $opt->{stoptime_end};
661     $svc_acct = $opt->{svc_acct};
662     $ip       = $opt->{ip};
663     $prefix   = $opt->{prefix};
664     $summarize   = $opt->{summarize};
665   } else {
666     ( $start, $end ) = splice(@_, 0, 2);
667     $svc_acct = @_ ? shift : '';
668     $ip = @_ ? shift : '';
669     $prefix = @_ ? shift : '';
670     #my $select = @_ ? shift : '*';
671   }
672
673   $end ||= 2147483647;
674
675   return [] if $self->option('ignore_accounting');
676
677   my $dbh = sqlradius_connect( map $self->option($_),
678                                    qw( datasrc username password ) );
679
680   #select a unix time conversion function based on database type
681   my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
682
683   my @fields = (
684                  qw( username realm framedipaddress
685                      acctsessiontime acctinputoctets acctoutputoctets
686                      calledstationid
687                    ),
688                  "$str2time acctstarttime ) as acctstarttime",
689                  "$str2time acctstoptime ) as acctstoptime",
690                );
691
692   @fields = ( 'username', 'sum(acctsessiontime) as acctsessiontime', 'sum(acctinputoctets) as acctinputoctets',
693               'sum(acctoutputoctets) as acctoutputoctets',
694             ) if $summarize;
695
696   my @param = ();
697   my @where = ();
698
699   if ( $svc_acct ) {
700     my $username = $self->export_username($svc_acct);
701     if ( $username =~ /^([^@]+)\@([^@]+)$/ ) {
702       push @where, '( UserName = ? OR ( UserName = ? AND Realm = ? ) )';
703       push @param, $username, $1, $2;
704     } else {
705       push @where, 'UserName = ?';
706       push @param, $username;
707     }
708   }
709
710   if ($self->option('process_single_realm')) {
711     push @where, 'Realm = ?';
712     push @param, $self->option('realm');
713   }
714
715   if ( length($ip) ) {
716     push @where, ' FramedIPAddress = ?';
717     push @param, $ip;
718   }
719
720   if ( length($prefix) ) {
721     #assume sip: for now, else things get ugly trying to match /^\w+:$prefix/
722     push @where, " CalledStationID LIKE 'sip:$prefix\%'";
723   }
724
725   if ( $start ) {
726     push @where, "$str2time AcctStopTime ) >= ?";
727     push @param, $start;
728   }
729   if ( $end ) {
730     push @where, "$str2time AcctStopTime ) <= ?";
731     push @param, $end;
732   }
733   if ( $opt->{open_sessions} ) {
734     push @where, 'AcctStopTime IS NULL';
735   }
736   if ( $opt->{starttime_start} ) {
737     push @where, "$str2time AcctStartTime ) >= ?";
738     push @param, $opt->{starttime_start};
739   }
740   if ( $opt->{starttime_end} ) {
741     push @where, "$str2time AcctStartTime ) <= ?";
742     push @param, $opt->{starttime_end};
743   }
744
745   my $where = join(' AND ', @where);
746   $where = "WHERE $where" if $where;
747
748   my $groupby = '';
749   $groupby = 'GROUP BY username' if $summarize;
750
751   my $orderby = 'ORDER BY AcctStartTime DESC';
752   $orderby = '' if $summarize;
753
754   my $sth = $dbh->prepare('SELECT '. join(', ', @fields).
755                           "  FROM radacct $where $groupby $orderby
756                         ") or die $dbh->errstr;                                 
757   $sth->execute(@param) or die $sth->errstr;
758
759   [ map { { %$_ } } @{ $sth->fetchall_arrayref({}) } ];
760
761 }
762
763 =item update_svc
764
765 =cut
766
767 sub update_svc {
768   my $self = shift;
769
770   my $conf = new FS::Conf;
771
772   my $fdbh = dbh;
773   my $dbh = sqlradius_connect( map $self->option($_),
774                                    qw( datasrc username password ) );
775
776   my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
777   my @fields = qw( radacctid username realm acctsessiontime );
778
779   my @param = ();
780   my $where = '';
781
782   my $sth = $dbh->prepare("
783     SELECT RadAcctId, UserName, Realm, AcctSessionTime,
784            $str2time AcctStartTime),  $str2time AcctStopTime), 
785            AcctInputOctets, AcctOutputOctets
786       FROM radacct
787       WHERE FreesideStatus IS NULL
788         AND AcctStopTime IS NOT NULL
789   ") or die $dbh->errstr;
790   $sth->execute() or die $sth->errstr;
791
792   while ( my $row = $sth->fetchrow_arrayref ) {
793     my($RadAcctId, $UserName, $Realm, $AcctSessionTime, $AcctStartTime,
794        $AcctStopTime, $AcctInputOctets, $AcctOutputOctets) = @$row;
795     warn "processing record: ".
796          "$RadAcctId ($UserName\@$Realm for ${AcctSessionTime}s"
797       if $DEBUG;
798
799     $UserName = lc($UserName) unless $conf->exists('username-uppercase');
800
801     #my %search = ( 'username' => $UserName );
802
803     my $extra_sql = '';
804     if ( ref($self) =~ /withdomain/ ) { #well...
805       $extra_sql = " AND '$Realm' = ( SELECT domain FROM svc_domain
806                           WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
807     }
808
809     my $oldAutoCommit = $FS::UID::AutoCommit; # can't undo side effects, but at
810     local $FS::UID::AutoCommit = 0;           # least we can avoid over counting
811
812     my $status = 'skipped';
813     my $errinfo = "for RADIUS detail RadAcctID $RadAcctId ".
814                   "(UserName $UserName, Realm $Realm)";
815
816     if (    $self->option('process_single_realm')
817          && $self->option('realm') ne $Realm )
818     {
819       warn "WARNING: wrong realm $errinfo - skipping\n" if $DEBUG;
820     } else {
821       my @svc_acct =
822         grep { qsearch( 'export_svc', { 'exportnum' => $self->exportnum,
823                                         'svcpart'   => $_->cust_svc->svcpart, } )
824              }
825         qsearch( 'svc_acct',
826                    { 'username' => $UserName },
827                    '',
828                    $extra_sql
829                  );
830
831       if ( !@svc_acct ) {
832         warn "WARNING: no svc_acct record found $errinfo - skipping\n";
833       } elsif ( scalar(@svc_acct) > 1 ) {
834         warn "WARNING: multiple svc_acct records found $errinfo - skipping\n";
835       } else {
836
837         my $svc_acct = $svc_acct[0];
838         warn "found svc_acct ". $svc_acct->svcnum. " $errinfo\n" if $DEBUG;
839
840         $svc_acct->last_login($AcctStartTime);
841         $svc_acct->last_logout($AcctStopTime);
842
843         my $session_time = $AcctStopTime;
844         $session_time = $AcctStartTime if $self->option('ignore_long_sessions');
845
846         my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
847         if ( $cust_pkg && $session_time < (    $cust_pkg->last_bill
848                                             || $cust_pkg->setup     )  ) {
849           $status = 'skipped (too old)';
850         } else {
851           my @st;
852           push @st, _try_decrement($svc_acct, 'seconds',    $AcctSessionTime);
853           push @st, _try_decrement($svc_acct, 'upbytes',    $AcctInputOctets);
854           push @st, _try_decrement($svc_acct, 'downbytes',  $AcctOutputOctets);
855           push @st, _try_decrement($svc_acct, 'totalbytes', $AcctInputOctets
856                                                           + $AcctOutputOctets);
857           $status=join(' ', @st);
858         }
859       }
860     }
861
862     warn "setting FreesideStatus to $status $errinfo\n" if $DEBUG; 
863     my $psth = $dbh->prepare("UPDATE radacct
864                                 SET FreesideStatus = ?
865                                 WHERE RadAcctId = ?"
866     ) or die $dbh->errstr;
867     $psth->execute($status, $RadAcctId) or die $psth->errstr;
868
869     $fdbh->commit or die $fdbh->errstr if $oldAutoCommit;
870
871   }
872
873 }
874
875 sub _try_decrement {
876   my ($svc_acct, $column, $amount) = @_;
877   if ( $svc_acct->$column !~ /^$/ ) {
878     warn "  svc_acct.$column found (". $svc_acct->$column.
879          ") - decrementing\n"
880       if $DEBUG;
881     my $method = 'decrement_' . $column;
882     my $error = $svc_acct->$method($amount);
883     die $error if $error;
884     return 'done';
885   } else {
886     warn "  no existing $column value for svc_acct - skipping\n" if $DEBUG;
887   }
888   return 'skipped';
889 }
890
891 =item export_nas_insert NAS
892
893 =item export_nas_delete NAS
894
895 =item export_nas_replace NEW_NAS OLD_NAS
896
897 Update the NAS table (allowed RADIUS clients) on the attached RADIUS 
898 server.  Currently requires the table to be named 'nas' and to follow 
899 the stock schema (/etc/freeradius/nas.sql).
900
901 =cut
902
903 sub export_nas_insert {  shift->export_nas_action('insert', @_); }
904 sub export_nas_delete {  shift->export_nas_action('delete', @_); }
905 sub export_nas_replace { shift->export_nas_action('replace', @_); }
906
907 sub export_nas_action {
908   my $self = shift;
909   my ($action, $new, $old) = @_;
910   # find the NAS in the target table by its name
911   my $nasname = ($action eq 'replace') ? $old->nasname : $new->nasname;
912   my $nasnum = $new->nasnum;
913
914   my $err_or_queue = $self->sqlradius_queue('', "nas_$action", 
915     nasname => $nasname,
916     nasnum => $nasnum
917   );
918   return $err_or_queue unless ref $err_or_queue;
919   '';
920 }
921
922 sub sqlradius_nas_insert {
923   my $dbh = sqlradius_connect(shift, shift, shift);
924   my %opt = @_;
925   my $nas = qsearchs('nas', { nasnum => $opt{'nasnum'} })
926     or die "nasnum ".$opt{'nasnum'}.' not found';
927   # insert actual NULLs where FS::Record has translated to empty strings
928   my @values = map { length($nas->$_) ? $nas->$_ : undef }
929     qw( nasname shortname type secret server community description );
930   my $sth = $dbh->prepare('INSERT INTO nas 
931 (nasname, shortname, type, secret, server, community, description)
932 VALUES (?, ?, ?, ?, ?, ?, ?)');
933   $sth->execute(@values) or die $dbh->errstr;
934 }
935
936 sub sqlradius_nas_delete {
937   my $dbh = sqlradius_connect(shift, shift, shift);
938   my %opt = @_;
939   my $sth = $dbh->prepare('DELETE FROM nas WHERE nasname = ?');
940   $sth->execute($opt{'nasname'}) or die $dbh->errstr;
941 }
942
943 sub sqlradius_nas_replace {
944   my $dbh = sqlradius_connect(shift, shift, shift);
945   my %opt = @_;
946   my $nas = qsearchs('nas', { nasnum => $opt{'nasnum'} })
947     or die "nasnum ".$opt{'nasnum'}.' not found';
948   my @values = map {$nas->$_} 
949     qw( nasname shortname type secret server community description );
950   my $sth = $dbh->prepare('UPDATE nas SET
951     nasname = ?, shortname = ?, type = ?, secret = ?,
952     server = ?, community = ?, description = ?
953     WHERE nasname = ?');
954   $sth->execute(@values, $opt{'nasname'}) or die $dbh->errstr;
955 }
956
957 =item export_attr_insert RADIUS_ATTR
958
959 =item export_attr_delete RADIUS_ATTR
960
961 =item export_attr_replace NEW_RADIUS_ATTR OLD_RADIUS_ATTR
962
963 Update the group attribute tables (radgroupcheck and radgroupreply) on
964 the RADIUS server.  In delete and replace actions, the existing records
965 are identified by the combination of group name and attribute name.
966
967 In the special case where attributes are being replaced because a group 
968 name (L<FS::radius_group>->groupname) is changing, the pseudo-field 
969 'groupname' must be set in OLD_RADIUS_ATTR.  It's probably best to do this
970
971
972 =cut
973
974 # some false laziness with NAS export stuff...
975
976 sub export_attr_insert  { shift->export_attr_action('insert', @_); }
977
978 sub export_attr_delete  { shift->export_attr_action('delete', @_); }
979
980 sub export_attr_replace { shift->export_attr_action('replace', @_); }
981
982 sub export_attr_action {
983   my $self = shift;
984   my ($action, $new, $old) = @_;
985   my ($attrname, $attrtype, $groupname) = 
986     ($new->attrname, $new->attrtype, $new->radius_group->groupname);
987   if ( $action eq 'replace' ) {
988
989     if ( $new->attrtype ne $old->attrtype ) {
990       # they're in separate tables in the target
991       return $self->export_attr_action('delete', $old) 
992           || $self->export_attr_action('insert', $new)
993       ;
994     }
995
996     # otherwise, just make sure we know the old attribute/group names 
997     # so we can find the existing record
998     $attrname = $old->attrname;
999     $groupname = $old->groupname || $old->radius_group->groupname;
1000     # maybe this should be enforced more strictly
1001     warn "WARNING: attribute replace without 'groupname' set; assuming '$groupname'\n"
1002       if !defined($old->groupname);
1003   }
1004
1005   my $err_or_queue = $self->sqlradius_queue('', "attr_$action",
1006     attrnum => $new->attrnum,
1007     attrname => $attrname,
1008     attrtype => $attrtype,
1009     groupname => $groupname,
1010   );
1011   return $err_or_queue unless ref $err_or_queue;
1012   '';
1013 }
1014
1015 sub sqlradius_attr_insert {
1016   my $dbh = sqlradius_connect(shift, shift, shift);
1017   my %opt = @_;
1018   my $radius_attr = qsearchs('radius_attr', { attrnum => $opt{'attrnum'} })
1019     or die 'attrnum '.$opt{'attrnum'}.' not found';
1020
1021   my $table;
1022   # make sure $table is completely safe
1023   if ( $opt{'attrtype'} eq 'C' ) {
1024     $table = 'radgroupcheck';
1025   }
1026   elsif ( $opt{'attrtype'} eq 'R' ) {
1027     $table = 'radgroupreply';
1028   }
1029   else {
1030     die "unknown attribute type '".$radius_attr->attrtype."'";
1031   }
1032
1033   my @values = ( 
1034     $opt{'groupname'}, map { $radius_attr->$_ } qw(attrname op value)
1035   );
1036   my $sth = $dbh->prepare(
1037     'INSERT INTO '.$table.' (groupname, attribute, op, value) VALUES (?,?,?,?)'
1038   );
1039   $sth->execute(@values) or die $dbh->errstr;
1040 }
1041
1042 sub sqlradius_attr_delete {
1043   my $dbh = sqlradius_connect(shift, shift, shift);
1044   my %opt = @_;
1045
1046   my $table;
1047   if ( $opt{'attrtype'} eq 'C' ) {
1048     $table = 'radgroupcheck';
1049   }
1050   elsif ( $opt{'attrtype'} eq 'R' ) {
1051     $table = 'radgroupreply';
1052   }
1053   else {
1054     die "unknown attribute type '".$opt{'attrtype'}."'";
1055   }
1056
1057   my $sth = $dbh->prepare(
1058     'DELETE FROM '.$table.' WHERE groupname = ? AND attribute = ?'
1059   );
1060   $sth->execute( @opt{'groupname', 'attrname'} ) or die $dbh->errstr;
1061 }
1062
1063 sub sqlradius_attr_replace {
1064   my $dbh = sqlradius_connect(shift, shift, shift);
1065   my %opt = @_;
1066   my $radius_attr = qsearchs('radius_attr', { attrnum => $opt{'attrnum'} })
1067     or die 'attrnum '.$opt{'attrnum'}.' not found';
1068
1069   my $table;
1070   if ( $opt{'attrtype'} eq 'C' ) {
1071     $table = 'radgroupcheck';
1072   }
1073   elsif ( $opt{'attrtype'} eq 'R' ) {
1074     $table = 'radgroupreply';
1075   }
1076   else {
1077     die "unknown attribute type '".$opt{'attrtype'}."'";
1078   }
1079
1080   my $sth = $dbh->prepare(
1081     'UPDATE '.$table.' SET groupname = ?, attribute = ?, op = ?, value = ?
1082      WHERE groupname = ? AND attribute = ?'
1083   );
1084
1085   my $new_groupname = $radius_attr->radius_group->groupname;
1086   my @new_values = ( 
1087     $new_groupname, map { $radius_attr->$_ } qw(attrname op value) 
1088   );
1089   $sth->execute( @new_values, @opt{'groupname', 'attrname'} )
1090     or die $dbh->errstr;
1091 }
1092
1093 =item export_group_replace NEW OLD
1094
1095 Replace the L<FS::radius_group> object OLD with NEW.  This will change
1096 the group name and priority in all radusergroup records, and the group 
1097 name in radgroupcheck and radgroupreply.
1098
1099 =cut
1100
1101 sub export_group_replace {
1102   my $self = shift;
1103   my ($new, $old) = @_;
1104   return '' if $new->groupname eq $old->groupname
1105            and $new->priority  == $old->priority;
1106
1107   my $err_or_queue = $self->sqlradius_queue(
1108     '',
1109     'group_replace',
1110     ($self->option('usergroup') || 'usergroup'),
1111     $new->hashref,
1112     $old->hashref,
1113   );
1114   return $err_or_queue unless ref $err_or_queue;
1115   '';
1116 }
1117
1118 sub sqlradius_group_replace {
1119   my $dbh = sqlradius_connect(shift, shift, shift);
1120   my $usergroup = shift;
1121   $usergroup =~ /^(rad)?usergroup$/
1122     or die "bad usergroup table name: $usergroup";
1123   my ($new, $old) = (shift, shift);
1124   # apply renames to check/reply attribute tables
1125   if ( $new->{'groupname'} ne $old->{'groupname'} ) {
1126     foreach my $table (qw(radgroupcheck radgroupreply)) {
1127       my $sth = $dbh->prepare(
1128         'UPDATE '.$table.' SET groupname = ? WHERE groupname = ?'
1129       );
1130       $sth->execute($new->{'groupname'}, $old->{'groupname'})
1131         or die $dbh->errstr;
1132     }
1133   }
1134   # apply renames and priority changes to usergroup table
1135   my $sth = $dbh->prepare(
1136     'UPDATE '.$usergroup.' SET groupname = ?, priority = ? WHERE groupname = ?'
1137   );
1138   $sth->execute($new->{'groupname'}, $new->{'priority'}, $old->{'groupname'})
1139     or die $dbh->errstr;
1140 }
1141
1142 ###
1143 # class method to fetch groups/attributes from the sqlradius install on upgrade
1144 ###
1145
1146 sub _upgrade_exporttype {
1147   # do this only if the radius_attr table is empty
1148   local $FS::radius_attr::noexport_hack = 1;
1149   my $class = shift;
1150   return if qsearch('radius_attr', {});
1151
1152   foreach my $self ($class->all_sqlradius) {
1153     my $error = $self->import_attrs;
1154     die "exportnum ".$self->exportnum.":\n$error\n" if $error;
1155   }
1156   return;
1157 }
1158
1159 sub import_attrs {
1160   my $self = shift;
1161   my $dbh =  DBI->connect( map $self->option($_),
1162                                    qw( datasrc username password ) );
1163   unless ( $dbh ) {
1164     warn "Error connecting to RADIUS server: $DBI::errstr\n";
1165     return;
1166   }
1167
1168   my $usergroup = $self->option('usergroup') || 'usergroup';
1169   my $error;
1170   warn "Importing RADIUS groups and attributes from ".$self->option('datasrc').
1171     "\n";
1172
1173   # map out existing groups and attrs
1174   my %attrs_of;
1175   my %groupnum_of;
1176   foreach my $radius_group ( qsearch('radius_group', {}) ) {
1177     $attrs_of{$radius_group->groupname} = +{
1178       map { $_->attrname => $_ } $radius_group->radius_attr
1179     };
1180     $groupnum_of{$radius_group->groupname} = $radius_group->groupnum;
1181   }
1182
1183   # get groupnames from radgroupcheck and radgroupreply
1184   my $sql = '
1185 SELECT groupname, attribute, op, value, \'C\' FROM radgroupcheck
1186 UNION
1187 SELECT groupname, attribute, op, value, \'R\' FROM radgroupreply';
1188   foreach my $row ( @{ $dbh->selectall_arrayref($sql) } ) {
1189     my ($groupname, $attrname, $op, $value, $attrtype) = @$row;
1190     warn "$groupname.$attrname\n";
1191     if ( !exists($groupnum_of{$groupname}) ) {
1192       my $radius_group = new FS::radius_group {
1193         'groupname' => $groupname,
1194         'priority'  => 1,
1195       };
1196       $error = $radius_group->insert;
1197       if ( $error ) {
1198         warn "error inserting group $groupname: $error";
1199         next;#don't continue trying to insert the attribute
1200       }
1201       $attrs_of{$groupname} = {};
1202       $groupnum_of{$groupname} = $radius_group->groupnum;
1203     }
1204
1205     my $a = $attrs_of{$groupname};
1206     my $old = $a->{$attrname};
1207     my $new;
1208
1209     if ( defined $old ) {
1210       # replace
1211       $new = new FS::radius_attr {
1212         $old->hash,
1213         'op'    => $op,
1214         'value' => $value,
1215       };
1216       $error = $new->replace($old);
1217       if ( $error ) {
1218         warn "error modifying attr $attrname: $error";
1219         next;
1220       }
1221     }
1222     else {
1223       $new = new FS::radius_attr {
1224         'groupnum' => $groupnum_of{$groupname},
1225         'attrname' => $attrname,
1226         'attrtype' => $attrtype,
1227         'op'       => $op,
1228         'value'    => $value,
1229       };
1230       $error = $new->insert;
1231       if ( $error ) {
1232         warn "error inserting attr $attrname: $error" if $error;
1233         next;
1234       }
1235     }
1236     $attrs_of{$groupname}->{$attrname} = $new;
1237   } #foreach $row
1238   return;
1239 }
1240
1241 ###
1242 #class methods
1243 ###
1244
1245 sub all_sqlradius {
1246   #my $class = shift;
1247
1248   #don't just look for ->can('usage_sessions'), we're sqlradius-specific
1249   # (radiator is supposed to be setup with a radacct table)
1250   #i suppose it would be more slick to look for things that inherit from us..
1251
1252   my @part_export = ();
1253   push @part_export, qsearch('part_export', { 'exporttype' => $_ } )
1254     foreach qw( sqlradius sqlradius_withdomain radiator phone_sqlradius
1255                 broadband_sqlradius );
1256   @part_export;
1257 }
1258
1259 sub all_sqlradius_withaccounting {
1260   my $class = shift;
1261   grep { ! $_->option('ignore_accounting') } $class->all_sqlradius;
1262 }
1263
1264 1;
1265