server.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847
  1. #!/usr/bin/python
  2. # -*- coding: utf-8 -*-
  3. from __future__ import print_function, unicode_literals
  4. from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
  5. import cgi
  6. import difflib
  7. import json
  8. import logging
  9. import re
  10. import socket
  11. from SocketServer import ThreadingMixIn
  12. import time
  13. import ffstatus
  14. # each match of these regex is removed to normalize an ISP's name
  15. ISP_NORMALIZATIONS = [
  16. # normalize name: strip company indication
  17. re.compile(r'(AG|UG|G?mbH( ?& ?Co\.? ?(OH|K)G)?)$', flags=re.IGNORECASE),
  18. # normalize name: strip "pool" suffixes
  19. re.compile(r'(dynamic )?(customer |subscriber )?(ip )?(pool|(address )?range|addresses)$', flags=re.IGNORECASE),
  20. # normalize name: strip "B2B" and aggregation suffixes
  21. re.compile(r'(aggregate|aggregation)?$', flags=re.IGNORECASE),
  22. re.compile(r'(B2B)?$', flags=re.IGNORECASE),
  23. # normalize name: strip country suffixes (in Germany)
  24. re.compile(r'(' +
  25. 'DE|Deutschland|Germany|' +
  26. 'Nordrhein[- ]Westfalen|NRW|' +
  27. 'Baden[- ]Wuerttemburg|BW|' +
  28. 'Hessen|' +
  29. 'Niedersachsen|' +
  30. 'Rheinland[- ]Pfalz|RLP' +
  31. ')$',
  32. flags=re.IGNORECASE),
  33. ]
  34. REGEX_QUERYPARAM = re.compile(
  35. r'(?P<key>.+?)=(?P<value>.+?)(&|$)')
  36. REGEX_URL_NODEINFO = re.compile(
  37. r'node/(?P<id>[a-fA-F0-9]{12})(?P<cmd>\.json|/[a-zA-Z0-9_\-\.]+)$')
  38. REGEX_URL_NODESTATUS = re.compile(
  39. r'status/([a-f0-9]{12})$')
  40. def normalize_ispname(isp):
  41. """Removes all matches on ISP_NORMALIZATIONS."""
  42. isp = isp.strip()
  43. for regex in ISP_NORMALIZATIONS:
  44. isp = regex.sub('', isp).strip()
  45. return isp
  46. def csvize_values(values):
  47. for x in values:
  48. if x is None:
  49. yield '-/-'
  50. elif ' ' in x:
  51. yield '"' + x + '"'
  52. else:
  53. yield str(x)
  54. class BatcaveHttpRequestHandler(BaseHTTPRequestHandler):
  55. """Handles a single HTTP request to the BATCAVE."""
  56. def __init__(self, request, client_address, sockserver):
  57. self.logger = logging.getLogger('API')
  58. BaseHTTPRequestHandler.__init__(
  59. self, request, client_address, sockserver)
  60. def __parse_url_pathquery(self):
  61. """Extracts the query parameters from the request path."""
  62. url = re.match(r'^/(?P<path>.*?)(\?(?P<query>.+))?$', self.path.strip())
  63. if url is None:
  64. logging.warn('Failed to parse URL \'' + str(self.path) + '\'.')
  65. return (None, None)
  66. path = url.group('path')
  67. query = {}
  68. if not url.group('query') is None:
  69. for match in REGEX_QUERYPARAM.finditer(url.group('query')):
  70. query[match.group('key')] = match.group('value')
  71. return (path, query)
  72. def do_GET(self):
  73. """Handles all HTTP GET requests."""
  74. path, query = self.__parse_url_pathquery()
  75. if path is None:
  76. self.send_error(400, 'Could not parse URL (' + str(self.path) + ')')
  77. return
  78. # / - index page, shows generic help
  79. if path == '':
  80. self.__respond_index(query)
  81. return
  82. # /nodes.json
  83. if path == 'nodes.json':
  84. self.__respond_nodes(query)
  85. return
  86. # /nodes.csv
  87. if path == 'nodes.csv':
  88. self.__respond_nodes_csv(query)
  89. return
  90. # /list - list stored nodes
  91. if path == 'list':
  92. self.__respond_list(query)
  93. return
  94. # /vpn - notification endpoint for gateway's VPN connections
  95. if path == 'vpn':
  96. self.__respond_vpn(query)
  97. return
  98. # /providers
  99. if path == 'providers':
  100. self.__respond_providers(query)
  101. return
  102. # /find?name=foo&fuzzy=1
  103. if path == 'find':
  104. self.__respond_findnode(query)
  105. return
  106. # /identify?ident=xyz
  107. if path == 'identify':
  108. self.__respond_identify([query.get('ident')])
  109. return
  110. # /node/<id>.json - node's data
  111. # /node/<id>/field - return specific field from node's data
  112. match = REGEX_URL_NODEINFO.match(path)
  113. if match is not None:
  114. cmd = match.group('cmd')
  115. nodeid = match.group('id').lower()
  116. if cmd == '.json':
  117. self.__respond_node(nodeid, query)
  118. else:
  119. self.__respond_nodedetail(nodeid, cmd[1:], query)
  120. return
  121. # /status - overall status (incl. node and client count)
  122. if path == 'status.json':
  123. self.__respond_status()
  124. return
  125. # /status/<id> - node's status
  126. match = REGEX_URL_NODESTATUS.match(path)
  127. if match is not None:
  128. self.__respond_nodestatus(match.group(1))
  129. return
  130. # no match -> 404
  131. self.send_error(404, 'The URL \'{0}\' was not found here.'.format(path))
  132. def do_POST(self):
  133. """Handles all HTTP POST requests."""
  134. path, query = self.__parse_url_pathquery()
  135. if path is None:
  136. self.send_error(400, 'Could not parse URL (' + str(self.path) + ')')
  137. return
  138. params = self.__parse_post_params()
  139. # identify listed macs
  140. if path == 'identify':
  141. self.__respond_identify(params)
  142. return
  143. # node id/mac to name mapping
  144. if path == 'idmac2name':
  145. self.__respond_nodeidmac2name(params)
  146. return
  147. # no match -> 404
  148. self.send_error(404, 'The URL \'{0}\' was not found here.'.format(path))
  149. def __send_nocache_headers(self):
  150. """
  151. Sets HTTP headers indicating that this response shall not be cached.
  152. """
  153. self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
  154. self.send_header('Pragma', 'no-cache')
  155. self.send_header('Expires', '0')
  156. def __send_headers(self,
  157. content_type='text/html; charset=utf-8',
  158. nocache=True, extra={}):
  159. """Send HTTP 200 Response header with the given Content-Type.
  160. Optionally send no-caching headers, too."""
  161. self.send_response(200)
  162. self.send_header('Content-Type', content_type)
  163. if nocache:
  164. self.__send_nocache_headers()
  165. for key in extra:
  166. self.send_header(key, extra[key])
  167. self.end_headers()
  168. def __parse_post_params(self):
  169. ctype, pdict = cgi.parse_header(self.headers.getheader('content-type'))
  170. if ctype == 'multipart/form-data':
  171. postvars = cgi.parse_multipart(self.rfile, pdict)
  172. elif ctype == 'application/x-www-form-urlencoded':
  173. length = int(self.headers.getheader('content-length'))
  174. postvars = cgi.parse_qs(
  175. self.rfile.read(length),
  176. keep_blank_values=1,
  177. )
  178. else:
  179. postvars = {}
  180. return postvars
  181. def __respond_index(self, query):
  182. """Display the index page."""
  183. self.__send_headers()
  184. index_page = '''<!DOCTYPE html>
  185. <html><head><title>BATCAVE</title></head>
  186. <body>
  187. <H1 title="Batman/Alfred Transmission Collection, Aggregation & Value Engine">
  188. BATCAVE
  189. </H1>
  190. <p>Dies ist ein interner Hintergrund-Dienst. Er wird nur von anderen Diensten
  191. angesprochen und sollte aus einer Mehrzahl von Gr&uuml;nden nicht
  192. &ouml;ffentlich zug&auml;nglich sein.</p>
  193. <H2>API</H2>
  194. <p>
  195. Grunds&auml;tzlich ist das Antwort-Format JSON und alle Daten sind
  196. Live-Daten (kein Cache) die ggf. etwas Bearbeitungs-Zeit erfordern.
  197. </p>
  198. <dl>
  199. <dt>GET <a href="/nodes.json">nodes.json</a></dt>
  200. <dd>zur Verwendung mit ffmap (MACs anonymisiert)</dd>
  201. <dt>GET /node/&lt;id&gt;.json</dt>
  202. <dd>alle Daten zu dem gew&uuml;nschten Knoten</dd>
  203. <dt>GET /providers?format=json</dt>
  204. <dd>Liste der Provider</dd>
  205. <dt>GET <a href="/status">/status</a></dt>
  206. <dd>Status der BATCAVE inkl. Zahl der Nodes+Clients (JSON)</dd>
  207. <dt>GET /status/&lt;id&gt;</dt>
  208. <dd>Status des Knotens</dd>
  209. </dl>
  210. </body></html>'''
  211. self.wfile.write(index_page)
  212. def __respond_list(self, query):
  213. """List stored data."""
  214. list_header_info = {
  215. # Caption => ('field', 'sortkey-or-None'),
  216. "ID": ('node_id', 'id'),
  217. "Name": ('hostname', 'name'),
  218. "Status": ('status', None),
  219. "Type": ('type', None),
  220. }
  221. list_headers = ('ID', 'Name', 'Status', 'Type')
  222. sortkey = query.get('sort')
  223. self.__send_headers()
  224. self.wfile.write('<!DOCTYPE html><html>\n')
  225. self.wfile.write('<head><title>BATCAVE</title></head>\n')
  226. self.wfile.write('<body>\n')
  227. self.wfile.write('<H1>BATCAVE - LIST</H1>\n')
  228. self.wfile.write('<table>\n')
  229. self.wfile.write('<thead><tr>')
  230. for caption in list_headers:
  231. info = list_header_info[caption]
  232. th_attrib, th_prefix, th_suffix = '', '', ''
  233. if info[1] is not None:
  234. th_prefix = '<a href=\"/list?sort=' + info[1] + '\">'
  235. th_suffix = '</a>'
  236. if sortkey == info[1]:
  237. th_attrib = ' class="sorted"'
  238. self.wfile.write(
  239. '<th' + th_attrib + '>' +
  240. th_prefix + caption + th_suffix +
  241. '</th>')
  242. self.wfile.write('</tr></thead>\n')
  243. self.wfile.write('<tbody>\n')
  244. data = self.server.storage.get_nodes(sortby=sortkey)
  245. count_status = {'active': 0}
  246. count_type = {'gateway': 0, 'node': 0}
  247. for node in data:
  248. nodestatus = self.server.storage.get_nodestatus(node=node)
  249. nodetype = node.get('type', 'node')
  250. count_status[nodestatus] = count_status.get(nodestatus, 0) + 1
  251. count_type[nodetype] = count_type.get(nodetype, 0) + 1
  252. self.wfile.write('<tr>')
  253. for caption in list_headers:
  254. info = list_header_info[caption]
  255. value = node.get(info[0], '<?>')
  256. cellcontent = value
  257. # special cell contents
  258. if info[0] == 'node_id':
  259. cellcontent = \
  260. '<a href="/node/{0}.json">{0}</a>'.format(value)
  261. elif info[0] == 'status':
  262. cellcontent = nodestatus
  263. self.wfile.write('<td>' + cellcontent + '</td>')
  264. self.wfile.write('</tr>\n')
  265. self.wfile.write('</tbody>\n')
  266. self.wfile.write('<tfoot><tr><td colspan="{0}">'.format(
  267. len(list_headers)))
  268. self.wfile.write('<p>{0} entries</p>'.format(len(data)))
  269. self.wfile.write('<p>status: ' + ', '.join(
  270. ['{0}={1}'.format(x, count_status[x]) for x in count_status]) +
  271. '</p>')
  272. self.wfile.write('<p>type: ' + ', '.join(
  273. ['{0}={1}'.format(x, count_type[x]) for x in count_type]) +
  274. '</p>')
  275. self.wfile.write('</td></tr></tfoot>')
  276. self.wfile.write('</table>\n')
  277. def __map_item(self, haystack, needle, prefix=None):
  278. if not isinstance(haystack, dict):
  279. raise Exception("haystack must be a dict")
  280. if needle in haystack:
  281. return haystack[needle]
  282. idx = len(haystack) + 1
  283. name = prefix + str(idx)
  284. while name in haystack:
  285. idx += 1
  286. name = prefix + str(idx)
  287. haystack[needle] = name
  288. return name
  289. def __respond_nodes(self, query):
  290. indent = 2 if query.get('pretty', 0) == '1' else None
  291. nodes = []
  292. clientmapping = {}
  293. for node in self.server.storage.get_nodes():
  294. sw = node.get('software', {})
  295. entry = {
  296. 'id': node.get('node_id'),
  297. 'name': node.get('hostname'),
  298. 'clients': [self.__map_item(clientmapping, x, "c")
  299. for x in node.get('clients', [])],
  300. 'autoupdater': sw.get('autoupdater', 'unknown'),
  301. 'firmware': sw.get('firmware'),
  302. 'neighbours': node.get('neighbours', {}),
  303. 'status': self.server.storage.get_nodestatus(node=node),
  304. }
  305. nodetype = node.get('type', 'node')
  306. if nodetype != 'node':
  307. entry['type'] = nodetype
  308. geo = node.get('location', None)
  309. if geo is not None:
  310. entry['geo'] = geo if isinstance(geo, list) else [geo['latitude'], geo['longitude']]
  311. nodes.append(entry)
  312. result = {'nodes': nodes}
  313. self.__send_headers(content_type='application/json', nocache=True,
  314. extra={'Content-Disposition': 'inline'})
  315. self.wfile.write(json.dumps(result, indent=indent))
  316. def __respond_nodes_csv(self, query):
  317. self.__send_headers(content_type='text/csv', nocache=True)
  318. csv_fs = ";"
  319. fields = query.get('fields', 'ID,Name,Firmware,Autoupdater,Location,Status,Clients').split(',')
  320. fieldmapping = {
  321. 'ID': 'node_id',
  322. 'Name': 'hostname',
  323. 'Status': lambda n: self.server.storage.get_nodestatus(node=n),
  324. 'Location': lambda n: ' '.join(n.get('location')) if isinstance(n.get('location'), list) else '{latitude} {longitude}'.format(**n.get('location')) if n.get('location') is not None else '',
  325. 'Firmware': lambda n: n.get('software', {}).get('firmware'),
  326. 'Autoupdater': lambda n: n.get('software', {}).get('autoupdater', 'unknown'),
  327. 'Clients': lambda n: str(len(n.get('clients', []))),
  328. }
  329. # write CSV header line
  330. self.wfile.write(csv_fs.join(csvize_values(
  331. [f for f in fields if f in fieldmapping])))
  332. self.wfile.write('\n')
  333. # output each node
  334. for node in self.server.storage.get_nodes():
  335. value_gen = [fieldmapping[f] for f in fields if f in fieldmapping]
  336. values = [gen(node) if not isinstance(gen, basestring) else node.get(gen) for gen in value_gen]
  337. self.wfile.write(csv_fs.join(csvize_values(values)))
  338. self.wfile.write('\n')
  339. def __respond_node(self, rawid, query):
  340. """Display node data."""
  341. use_aliases = query.get('search_aliases', '1') != '0'
  342. # search node by the given id
  343. node = self.server.storage.find_node(rawid, search_aliases=use_aliases)
  344. # handle unknown nodes
  345. if node is None:
  346. self.send_error(404, 'No node with id \'' + rawid + '\' present.')
  347. return
  348. # add node's status
  349. node['status'] = self.server.storage.get_nodestatus(node=node)
  350. # dump node data as JSON
  351. self.__send_headers('application/json',
  352. extra={'Content-Disposition': 'inline'})
  353. self.wfile.write(json.dumps(node))
  354. def __respond_nodestatus(self, rawid):
  355. """Display node status."""
  356. status = self.server.storage.get_nodestatus(rawid)
  357. if status is None:
  358. self.send_error(404, 'No node with id \'' + rawid + '\' present.')
  359. self.__send_headers('text/plain')
  360. self.wfile.write(status)
  361. def __respond_findnode(self, query):
  362. """Find nodes matching the supplied name."""
  363. self.__send_headers('application/json',
  364. extra={'Content-Disposition': 'inline'})
  365. name = query.get('name')
  366. mac = query.get('mac')
  367. if name is not None:
  368. fuzzy = query.get('fuzzy', '0') == '1'
  369. return self.__respond_findnode_name(name, fuzzy)
  370. if mac is not None:
  371. return self.__respond_findnode_mac(mac)
  372. self.logger.error('/find called without name or mac parameter')
  373. self.wfile.write('null')
  374. def __respond_findnode_name(self, name, fuzzy):
  375. name = name.lower()
  376. names = {}
  377. for node in self.server.storage.get_nodes():
  378. nodename = node.get('hostname')
  379. if nodename is None:
  380. continue
  381. nodename = nodename.lower()
  382. if nodename not in names:
  383. # first time we see this name
  384. names[nodename] = [node]
  385. else:
  386. # we've seen this name before
  387. names[nodename].append(node)
  388. allnames = [x for x in names]
  389. resultnames = []
  390. # check for exact match
  391. if name in allnames:
  392. # write the exact matches and we're done
  393. resultnames = [name]
  394. else:
  395. # are we allowed to fuzzy match?
  396. if not fuzzy:
  397. # no -> return zero matches
  398. self.wfile.write('[]')
  399. return
  400. # let's do fuzzy matching
  401. resultnames = difflib.get_close_matches(name, allnames,
  402. cutoff=0.75)
  403. result = []
  404. for possibility in resultnames:
  405. for x in names[possibility]:
  406. x_id = x.get('node_id')
  407. result.append({
  408. 'id': x_id,
  409. 'name': x.get('hostname'),
  410. 'status': self.server.storage.get_nodestatus(x_id),
  411. })
  412. self.wfile.write(json.dumps(result))
  413. def __respond_findnode_mac(self, mac):
  414. mac = mac.lower()
  415. result = []
  416. for node in self.server.storage.get_nodes():
  417. if node.get('mac', '').lower() == mac or \
  418. mac in [x.lower() for x in node.get('macs', [])]:
  419. node_id = node.get('node_id')
  420. result.append({
  421. 'id': node_id,
  422. 'name': node.get('hostname'),
  423. 'status': self.server.storage.get_nodestatus(node_id),
  424. })
  425. self.wfile.write(json.dumps(result))
  426. def __respond_nodeidmac2name(self, ids):
  427. """Return a mapping of the given IDs (or MACs) into their hostname."""
  428. self.__send_headers('text/plain')
  429. for nodeid in ids:
  430. node = None
  431. if not ':' in nodeid:
  432. node = self.server.storage.find_node(nodeid)
  433. else:
  434. node = self.server.storage.find_node_by_mac(nodeid)
  435. nodename = node.get('hostname', nodeid) if node is not None else nodeid
  436. self.wfile.write('{0}={1}\n'.format(nodeid, nodename))
  437. def __respond_identify(self, idents):
  438. self.__send_headers('application/json')
  439. nodes = {n['node_id']: n for n in self.server.storage.get_nodes()}
  440. answers = {}
  441. for ident in idents:
  442. ident = ident.lower()
  443. answer = []
  444. answers[ident] = answer
  445. if ident in nodes:
  446. answer.append('node id of "%s"' % nodes[ident].get('hostname'))
  447. continue
  448. for nodeid in nodes:
  449. node = nodes[nodeid]
  450. nodename = node.get('hostname') or '?'
  451. nodeinfo = 'node "%s" (id %s)' % (nodename.decode('utf-8'), nodeid)
  452. if ident == (node.get('mac') or '').lower():
  453. answer.append('primary mac of ' + nodeinfo)
  454. elif ident in [x.lower() for x in node.get('macs', [])]:
  455. answer.append('mac of ' + nodeinfo)
  456. if ident in [c.lower() for c in node.get('clients', [])]:
  457. answer.append('client at ' + nodeinfo)
  458. self.wfile.write(json.dumps(answers))
  459. def __respond_nodedetail(self, nodeid, field, query):
  460. """
  461. Return a field from the given node.
  462. String and integers are returned as text/plain,
  463. all other as JSON.
  464. """
  465. use_aliases = query.get('search_aliases', 1) != 0
  466. # search node by given id
  467. node = self.server.storage.find_node(nodeid,
  468. include_raw_data=True,
  469. search_aliases=use_aliases)
  470. if node is None:
  471. self.send_error(404, 'No node with id \'' + nodeid + '\' present.')
  472. return
  473. return_count = False
  474. if field.endswith('.count'):
  475. return_count = True
  476. field = field[0:-6]
  477. if not field in node:
  478. self.send_error(
  479. 404,
  480. 'The node \'{0}\' does not have a field named \'{1}\'.'.format(
  481. nodeid, field
  482. )
  483. )
  484. return
  485. value = node[field]
  486. if return_count:
  487. value = len(value)
  488. no_json = isinstance(value, basestring) or isinstance(value, int)
  489. self.__send_headers('text/plain' if no_json else 'application/json',
  490. extra={'Content-Disposition': 'inline'})
  491. self.wfile.write(value if no_json else json.dumps(value))
  492. def __respond_status(self):
  493. status = self.server.storage.status
  494. self.__send_headers('application/json',
  495. extra={'Content-Disposition': 'inline'})
  496. self.wfile.write(json.dumps(status, indent=2))
  497. def __respond_vpn(self, query):
  498. storage = self.server.storage
  499. peername = query.get('peer')
  500. key = query.get('key')
  501. action = query.get('action')
  502. remote = query.get('remote')
  503. gateway = query.get('gw')
  504. timestamp = query.get('ts', time.time())
  505. if action == 'list':
  506. self.__respond_vpnlist()
  507. return
  508. if action != 'establish' and action != 'disestablish':
  509. self.logger.error('VPN: unknown action \'{0}\''.format(action))
  510. self.send_error(400, 'Invalid action.')
  511. return
  512. check = {
  513. 'peername': peername,
  514. 'key': key,
  515. 'remote': remote,
  516. 'gw': gateway,
  517. }
  518. for k, val in check.items():
  519. if val is None or len(val.strip()) == 0:
  520. self.logger.error('VPN {0}: no or empty {1}'.format(action, k))
  521. self.send_error(400, 'Missing value for ' + str(k))
  522. return
  523. try:
  524. if action == 'establish':
  525. self.server.storage.log_vpn_connect(
  526. key, peername, remote, gateway, timestamp)
  527. elif action == 'disestablish':
  528. self.server.storage.log_vpn_disconnect(key, gateway, timestamp)
  529. else:
  530. self.logger.error('Unknown VPN action \'%s\' not filtered.',
  531. action)
  532. self.send_error(500)
  533. return
  534. except ffstatus.exceptions.VpnKeyFormatError:
  535. self.logger.error('VPN peer \'{0}\' {1}: bad key \'{2}\''.format(
  536. peername, action, key,
  537. ))
  538. self.send_error(400, 'Bad key.')
  539. return
  540. self.__send_headers('text/plain')
  541. self.wfile.write('OK')
  542. storage.save()
  543. def __respond_vpnlist(self):
  544. self.__send_headers()
  545. self.wfile.write('''<!DOCTYPE html>
  546. <html><head><title>BATCAVE - VPN LIST</title></head>
  547. <body>
  548. <style type="text/css">
  549. table { border: 2px solid #999; border-collapse: collapse; }
  550. th, td { border: 1px solid #CCC; }
  551. table tbody tr.online { background-color: #CFC; }
  552. table tbody tr.offline { background-color: #FCC; }
  553. </style>
  554. <table>''')
  555. gateways = self.server.storage.get_vpn_gateways()
  556. gws_header = '<th>' + '</th><th>'.join(gateways) + '</th>'
  557. self.wfile.write('<thead>\n')
  558. self.wfile.write('<tr><th rowspan="2">names (key)</th>')
  559. self.wfile.write('<th colspan="' + str(len(gateways)) + '">active</th>')
  560. self.wfile.write('<th colspan="' + str(len(gateways)) + '">last</th>')
  561. self.wfile.write('</tr>\n')
  562. self.wfile.write('<tr>' + gws_header + gws_header + '</tr>\n')
  563. self.wfile.write('</thead>\n')
  564. for item in self.server.storage.get_vpn_connections():
  565. row_class = 'online' if item['online'] else 'offline'
  566. self.wfile.write('<tr class="{0}">'.format(row_class))
  567. self.wfile.write('<td title="{0}">{1}</td>'.format(
  568. item['key'],
  569. ' / '.join(item['names']) if len(item['names']) > 0 else '?',
  570. ))
  571. for conntype in ['active', 'last']:
  572. for gateway in gateways:
  573. remote = ''
  574. if conntype in item['remote'] and \
  575. gateway in item['remote'][conntype]:
  576. remote = item['remote'][conntype][gateway]
  577. if isinstance(remote, dict):
  578. remote = remote['name']
  579. symbol = '&check;' if len(remote) > 0 else '&times;'
  580. self.wfile.write('<td title="{0}">{1}</td>'.format(
  581. remote, symbol))
  582. self.wfile.write('</tr>\n')
  583. self.wfile.write('</table>\n')
  584. self.wfile.write('</body>')
  585. self.wfile.write('</html>')
  586. def __respond_providers(self, query):
  587. """Return a summary of providers."""
  588. outputformat = query['format'].lower() if 'format' in query else 'html'
  589. isps = {}
  590. ispblocks = {}
  591. for item in self.server.storage.get_vpn_connections():
  592. if item['count']['active'] == 0:
  593. continue
  594. remotes = []
  595. for gateway in item['remote']['active']:
  596. remote = item['remote']['active'][gateway]
  597. remotes.append(remote)
  598. if len(remotes) == 0:
  599. self.logger.warn(
  600. 'VPN key \'%s\' is marked with active remotes but 0 found?',
  601. item['key'])
  602. continue
  603. item_isps = set()
  604. for remote in remotes:
  605. isp = "UNKNOWN"
  606. ispblock = remote
  607. if isinstance(remote, dict):
  608. ispblock = remote['name']
  609. desc_lines = remote['description'].split('\n')
  610. isp = normalize_ispname(desc_lines[0])
  611. if not isp in ispblocks:
  612. ispblocks[isp] = set()
  613. ispblocks[isp].add(ispblock)
  614. item_isps.add(isp)
  615. if len(item_isps) == 0:
  616. item_isps.add('unknown')
  617. elif len(item_isps) > 1:
  618. self.logger.warn(
  619. 'VPN key \'%s\' has %d active IPs ' +
  620. 'which resolved to %d ISPs: \'%s\'',
  621. item['key'],
  622. len(remotes),
  623. len(item_isps),
  624. '\', \''.join(item_isps)
  625. )
  626. for isp in item_isps:
  627. if not isp in isps:
  628. isps[isp] = 0
  629. isps[isp] += 1.0 / len(item_isps)
  630. isps_sum = sum([isps[x] for x in isps])
  631. if outputformat == 'csv':
  632. self.__send_headers('text/csv')
  633. self.wfile.write('Count;Name\n')
  634. for isp in isps:
  635. self.wfile.write('{0};"{1}"\n'.format(isps[isp], isp))
  636. elif outputformat == 'json':
  637. self.__send_headers('application/json',
  638. extra={'Content-Disposition': 'inline'})
  639. data = [
  640. {
  641. 'name': isp,
  642. 'count': isps[isp],
  643. 'percentage': isps[isp]*100.0/isps_sum,
  644. 'blocks': [block for block in ispblocks[isp]],
  645. } for isp in isps
  646. ]
  647. self.wfile.write(json.dumps(data))
  648. elif outputformat == 'html':
  649. self.__send_headers()
  650. self.wfile.write('''<!DOCTYPE html>
  651. <html>
  652. <head><title>BATCAVE - PROVIDERS</title></head>
  653. <body>
  654. <table border="2">
  655. <thead>
  656. <tr><th>Count</th><th>Percentage</th><th>Name</th><th>Blocks</th></tr>
  657. </thead>
  658. <tbody>\n''')
  659. for isp in sorted(isps, key=lambda x: isps[x], reverse=True):
  660. self.wfile.write('<tr><td>{0}</td><td>{1:.1f}%</td><td>{2}</td><td>{3}</td></tr>\n'.format(
  661. isps[isp],
  662. isps[isp]*100.0/isps_sum,
  663. isp,
  664. ', '.join(sorted(ispblocks[isp])) if isp in ispblocks else '?',
  665. ))
  666. self.wfile.write('</tbody></table>\n')
  667. self.wfile.write('<p>Totals: {0} ISPs, {1} connections</p>\n'.format(len(isps), isps_sum))
  668. self.wfile.write('</body></html>')
  669. else:
  670. self.send_error(400, 'Unknown output format.')
  671. class ApiServer(ThreadingMixIn, HTTPServer):
  672. def __init__(self, endpoint, storage):
  673. if ':' in endpoint[0]:
  674. self.address_family = socket.AF_INET6
  675. HTTPServer.__init__(self, endpoint, BatcaveHttpRequestHandler)
  676. self.storage = storage
  677. def __str__(self):
  678. return 'ApiServer on {0}'.format(self.server_address)
  679. if __name__ == '__main__':
  680. dummystorage = ffstatus.basestorage.BaseStorage()
  681. server = ApiServer(('0.0.0.0', 8888), dummystorage)
  682. print("Server:", str(server))
  683. server.serve_forever()