diff options
Diffstat (limited to 'FS')
| -rw-r--r-- | FS/FS/Schema.pm | 1 | ||||
| -rw-r--r-- | FS/FS/part_export.pm | 72 | ||||
| -rw-r--r-- | FS/FS/part_export/voip_ms.pm | 648 | ||||
| -rw-r--r-- | FS/FS/svc_phone.pm | 14 | 
4 files changed, 718 insertions, 17 deletions
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 8b362a7a6..f55956469 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -5656,6 +5656,7 @@ sub tables_hashref {          'e911_class',                    'char', 'NULL',       1, '', '',          'e911_type',                     'char', 'NULL',       1, '', '',           'circuit_svcnum',                 'int', 'NULL',      '', '', '', +        'sip_server',                 'varchar', 'NULL', $char_d, '', '',        ],        'primary_key'  => 'svcnum',        'unique'       => [ [ 'sms_carrierid', 'sms_account'] ], diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm index 7819a7c86..f3d977480 100644 --- a/FS/FS/part_export.pm +++ b/FS/FS/part_export.pm @@ -535,23 +535,6 @@ sub default_export_machine {    die "no default export hostname for export ".$self->exportnum;  } -=item svc_role SVC_X - -Returns the role that SVC_X occupies with respect to this export, if any. -This is part of the part_svc's export configuration. - -=cut - -sub svc_role { -  my $self = shift; -  my $svc_x = shift; -  my $cust_svc = $svc_x->cust_svc or return ''; -  my $export_svc = qsearchs('export_svc', { exportnum => $self->exportnum, -                                            svcpart   => $cust_svc->svcpart }) -                   or return ''; -  $export_svc->role; -}  -  #these should probably all go away, just let the subclasses define em  =item export_insert SVC_OBJECT @@ -753,6 +736,61 @@ sub get_dids_npa_select   { 1; }  # change the phone number for a service. if false, then they can't (have to  # reprovision completely). +=item svc_role SVC + +Returns the role that SVC occupies with respect to this export, if any. +This is part of the part_svc's export configuration. + +=cut + +sub svc_role { +  my $self = shift; +  my $svc_x = shift; +  my $cust_svc = $svc_x->cust_svc or return ''; +  my $export_svc = qsearchs('export_svc', { exportnum => $self->exportnum, +                                            svcpart   => $cust_svc->svcpart }) +                   or return ''; +  $export_svc->role; +}  + +=item svc_with_role { SVC | PKGNUM }, ROLE + +Given a svc_* object SVC or pkgnum PKG, and a role name ROLE, finds the +service(s) in the same package that are linked to this export with ROLE. + +=cut + +sub svc_with_role { +  my $self = shift; +  my $svc_or_pkgnum = shift; +  my $role = shift;  +  my $pkgnum; +  if ( ref $svc_or_pkgnum ) { +    $pkgnum = $svc_or_pkgnum->cust_svc->pkgnum or return ''; +  } else { +    $pkgnum = $svc_or_pkgnum; +  } +  my $role_info = $self->info->{roles}->{$role} +    or die "role '$role' does not exist for export '".$self->exporttype."'\n"; +  my $svcdb = $role_info->{svcdb}; + +  my @svcs = qsearch({ +    'table'     =>  $svcdb, +    'addl_from' =>  ' JOIN cust_svc USING (svcnum)' . +                    ' JOIN export_svc USING (svcpart)', +    'extra_sql' =>  " WHERE cust_svc.pkgnum = $pkgnum" . +                    " AND export_svc.exportnum = ".$self->exportnum . +                    " AND export_svc.role = '$role'", +  });                +  if ( $role_info->{multiple} ) { +    return @svcs; +  } else { +    if ( @svcs > 1 ) { +      warn "multiple $role services in pkgnum $pkgnum; returning the first one.\n"; +    } +    return $svcs[0]; +  } +}  =back diff --git a/FS/FS/part_export/voip_ms.pm b/FS/FS/part_export/voip_ms.pm new file mode 100644 index 000000000..44ce908eb --- /dev/null +++ b/FS/FS/part_export/voip_ms.pm @@ -0,0 +1,648 @@ +package FS::part_export::voip_ms; + +use base qw( FS::part_export ); +use strict; + +use Tie::IxHash; +use LWP::UserAgent; +use URI; +use URI::Escape; +use JSON; +use HTTP::Request::Common; +use Cache::FileCache; + +our $me = '[voip.ms]'; +our $DEBUG = 2; +our $base_url = 'https://voip.ms/api/v1/rest.php'; + +# cache cities and provinces +our $CACHE; # a FileCache; their API is not as quick as I'd like +our $cache_timeout = 86400; # seconds + +tie my %options, 'Tie::IxHash', +  'account'         => { label => 'Main account ID' }, +  'username'        => { label => 'API username', }, +  'password'        => { label => 'API password', }, +  'debug'           => { label => 'Enable debugging', type => 'checkbox', value => 1 }, +  # could dynamically pull this from the API... +  'protocol'        => { +    label             => 'Protocol', +    type              => 'select', +    options           => [ 1, 3 ], +    option_labels     => { 1 => 'SIP', 3 => 'IAX' }, +  }, +  'auth_type'       => { +    label             => 'Authorization type', +    type              => 'select', +    options           => [ 1, 2 ], +    option_labels     => { 1 => 'User/Password', 2 => 'Static IP' }, +  }, +  'billing_type'    => { +    label             => 'DID billing mode', +    type              => 'select', +    options           => [ 1, 2 ], +    option_labels     => { 1 => 'Per minute', 2 => 'Flat rate' }, +  }, +  'device_type'     => { +    label             => 'Device type', +    type              => 'select', +    options           => [ 1, 2 ], +    option_labels     => { 1 => 'IP PBX, e.g. Asterisk', +                           2 => 'IP phone or softphone', +                         }, +  }, +  'canada_routing'    => { +    label             => 'Canada routing policy', +    type              => 'select', +    options           => [ 1, 2 ], +    option_labels     => { 1 => 'Value (lowest price)', +                           2 => 'Premium (highest quality)' +                         }, +  }, +  'international_route' => { # yes, 'route' +    label             => 'International routing policy', +    type              => 'select', +    options           => [ 0, 1, 2 ], +    option_labels     => { 0 => 'Disable international calls', +                           1 => 'Value (lowest price)', +                           2 => 'Premium (highest quality)' +                         }, +  }, +  'cnam_lookup' => { +    label             => 'Enable CNAM lookup on incoming calls', +    type              => 'checkbox', +  }, + +; + +tie my %roles, 'Tie::IxHash', +  'subacct'       => {  label     => 'SIP client', +                        svcdb     => 'svc_acct', +                     }, +  'did'           => {  label     => 'DID', +                        svcdb     => 'svc_phone', +                        multiple  => 1, +                     }, +; + +our %info = ( +  'svc'      => [qw( svc_acct svc_phone )], +  'desc'     => +    'Provision subaccounts and DIDs to voip.ms wholesale', +  'options'  => \%options, +  'roles'    => \%roles, +  'no_machine' => 1, +  'notes'    => <<'END' +<P>Export to <b>voip.ms</b> hosted PBX service.</P> +<P>This requires two service definitions to be configured on the same package: +  <OL> +    <LI>An account service for the subaccount (the "login" used by the  +    customer's PBX or IP phone, and the call routing service). This should +    be attached to the export in the "subacct" role. If you are using  +    password authentication, the <i>username</i> and <i>_password</i> will  +    be used to authenticate to voip.ms. If you are using static IP  +    authentication, the <i>slipip</I> (IP address) field should be set to  +    the address.</LI> +    <LI>A phone service for a DID, attached to the export in the DID role. +    You must select a server for the "SIP Host" field. Calls from this DID +    will be routed to the customer via that server.</LI> +  </OL> +</P> +<P>Export options: +  <UL> +    <LI>Main account ID: the numeric ID for the master account.  +    Subaccount usernames will be prefixed with this number and an underscore, +    so if you create a subaccount in Freeside with a username of "myuser",  +    the SIP device will have to authenticate as something like  +    "123456_myuser".</LI> +    <LI>API username/password: your API login; see  +    <a href="https://www.voip.ms/m/api.php">this page</a> to configure it +    if you haven't done so yet.</LI> +    <LI>Enable debugging: writes all traffic with the API server to the log. +    This includes passwords.</LI> +  </UL> +  The other options correspond to options in either the subaccount or DID  +  configuration menu in the voip.ms portal; see documentation there for  +  details. +</P> +END +); + +sub export_insert { +  my($self, $svc_x) = (shift, shift); + +  my $role = $self->svc_role($svc_x); +  if ( $role eq 'subacct' ) { + +    my $error = $self->insert_subacct($svc_x); +    return "$me $error" if $error; + +    my @existing_dids = ( $self->svc_with_role($svc_x, 'did') ); + +    foreach my $svc_phone (@existing_dids) { +      $error = $self->insert_did($svc_phone, $svc_x); +      return "$me $error ordering DID ".$svc_phone->phonenum +        if $error; +    } + +  } elsif ( $role eq 'did' ) { + +    my $svc_acct = $self->svc_with_role($svc_x, 'subacct'); +    return if !$svc_acct; +  +    my $error = $self->insert_did($svc_x, $svc_acct); +    return "$me $error" if $error; + +  } +  ''; +} + +sub export_replace { +  my ($self, $svc_new, $svc_old) = @_; +  my $role = $self->svc_role($svc_new); +  my $error; +  if ( $role eq 'subacct' ) { +    $error = $self->replace_subacct($svc_new, $svc_old); +  } elsif ( $role eq 'did' ) { +    $error = $self->replace_did($svc_new, $svc_old); +  } +  return "$me $error" if $error; +  ''; +} + +sub export_delete { +  my ($self, $svc_x) = (shift, shift); +  my $role = $self->svc_role($svc_x); +  if ( $role eq 'subacct' ) { + +    my @existing_dids = ( $self->svc_with_role($svc_x, 'did') ); + +    my $error; +    foreach my $svc_phone (@existing_dids) { +      $error = $self->delete_did($svc_phone); +      return "$me $error canceling DID ".$svc_phone->phonenum +        if $error; +    } + +    $error = $self->delete_subacct($svc_x); +    return "$me $error" if $error; + +  } elsif ( $role eq 'did' ) { + +    my $svc_acct = $self->svc_with_role($svc_x, 'subacct'); +    return if !$svc_acct; +  +    my $error = $self->delete_did($svc_x); +    return "$me $error" if $error; + +  } +  ''; +} + +sub export_suspend { +  my $self = shift; +  my $svc_x = shift; +  my $role = $self->svc_role($svc_x); +  return if $role ne 'subacct'; # can't suspend DIDs directly + +  my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it +  return "$me $error" if $error; +  ''; +} + +sub export_unsuspend { +  my $self = shift; +  my $svc_x = shift; +  my $role = $self->svc_role($svc_x); +  return if $role ne 'subacct'; # can't suspend DIDs directly + +  $svc_x->set('unsuspended', 1); # hack to tell replace_subacct to do it +  my $error = $self->replace_subacct($svc_x, $svc_x); #same +  return "$me $error" if $error; +  ''; +} + + +sub insert_subacct { +  my ($self, $svc_acct) = @_; +  my $method = 'createSubAccount'; +  my $content = $self->subacct_content($svc_acct); + +  my $result = $self->api_request($method, $content); +  if ( $result->{status} ne 'success' ) { +    return $result->{status}; # or look up the error message string? +  } + +  # result includes the account ID and the full username, but we don't +  # really need to keep those; we can look them up later +  ''; +} + +sub insert_did { +  my ($self, $svc_phone, $svc_acct) = @_; +  my $method = 'orderDID'; +  my $content = $self->did_content($svc_phone, $svc_acct); +  my $result = $self->api_request($method, $content); +  if ( $result->{status} ne 'success' ) { +    return $result->{status}; # or look up the error message string? +  } +  ''; +} + +sub delete_subacct { +  my ($self, $svc_acct) = @_; +  my $account = $self->option('account') . '_' . $svc_acct->username; + +  my $id = $self->subacct_id($svc_acct); +  if ( $id =~ /\D/ ) { + +    return $id; # it's an error + +  } elsif ( $id eq '' ) { + +    return ''; # account doesn't exist, don't need to delete + +  } # else it's numeric + +  warn "$me deleting account $account with ID $id\n" if $DEBUG; +  my $result = $self->api_request('delSubAccount', { id => $id }); +  if ( $result->{status} ne 'success' ) { +    return $result->{status}; +  } +  ''; +} + +sub delete_did { +  my ($self, $svc_phone) = @_; +  my $phonenum = $svc_phone->phonenum; + +  my $result = $self->api_request('cancelDID', { did => $phonenum }); +  if ( $result->{status} ne 'success' and $result->{status} ne 'invalid_did' ) +  { +    return $result->{status}; +  } +  ''; +} + +sub replace_subacct { +  my ($self, $svc_new, $svc_old) = @_; +  if ( $svc_new->username ne $svc_old->username ) { +    return "can't change account username; delete and recreate the account instead"; +  } +   +  my $id = $self->subacct_id($svc_new); +  if ( $id =~ /\D/ ) { + +    return $id; + +  } elsif ( $id eq '' ) { + +    # account doesn't exist; provision it anew +    return $self->insert_subacct($svc_new); + +  } + +  my $content = $self->subacct_content($svc_new); +  delete $content->{username}; +  $content->{id} = $id; + +  my $result = $self->api_request('setSubAccount', $content); +  if ( $result->{status} ne 'success' ) { +    return $result->{status}; +  } + +  ''; +} + +sub replace_did { +  my ($self, $svc_new, $svc_old) = @_; +  if ( $svc_new->phonenum ne $svc_old->phonenum ) { +    return "can't change DID phone number"; +  } +  # check that there's a subacct set up +  my $svc_acct = $self->svc_with_role($svc_new, 'subacct') +    or return ''; + +  # check for the existing DID +  my $result = $self->api_request('getDIDsInfo', +    { did => $svc_new->phonenum } +  ); +  if ( $result->{status} eq 'invalid_did' ) { + +    # provision the DID +    return $self->insert_did($svc_new, $svc_acct); + +  } elsif ( $result->{status} ne 'success' ) { + +    return $result->{status}; + +  } + +  my $existing = $result->{dids}[0]; + +  my $content = $self->did_content($svc_new, $svc_acct); +  if ( $content->{billing_type} == $existing->{billing_type} ) { +    delete $content->{billing_type}; # confuses the server otherwise +  } +  $result = $self->api_request('setDIDInfo', $content); +  if ( $result->{status} ne 'success' ) { +    return $result->{status}; +  } + +  return ''; +} + +####################### +# CONVENIENCE METHODS # +####################### + +sub subacct_id { +  my ($self, $svc_acct) = @_; +  my $account = $self->option('account') . '_' . $svc_acct->username; + +  # look up the subaccount's numeric ID +  my $result = $self->api_request('getSubAccounts', { account => $account }); +  if ( $result->{status} eq 'invalid_account' ) { +    return ''; +  } elsif ( $result->{status} ne 'success' ) { +    return "$result->{status} looking up account ID"; +  } else { +    return $result->{accounts}[0]{id}; +  } +} + +sub subacct_content { +  my ($self, $svc_acct) = @_; + +  my $cust_pkg = $svc_acct->cust_svc->cust_pkg; + +  my $desc = $svc_acct->finger || $svc_acct->username; +  my $intl = $self->option('international_route'); +  my $lockintl = 0; +  if ($intl == 0) { +    $intl = 1; # can't send zero +    $lockintl = 1; +  } + +  my %auth; +  if ( $cust_pkg and $cust_pkg->susp > 0 and !$svc_acct->get('unsuspended') ) { +    # we can't explicitly suspend their account, so just set its password to  +    # a partially random string that satisfies the password rules +    # (we still have their real password in the svc_acct record) +    %auth = ( auth_type => 1, +              password  => sprintf('Suspend-%08d', int(rand(100000000)) ), +            ); +  } else { +    %auth = ( auth_type => $self->option('auth_type'), +              password  => $svc_acct->_password, +              ip        => $svc_acct->slipip, +            ); +  } +  return { +    username            => $svc_acct->username, +    description         => $desc, +    %auth, +    device_type         => $self->option('device_type'), +    canada_routing      => $self->option('canada_routing'), +    lock_international  => $lockintl, +    international_route => $intl, +    # sensible defaults for these +    music_on_hold       => 'default', # silence +    allowed_codecs      => 'ulaw;g729;gsm', +    dtmf_mode           => 'AUTO', +    nat                 => 'yes', +  }; +} + +sub did_content { +  my ($self, $svc_phone, $svc_acct) = @_; + +  my $account = $self->option('account') . '_' . $svc_acct->username; +  my $phonenum = $svc_phone->phonenum; +  # look up POP number (for some reason this is assigned per DID...) +  my $sip_server = $svc_phone->sip_server +    or return "SIP server required"; +  my $popnum = $self->cache('server_popnum')->{ $svc_phone->sip_server } +    or return "SIP server '$sip_server' is unknown"; +  return { +    did                 => $phonenum, +    routing             => "account:$account", +    # secondary routing options (failovers, voicemail) are outside our  +    # scope here +    # though we could support them using the "forwarddst" field? +    pop                 => $popnum, +    dialtime            => 60, # sensible default, add an option if needed +    cnam                => ($self->option('cnam_lookup') ? 1 : 0), +    note                => $svc_phone->phone_name, +    billing_type        => $self->option('billing_type'), +  }; +} + +################# +# DID SELECTION # +################# + +sub get_dids_npa_select { 0 } # all Canadian VoIP providers seem to have this + +sub get_dids { +  my $self = shift; +  my %opt = @_; + +  my ($exportnum) = $self->exportnum =~ /^(\d+)$/; + +  if ( $opt{'region'} ) { + +    # return numbers (probably shouldn't cache this) +    my ($ratecenter, $province) = $opt{'region'} =~ /^(.*), (..)$/; +    my $country = $self->cache('province_country')->{ $province }; +    my $result; +    if ( $country eq 'CAN' ) { +      $result = $self->api_insist('getDIDsCAN', +                                  { province => $province, +                                    ratecenter => $ratecenter +                                  } +                                 ); +    } elsif ( $country eq 'USA' ) { +      $result = $self->api_insist('getDIDsUSA', +                                  { state => $province, +                                    ratecenter => $ratecenter +                                  } +                                 ); +    } +    my @return = map { $_->{did} } @{ $result->{dids} }; +    return \@return; +  } else { + +    if ( $opt{'state'} ) { +      my $province = $opt{'state'}; + +      # cache() will refresh the cache if necessary, and die on failure. +      # default here is only in case someone gives us a state that +      # doesn't exist. +      return $self->cache('province_city', $province) || []; + +    } else { + +      # return a list of provinces +      return [ +        @{ $self->cache('country_province')->{CAN} }, +        @{ $self->cache('country_province')->{USA} }, +      ]; +    } +  } +} + +sub get_sip_servers { +  my $self = shift; +  return [ sort keys %{ $self->cache('server_popnum') } ]; +} + +sub cache { +  my $self = shift; +  my $element = shift or return; +  my $province = shift; + +  $CACHE ||= Cache::FileCache->new({ +    'cache_root' => $FS::UID::cache_dir.'/cache'.$FS::UID::datasrc, +    'namespace'  => __PACKAGE__, +    'default_expires_in' => $cache_timeout, +  }); + +  if ( $element eq 'province_city' ) { +    $element .= ".$province"; +  } +  return $CACHE->get($element) || $self->reload_cache($element); +} + +sub reload_cache { +  my $self = shift; +  my $element = shift; +  if ( $element eq 'province_country' or $element eq 'country_province' ) { +    # populate provinces/states + +    my %province_country; +    my %country_province = ( CAN => [], USA => [] ); + +    my $result = $self->api_insist('getProvinces'); +    foreach my $province (map { $_->{province} } @{ $result->{provinces} }) { +      $province_country{$province} = 'CAN'; +      push @{ $country_province{CAN} }, $province; +    } + +    $result = $self->api_insist('getStates'); +    foreach my $state (map { $_->{state} } @{ $result->{states} }) { +      $province_country{$state} = 'USA'; +      push @{ $country_province{USA} }, $state; +    } + +    $CACHE->set('province_country', \%province_country); +    $CACHE->set('country_province', \%country_province); +    return $CACHE->get($element); + +  } elsif ( $element eq 'server_popnum' ) { + +    my $result = $self->api_insist('getServersInfo'); +    my %server_popnum; +    foreach (@{ $result->{servers} }) { +      $server_popnum{ $_->{server_hostname} } = $_->{server_pop}; +    } + +    $CACHE->set('server_popnum', \%server_popnum); +    return \%server_popnum; + +  } elsif ( $element =~ /^province_city\.(\w+)$/ ) { + +    my $province = $1; + +    # then get the ratecenters for that province +    my $country = $self->cache('province_country')->{$province}; +    my @ratecenters; + +    if ( $country eq 'CAN' ) { + +      my $result = $self->api_insist('getRateCentersCAN', +                                   { province => $province }); + +      foreach (@{ $result->{ratecenters} }) { +        my $ratecenter = $_->{ratecenter} . ", $province"; # disambiguate +        push @ratecenters, $ratecenter; +      } + +    } elsif ( $country eq 'USA' ) { + +      my $result = $self->api_insist('getRateCentersUSA', +                                   { state => $province }); +      foreach (@{ $result->{ratecenters} }) { +        my $ratecenter = $_->{ratecenter} . ", $province"; +        push @ratecenters, $ratecenter; +      } + +    } + +    $CACHE->set($element, \@ratecenters); +    return \@ratecenters; + +  } else { +    return; +  } +} + +############## +# API ACCESS # +############## + +=item api_request METHOD, CONTENT + +Makes a REST request with method name METHOD, and POST content CONTENT (as +a hashref). + +=cut + +sub api_request { +  my $self = shift; +  my ($method, $content) = @_; +  $DEBUG ||= 1 if $self->option('debug'); +  my $url = URI->new($base_url); +  $url->query_form( +    'method'        => $method, +    'api_username'  => $self->option('username'), +    'api_password'  => $self->option('password'), +    %$content +  ); + +  my $request = GET($url, +    'Accept'        => 'text/json', +  ); + +  warn "$me $method\n" . $request->as_string ."\n" if $DEBUG; +  my $ua = LWP::UserAgent->new; +  my $response = $ua->request($request); +  warn "$me received\n" . $response->as_string ."\n" if $DEBUG; +  if ( !$response->is_success ) { +    return { status => $response->content }; +  } + +  return decode_json($response->content); +} + +=item api_insist METHOD, CONTENT + +Exactly like L</api_request>, but if the returned "status" is not "success", +throws an exception. + +=cut + +sub api_insist { +  my $self = shift; +  my $method = $_[0]; +  my $result = $self->api_request(@_); +  if ( $result->{status} eq 'success' ) { +    return $result; +  } elsif ( $result->{status} ) { +    die "$me $method: $result->{status}\n"; +  } else { +    die "$me $method: no status returned\n"; +  } +} + +1; diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm index bd35cbac4..06ce94848 100644 --- a/FS/FS/svc_phone.pm +++ b/FS/FS/svc_phone.pm @@ -136,6 +136,15 @@ Class of Service for E911 service (per the NENA 2.1 standard).  Type of Service for E911 service. +=item circuit_svcnum + +The L<FS::svc_circuit> record for the physical circuit that transports this +phone line. + +=item sip_server + +The hostname of the SIP server that this phone number is routed to. +  =back  =head1 METHODS @@ -253,6 +262,10 @@ sub table_info {                                  disable_inventory => 1,                                  multiple => 1,                          }, +        'sip_server'  => { +                                label => 'SIP Host', +                                %dis2, +                         },      },    };  } @@ -548,6 +561,7 @@ sub check {  				'native', 'portin-reject', 'portout-reject'])      || $self->ut_enumn('portable', ['','Y'])      || $self->ut_textn('lnp_reject_reason') +    || $self->ut_domainn('sip_server')    ;    return $error if $error;  | 
