ffpb.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. # -*- coding: utf-8 -*-
  2. from __future__ import print_function
  3. import willie
  4. import datetime
  5. from email.utils import mktime_tz
  6. import git
  7. import netaddr
  8. import json
  9. import urllib2
  10. import re
  11. import os
  12. import subprocess
  13. import dns.resolver,dns.reversename
  14. import socket
  15. import SocketServer
  16. import threading
  17. msgserver = None
  18. peers_repo = None
  19. stats = None
  20. alfred_method = None
  21. alfred_data = None
  22. alfred_update = datetime.datetime(1970,1,1,23,42)
  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. if not bot.config.has_section('ffpb'):
  55. return
  56. if not bot.config.ffpb.peers_directory is None:
  57. peers_repo = git.Repo(bot.config.ffpb.peers_directory)
  58. assert peers_repo.bare == False
  59. if int(bot.config.ffpb.msg_enable) == 1:
  60. host = "localhost"
  61. port = 2342
  62. if not bot.config.ffpb.msg_host is None: host = bot.config.ffpb.msg_host
  63. if not bot.config.ffpb.msg_port is None: port = int(bot.config.ffpb.msg_port)
  64. msgserver = ThreadingTCPServer((host,port), MsgHandler)
  65. msgserver.bot = bot
  66. ip, port = msgserver.server_address
  67. print("Messaging server listening on {}:{}".format(ip,port))
  68. msgserver_thread = threading.Thread(target=msgserver.serve_forever)
  69. msgserver_thread.daemon = True
  70. msgserver_thread.start()
  71. alfred_method = bot.config.ffpb.alfred_method
  72. ffpb_updatealfred(bot)
  73. def shutdown(bot):
  74. global msgserver
  75. if not msgserver is None:
  76. msgserver.shutdown()
  77. print("Closed messaging server.")
  78. msgserver = None
  79. @willie.module.commands("help")
  80. @willie.module.commands("hilfe")
  81. @willie.module.commands("man")
  82. def ffpb_help(bot, trigger):
  83. functions = {
  84. "!ping <knoten>": "Prüfe ob der Knoten erreichbar ist.",
  85. "!status": "Aktuellen Status des Netzwerks (insb. Anzahl Knoten und Clients) ausgegeben.",
  86. "!info <knoten>": "Allgemeine Information zu dem Knoten anzeigen.",
  87. "!link <knoten>": "MAC-Adresse und Link zur Status-Seite des Knotens anzeigen.",
  88. "!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)",
  89. }
  90. param = trigger.group(2)
  91. if param is None:
  92. bot.say("Funktionen: " + str.join(", ", sorted(functions.keys())))
  93. return
  94. if param.startswith("!"): param = param[1:]
  95. for fun in functions.keys():
  96. if fun.startswith("!" + param + " "):
  97. bot.say("Hilfe zu '" + fun + "': " + functions[fun])
  98. return
  99. bot.say("Allgemeine Hilfe gibt's mit !help - ohne Parameter.")
  100. def ffpb_findnode(name):
  101. if name is None or len(name) == 0:
  102. return None
  103. name = str(name).strip()
  104. # try to match MAC
  105. m = re.search("^([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]$", name)
  106. if (not m is None):
  107. mac = m.group(0).lower()
  108. if mac in alfred_data:
  109. return alfred_data[mac]
  110. # try to find alias MAC
  111. for nodeid in alfred_data:
  112. node = alfred_data[nodeid]
  113. if "network" in node:
  114. if "mac" in node["network"] and node["network"]["mac"].lower() == mac:
  115. return node
  116. if "mesh_interfaces" in node["network"]:
  117. for mim in node["network"]["mesh_interfaces"]:
  118. if mim.lower() == mac:
  119. return node
  120. # look through the ALFRED peers
  121. possible_matches = []
  122. for nodeid in alfred_data:
  123. node = alfred_data[nodeid]
  124. if "hostname" in node and node["hostname"].lower() == name.lower():
  125. return node
  126. # still not found -> try peers_repo
  127. if not peers_repo is None:
  128. peer_name = None
  129. peer_mac = None
  130. peer_file = None
  131. for b in peers_repo.heads.master.commit.tree.blobs:
  132. if b.name.lower() == name.lower():
  133. peer_name = b.name
  134. peer_file = b.abspath
  135. break
  136. if (not peer_file is None) and os.path.exists(peer_file):
  137. peerfile = open(peer_file, "r")
  138. for line in peerfile:
  139. if line.startswith("# MAC:"):
  140. peer_mac = line[6:].strip()
  141. peerfile.close()
  142. if not (peer_mac is None):
  143. return { "hostname": peer_name, "network": { "addresses": [ mac2ipv6(peer_mac, "fdca:ffee:ff12:132:") ], "mac": peer_mac } }
  144. return None
  145. def ffpb_findnode_from_botparam(bot, name, ensure_recent_alfreddata = True):
  146. if (name is None or len(name) == 0):
  147. bot.reply("Grün.")
  148. return None
  149. if ensure_recent_alfreddata and alfred_data is None:
  150. bot.say("Informationen sind ausverkauft, kommen erst morgen wieder rein.")
  151. return None
  152. if ensure_recent_alfreddata and ffpb_alfred_data_outdated():
  153. bot.say("Ich habe gerade keine aktuellen Informationen, daher sage ich mal lieber nichts zu '" + name + "'.")
  154. return None
  155. node = ffpb_findnode(name)
  156. if node is None:
  157. bot.say("Kein Plan wer oder was mit '" + name + "' gemeint ist :(")
  158. return node
  159. def mac2ipv6(mac, prefix=None):
  160. result = str(netaddr.EUI(mac).ipv6_link_local())
  161. if (not prefix is None) and (result.startswith("fe80::")):
  162. result = prefix + result[6:]
  163. return result
  164. @willie.module.interval(30)
  165. def ffpb_updatealfred(bot):
  166. """Aktualisiere ALFRED-Daten"""
  167. global alfred_data, alfred_update
  168. if alfred_method is None or alfred_method == "None":
  169. return
  170. updated = None
  171. if alfred_method == "exec":
  172. rawdata = subprocess.check_output(['alfred-json', '-z', '-r', '158'])
  173. updated = datetime.datetime.now()
  174. elif alfred_method.startswith("http"):
  175. try:
  176. rawdata = urllib2.urlopen(alfred_method)
  177. except:
  178. print("Failed to download ALFRED data.")
  179. return
  180. updated = datetime.datetime.fromtimestamp(mktime_tz(rawdata.info().getdate_tz("Last-Modified")))
  181. else:
  182. print("Unknown ALFRED data method '", alfred_method, "', cannot load new data.", sep="")
  183. alfred_data = None
  184. return
  185. try:
  186. alfred_data = json.load(rawdata)
  187. #print("Fetched new ALFRED data:", len(alfred_data), "entries")
  188. alfred_update = updated
  189. except ValueError as e:
  190. print("Failed to parse ALFRED data: " + str(e))
  191. return
  192. def ffpb_alfred_data_outdated():
  193. timeout = datetime.datetime.now() - datetime.timedelta(minutes=5)
  194. is_outdated = timeout > alfred_update
  195. #print("ALFRED outdated? {0} (timeout={1} vs. lastupdate={2})".format(is_outdated, timeout, alfred_update))
  196. return is_outdated
  197. @willie.module.commands('debug-alfred')
  198. def ffpb_debug_alfred(bot, trigger):
  199. if alfred_data is None:
  200. bot.say("Keine ALFRED-Daten vorhanden.")
  201. else:
  202. bot.say("ALFRED Daten: count={0} lastupdate={1}".format(len(alfred_data), alfred_update))
  203. @willie.module.commands('alfred-data')
  204. def ffpb_peerdata(bot, trigger):
  205. if (not trigger.admin):
  206. bot.say('I wont leak (possibly) sensitive data to you.')
  207. return
  208. if (not trigger.is_privmsg) and (not trigger.nick in bot.ops[trigger.sender]):
  209. bot.say('Kein Keks? Keine Daten.')
  210. return
  211. target_name = trigger.group(2)
  212. node = ffpb_findnode_from_botparam(bot, target_name)
  213. if node is None: return
  214. for key in node:
  215. if key in [ 'hostname' ]: continue
  216. bot.say("{0}.{1} = {2}".format(node['hostname'], key, str(node[key])))
  217. @willie.module.commands('info')
  218. def ffpb_peerinfo(bot, trigger):
  219. target_name = trigger.group(2)
  220. node = ffpb_findnode_from_botparam(bot, target_name)
  221. if node is None: return
  222. info_mac = node["network"]["mac"]
  223. info_name = node["hostname"]
  224. info_hw = ""
  225. if "hardware" in node:
  226. if "model" in node["hardware"]:
  227. model = node["hardware"]["model"]
  228. info_hw = " model='" + model + "'"
  229. info_fw = ""
  230. info_update = ""
  231. if "software" in node:
  232. if "firmware" in node["software"]:
  233. fwinfo = str(node["software"]["firmware"]["release"]) if "release" in node["software"]["firmware"] else "unknown"
  234. info_fw = " firmware=" + fwinfo
  235. if "autoupdater" in node["software"]:
  236. autoupdater = node["software"]["autoupdater"]["branch"] if node["software"]["autoupdater"]["enabled"] else "off"
  237. info_update = " (autoupdater="+autoupdater+")"
  238. info_uptime = ""
  239. if "statistics" in node and "uptime" in node["statistics"]:
  240. u = int(float(node["statistics"]["uptime"]))
  241. d, r1 = divmod(int(float(node["statistics"]["uptime"])), 86400)
  242. h, r2 = divmod(r1, 3600)
  243. m, s = divmod(r2, 60)
  244. if d > 0:
  245. info_uptime = ' up {0}d {1}h'.format(d,h)
  246. elif h > 0:
  247. info_uptime = ' up {0}h {1}m'.format(h,m)
  248. else:
  249. info_uptime = ' up {0}m'.format(m)
  250. bot.say('[{1}]{2}{3}{4}{5}'.format(info_mac, info_name, info_hw, info_fw, info_update, info_uptime))
  251. @willie.module.commands('link')
  252. def ffpb_peerlink(bot, trigger):
  253. target_name = trigger.group(2)
  254. node = ffpb_findnode_from_botparam(bot, target_name)
  255. if node is None: return
  256. info_mac = node["network"]["mac"]
  257. info_name = node["hostname"]
  258. info_v6 = mac2ipv6(info_mac, 'fdca:ffee:ff12:132:')
  259. bot.say('[{1}] mac {0} -> http://[{2}]/'.format(info_mac, info_name, info_v6))
  260. @willie.module.interval(60)
  261. def ffpb_updatepeers(bot):
  262. """Aktualisiere die Knotenliste und melde das Diff"""
  263. if peers_repo is None:
  264. print('WARNING: peers_repo is None')
  265. return
  266. old_head = peers_repo.head.commit
  267. peers_repo.remotes.origin.pull()
  268. new_head = peers_repo.head.commit
  269. if new_head != old_head:
  270. print('git pull: from ' + str(old_head) + ' to ' + str(new_head))
  271. added = []
  272. changed = []
  273. renamed = []
  274. deleted = []
  275. for f in old_head.diff(new_head):
  276. if f.new_file:
  277. added.append(f.b_blob.name)
  278. elif f.deleted_file:
  279. deleted.append(f.a_blob.name)
  280. elif f.renamed:
  281. renamed.append([f.rename_from, f.rename_to])
  282. else:
  283. changed.append(f.a_blob.name)
  284. response = "Knoten-Update (VPN +{0} %{1} -{2}): ".format(len(added), len(renamed)+len(changed), len(deleted))
  285. for f in added:
  286. response += " +'{}'".format(f)
  287. for f in changed:
  288. response += " %'{}'".format(f)
  289. for f in renamed:
  290. response += " '{}'->'{}'".format(f[0],f[1])
  291. for f in deleted:
  292. response += " -'{}'".format(f)
  293. bot.msg(bot.config.ffpb.msg_target, response)
  294. @willie.module.interval(15)
  295. def ffpb_get_stats(bot):
  296. global stats
  297. response = urllib2.urlopen('http://map.paderborn.freifunk.net/nodes.json')
  298. data = json.load(response)
  299. nodes_active = 0
  300. nodes_total = 0
  301. clients_count = 0
  302. for node in data['nodes']:
  303. if node['flags']['gateway'] or node['flags']['client']:
  304. continue
  305. nodes_total += 1
  306. if node['flags']['online']:
  307. nodes_active += 1
  308. for link in data['links']:
  309. if link['type'] == 'client':
  310. clients_count += 1
  311. if stats is None:
  312. stats = { }
  313. stats["nodes_active"] = nodes_active
  314. stats["nodes_total"] = nodes_total
  315. stats["clients"] = clients_count
  316. @willie.module.commands('status')
  317. def ffpb_status(bot, trigger):
  318. """Status des FFPB-Netzes: Anzahl (aktiver) Knoten + Clients"""
  319. if stats is None:
  320. bot.say('Uff, kein Plan wo der Zettel ist. Fragst du später nochmal?')
  321. return
  322. bot.say('Es sind {0} Knoten und ca. {1} Clients online.'.format(stats["nodes_active"], stats["clients"]))
  323. @willie.module.commands('rollout-status')
  324. def ffpb_rolloutstatus(bot, trigger):
  325. result = { }
  326. for branch in [ 'stable', 'testing' ]:
  327. result[branch] = None
  328. skipped = 0
  329. if (not (trigger.admin and trigger.is_privmsg)) and (not trigger.nick in bot.ops[trigger.sender]):
  330. bot.say('Geh zur dunklen Seite, die haben Kekse - ohne Keks kein Rollout-Status.')
  331. return
  332. expected_release = trigger.group(2)
  333. if expected_release is None or len(expected_release) == 0:
  334. bot.say('Von welcher Firmware denn?')
  335. return
  336. for nodeid in alfred_data:
  337. item = alfred_data[nodeid]
  338. if (not 'software' in item) or (not 'firmware' in item['software']) or (not 'autoupdater' in item['software']):
  339. skipped+=1
  340. continue
  341. release = item['software']['firmware']['release']
  342. branch = item['software']['autoupdater']['branch']
  343. enabled = item['software']['autoupdater']['enabled']
  344. if not branch in result or result[branch] is None:
  345. result[branch] = { 'auto_count': 0, 'auto_not': 0, 'manual_count': 0, 'manual_not': 0, 'total': 0 }
  346. result[branch]['total'] += 1
  347. match = 'count' if release == expected_release else 'not'
  348. mode = 'auto' if enabled else 'manual'
  349. result[branch][mode+'_'+match] += 1
  350. output = "Rollout von '{0}':".format(expected_release)
  351. for branch in result:
  352. auto_count = result[branch]['auto_count']
  353. auto_total = auto_count + result[branch]['auto_not']
  354. manual_count = result[branch]['manual_count']
  355. manual_total = manual_count + result[branch]['manual_not']
  356. bot.say("Rollout von '{0}': {1} = {2}/{3} per Auto-Update, {4}/{5} manuell".format(expected_release, branch, auto_count, auto_total, manual_count, manual_total))
  357. if skipped > 0:
  358. bot.say("Rollout von '{0}': {1} Knoten unklar".format(expected_release, skipped))
  359. @willie.module.commands('ping')
  360. def ffpb_ping(bot, trigger):
  361. """Ping FFPB-Knoten"""
  362. target_name = trigger.group(2)
  363. node = ffpb_findnode_from_botparam(bot, target_name, ensure_recent_alfreddata=False)
  364. if node is None: return
  365. target = node["network"]["addresses"][0]
  366. target_alias = node["hostname"]
  367. print("ping '", target , '"', sep='')
  368. result = os.system('ping6 -c 2 -W 1 ' + target + ' 2>/dev/null')
  369. if result == 0:
  370. bot.say('Knoten "' + target_alias + '" antwortet \o/')
  371. elif result == 1 or result == 256:
  372. bot.say('Keine Antwort von "' + target_alias + '" :-(')
  373. else:
  374. bot.say('Uh oh, irgendwas ist kaputt. Chef, ping result = ' + str(result) + ' - darf ich das essen?')
  375. @willie.module.commands('exec-on-peer')
  376. def ffpb_remoteexec(bot, trigger):
  377. """Remote Execution fuer FFPB_Knoten"""
  378. bot_params = trigger.group(2).split(' ',1)
  379. if len(bot_params) != 2:
  380. bot.say('Wenn du nicht sagst wo mach ich remote execution bei dir!')
  381. bot.say('Tipp: !exec-on-peer <peer> <cmd>')
  382. return
  383. target_name = bot_params[0]
  384. target_cmd = bot_params[1]
  385. if not trigger.admin:
  386. bot.say('I can haz sudo?')
  387. return
  388. if trigger.is_privmsg:
  389. bot.say('Bitte per Channel.')
  390. return
  391. if not trigger.nick in bot.ops[trigger.sender]:
  392. bot.say('Geh weg.')
  393. return
  394. node = ffpb_findnode_from_botparam(bot, target_name, ensure_recent_alfreddata=False)
  395. if node is None: return
  396. target = node["network"]["addresses"][0]
  397. target_alias = node["hostname"]
  398. cmd = 'ssh -6 -l root ' + target + ' -- "' + target_cmd + '"'
  399. print("REMOTE EXEC = " + cmd)
  400. try:
  401. result = subprocess.check_output(['ssh', '-6n', '-l', 'root', '-o', 'BatchMode=yes', '-o','StrictHostKeyChecking=no', target, target_cmd], stderr=subprocess.STDOUT, shell=False)
  402. lines = str(result).splitlines()
  403. if len(lines) == 0:
  404. bot.say('exec-on-peer(' + target_alias + '): No output')
  405. return
  406. msg = 'exec-on-peer(' + target_alias + '): ' + str(len(lines)) + ' Zeilen'
  407. if len(lines) > 8:
  408. msg += ' (zeige max. 8)'
  409. bot.say(msg + ':')
  410. for line in lines[0:8]:
  411. bot.say(line)
  412. except subprocess.CalledProcessError as e:
  413. bot.say('Fehler '+str(e.returncode)+' bei exec-on-peer('+target_alias+'): ' + e.output)