source: trunk/cgi-bin/search-rpc.cgi @ 928

Last change on this file since 928 was 928, checked in by kdeugau, 5 months ago

/trunk

Extend flexibility for search RPC a bit; allow 1 or 0 to map to y or n
respectively for available. Field is projected to become functionally
non-boolean soon, so callers should use the known state values, however
y/n are the significant majority.

  • Property svn:executable set to *
File size: 13.7 KB
Line 
1#!/usr/bin/perl
2# XMLRPC interface to IPDB search
3# Copyright (C) 2017 Kris Deugau <kdeugau@deepnet.cx>
4
5use strict;
6use warnings;
7
8use DBI;
9use NetAddr::IP;
10use FCGI;
11use Frontier::Responder;
12
13use Sys::Syslog;
14
15# don't remove!  required for GNU/FHS-ish install from tarball
16##uselib##
17
18# push "the directory the script is in" into @INC
19use FindBin;
20use lib "$FindBin::RealBin/";
21
22use MyIPDB;
23use CustIDCK;
24
25openlog "IPDB-search-rpc","pid","$IPDB::syslog_facility";
26
27##fixme:  username source?  can we leverage some other auth method?
28# we don't care except for logging here, and Frontier::Client needs
29# a patch that's not well-distributed to use HTTP AUTH.
30
31# Collect the username from HTTP auth.  If undefined, we're in
32# a test environment, or called without a username.
33my $authuser;
34if (!defined($ENV{'REMOTE_USER'})) {
35  $authuser = '__temptest';
36} else {
37  $authuser = $ENV{'REMOTE_USER'};
38}
39
40# Why not a global DB handle?  (And a global statement handle, as well...)
41# Use the connectDB function, otherwise we end up confusing ourselves
42my $ip_dbh;
43my $sth;
44my $errstr;
45($ip_dbh,$errstr) = connectDB_My;
46initIPDBGlobals($ip_dbh);
47
48my $methods = {
49        'ipdb.search'   =>      \&rpc_search,
50};
51
52my $reqcnt = 0;
53
54my $req = FCGI::Request();
55
56# main FCGI loop.
57while ($req->Accept() >= 0) {
58  # done here to a) prevent $ENV{'REMOTE_ADDR'} from being empty and b) to collect
59  # the right user for the individual call (since we may be running with FCGI)
60  syslog "debug", "$authuser active, $ENV{'REMOTE_ADDR'}";
61
62  # don't *think* we need any of these...
63  # %disp_alloctypes, %def_custids, %list_alloctypes
64  # @citylist, @poplist
65  # @masterblocks, %allocated, %free, %bigfree, %routed  (removed in /trunk)
66  # %IPDBacl
67  #initIPDBGlobals($ip_dbh);
68
69  my $res = Frontier::Responder->new(
70        methods => $methods
71        );
72
73  # "Can't do that" errors
74  if (!$ip_dbh) {
75    print "Content-type: text/xml\n\n".$res->{_decode}->encode_fault(5, $DBI::errstr);
76  } else {
77    print $res->answer;
78  }
79  last if $reqcnt++ > $IPDB::maxfcgi;
80} # while FCGI::accept
81
82exit 0;
83
84
85##
86## Private subs
87##
88
89# Check RPC ACL
90sub _aclcheck {
91  my $subsys = shift;
92  return 1 if grep /$ENV{REMOTE_ADDR}/, @{$IPDB::rpcacl{$subsys}};
93  warn "$subsys/$ENV{REMOTE_ADDR} not in ACL\n";        # a bit of logging
94  return 0;
95}
96
97sub _commoncheck {
98  my $argref = shift;
99  my $needslog = shift;
100
101  die "Missing remote system name\n" if !$argref->{rpcsystem};
102  die "Access denied\n" if !_aclcheck($argref->{rpcsystem});
103  if ($needslog) {
104    die "Missing remote username\n" if !$argref->{rpcuser};
105  }
106}
107
108# stripped-down copy from from main.cgi.  should probably be moved to IPDB.pm
109sub _validateInput {
110  my $argref = shift;
111
112  if (!$argref->{block}) {
113    $argref->{block} = $argref->{cidr} if $argref->{cidr};
114    die "Block/IP is required\n" if !$argref->{block};
115  }
116
117  # Alloctype check.
118  chomp $argref->{type};
119
120  die "Invalid allocation type\n" if (!grep /$argref->{type}/, keys %disp_alloctypes);
121
122  # Arguably not quite correct, as the custID won't be checked for
123  # validity if there's a default on the type.
124  if ($def_custids{$argref->{type}} eq '') {
125    # Types without a default custID must have one passed in
126    die "Customer ID is required\n" if !$argref->{custid};
127    # Crosscheck with billing.
128    my $status = CustIDCK->custid_exist($argref->{custid});
129    die "Error verifying customer ID: $CustIDCK::ErrMsg\n" if $CustIDCK::Error;
130    die "Customer ID not valid\n" if !$status;
131  } else {
132    # Types that have a default will use it unless one is specified.
133    if ((!$argref->{custid}) || ($argref->{custid} ne 'STAFF')) {
134      $argref->{custid} = $def_custids{$argref->{type}};
135    }
136  }
137} # end validateInput()
138
139
140##
141## RPC method subs
142##
143
144sub rpc_search {
145  my %args = @_;
146
147  _commoncheck(\%args);
148
149  my @fields;
150  my @vals;
151  my @matchtypes;
152
153  my %mt = (
154        EXACT => '=',
155        EQUAL => '=',
156        NOT => '!~',    # text only?
157        # CIDR options
158        MASK => 'MASK',
159        WITHIN => '<<=',
160        CONTAINS => '>>=',
161        );
162
163  if ($args{type}) {
164    # assume alloctype class if we only get one letter
165    $args{type} = "_$args{type}" if $args{type} =~ /^.$/;
166    my $notflag = '';
167    if ($args{type} =~ /^NOT:/) {
168      $args{type} =~ s/^NOT://;
169      $notflag = 'NOT ';
170    }
171    if ($args{type} =~ /\./) {
172      $args{type} =~ s/\./_/;
173      push @matchtypes, $notflag.'LIKE';
174    } else {
175      push @matchtypes, ($notflag ? '<>' : '=');
176    }
177    push @fields, 's.type';
178    push @vals, $args{type};
179  }
180
181  ## CIDR query options.
182  if ($args{cidr}) {
183    $args{cidr} =~ s/^\s*(.+)\s*$/$1/g;
184    # strip matching type substring, if any - only applies to full-CIDR
185    my ($mnote) = $args{cidr} =~ /^(\w+):/;
186    $args{cidr} =~ s/^$mnote:// if $mnote;
187
188    if ($args{cidr} eq '') { # We has a blank CIDR.  Ignore it.
189    } elsif ($args{cidr} =~ /\//) {
190      my ($net,$maskbits) = split /\//, $args{cidr};
191      if ($args{cidr} =~ /^(\d{1,3}\.){3}\d{1,3}\/\d{2}$/) {
192        # Full CIDR match.
193        push @fields, 's.cidr';
194        push @vals, $args{cidr};
195        if ($mnote =~ /(EQUAL|EXACT|CONTAINS|WITHIN)/) {
196          push @matchtypes, $mt{$1};
197        } else { # default to exact match
198          push @matchtypes, '=';
199        }
200      } elsif ($args{cidr} =~ /^(\d{1,3}\.){2}\d{1,3}\/\d{2}$/) {
201        # Partial match;  beginning of subnet and maskbits are provided
202        # Show any blocks with the leading octet(s) and that masklength
203        # eg 192.168.179/26 should show all /26 subnets in 192.168.179
204        # Need some more magic for bare /nn searches:
205        push @fields, 's.cidr','masklen(s.cidr)';
206        push @vals, "$net.0/24", $maskbits;
207        push @matchtypes, '<<=','=';
208      }
209    } elsif ($args{cidr} =~ /^(\d{1,3}\.){3}\d{1,3}$/) {
210      # Specific IP address match.  Will show the parent chain down to the final allocation.
211      push @fields, 's.cidr';
212      push @vals, $args{cidr};
213      push @matchtypes, '>>=';
214    } elsif ($args{cidr} =~ /^\d{1,3}(\.(\d{1,3}(\.(\d{1,3}\.?)?)?)?)?$/) {
215      # 1, 2, or 3 leading octets in CIDR
216      push @fields, 'text(s.cidr)';
217      push @vals, "$args{cidr}\%";
218      push @matchtypes, 'LIKE';  # hmm
219    } else {
220      # do nothing.
221      ##fixme  we'll ignore this to clear out the references to legacy code.
222    } # done with CIDR query options.
223
224  } # args{cidr}
225
226  foreach my $sfield (qw(custid description notes city) ) {
227    if ($args{$sfield}) {
228      push @fields, "s.$sfield";
229      if ($args{$sfield} =~ /^(EXACT|NOT):/) {
230        push @matchtypes, $mt{$1};
231        $args{$sfield} =~ s/^$1://;
232      } else {
233        push @matchtypes, '~*';
234      }
235      push @vals, $args{$sfield};
236    }
237  }
238
239  if ($args{parent_id}) {
240    # parent_id is always exact.  default to positive match
241    if ($args{parent_id} =~ /^NOT:/) {
242      $args{parent_id} =~ s/^NOT://;
243      push @matchtypes, '<>';
244    } else {
245      push @matchtypes, '=';
246    }
247    push @fields, 's.parent_id';
248    push @vals, $args{parent_id};
249  }
250
251  # Filter on "available", because we can.
252  if (defined($args{available}) {
253    # Flex input;  accept 1 or 0 as y/n respectively.
254    $args{available} =~ tr/10/yn/;
255    if ($args{available} =~ /^[yn]$/) {
256      push @fields, "s.available";
257      push @matchtypes, '=';
258      push @vals, $args{available};
259    }
260  }
261
262  my $cols = "s.cidr, s.custid, s.type, s.city, s.description, s.id, s.parent_id, s.available, a.vrf, at.dispname";
263
264  # Validation and SQL field name mapping all in one!
265  my %validcols = (cidr => 's.cidr', custid => 's.custid', oldcustid => 's.oldcustid', type => 's.type', city => 's.city',
266    description => 's.description', notes => 's.notes', circuitid => 's.circuitid', vrf => 'a.vrf', vlan => 's.vlan',
267    id => 's.id', parent_id => 's.parent_id', master_id => 's.master_id', available => 's.available');
268  my @usercols;
269
270  if ($args{retfields}) {
271    # caller wants a custom set of returned fields
272    if (ref($args{retfields}) eq ref([])) {
273      # field list passed as list/array
274      foreach (@{$args{retfields}}) {
275        push @usercols, $validcols{$_} if $validcols{$_};
276      }
277    } elsif (not ref $args{retfields}) {
278      # field list passed as simple string
279      foreach (split /\s+/, $args{retfields}) {
280        push @usercols, $validcols{$_} if $validcols{$_};
281      }
282    } else {
283      # nonfatal fail.  only accepts array or string.  fall back to default list
284    }
285  }
286
287  # only replace the default set if a custom set was passed in
288  $cols = join ', ', @usercols if @usercols;
289
290  my $sql = qq(SELECT $cols FROM searchme s JOIN alloctypes at ON s.type = at.type JOIN allocations a ON s.master_id=a.id);
291  my @sqlcriteria;
292  for (my $i = 0; $i <= $#fields; $i++) {
293    push @sqlcriteria, "$fields[$i] $matchtypes[$i] ?";
294  }
295  $sql .= " WHERE ".join(' AND ', @sqlcriteria) if @sqlcriteria;
296
297  # multifield sorting!
298  if ($args{order}) {
299    my @ordfields = split /,/, $args{order};
300    # there are probably better ways to do this
301    my %omap = (cidr => 's.cidr', net => 's.cidr', network => 's.cidr', ip => 's.cidr',
302      custid => 's.custid', type => 's.type', city => 's.city',
303      desc => 's.description', description => 's.description');
304    my @ordlist;
305    # only pass sort field values from the list of acceptable field names or aliases as per %omap
306    foreach my $ord (@ordfields) {
307      push @ordlist, $omap{$ord}
308        if grep /^$ord$/, (keys %omap);
309    }
310    if (@ordlist) {
311      $sql .= " ORDER BY ". join(',', @ordlist);
312    }
313  }
314
315  my $result = $ip_dbh->selectall_arrayref($sql, {Slice=>{}}, @vals);
316  die $ip_dbh->errstr if !$result;
317
318  return $result;
319} # rpc_search()
320
321
322__END__
323
324=pod
325
326=head1 IPDB XMLRPC Search
327
328This is a general-purpose search API for IPDB.  It is currently being extended based on requirements from other tools needing to
329search for data in IPDB.
330
331It supports one XMLRPC sub, "search".
332
333The calling URL for this API should end with "/search-rpc.cgi".  If you are doing many requests, you should use the FastCGI variant
334with .fcgi instead of .cgi.
335
336=head2 Calling conventions
337
338IPDB RPC services use "XMLRPC", http://xmlrpc.com, for data exchange.
339
340Arguments are passed in as a key-value list, and data is returned as an array of hashes in some form.
341
342=over 4
343
344=item Perl
345
346 use Frontier::Client;
347 my $server = Frontier::Client->new(
348   url => "http://server/path/search-rpc.cgi",
349 );
350 my %args = (
351   rpcsystem => 'somesystem',
352   rpcuser => 'someuser',
353   arg1 => 'val1',
354   arg2 => 'val2',
355 );
356 my $result = $server->call('ipdb.search', %args);
357
358=item Python 2
359
360 import xmlrpclib
361 server = xmlrpclib.Server("http://server/path/search-rpc.cgi")
362 result = server.ipdb.search(
363     'rpcsystem', 'comesystems',
364     'rpcuser', 'someuser',
365     'arg1', 'val1',
366     'arg2', 'val2',
367     )
368
369=item Python 3
370
371 import xmlrpc.client
372 server = xmlrpc.client.ServerProxy("http://server/path/search-rpc.cgi")
373 result = server.ipdb.search(
374     'rpcsystem', 'somesystem',
375     'rpcuser', 'someuser',
376     'arg1', 'val1',
377     'arg2', 'val2',
378     )
379
380=back
381
382=head3 Standard arguments
383
384The C<rpcsystem> argument is required, and C<rpcuser> is strongly recommended as it may be used for access control in some future
385updates.
386
387C<rpcsystem> must match a configuration entry in the IPDB configuration, and a given string may only be used from an IP listed under
388that configuration entry.
389
390=head2 Search fields and metaoperators
391
392Not all fields are exposed for search.  For most purposes these should be sufficient.
393
394Most fields support EXACT: or NOT: prefixes on the search term to restrict the matches.
395
396=over 4
397
398=item cidr
399
400A full or partial CIDR network or IP address.  Valid formats include:
401
402=over 4
403
404=item Complete CIDR network, eg 192.168.2.0/24
405
406Returns an exact match for the passed CIDR network.
407
408If prefixed with "CONTAINS:", the containing netblocks up to the master block
409will also be returned.
410
411If prefixed with "WITHIN:", any suballocations in that IP range will be returned.
412
413=item Partial/short CIDR specification with mask length, eg 192.168.3/27
414
415Returns all /27 assignments within 192.168.3.0/24.
416
417=item Partial/short CIDR specification, eg 192.168.4
418
419Returns all assignments matching that leading partial string.  Note that 192.168.4 will also return 192.168.40.0/24 through
420192.168.49.0/24 as well as the obvious 192.168.4.0/24.
421
422=item Bare IP address with no mask, eg 192.168.5.42
423
424Returns all assignments containing that IP.
425
426=back
427
428=item custid
429
430Match on a customer ID.  Defaults to a partial match.
431
432=item type
433
434Match the two-character internal allocation type identifier.
435
436Defaults to an exact match.  Replace the first character with a dot or underscore, or leave it off, to match all subtypes of a
437class;  eg .i will return all types of static IP assignments.
438
439A full list of current allocation types is available from the main RPC API's getTypeList sub.
440
441=item city
442
443Matches in the city string.
444
445=item description
446
447Matches in the description string.
448
449=item notes
450
451Matches in the notes field.
452
453=item available
454
455Only useful for static IPs.  For historic and architectural reasons, unallocated static IPs are included in general search results. 
456Specify 'y' or 'n' to return only unallocated or allocated static IPs respectively.
457
458To search for a free block, use the main RPC API's listFree or findAllocateFrom subs.
459
460=item parent_id
461
462Restrict to allocations in the given parent.
463
464=item order
465
466Sort order specification.  Send a string of comma-separated field names for subsorting.  Valid sort fields are cidr, custid, type,
467city, and description.
468
469=item fields
470
471Specify the fields to return from the search.  By default, these are returned:
472
473=over 4
474
475cidr
476custid
477type
478city
479description
480id
481parent_id
482available
483vrf
484dispname  (the "display name" for the type)
485
486=back
487
488The following are available from this interface:
489
490=over 4
491
492cidr
493custid
494oldcustid
495type
496city
497description
498notes
499circuitid
500vrf
501vlan
502id
503parent_id
504master_id
505available
506
507=back
508
509The list may be sent as a space-separated string or as an array.  Unknown field names will be ignored.
510
511=back
Note: See TracBrowser for help on using the repository browser.