ffpb.py 17 KB

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