ffpb.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. # -*- coding: utf-8 -*-
  2. from __future__ import print_function
  3. import willie
  4. import datetime
  5. import difflib
  6. from email.utils import mktime_tz
  7. import git
  8. import netaddr
  9. import json
  10. import urllib2
  11. import re
  12. import os
  13. import shelve
  14. import subprocess
  15. import time
  16. import dns.resolver,dns.reversename
  17. import socket
  18. import SocketServer
  19. import threading
  20. msgserver = None
  21. peers_repo = None
  22. alfred_method = None
  23. ffpb_resolver = dns.resolver.Resolver ()
  24. ffpb_resolver.nameservers = ['10.132.254.53']
  25. class MsgHandler(SocketServer.BaseRequestHandler):
  26. """Reads line from TCP stream and forwards it to configured IRC channels."""
  27. def handle(self):
  28. data = self.request.recv(2048).strip()
  29. sender = self._resolve_name (self.client_address[0])
  30. bot = self.server.bot
  31. if bot is None:
  32. print("ERROR: No bot in handle( ) :-(")
  33. return
  34. target = bot.config.core.owner
  35. if bot.config.has_section('ffpb'):
  36. is_public = data.lstrip().lower().startswith("public:")
  37. if is_public and not (bot.config.ffpb.msg_target_public is None):
  38. data = data[7:].lstrip()
  39. target = bot.config.ffpb.msg_target_public
  40. elif not (bot.config.ffpb.msg_target is None):
  41. target = bot.config.ffpb.msg_target
  42. bot.msg(target, "[{0}] {1}".format(sender, str(data)))
  43. def _resolve_name (self, ip):
  44. """Resolves the host name of the given IP address
  45. and strips away the suffix (.infra)?.ffpb"""
  46. if ip.startswith ("127."):
  47. return "localhost"
  48. try:
  49. addr = dns.reversename.from_address (ip)
  50. return re.sub ("(.infra)?.ffpb.", "", str (ffpb_resolver.query (addr, "PTR")[0]))
  51. except dns.resolver.NXDOMAIN:
  52. return ip
  53. class ThreadingTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
  54. pass
  55. def setup(bot):
  56. global msgserver, peers_repo, alfred_method
  57. # open highscores file (backed to filesystem)
  58. if 'highscores' in bot.memory and not bot.memory['highscores'] is None:
  59. bot.memory['highscores'].close()
  60. highscores = shelve.open('highscoredata', writeback=True)
  61. if not 'nodes' in highscores:
  62. highscores['nodes'] = 0
  63. highscores['nodes_ts'] = time.time()
  64. if not 'clients' in highscores:
  65. highscores['clients'] = 0
  66. highscores['clients_ts'] = time.time()
  67. bot.memory['highscores'] = highscores
  68. # no need to configure anything else if the ffpb config section is missing
  69. if not bot.config.has_section('ffpb'):
  70. return
  71. # open the git repository containing the peers files
  72. if not bot.config.ffpb.peers_directory is None:
  73. peers_repo = git.Repo(bot.config.ffpb.peers_directory)
  74. assert peers_repo.bare == False
  75. # if configured start the messaging server
  76. if int(bot.config.ffpb.msg_enable) == 1:
  77. host = "localhost"
  78. port = 2342
  79. if not bot.config.ffpb.msg_host is None: host = bot.config.ffpb.msg_host
  80. if not bot.config.ffpb.msg_port is None: port = int(bot.config.ffpb.msg_port)
  81. msgserver = ThreadingTCPServer((host,port), MsgHandler)
  82. msgserver.bot = bot
  83. ip, port = msgserver.server_address
  84. print("Messaging server listening on {}:{}".format(ip,port))
  85. msgserver_thread = threading.Thread(target=msgserver.serve_forever)
  86. msgserver_thread.daemon = True
  87. msgserver_thread.start()
  88. # initially fetch ALFRED data
  89. alfred_method = bot.config.ffpb.alfred_method
  90. ffpb_updatealfred(bot)
  91. def shutdown(bot):
  92. global msgserver
  93. # store highscores
  94. if 'highscores' in bot.memory and not bot.memory['highscores'] is None:
  95. bot.memory['highscores'].sync()
  96. bot.memory['highscores'].close()
  97. del(bot.memory['highscores'])
  98. # shut down messaging server
  99. if not msgserver is None:
  100. msgserver.shutdown()
  101. print("Closed messaging server.")
  102. msgserver = None
  103. @willie.module.commands("help")
  104. @willie.module.commands("hilfe")
  105. @willie.module.commands("man")
  106. def ffpb_help(bot, trigger):
  107. """Meldet häufig benutzte Funktionen."""
  108. functions = {
  109. "!ping <knoten>": "Prüfe ob der Knoten erreichbar ist.",
  110. "!status": "Aktuellen Status des Netzwerks (insb. Anzahl Knoten und Clients) ausgegeben.",
  111. "!info <knoten>": "Allgemeine Information zu dem Knoten anzeigen.",
  112. "!link <knoten>": "MAC-Adresse und Link zur Status-Seite des Knotens anzeigen.",
  113. "!exec-on-peer <knoten> <kommando>": "Befehl auf dem Knoten ausführen (nur möglich bei eigenen Knoten oder als Admin, in beiden Fällen auch nur wenn der SSH-Key des Bots hinterlegt wurde)",
  114. }
  115. param = trigger.group(2)
  116. if param is None:
  117. bot.say("Funktionen: " + str.join(", ", sorted(functions.keys())))
  118. return
  119. if param.startswith("!"): param = param[1:]
  120. for fun in functions.keys():
  121. if fun.startswith("!" + param + " "):
  122. bot.say("Hilfe zu '" + fun + "': " + functions[fun])
  123. return
  124. bot.say("Allgemeine Hilfe gib t's mit !help - ohne Parameter.")
  125. def ffpb_findnode(bot, name):
  126. """helper: try to identify the node the user meant by the given name"""
  127. # no name, no node
  128. if name is None or len(name) == 0:
  129. return None
  130. name = str(name).strip()
  131. names = {}
  132. alfred_data = bot.memory['alfred_data'] if 'alfred_data' in bot.memory else None
  133. if not alfred_data is None:
  134. # try to match MAC in ALFRED data
  135. m = re.search("^([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]$", name)
  136. if (not m is None):
  137. mac = m.group(0).lower()
  138. if mac in alfred_data:
  139. return alfred_data[mac]
  140. # try to find alias MAC
  141. for nodeid in alfred_data:
  142. node = alfred_data[nodeid]
  143. if "network" in node:
  144. if "mac" in node["network"] and node["network"]["mac"].lower() == mac:
  145. return node
  146. if "mesh_interfaces" in node["network"]:
  147. for mim in node["network"]["mesh_interfaces"]:
  148. if mim.lower() == mac:
  149. return node
  150. return {
  151. 'hostname': '?-' + mac.replace(':','').lower(),
  152. 'network': { 'addresses': [ mac2ipv6(mac, 'fdca:ffee:ff12:132:') ], 'mac': mac, },
  153. 'hardware': { 'model': 'derived-from-mac' },
  154. }
  155. # look through the ALFRED peers
  156. for nodeid in alfred_data:
  157. node = alfred_data[nodeid]
  158. if 'hostname' in node:
  159. h = node['hostname']
  160. if h.lower() == name.lower():
  161. return node
  162. else:
  163. names[h] = nodeid
  164. # try peers_repo
  165. if not peers_repo is None:
  166. peer_name = None
  167. peer_mac = None
  168. peer_file = None
  169. for b in peers_repo.heads.master.commit.tree.blobs:
  170. if b.name.lower() == name.lower():
  171. peer_name = b.name
  172. peer_file = b.abspath
  173. break
  174. if (not peer_file is None) and os.path.exists(peer_file):
  175. peerfile = open(peer_file, "r")
  176. for line in peerfile:
  177. if line.startswith("# MAC:"):
  178. peer_mac = line[6:].strip()
  179. peerfile.close()
  180. if not (peer_mac is None):
  181. return {
  182. 'hostname': peer_name,
  183. 'network': { 'addresses': [ mac2ipv6(peer_mac, 'fdca:ffee:ff12:132:') ], 'mac': peer_mac },
  184. 'hardware': { 'model': 'derived-from-vpnkeys' },
  185. }
  186. # do a similar name lookup in the ALFRED data
  187. if not alfred_data is None:
  188. possibilities = difflib.get_close_matches(name, [ x for x in names ], cutoff=0.8)
  189. print('findnode: Fuzzy matching \'{0}\' got {1} entries: {2}'.format(name, len(possibilities), ', '.join(possibilities)))
  190. if len(possibilities) == 1:
  191. # if we got exactly one candidate that might be it
  192. return alfred_data[names[possibilities[0]]]
  193. # none of the above was able to identify the requested node
  194. return None
  195. def ffpb_get_alfreddata(bot, ensure_recent=True):
  196. """helper: return current ALFRED data (or None,
  197. if the data is outdated and ensure_recent is set)"""
  198. if not 'alfred_data' in bot.memory or bot.memory['alfred_data'] is None:
  199. return None
  200. if ensure_recent:
  201. # get timestamp of last ALFRED update (set by ffpb_updatealfred())
  202. alfred_update = bot.memory['alfred_update'] if 'alfred_update' in bot.memory else None
  203. if alfred_update is None: return None
  204. # data must not be older than 5 minutes
  205. timeout = datetime.datetime.now() - datetime.timedelta(minutes=5)
  206. is_outdated = timeout > alfred_update
  207. #print("ALFRED outdated? {0} (timeout={1} vs. lastupdate={2})".format(is_outdated, timeout, alfred_update))
  208. if is_outdated:
  209. return None
  210. return bot.memory['alfred_data']
  211. def ffpb_findnode_from_botparam(bot, name, ensure_recent_alfreddata = True):
  212. """helper: call ffpb_findnode() and give common answers via bot if nothing has been found"""
  213. if (name is None or len(name) == 0):
  214. bot.reply("Grün.")
  215. return None
  216. alfred_data = ffpb_get_alfreddata(bot, ensure_recent_alfreddata)
  217. if alfred_data is None and ensure_recent_alfreddata:
  218. bot.say("Ich habe gerade keine (aktuellen) Informationen, daher sage ich mal lieber nichts zu '" + name + "'.")
  219. return None
  220. node = ffpb_findnode(bot, name)
  221. if node is None:
  222. bot.say("Kein Plan wer oder was mit '" + name + "' gemeint ist :(")
  223. return node
  224. def mac2ipv6(mac, prefix=None):
  225. """Calculate IPv6 address from given MAC,
  226. optionally replacing the fe80:: prefix with a given one."""
  227. result = str(netaddr.EUI(mac).ipv6_link_local())
  228. if (not prefix is None) and (result.startswith("fe80::")):
  229. result = prefix + result[6:]
  230. return result
  231. @willie.module.interval(30)
  232. def ffpb_updatealfred(bot):
  233. """Aktualisiere ALFRED-Daten"""
  234. if alfred_method is None or alfred_method == "None":
  235. return
  236. alfred_data = None
  237. updated = None
  238. if alfred_method == "exec":
  239. rawdata = subprocess.check_output(['alfred-json', '-z', '-r', '158'])
  240. updated = datetime.datetime.now()
  241. elif alfred_method.startswith("http"):
  242. try:
  243. rawdata = urllib2.urlopen(alfred_method)
  244. except:
  245. print("Failed to download ALFRED data.")
  246. return
  247. updated = datetime.datetime.fromtimestamp(mktime_tz(rawdata.info().getdate_tz("Last-Modified")))
  248. else:
  249. print("Unknown ALFRED data method '", alfred_method, "', cannot load new data.", sep="")
  250. bot.memory['alfred_data'] = None
  251. return
  252. try:
  253. alfred_data = json.load(rawdata)
  254. #print("Fetched new ALFRED data:", len(alfred_data), "entries")
  255. except ValueError as e:
  256. print("Failed to parse ALFRED data: " + str(e))
  257. return
  258. bot.memory['alfred_data'] = alfred_data
  259. bot.memory['alfred_update'] = updated
  260. @willie.module.commands('debug-alfred')
  261. def ffpb_debug_alfred(bot, trigger):
  262. """Zeige Stand der Alfred-Daten an."""
  263. alfred_data = ffpb_get_alfreddata(bot)
  264. if alfred_data is None:
  265. bot.say("Keine ALFRED-Daten vorhanden.")
  266. else:
  267. bot.say("ALFRED Daten: count={0} lastupdate={1}".format(len(alfred_data), bot.memory['alfred_update'] if 'alfred_memory' in bot.memory else '?'))
  268. @willie.module.commands('alfred-data')
  269. def ffpb_peerdata(bot, trigger):
  270. """Zeige Daten zum angegebenen Node an."""
  271. # user must be a bot admin
  272. if (not trigger.admin):
  273. bot.say('I wont leak (possibly) sensitive data to you.')
  274. return
  275. # query must be a PM or as OP in the channel
  276. if (not trigger.is_privmsg) and (not trigger.nick in bot.ops[trigger.sender]):
  277. bot.say('Kein Keks? Keine Daten.')
  278. return
  279. # identify node or bail out
  280. target_name = trigger.group(2)
  281. node = ffpb_findnode_from_botparam(bot, target_name)
  282. if node is None: return
  283. # reply each key in the node's data
  284. for key in node:
  285. if key in [ 'hostname' ]: continue
  286. bot.say("{0}.{1} = {2}".format(node['hostname'], key, str(node[key])))
  287. @willie.module.interval(60)
  288. def ffpb_updatepeers(bot):
  289. """Aktualisiere die Knotenliste und melde das Diff"""
  290. if peers_repo is None:
  291. print('WARNING: peers_repo is None')
  292. return
  293. old_head = peers_repo.head.commit
  294. peers_repo.remotes.origin.pull()
  295. new_head = peers_repo.head.commit
  296. if new_head != old_head:
  297. print('git pull: from ' + str(old_head) + ' to ' + str(new_head))
  298. added = []
  299. changed = []
  300. renamed = []
  301. deleted = []
  302. for f in old_head.diff(new_head):
  303. if f.new_file:
  304. added.append(f.b_blob.name)
  305. elif f.deleted_file:
  306. deleted.append(f.a_blob.name)
  307. elif f.renamed:
  308. renamed.append([f.rename_from, f.rename_to])
  309. else:
  310. changed.append(f.a_blob.name)
  311. response = "Knoten-Update (VPN +{0} %{1} -{2}): ".format(len(added), len(renamed)+len(changed), len(deleted))
  312. for f in added:
  313. response += " +'{}'".format(f)
  314. for f in changed:
  315. response += " %'{}'".format(f)
  316. for f in renamed:
  317. response += " '{}'->'{}'".format(f[0],f[1])
  318. for f in deleted:
  319. response += " -'{}'".format(f)
  320. bot.msg(bot.config.ffpb.msg_target, response)
  321. def ffpb_fetch_stats(bot, url, memoryid):
  322. """Fetch a ffmap-style nodes.json from the given URL and
  323. store it in the bot's memory."""
  324. response = urllib2.urlopen(url)
  325. data = json.load(response)
  326. nodes_active = 0
  327. nodes_total = 0
  328. clients_count = 0
  329. for node in data['nodes']:
  330. if node['flags']['gateway'] or node['flags']['client']:
  331. continue
  332. nodes_total += 1
  333. if node['flags']['online']:
  334. nodes_active += 1
  335. if 'legacy' in node['flags'] and node['flags']['legacy']:
  336. clients_count -= 1
  337. for link in data['links']:
  338. if link['type'] == 'client':
  339. clients_count += 1
  340. if not memoryid in bot.memory:
  341. bot.memory[memoryid] = { }
  342. stats = bot.memory[memoryid]
  343. stats["fetchtime"] = time.time()
  344. stats["nodes_active"] = nodes_active
  345. stats["nodes_total"] = nodes_total
  346. stats["clients"] = clients_count
  347. return (nodes_active, nodes_total, clients_count)
  348. @willie.module.interval(15)
  349. def ffpb_get_stats(bot):
  350. """Hole aktuelle Statistik-Daten, falls sich der Highscore ändert melde dies."""
  351. highscores = bot.memory['highscores'] if 'highscores' in bot.memory else None
  352. if highscores is None:
  353. print('HIGHSCORE not in bot memory')
  354. return
  355. (nodes_active, nodes_total, clients_count) = ffpb_fetch_stats(bot, 'http://map.paderborn.freifunk.net/nodes.json', 'ffpb_stats')
  356. highscore_changed = False
  357. if nodes_active > highscores['nodes']:
  358. highscores['nodes'] = nodes_active
  359. highscores['nodes_ts'] = time.time()
  360. highscore_changed = True
  361. if clients_count > highscores['clients']:
  362. highscores['clients'] = clients_count
  363. highscores['clients_ts'] = time.time()
  364. highscore_changed = True
  365. if highscore_changed:
  366. print('HIGHSCORE changed: {0} nodes ({1}), {2} clients ({3})'.format(highscores['nodes'], highscores['nodes_ts'], highscores['clients'], highscores['clients_ts']))
  367. if not (bot.config.ffpb.msg_target is None):
  368. action_msg = 'notiert sich den neuen Highscore: {0} Knoten ({1}), {2} Clients ({3})'.format(highscores['nodes'], pretty_date(int(highscores['nodes_ts'])), highscores['clients'], pretty_date(int(highscores['clients_ts'])))
  369. action_target = bot.config.ffpb.msg_target
  370. if (not bot.config.ffpb.msg_target_public is None):
  371. action_target = bot.config.ffpb.msg_target_public
  372. bot.msg(action_target, '\x01ACTION %s\x01' % action_msg)
  373. def pretty_date(time=False):
  374. """
  375. Get a datetime object or a int() Epoch timestamp and return a
  376. pretty string like 'an hour ago', 'Yesterday', '3 months ago',
  377. 'just now', etc
  378. """
  379. from datetime import datetime
  380. now = datetime.now()
  381. compare = None
  382. if type(time) is int:
  383. compare = datetime.fromtimestamp(time)
  384. elif type(time) is float:
  385. compare = datetime.fromtimestamp(int(time))
  386. elif isinstance(time,datetime):
  387. compare = time
  388. elif not time:
  389. compare = now
  390. diff = now - compare
  391. second_diff = diff.seconds
  392. day_diff = diff.days
  393. if day_diff < 0:
  394. return ''
  395. if day_diff == 0:
  396. if second_diff < 10:
  397. return "gerade eben"
  398. if second_diff < 60:
  399. return "vor " + str(second_diff) + " Sekunden"
  400. if second_diff < 120:
  401. return "vor einer Minute"
  402. if second_diff < 3600:
  403. return "vor " + str(second_diff / 60) + " Minuten"
  404. if second_diff < 7200:
  405. return "vor einer Stunde"
  406. if second_diff < 86400:
  407. return "vor " + str(second_diff / 3600) + " Stunden"
  408. if day_diff == 1:
  409. return "gestern"
  410. if day_diff < 7:
  411. return "vor " + str(day_diff) + " Tagen"
  412. return "am " + compare.strftime('%d.%m.%Y um %H:%M Uhr')
  413. @willie.module.commands('ping')
  414. def ffpb_ping(bot, trigger=None, target_name=None):
  415. """Ping an Knoten"""
  416. if target_name is None: target_name = trigger.group(2)
  417. node = ffpb_findnode_from_botparam(bot, target_name, ensure_recent_alfreddata=False)
  418. if node is None: return None
  419. target = [x for x in node["network"]["addresses"] if not x.lower().startswith("fe80:")][0]
  420. target_alias = node["hostname"]
  421. print("pinging '{0}' at {1} ...".format(target_name, target))
  422. result = os.system('ping6 -c 2 -W 1 ' + target + ' >/dev/null')
  423. if result == 0:
  424. print("ping to '{0}' succeeded".format(target_name))
  425. if not bot is None: bot.say('Knoten "' + target_alias + '" antwortet \o/')
  426. return True
  427. elif result == 1 or result == 256:
  428. print("ping to '{0}' failed".format(target_name))
  429. if not bot is None: bot.say('Keine Antwort von "' + target_alias + '" :-(')
  430. return False
  431. else:
  432. print("ping to '{0}' broken: result='{1}'".format(target_name, result))
  433. if not bot is None: bot.say('Uh oh, irgendwas ist kaputt. Chef, ping result = ' + str(result) + ' - darf ich das essen?')
  434. return None
  435. @willie.module.commands('exec-on-peer')
  436. def ffpb_remoteexec(bot, trigger):
  437. """Remote Execution für Knoten (mit SSH-Key des Bots)"""
  438. bot_params = trigger.group(2).split(' ',1)
  439. if len(bot_params) != 2:
  440. bot.say('Wenn du nicht sagst wo mach ich remote execution bei dir!')
  441. bot.say('Tipp: !exec-on-peer <peer> <cmd>')
  442. return
  443. target_name = bot_params[0]
  444. target_cmd = bot_params[1]
  445. # remote execution may only be trigger by bot admins
  446. if not trigger.admin:
  447. bot.say('I can haz sudo?')
  448. return
  449. # make sure remote execution is done in public
  450. if trigger.is_privmsg:
  451. bot.say('Bitte per Channel.')
  452. return
  453. # double-safety: user must be op in the channel, too (hoping for NickServ authentication)
  454. if not trigger.nick in bot.ops[trigger.sender]:
  455. bot.say('Geh weg.')
  456. return
  457. # identify requested node or bail out
  458. node = ffpb_findnode_from_botparam(bot, target_name, ensure_recent_alfreddata=False)
  459. if node is None: return
  460. # use the node's first non-linklocal address
  461. target = [x for x in node["network"]["addresses"] if not x.lower().startswith("fe80:")][0]
  462. target_alias = node["hostname"]
  463. # assemble SSH command
  464. cmd = 'ssh -6 -l root ' + target + ' -- "' + target_cmd + '"'
  465. print("REMOTE EXEC = " + cmd)
  466. try:
  467. # call SSH
  468. result = subprocess.check_output(['ssh', '-6n', '-l', 'root', '-o', 'BatchMode=yes', '-o','StrictHostKeyChecking=no', target, target_cmd], stderr=subprocess.STDOUT, shell=False)
  469. # fetch results and send at most 8 of them as response
  470. lines = str(result).splitlines()
  471. if len(lines) == 0:
  472. bot.say('exec-on-peer(' + target_alias + '): No output')
  473. return
  474. msg = 'exec-on-peer(' + target_alias + '): ' + str(len(lines)) + ' Zeilen'
  475. if len(lines) > 8:
  476. msg += ' (zeige max. 8)'
  477. bot.say(msg + ':')
  478. for line in lines[0:8]:
  479. bot.say(line)
  480. except subprocess.CalledProcessError as e:
  481. bot.say('Fehler '+str(e.returncode)+' bei exec-on-peer('+target_alias+'): ' + e.output)