better duplicate checking for ip address, #17515
[freeside.git] / FS / FS / svc_broadband.pm
1 package FS::svc_broadband;
2
3 use strict;
4 use vars qw(@ISA $conf);
5
6 use base qw(FS::svc_Radius_Mixin FS::svc_Tower_Mixin FS::svc_Common);
7 { no warnings 'redefine'; use NetAddr::IP; }
8 use FS::Record qw( qsearchs qsearch dbh );
9 use FS::svc_Common;
10 use FS::cust_svc;
11 use FS::addr_block;
12 use FS::part_svc_router;
13 use FS::tower_sector;
14
15 $FS::UID::callback{'FS::svc_broadband'} = sub { 
16   $conf = new FS::Conf;
17 };
18
19 =head1 NAME
20
21 FS::svc_broadband - Object methods for svc_broadband records
22
23 =head1 SYNOPSIS
24
25   use FS::svc_broadband;
26
27   $record = new FS::svc_broadband \%hash;
28   $record = new FS::svc_broadband { 'column' => 'value' };
29
30   $error = $record->insert;
31
32   $error = $new_record->replace($old_record);
33
34   $error = $record->delete;
35
36   $error = $record->check;
37
38   $error = $record->suspend;
39
40   $error = $record->unsuspend;
41
42   $error = $record->cancel;
43
44 =head1 DESCRIPTION
45
46 An FS::svc_broadband object represents a 'broadband' Internet connection, such
47 as a DSL, cable modem, or fixed wireless link.  These services are assumed to
48 have the following properties:
49
50 FS::svc_broadband inherits from FS::svc_Common.  The following fields are
51 currently supported:
52
53 =over 4
54
55 =item svcnum - primary key
56
57 =item blocknum - see FS::addr_block
58
59 =item
60 speed_up - maximum upload speed, in bits per second.  If set to zero, upload
61 speed will be unlimited.  Exports that do traffic shaping should handle this
62 correctly, and not blindly set the upload speed to zero and kill the customer's
63 connection.
64
65 =item
66 speed_down - maximum download speed, as above
67
68 =item ip_addr - the customer's IP address.  If the customer needs more than one
69 IP address, set this to the address of the customer's router.  As a result, the
70 customer's router will have the same address for both its internal and external
71 interfaces thus saving address space.  This has been found to work on most NAT
72 routers available.
73
74 =item plan_id
75
76 =back
77
78 =head1 METHODS
79
80 =over 4
81
82 =item new HASHREF
83
84 Creates a new svc_broadband.  To add the record to the database, see
85 "insert".
86
87 Note that this stores the hash reference, not a distinct copy of the hash it
88 points to.  You can ask the object for a copy with the I<hash> method.
89
90 =cut
91
92 sub table_info {
93   {
94     'name' => 'Broadband',
95     'name_plural' => 'Broadband services',
96     'longname_plural' => 'Fixed (username-less) broadband services',
97     'display_weight' => 50,
98     'cancel_weight'  => 70,
99     'ip_field' => 'ip_addr',
100     'fields' => {
101       'svcnum'      => 'Service',
102       'description' => 'Descriptive label for this particular device',
103       'speed_down'  => 'Maximum download speed for this service in Kbps.  0 denotes unlimited.',
104       'speed_up'    => 'Maximum upload speed for this service in Kbps.  0 denotes unlimited.',
105       'ip_addr'     => 'IP address.  Leave blank for automatic assignment.',
106       'blocknum'    => 
107       { 'label' => 'Address block',
108                          'type'  => 'select',
109                          'select_table' => 'addr_block',
110                           'select_key'   => 'blocknum',
111                          'select_label' => 'cidr',
112                          'disable_inventory' => 1,
113                        },
114      'plan_id' => 'Service Plan Id',
115      'performance_profile' => 'Peformance Profile',
116      'authkey'      => 'Authentication key',
117      'mac_addr'     => 'MAC address',
118      'latitude'     => 'Latitude',
119      'longitude'    => 'Longitude',
120      'altitude'     => 'Altitude',
121      'vlan_profile' => 'VLAN profile',
122      'sectornum'    => 'Tower/sector',
123      'routernum'    => 'Router/block',
124      'usergroup'    => { 
125                          label => 'RADIUS groups',
126                          type  => 'select-radius_group.html',
127                          #select_table => 'radius_group',
128                          #select_key   => 'groupnum',
129                          #select_label => 'groupname',
130                          disable_inventory => 1,
131                          multiple => 1,
132                        },
133     },
134   };
135 }
136
137 sub table { 'svc_broadband'; }
138
139 sub table_dupcheck_fields { ( 'ip_addr', 'mac_addr' ); }
140
141 =item search HASHREF
142
143 Class method which returns a qsearch hash expression to search for parameters
144 specified in HASHREF.
145
146 Parameters:
147
148 =over 4
149
150 =item unlinked - set to search for all unlinked services.  Overrides all other options.
151
152 =item agentnum
153
154 =item custnum
155
156 =item svcpart
157
158 =item ip_addr
159
160 =item pkgpart - arrayref
161
162 =item routernum - arrayref
163
164 =item sectornum - arrayref
165
166 =item towernum - arrayref
167
168 =item order_by
169
170 =back
171
172 =cut
173
174 sub search {
175   my ($class, $params) = @_;
176   my @where = ();
177   my @from = (
178     'LEFT JOIN cust_svc  USING ( svcnum  )',
179     'LEFT JOIN part_svc  USING ( svcpart )',
180     'LEFT JOIN cust_pkg  USING ( pkgnum  )',
181     'LEFT JOIN cust_main USING ( custnum )',
182   );
183
184   # based on FS::svc_acct::search, probably the most mature of the bunch
185   #unlinked
186   push @where, 'pkgnum IS NULL' if $params->{'unlinked'};
187   
188   #agentnum
189   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
190     push @where, "cust_main.agentnum = $1";
191   }
192   push @where, $FS::CurrentUser::CurrentUser->agentnums_sql(
193     'null_right' => 'View/link unlinked services',
194     'table' => 'cust_main'
195   );
196
197   #custnum
198   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
199     push @where, "custnum = $1";
200   }
201
202   #pkgpart, now properly untainted, can be arrayref
203   for my $pkgpart ( $params->{'pkgpart'} ) {
204     if ( ref $pkgpart ) {
205       my $where = join(',', map { /^(\d+)$/ ? $1 : () } @$pkgpart );
206       push @where, "cust_pkg.pkgpart IN ($where)" if $where;
207     }
208     elsif ( $pkgpart =~ /^(\d+)$/ ) {
209       push @where, "cust_pkg.pkgpart = $1";
210     }
211   }
212
213   #routernum, can be arrayref
214   for my $routernum ( $params->{'routernum'} ) {
215     # this no longer uses addr_block
216     if ( ref $routernum and grep { $_ } @$routernum ) {
217       my $in = join(',', map { /^(\d+)$/ ? $1 : () } @$routernum );
218       my @orwhere;
219       push @orwhere, "svc_broadband.routernum IN ($in)" if $in;
220       push @orwhere, "svc_broadband.routernum IS NULL" 
221         if grep /^none$/, @$routernum;
222       push @where, '( '.join(' OR ', @orwhere).' )';
223     }
224     elsif ( $routernum =~ /^(\d+)$/ ) {
225       push @where, "svc_broadband.routernum = $1";
226     }
227     elsif ( $routernum eq 'none' ) {
228       push @where, "svc_broadband.routernum IS NULL";
229     }
230   }
231
232   #sector and tower, as above
233   my @where_sector = $class->tower_sector_sql($params);
234   if ( @where_sector ) {
235     push @where, @where_sector;
236     push @from, 'LEFT JOIN tower_sector USING ( sectornum )';
237   }
238  
239   #svcnum
240   if ( $params->{'svcnum'} =~ /^(\d+)$/ ) {
241     push @where, "svcnum = $1";
242   }
243
244   #svcpart
245   if ( $params->{'svcpart'} =~ /^(\d+)$/ ) {
246     push @where, "svcpart = $1";
247   }
248
249   #ip_addr
250   if ( $params->{'ip_addr'} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/ ) {
251     push @where, "ip_addr = '$1'";
252   }
253
254   #custnum
255   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1) {
256     push @where, "custnum = $1";
257   }
258   
259   my $addl_from = join(' ', @from);
260   my $extra_sql = '';
261   $extra_sql = 'WHERE '.join(' AND ', @where) if @where;
262   my $count_query = "SELECT COUNT(*) FROM svc_broadband $addl_from $extra_sql";
263   return( {
264       'table'   => 'svc_broadband',
265       'hashref' => {},
266       'select'  => join(', ',
267         'svc_broadband.*',
268         'part_svc.svc',
269         'cust_main.custnum',
270         FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
271       ),
272       'extra_sql' => $extra_sql,
273       'addl_from' => $addl_from,
274       'order_by'  => "ORDER BY ".($params->{'order_by'} || 'svcnum'),
275       'count_query' => $count_query,
276     } );
277 }
278
279 =item search_sql STRING
280
281 Class method which returns an SQL fragment to search for the given string.
282
283 =cut
284
285 sub search_sql {
286   my( $class, $string ) = @_;
287   if ( $string =~ /^(\d{1,3}\.){3}\d{1,3}$/ ) {
288     $class->search_sql_field('ip_addr', $string );
289   }elsif ( $string =~ /^([a-fA-F0-9]{12})$/ ) {
290     $class->search_sql_field('mac_addr', uc($string));
291   }elsif ( $string =~ /^(([a-fA-F0-9]{1,2}:){5}([a-fA-F0-9]{1,2}))$/ ) {
292     $class->search_sql_field('mac_addr', uc("$2$3$4$5$6$7") );
293   } else {
294     '1 = 0'; #false
295   }
296 }
297
298 =item label
299
300 Returns the IP address.
301
302 =cut
303
304 sub label {
305   my $self = shift;
306   $self->ip_addr;
307 }
308
309 =item insert [ , OPTION => VALUE ... ]
310
311 Adds this record to the database.  If there is an error, returns the error,
312 otherwise returns false.
313
314 The additional fields pkgnum and svcpart (see FS::cust_svc) should be 
315 defined.  An FS::cust_svc record will be created and inserted.
316
317 Currently available options are: I<depend_jobnum>
318
319 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
320 jobnums), all provisioning jobs will have a dependancy on the supplied
321 jobnum(s) (they will not run until the specific job(s) complete(s)).
322
323 # Standard FS::svc_Common::insert
324
325 =item delete
326
327 Delete this record from the database.
328
329 =cut
330
331 # Standard FS::svc_Common::delete
332
333 =item replace OLD_RECORD
334
335 Replaces the OLD_RECORD with this one in the database.  If there is an error,
336 returns the error, otherwise returns false.
337
338 # Standard FS::svc_Common::replace
339
340 =item suspend
341
342 Called by the suspend method of FS::cust_pkg (see FS::cust_pkg).
343
344 =item unsuspend
345
346 Called by the unsuspend method of FS::cust_pkg (see FS::cust_pkg).
347
348 =item cancel
349
350 Called by the cancel method of FS::cust_pkg (see FS::cust_pkg).
351
352 =item check
353
354 Checks all fields to make sure this is a valid broadband service.  If there is
355 an error, returns the error, otherwise returns false.  Called by the insert
356 and replace methods.
357
358 =cut
359
360 sub check {
361   my $self = shift;
362   my $x = $self->setfixed;
363
364   return $x unless ref($x);
365
366   # remove delimiters
367   my $mac_addr = uc($self->get('mac_addr'));
368   $mac_addr =~ s/[-: ]//g;
369   $self->set('mac_addr', $mac_addr);
370
371   my $error =
372     $self->ut_numbern('svcnum')
373     || $self->ut_numbern('blocknum')
374     || $self->ut_foreign_keyn('routernum', 'router', 'routernum')
375     || $self->ut_foreign_keyn('sectornum', 'tower_sector', 'sectornum')
376     || $self->ut_textn('description')
377     || $self->ut_numbern('speed_up')
378     || $self->ut_numbern('speed_down')
379     || $self->ut_ipn('ip_addr')
380     || $self->ut_hexn('mac_addr')
381     || $self->ut_hexn('auth_key')
382     || $self->ut_coordn('latitude')
383     || $self->ut_coordn('longitude')
384     || $self->ut_sfloatn('altitude')
385     || $self->ut_textn('vlan_profile')
386     || $self->ut_textn('plan_id')
387   ;
388   return $error if $error;
389
390   if(($self->speed_up || 0) < 0) { return 'speed_up must be positive'; }
391   if(($self->speed_down || 0) < 0) { return 'speed_down must be positive'; }
392
393   my $cust_svc = $self->svcnum
394                  ? qsearchs('cust_svc', { 'svcnum' => $self->svcnum } )
395                  : '';
396   my $cust_pkg;
397   my $svcpart;
398   if ($cust_svc) {
399     $cust_pkg = $cust_svc->cust_pkg;
400     $svcpart = $cust_svc->svcpart;
401   }else{
402     $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $self->pkgnum } );
403     return "Invalid pkgnum" unless $cust_pkg;
404     $svcpart = $self->svcpart;
405   }
406   my $agentnum = $cust_pkg->cust_main->agentnum if $cust_pkg;
407
408   if ( $conf->exists('auto_router') and $self->ip_addr and !$self->routernum ) {
409     # assign_router is guaranteed to provide a router that's legal
410     # for this agent and svcpart
411     my $error = $self->_check_ip_addr || $self->assign_router;
412     return $error if $error;
413   }
414   elsif ($self->routernum) {
415     return "Router ".$self->routernum." does not provide this service"
416       unless qsearchs('part_svc_router', { 
417         svcpart => $svcpart,
418         routernum => $self->routernum
419     });
420   
421     my $router = $self->router;
422     return "Router ".$self->routernum." does not serve this customer"
423       if $router->agentnum and $agentnum and $router->agentnum != $agentnum;
424
425     if ( $router->manual_addr ) {
426       $self->blocknum('');
427     }
428     else {
429       my $addr_block = $self->addr_block;
430       if ( $self->ip_addr eq '' 
431            and not ( $addr_block and $addr_block->manual_flag ) ) {
432         my $error = $self->assign_ip_addr;
433         return $error if $error;
434       }
435     }
436  
437     my $error = $self->_check_ip_addr;
438     return $error if $error;
439   } # if $self->routernum
440
441   if ( $cust_pkg && ! $self->latitude && ! $self->longitude ) {
442     my $l = $cust_pkg->cust_location_or_main;
443     if ( $l->ship_latitude && $l->ship_longitude ) {
444       $self->latitude(  $l->ship_latitude  );
445       $self->longitude( $l->ship_longitude );
446     } elsif ( $l->latitude && $l->longitude ) {
447       $self->latitude(  $l->latitude  );
448       $self->longitude( $l->longitude );
449     }
450   }
451
452   $self->SUPER::check;
453 }
454
455 =item assign_ip_addr
456
457 Assign an IP address matching the selected router, and the selected block
458 if there is one.
459
460 =cut
461
462 sub assign_ip_addr {
463   my $self = shift;
464   my @blocks;
465   my $ip_addr;
466
467   if ( $self->addr_block and $self->addr_block->routernum == $self->routernum ) {
468     # simple case: user chose a block, find an address in that block
469     # (this overrides an existing IP address if it's not in the block)
470     @blocks = ($self->addr_block);
471   }
472   elsif ( $self->routernum ) {
473     @blocks = $self->router->auto_addr_block;
474   }
475   else { 
476     return '';
477   }
478 #warn "assigning ip address in blocks\n".join("\n",map{$_->cidr} @blocks)."\n";
479
480   foreach my $block ( @blocks ) {
481     if ( $self->ip_addr and $block->NetAddr->contains($self->NetAddr) ) {
482       # don't change anything
483       return '';
484     }
485     $ip_addr = $block->next_free_addr;
486     if ( $ip_addr ) {
487       $self->set(ip_addr => $ip_addr->addr);
488       $self->set(blocknum => $block->blocknum);
489       return '';
490     }
491   }
492   return 'No IP address available on this router';
493 }
494
495 =item assign_router
496
497 Assign an address block and router matching the selected IP address.
498 Does nothing if IP address is null.
499
500 =cut
501
502 sub assign_router {
503   my $self = shift;
504   return '' if !$self->ip_addr;
505   #warn "assigning router/block for ".$self->ip_addr."\n";
506   foreach my $router ($self->allowed_routers) {
507     foreach my $block ($router->addr_block) {
508       if ( $block->NetAddr->contains($self->NetAddr) ) {
509         $self->blocknum($block->blocknum);
510         $self->routernum($block->routernum);
511         return '';
512       }
513     }
514   }
515   return $self->ip_addr.' is not in an allowed block.';
516 }
517
518 sub _check_ip_addr {
519   my $self = shift;
520
521   if (not($self->ip_addr) or $self->ip_addr eq '0.0.0.0') {
522     return '' if $conf->exists('svc_broadband-allow_null_ip_addr'); 
523     return 'IP address required';
524   }
525   else {
526     return 'Cannot parse address: '.$self->ip_addr unless $self->NetAddr;
527   }
528
529   if ( $self->addr_block 
530       and not $self->addr_block->NetAddr->contains($self->NetAddr) ) {
531     return 'Address '.$self->ip_addr.' not in block '.$self->addr_block->cidr;
532   }
533
534 #  if (my $dup = qsearchs('svc_broadband', {
535 #        ip_addr => $self->ip_addr,
536 #        svcnum  => {op=>'!=', value => $self->svcnum}
537 #      }) ) {
538 #    return 'IP address conflicts with svcnum '.$dup->svcnum;
539 #  }
540   '';
541 }
542
543 sub _check_duplicate {
544   my $self = shift;
545   # Not a reliable check because the table isn't locked, but 
546   # that's why we have a unique index.  This is just to give a
547   # friendlier error message.
548   my @dup;
549   @dup = $self->find_duplicates('global', 'ip_addr');
550   if ( @dup ) {
551     return "IP address in use (svcnum ".$dup[0]->svcnum.")";
552   }
553   @dup = $self->find_duplicates('global', 'mac_addr');
554   if ( @dup ) {
555     return "MAC address in use (svcnum ".$dup[0]->svcnum.")";
556   }
557
558   '';
559 }
560
561
562 =item NetAddr
563
564 Returns a NetAddr::IP object containing the IP address of this service.  The netmask 
565 is /32.
566
567 =cut
568
569 sub NetAddr {
570   my $self = shift;
571   new NetAddr::IP ($self->ip_addr);
572 }
573
574 =item addr_block
575
576 Returns the FS::addr_block record (i.e. the address block) for this broadband service.
577
578 =cut
579
580 sub addr_block {
581   my $self = shift;
582   qsearchs('addr_block', { blocknum => $self->blocknum });
583 }
584
585 =item router
586
587 Returns the FS::router record for this service.
588
589 =cut
590
591 sub router {
592   my $self = shift;
593   qsearchs('router', { routernum => $self->routernum });
594 }
595
596 =item allowed_routers
597
598 Returns a list of allowed FS::router objects.
599
600 =cut
601
602 sub allowed_routers {
603   my $self = shift;
604   my $svcpart = $self->svcnum ? $self->cust_svc->svcpart : $self->svcpart;
605   my @r = map { $_->router } qsearch('part_svc_router', 
606     { svcpart => $svcpart });
607   if ( $self->cust_main ) {
608     my $agentnum = $self->cust_main->agentnum;
609     return grep { !$_->agentnum or $_->agentnum == $agentnum } @r;
610   }
611   else {
612     return @r;
613   }
614 }
615
616 =back
617
618
619 =item mac_addr_formatted CASE DELIMITER
620
621 Format the MAC address (for use by exports).  If CASE starts with "l"
622 (for "lowercase"), it's returned in lowercase.  DELIMITER is inserted
623 between octets.
624
625 =cut
626
627 sub mac_addr_formatted {
628   my $self = shift;
629   my ($case, $delim) = @_;
630   my $addr = $self->mac_addr;
631   $addr = lc($addr) if $case =~ /^l/i;
632   join( $delim || '', $addr =~ /../g );
633 }
634
635 #class method
636 sub _upgrade_data {
637   my $class = shift;
638
639   local($FS::svc_Common::noexport_hack) = 1;
640
641   # set routernum to addr_block.routernum
642   foreach my $self (qsearch('svc_broadband', {
643       blocknum => {op => '!=', value => ''},
644       routernum => ''
645     })) {
646     my $addr_block = $self->addr_block;
647     if ( !$addr_block ) {
648       # super paranoid mode
649       warn "WARNING: svcnum ".$self->svcnum." is assigned to addr_block ".$self->blocknum.", which does not exist; skipped.\n";
650       next;
651     }
652     my $ip_addr = $self->ip_addr;
653     my $routernum = $addr_block->routernum;
654     if ( $routernum ) {
655       $self->set(routernum => $routernum);
656       my $error = $self->check;
657       # sanity check: don't allow this to change IP address or block
658       # (other than setting blocknum to null for a non-auto-assigned router)
659       if ( $self->ip_addr ne $ip_addr 
660         or ($self->blocknum and $self->blocknum != $addr_block->blocknum)) {
661         warn "WARNING: Upgrading service ".$self->svcnum." would change its block/address; skipped.\n";
662         next;
663       }
664
665       $error ||= $self->replace;
666       warn "WARNING: error assigning routernum $routernum to service ".$self->svcnum.
667           ":\n$error; skipped\n"
668         if $error;
669     }
670     else {
671       warn "svcnum ".$self->svcnum.
672         ": no routernum in address block ".$addr_block->cidr.", skipped\n";
673     }
674   }
675
676   # assign blocknums to services that should have them
677   my @all_blocks = qsearch('addr_block', { });
678   SVC: foreach my $self ( 
679     qsearch({
680         'select' => 'svc_broadband.*',
681         'table' => 'svc_broadband',
682         'addl_from' => 'JOIN router USING (routernum)',
683         'hashref' => {},
684         'extra_sql' => 'WHERE svc_broadband.blocknum IS NULL '.
685                        'AND router.manual_addr IS NULL',
686     }) 
687   ) {
688    
689     next SVC if $self->ip_addr eq '';
690     my $NetAddr = $self->NetAddr;
691     # inefficient, but should only need to run once
692     foreach my $block (@all_blocks) {
693       if ($block->NetAddr->contains($NetAddr)) {
694         $self->set(blocknum => $block->blocknum);
695         my $error = $self->replace;
696         warn "WARNING: error assigning blocknum ".$block->blocknum.
697         " to service ".$self->svcnum."\n$error; skipped\n"
698           if $error;
699         next SVC;
700       }
701     }
702     warn "WARNING: no block found containing ".$NetAddr->addr." for service ".
703       $self->svcnum;
704     #next SVC;
705   }
706
707   '';
708 }
709
710 =back
711
712 =head1 BUGS
713
714 The business with sb_field has been 'fixed', in a manner of speaking.
715
716 allowed_routers isn't agent virtualized because part_svc isn't agent
717 virtualized
718
719 Having both routernum and blocknum as foreign keys is somewhat dubious.
720
721 =head1 SEE ALSO
722
723 FS::svc_Common, FS::Record, FS::addr_block,
724 FS::part_svc, schema.html from the base documentation.
725
726 =cut
727
728 1;
729