server.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. #!/usr/bin/python
  2. # -*- coding: utf-8 -*-
  3. from __future__ import print_function
  4. from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
  5. import cgi
  6. from storage import Storage
  7. import json
  8. import logging
  9. import pygeoip
  10. import re
  11. import socket
  12. from SocketServer import ThreadingMixIn
  13. import time
  14. class BatcaveHttpRequestHandler(BaseHTTPRequestHandler):
  15. DATAKEY_VPN = '__VPN__'
  16. FIELDKEY_UPDATED = '__UPDATED__'
  17. def __init__(self, request, client_address, server):
  18. self.logger = logging.getLogger('API')
  19. BaseHTTPRequestHandler.__init__(self, request, client_address, server)
  20. def parse_url_pathquery(self):
  21. """Extracts the query parameters from the request path."""
  22. url = re.match(r'^/(?P<path>.*?)(\?(?P<query>.+))?$', self.path.strip())
  23. if url is None:
  24. logging.warn('Failed to parse URL \'' + str(self.path) + '\'.')
  25. return ( None, None )
  26. path = url.group('path')
  27. query = {}
  28. if not url.group('query') is None:
  29. for m in re.finditer(r'(?P<key>.+?)=(?P<value>.+?)(&|$)', url.group('query')):
  30. query[m.group('key')] = m.group('value')
  31. return ( path, query )
  32. def do_GET(self):
  33. """Handles all HTTP GET requests."""
  34. path, query = self.parse_url_pathquery()
  35. if path is None:
  36. self.send_error(400, 'Could not parse URL (' + str(self.path) + ')')
  37. return
  38. # / - index page, shows generic help
  39. if path == '':
  40. self.respond_index(query)
  41. return
  42. # /list - list stored nodes
  43. if path == 'list':
  44. self.respond_list(query)
  45. return
  46. # /vpn - notification endpoint for gateway's VPN connections
  47. if path == 'vpn':
  48. self.respond_vpn(query)
  49. return
  50. # /providers
  51. if path == 'providers':
  52. self.respond_providers(query)
  53. return
  54. # /node/<id>.json - node's data
  55. # /node/<id>/field - return specific field from node's data
  56. m = re.match(r'node/(?P<id>[a-fA-F0-9]{12})(?P<cmd>\.json|/[a-zA-Z0-9_\-\.]+)$', path)
  57. if m != None:
  58. cmd = m.group('cmd')
  59. nodeid = m.group('id').lower()
  60. if cmd == '.json':
  61. self.respond_node(nodeid)
  62. else:
  63. self.respond_nodedetail(nodeid, cmd[1:])
  64. return
  65. # /status/<id> - node's status
  66. m = re.match(r'status/([a-f0-9]{12})$', path)
  67. if m != None:
  68. self.respond_nodestatus(m.group(1))
  69. return
  70. # no match -> 404
  71. self.send_error(404, 'The URL \'{0}\' was not found here.'.format(path))
  72. def do_POST(self):
  73. """Handles all HTTP POST 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. params = self.parse_post_params()
  79. # node id/mac to name mapping
  80. if path == 'idmac2name':
  81. self.respond_nodeidmac2name(params)
  82. return
  83. # no match -> 404
  84. self.send_error(404, 'The URL \'{0}\' was not found here.'.format(path))
  85. def send_nocache_headers(self):
  86. """Sets HTTP headers indicating that this response shall not be cached."""
  87. self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
  88. self.send_header('Pragma', 'no-cache')
  89. self.send_header('Expires', '0')
  90. def send_headers(self, content_type='text/html; charset=utf-8', nocache=True):
  91. """Send HTTP 200 Response header with the given Content-Type.
  92. Optionally send no-caching headers, too."""
  93. self.send_response(200)
  94. self.send_header('Content-Type', content_type)
  95. if nocache: self.send_nocache_headers()
  96. self.end_headers()
  97. def parse_post_params(self):
  98. ctype, pdict = cgi.parse_header(self.headers.getheader('content-type'))
  99. if ctype == 'multipart/form-data':
  100. postvars = cgi.parse_multipart(self.rfile, pdict)
  101. elif ctype == 'application/x-www-form-urlencoded':
  102. length = int(self.headers.getheader('content-length'))
  103. postvars = cgi.parse_qs(self.rfile.read(length), keep_blank_values=1)
  104. else:
  105. postvars = {}
  106. return postvars
  107. def respond_index(self, query):
  108. """Display the index page."""
  109. storage = self.server.storage
  110. self.send_headers()
  111. self.wfile.write('<!DOCTYPE html><html><head><title>BATCAVE</title></head>\n')
  112. self.wfile.write('<body>\n')
  113. self.wfile.write('<H1 title="Batman/Alfred Transmission Collection, Aggregation & Value Engine">BATCAVE</H1>\n')
  114. self.wfile.write('<p>Dies ist ein interner Hintergrund-Dienst. Er wird nur von anderen Diensten\n')
  115. self.wfile.write('angesprochen und sollte aus einer Mehrzahl von Gr&uuml;nden nicht &ouml;ffentlich\n')
  116. self.wfile.write('zug&auml;nglich sein.</p>\n')
  117. self.wfile.write('<H2>Status</H2>\n')
  118. self.wfile.write('Daten: <span id="datacount" class="value">')
  119. self.wfile.write(len(storage.data))
  120. self.wfile.write('</span>\n')
  121. self.wfile.write('<H2>API</H2>\n')
  122. self.wfile.write('<p>Grundsätzlich ist das Antwort-Format JSON und alle Daten sind Live-Daten (kein Cache) die ggf. etwas Bearbeitungs-Zeit erfordern.</p>')
  123. self.wfile.write('<dl>\n')
  124. self.wfile.write('<dt><a href="/nodes.json">nodes.json</a></dt><dd>zur Verwendung mit ffmap (MACs anonymisiert)</dd>\n')
  125. self.wfile.write('<dt><a href="/node/ff00ff00ff00.json">/node/&lt;id&gt;.json</a></dt><dd><u>alle</u> vorhandenen Information zu der gewünschten Node</dd>\n')
  126. self.wfile.write('</dl>\n')
  127. self.wfile.write('</body></html>')
  128. def respond_list(self, query):
  129. """List stored data."""
  130. storage = self.server.storage
  131. self.send_headers()
  132. self.wfile.write('<!DOCTYPE html><html><head><title>BATCAVE</title></head>\n')
  133. self.wfile.write('<body>\n')
  134. self.wfile.write('<H1>BATCAVE - LIST</H1>\n')
  135. self.wfile.write('<table>\n')
  136. self.wfile.write('<thead><tr><th>ID</th><th>Name</th></tr></thead>\n')
  137. self.wfile.write('<tbody>\n')
  138. data = storage.data
  139. if 'sort' in query:
  140. if query['sort'] == 'name':
  141. sorteddata = sorted(data, key=lambda x: data[x]['hostname'].lower())
  142. data = sorteddata
  143. elif query['sort'] == 'id':
  144. sorteddata = sorted(data)
  145. data = sorteddata
  146. for nodeid in data:
  147. if nodeid.startswith('__'): continue
  148. nodename = storage.data[nodeid]['hostname'] if 'hostname' in storage.data[nodeid] else '&lt;?&gt;'
  149. self.wfile.write('<tr><td><a href="/node/' + nodeid + '.json">' + nodeid + '</a></td><td>' + nodename + '</td></tr>')
  150. self.wfile.write('</tbody>\n')
  151. self.wfile.write('</table>\n')
  152. def find_node(self, rawid):
  153. """Fetch node data from storage by given id, if necessary looking thorugh node aliases."""
  154. storage = self.server.storage
  155. # if we have a direct hit, return it immediately
  156. if rawid in storage.data:
  157. return storage.data[rawid]
  158. # no direct hit -> search via aliases
  159. nodeid = rawid
  160. for n in storage.data:
  161. if 'aliases' in storage.data[n] and rawid in storage.data[n]['aliases']:
  162. nodeid = n
  163. # return found node
  164. return storage.data[nodeid] if nodeid in storage.data else None
  165. def find_node_by_mac(self, mac):
  166. """Fetch node data from storage by given MAC address."""
  167. storage = self.server.storage
  168. needle = mac.lower()
  169. # iterate over all nodes
  170. for nodeid in storage.data:
  171. if nodeid.startswith('__'): continue
  172. node = storage.data[nodeid]
  173. # check node's primary MAC
  174. if 'mac' in node and needle == node['mac'].lower():
  175. return node
  176. # check alias MACs
  177. if 'macs' in node:
  178. haystack = [ x.lower() for x in node['macs'] ]
  179. if mac in haystack:
  180. return node
  181. # MAC address not found
  182. return None
  183. def respond_node(self, rawid):
  184. """Display node data."""
  185. # handle API example linked on index page
  186. if rawid == 'ff00ff00ff00':
  187. self.send_headers('text/json')
  188. self.wfile.write(json.dumps({
  189. 'name': 'API-Example',
  190. 'nodeid': rawid,
  191. 'META': 'Dies ist ein minimaler Beispiel-Datensatz. Herzlichen Glückwunsch, du hast das Prinzip der API kapiert.',
  192. }))
  193. return
  194. # search node by the given id
  195. node = self.find_node(rawid)
  196. # handle unknown nodes
  197. if node is None:
  198. self.send_error(404, 'No node with id \'' + rawid + '\' present.')
  199. return
  200. # dump node data as JSON
  201. self.send_headers('text/json')
  202. self.wfile.write(json.dumps(node))
  203. def get_nodestatus(self, rawid):
  204. """Determine node's status."""
  205. # search node by the given id
  206. node = self.find_node(rawid)
  207. # handle unknown nodes
  208. if node is None:
  209. return None
  210. # check that the last batadv update is noted in the data
  211. updated = node[self.FIELDKEY_UPDATED] if self.FIELDKEY_UPDATED in node else None
  212. if updated is None or not 'batadv' in updated:
  213. return 'unknown'
  214. # make decision based on time of last batadv update
  215. diff = time.time() - updated['batadv']
  216. if diff < 150:
  217. return 'active'
  218. elif diff < 300:
  219. return 'stale'
  220. else:
  221. return 'offline'
  222. def respond_nodestatus(self, rawid):
  223. """Display node status."""
  224. status = self.get_nodestatus(rawid)
  225. if status is None:
  226. self.send_error(404, 'No node with id \'' + rawid + '\' present.')
  227. self.send_headers('text/plain')
  228. self.wfile.write(status)
  229. def respond_nodeidmac2name(self, ids):
  230. storage = self.server.storage
  231. self.send_headers('text/plain')
  232. for nodeid in ids:
  233. node = self.find_node(nodeid) if not ':' in nodeid else self.find_node_by_mac(nodeid)
  234. nodename = node['hostname'] if (not node is None) and 'hostname' in node else nodeid
  235. self.wfile.write('{0}={1}\n'.format(nodeid, nodename))
  236. def respond_nodedetail(self, nodeid, field):
  237. """Return a field from the given node - a string is returned as text, all other as JSON."""
  238. node = self.find_node(nodeid)
  239. if node is None:
  240. self.send_error(404, 'No node with id \'' + nodeid + '\' present.')
  241. return
  242. return_count = False
  243. if field.endswith('.count'):
  244. return_count = True
  245. field = field[0:-6]
  246. if not field in node:
  247. self.send_error(404, 'The node \'' + nodeid + '\' does not have a field named \'' + str(field) + '\'.')
  248. return
  249. value = node[field]
  250. if return_count: value = len(value)
  251. self.send_headers('text/plain' if isinstance(value, basestring) or isinstance(value, int) else 'text/json')
  252. self.wfile.write(value if isinstance(value, basestring) else json.dumps(value))
  253. def respond_vpn(self, query):
  254. storage = self.server.storage
  255. peername = query['peer'] if 'peer' in query else None
  256. key = query['key'] if 'key' in query else None
  257. action = query['action'] if 'action' in query else None
  258. remote = query['remote'] if 'remote' in query else None
  259. gw = query['gw'] if 'gw' in query else None
  260. if action == 'list':
  261. self.respond_vpnlist()
  262. return
  263. if action != 'establish' and action != 'disestablish':
  264. self.logger.error('VPN: unknown action \'{0}\''.format(action))
  265. self.send_error(400, 'Invalid action.')
  266. return
  267. for k,v in { 'peername': peername, 'key': key, 'remote': remote, 'gw': gw }.items():
  268. if v is None or len(v.strip()) == 0:
  269. self.logger.error('VPN {0}: no or empty {1}'.format(action, k))
  270. self.send_error(400, 'Missing value for ' + str(k))
  271. return
  272. if key is None or re.match(r'^[a-fA-F0-9]+$', key) is None:
  273. self.logger.error('VPN peer \'{0}\' {1}: bad key \'{2}\''.format(peername, action, key))
  274. self.send_error(400, 'Bad key.')
  275. return
  276. if not self.DATAKEY_VPN in storage.data: storage.data[self.DATAKEY_VPN] = {}
  277. if not key in storage.data[self.DATAKEY_VPN]: storage.data[self.DATAKEY_VPN][key] = { 'active': {}, 'last': {} }
  278. item = storage.data[self.DATAKEY_VPN][key]
  279. if action == 'establish':
  280. item['active'][gw] = { 'establish': time.time(), 'peer': peername, 'remote': remote }
  281. elif action == 'disestablish':
  282. active = {}
  283. if gw in item['active']:
  284. active = item['active'][gw]
  285. del(item['active'][gw])
  286. active['disestablish'] = time.time()
  287. item['last'][gw] = active
  288. else:
  289. self.send_error(500, 'Unknown action not filtered (' + str(action) + ')')
  290. return
  291. self.send_headers('text/plain')
  292. self.wfile.write('OK')
  293. storage.save()
  294. def respond_vpnlist(self):
  295. storage = self.server.storage
  296. gateways = ['gw01','gw02','gw03','gw04','gw05','gw06']
  297. self.send_headers()
  298. self.wfile.write('<!DOCTYPE html>\n')
  299. self.wfile.write('<html><head><title>BATCAVE - VPN LIST</title></head>\n')
  300. self.wfile.write('<body>\n')
  301. self.wfile.write('<style type="text/css">\n')
  302. self.wfile.write('table { border: 2px solid #999; border-collapse: collapse; }\n')
  303. self.wfile.write('th, td { border: 1px solid #CCC; }\n')
  304. self.wfile.write('table tbody tr.online { background-color: #CFC; }\n')
  305. self.wfile.write('table tbody tr.offline { background-color: #FCC; }\n')
  306. self.wfile.write('</style>\n')
  307. self.wfile.write('<table>\n<thead>\n')
  308. self.wfile.write('<tr><th rowspan="2">names (key)</th><th colspan="' + str(len(gateways)) + '">active</th><th colspan="' + str(len(gateways)) + '">last</th></tr>\n')
  309. self.wfile.write('<tr><th>' + '</th><th>'.join(gateways) + '</th><th>' + '</th><th>'.join(gateways) + '</th></tr>\n')
  310. self.wfile.write('</thead>\n')
  311. if self.DATAKEY_VPN in storage.data:
  312. for key in storage.data[self.DATAKEY_VPN]:
  313. item = storage.data[self.DATAKEY_VPN][key]
  314. if not isinstance(item, dict):
  315. continue
  316. names = set()
  317. count = {}
  318. for t in [ 'active', 'last' ]:
  319. count[t] = 0
  320. if t in item:
  321. for gw in item[t]:
  322. if 'remote' in item[t][gw] and len(item[t][gw]['remote']) > 0:
  323. count[t] += 1
  324. if 'peer' in item[t][gw]:
  325. names.add(item[t][gw]['peer'])
  326. self.wfile.write('<tr class="online">' if count['active'] > 0 else '<tr class="offline">')
  327. self.wfile.write('<td title="' + str(key) + '">' + (' / '.join(names) if len(names) > 0 else '?') + '</td>')
  328. for t in [ 'active', 'last' ]:
  329. for gw in gateways:
  330. ip = ''
  331. details = ''
  332. if t in item and gw in item[t]:
  333. ip = item[t][gw]['remote'] if 'remote' in item[t][gw] else ''
  334. self.wfile.write('<td title="' + ip + '">' + ('&check;' if len(ip) > 0 else '&times;') + '</td>')
  335. self.wfile.write('</tr>\n')
  336. self.wfile.write('</table>\n')
  337. self.wfile.write('</body>')
  338. self.wfile.write('</html>')
  339. def respond_providers(self, query):
  340. """Return a summary of providers."""
  341. vpn = self.server.storage.data[self.DATAKEY_VPN]
  342. outputformat = query['format'].lower() if 'format' in query else 'html'
  343. geo = None
  344. try:
  345. geo = pygeoip.GeoIP('GeoIPISP.dat')
  346. except:
  347. self.return_error(500, 'The GeoIP-ISP database file could not be opened.')
  348. return
  349. isps = {}
  350. for key in vpn:
  351. if key is None: continue
  352. item = vpn[key]
  353. if not isinstance(item, dict): continue
  354. if not 'active' in item: continue
  355. ips = set()
  356. for gw in item['active']:
  357. if 'remote' in item['active'][gw]:
  358. ips.add(item['active'][gw]['remote'])
  359. if len(ips) == 0: continue
  360. if len(ips) > 1:
  361. self.logger.warn('VPN key \'{0}\' has {1} active ips. Possible cause is an in-progress re-dialin.'.format(key, len(ips)))
  362. item_isps = set()
  363. for ip in ips:
  364. try:
  365. isp = geo.org_by_addr(ip)
  366. if not isp is None: item_isps.add(isp)
  367. except Exception as err:
  368. self.logger.debug('Failed to resolve ISP for \'{0}\': {1}'.format(ip, str(err)))
  369. if len(item_isps) == 0:
  370. item_isps.add('unknown')
  371. elif len(item_isps) > 1:
  372. self.logger.warn('VPN key \'{0}\' has {1} active IPs which resolved to {2} ISPs: \'{3}\''.format(key, len(ips), len(item_isps), '\', \''.join(item_isps)))
  373. for isp in item_isps:
  374. if not isp in isps: isps[isp] = 0
  375. isps[isp] += 1.0 / len(item_isps)
  376. isps_sum = sum([isps[x] for x in isps])
  377. if outputformat == 'csv':
  378. self.send_headers('text/csv')
  379. self.wfile.write('Count;Name\n')
  380. for isp in isps:
  381. self.wfile.write('{0};"{1}"\n'.format(isps[isp], isp))
  382. elif outputformat == 'json':
  383. self.send_headers('text/json')
  384. data = [ { 'name': x, 'count': isps[x], 'percentage': isps[x]*100.0/isps_sum } for x in isps ]
  385. self.wfile.write(json.dumps(data))
  386. elif outputformat == 'html':
  387. self.send_headers()
  388. self.wfile.write('<!DOCTYPE html><html><head><title>BATCAVE - PROVIDERS</title></head><body>\n')
  389. self.wfile.write('<table border="2"><thead><tr><th>Count</th><th>Percentage</th><th>Name</th></tr></thead><tbody>\n')
  390. for isp in isps:
  391. self.wfile.write('<tr><td>{0}</td><td>{1:.1f}%</td><td>{2}</td></tr>\n'.format(isps[isp], isps[isp]*100.0/isps_sum, isp))
  392. self.wfile.write('</tbody></table>\n')
  393. self.wfile.write('<p>Totals: {0} ISPs, {1} connections</p>\n'.format(len(isps), isps_sum))
  394. self.wfile.write('</body></html>')
  395. else:
  396. self.send_error(400, 'Unknown output format.')
  397. class ApiServer(ThreadingMixIn, HTTPServer):
  398. def __init__(self, endpoint, storage):
  399. if ':' in endpoint[0]: self.address_family = socket.AF_INET6
  400. HTTPServer.__init__(self, endpoint, BatcaveHttpRequestHandler)
  401. self.storage = storage
  402. def __str__(self):
  403. return 'ApiServer on {0}'.format(self.server_address)
  404. if __name__ == '__main__':
  405. dummystorage = Storage()
  406. server = ApiServer(('0.0.0.0', 8888), dummystorage)
  407. print("Server:", str(server))
  408. server.serve_forever()