ffpb.py 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139
  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. from fnmatch import fnmatch
  8. import git
  9. import netaddr
  10. import json
  11. import urllib2
  12. import re
  13. import os
  14. import random
  15. import shelve
  16. import subprocess
  17. import time
  18. import dns.resolver,dns.reversename
  19. import socket
  20. import SocketServer
  21. import threading
  22. msgserver = None
  23. peers_repo = None
  24. monitored_nodes = None
  25. highscores = None
  26. nodeaccess = None
  27. alfred_method = None
  28. alfred_data = None
  29. alfred_update = datetime.datetime(1970,1,1,23,42)
  30. ffpb_resolver = dns.resolver.Resolver ()
  31. ffpb_resolver.nameservers = ['10.132.254.53']
  32. class MsgHandler(SocketServer.BaseRequestHandler):
  33. """Reads line from TCP stream and forwards it to configured IRC channels."""
  34. def handle(self):
  35. data = self.request.recv(2048).strip()
  36. sender = self._resolve_name (self.client_address[0])
  37. bot = self.server.bot
  38. if bot is None:
  39. print("ERROR: No bot in handle() :-(")
  40. return
  41. target = bot.config.core.owner
  42. if bot.config.has_section('ffpb'):
  43. is_public = data.lstrip().lower().startswith("public:")
  44. if is_public and not (bot.config.ffpb.msg_target_public is None):
  45. data = data[7:].lstrip()
  46. target = bot.config.ffpb.msg_target_public
  47. elif not (bot.config.ffpb.msg_target is None):
  48. target = bot.config.ffpb.msg_target
  49. bot.msg(target, "[{0}] {1}".format(sender, str(data)))
  50. def _resolve_name (self, ip):
  51. """Resolves the host name of the given IP address
  52. and strips away the suffix (.infra)?.ffpb"""
  53. if ip.startswith ("127."):
  54. return "localhost"
  55. try:
  56. addr = dns.reversename.from_address (ip)
  57. return re.sub ("(.infra)?.ffpb.", "", str (ffpb_resolver.query (addr, "PTR")[0]))
  58. except dns.resolver.NXDOMAIN:
  59. return ip
  60. class ThreadingTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
  61. pass
  62. def setup(bot):
  63. global msgserver, peers_repo, alfred_method, highscores, monitored_nodes, nodeaccess
  64. # signal begin of setup routine
  65. bot.memory['ffpb_in_setup'] = True
  66. # load highscores from disk
  67. highscores = shelve.open('highscoredata', writeback=True)
  68. if not 'nodes' in highscores:
  69. highscores['nodes'] = 0
  70. highscores['nodes_ts'] = time.time()
  71. if not 'clients' in highscores:
  72. highscores['clients'] = 0
  73. highscores['clients_ts'] = time.time()
  74. # load list of monitored nodes and their last status from disk
  75. monitored_nodes = shelve.open('nodes.monitored', writeback=True)
  76. # load list of seen nodes from disk
  77. seen_nodes = shelve.open('nodes.seen', writeback=True)
  78. bot.memory['seen_nodes'] = seen_nodes
  79. # load list of node ACL from disk (used in playitsafe())
  80. nodeaccess = shelve.open('nodes.acl', writeback=True)
  81. # no need to configure anything else if the ffpb config section is missing
  82. if not bot.config.has_section('ffpb'):
  83. bot.memory['ffpb_in_setup'] = False
  84. return
  85. # open the git repository containing the peers files
  86. if not bot.config.ffpb.peers_directory is None:
  87. peers_repo = git.Repo(bot.config.ffpb.peers_directory)
  88. assert peers_repo.bare == False
  89. # if configured, start the messaging server
  90. if int(bot.config.ffpb.msg_enable) == 1:
  91. host = "localhost"
  92. port = 2342
  93. if not bot.config.ffpb.msg_host is None: host = bot.config.ffpb.msg_host
  94. if not bot.config.ffpb.msg_port is None: port = int(bot.config.ffpb.msg_port)
  95. msgserver = ThreadingTCPServer((host,port), MsgHandler)
  96. msgserver.bot = bot
  97. ip, port = msgserver.server_address
  98. print("Messaging server listening on {}:{}".format(ip,port))
  99. msgserver_thread = threading.Thread(target=msgserver.serve_forever)
  100. msgserver_thread.daemon = True
  101. msgserver_thread.start()
  102. # initially fetch ALFRED data
  103. alfred_method = bot.config.ffpb.alfred_method
  104. ffpb_updatealfred(bot)
  105. # signal end of setup routine
  106. bot.memory['ffpb_in_setup'] = False
  107. def shutdown(bot):
  108. global msgserver, highscores, monitored_nodes, nodeaccess
  109. # store highscores
  110. if not highscores is None:
  111. highscores.sync()
  112. highscores.close()
  113. highscores = None
  114. # store monitored nodes
  115. if not monitored_nodes is None:
  116. monitored_nodes.sync()
  117. monitored_nodes.close()
  118. monitored_nodes = None
  119. # store node acl
  120. if not nodeaccess is None:
  121. nodeaccess.sync()
  122. nodeaccess.close()
  123. nodeaccess = None
  124. # store seen nodes
  125. if 'seen_nodes' in bot.memory and bot.memory['seen_nodes'] != None:
  126. bot.memory['seen_nodes'].close()
  127. bot.memory['seen_nodes'] = None
  128. del(bot.memory['seen_nodes'])
  129. # shutdown messaging server
  130. if not msgserver is None:
  131. msgserver.shutdown()
  132. print("Closed messaging server.")
  133. msgserver = None
  134. @willie.module.commands("help")
  135. @willie.module.commands("hilfe")
  136. @willie.module.commands("man")
  137. def ffpb_help(bot, trigger):
  138. """Display commony ulsed functions."""
  139. functions = {
  140. "!ping <knoten>": "Prüfe ob der Knoten erreichbar ist.",
  141. "!status": "Aktuellen Status des Netzwerks (insb. Anzahl Knoten und Clients) ausgegeben.",
  142. "!info <knoten>": "Allgemeine Information zu dem Knoten anzeigen.",
  143. "!link <knoten>": "MAC-Adresse und Link zur Status-Seite des Knotens anzeigen.",
  144. "!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)",
  145. "!mesh <knoten>": "Zeige Mesh-Partner eines Knotens",
  146. }
  147. param = trigger.group(2)
  148. if param is None:
  149. bot.say("Funktionen: " + str.join(", ", sorted(functions.keys())))
  150. return
  151. if param.startswith("!"): param = param[1:]
  152. for fun in functions.keys():
  153. if fun.startswith("!" + param + " "):
  154. bot.say("Hilfe zu '" + fun + "': " + functions[fun])
  155. return
  156. bot.say("Allgemeine Hilfe gibt's mit !help - ohne Parameter.")
  157. def playitsafe(bot, trigger,
  158. botadmin=False, admin_channel=False, via_channel=False, via_privmsg=False, need_op=False, node=None,
  159. reply_directly=True, debug_user=None, debug_ignorebotadmin=False):
  160. """helper: checks that the triggering user has the necessary rights
  161. Returns true if everything is okay. If it's not, a reply is send via the bot and false is returned.
  162. """
  163. if via_channel and via_privmsg:
  164. raise Exception('Der Entwickler ist ein dummer, dummer Junge (playitsafe hat via_channel und via_privmsg gleichzeitig gesetzt).')
  165. user = trigger.nick if debug_user is None else debug_user
  166. user = user.lower()
  167. # botadmin: you need to be configured as a bot admin
  168. if botadmin and not trigger.admin:
  169. if reply_directly: bot.say('Du brauchst Super-Kuh-Kräfte um dieses Kommando auszuführen.')
  170. return False
  171. # via_channel: the request must not be a private conversation
  172. if via_channel and trigger.is_privmsg:
  173. if reply_directly: bot.say('Bitte per Channel - mehr Transparenz wagen und so!')
  174. return False
  175. # via_privmsg: the request must be a private conversation
  176. if via_privmsg and not trigger.is_privmsg:
  177. if reply_directly: bot.say('Solche Informationen gibt es nur per PM, da bin ich ja schon ein klein wenig sensibel ...')
  178. return False
  179. # need_op: if the message is in a channel, check that the user has OP there
  180. if need_op and (not trigger.is_privmsg) and (not user in bot.ops[trigger.sender]):
  181. if reply_directly: bot.say('Keine Zimtschnecke, keine Kekse.')
  182. return False
  183. # node: check that the user is whitelisted (or is admin)
  184. if not node is None and (debug_ignorebotadmin or not trigger.admin):
  185. acluser = [ x for x in nodeaccess if x.lower() == user ]
  186. acluser = acluser[0] if len(acluser) == 1 else None
  187. if nodeaccess is None or acluser is None:
  188. if reply_directly: bot.reply('You! Shall! Not! Access!')
  189. return False
  190. nodeid = node['node_id'] if 'node_id' in node else None
  191. matched = False
  192. for x in nodeaccess[acluser]:
  193. if x == nodeid or fnmatch(node['hostname'], x):
  194. matched = True
  195. break
  196. if not matched:
  197. if reply_directly: bot.reply('Mach das doch bitte auf deinen Knoten, kthxbye.')
  198. return False
  199. return True
  200. @willie.module.commands('nodeacl')
  201. def ffpb_nodeacl(bot, trigger):
  202. """Configure ACL for nodes."""
  203. if not playitsafe(bot, trigger, botadmin=True):
  204. # the check function already gives a bot reply, just exit here
  205. return
  206. # ensure the user gave arguments (group 2 is the concatenation of all following groups)
  207. if trigger.group(2) is None or len(trigger.group(2)) == 0:
  208. bot.say('Sag doch was du willst ... einmal mit Profis arbeiten, ey -.-')
  209. return
  210. # read additional arguments
  211. cmd = trigger.group(3).lower()
  212. if cmd == 'list':
  213. user = trigger.group(4)
  214. if user is None:
  215. bot.say('ACLs gesetzt für die User: ' + ', '.join([x for x in nodeaccess]))
  216. return
  217. user = user.lower()
  218. uid = [ x for x in nodeaccess if x.lower() == user ]
  219. if len(uid) == 0:
  220. bot.say('Für \'{0}\' ist keine Node ACL gesetzt.'.format(user))
  221. return
  222. bot.say('Node ACL für \'{0}\' = \'{1}\''.format(uid[0], '\', \''.join(nodeaccess[uid[0]])))
  223. return
  224. if cmd in [ 'add', 'del', 'check' ]:
  225. user = trigger.group(4)
  226. value = trigger.group(5)
  227. if user is None or value is None:
  228. bot.say('Du bist eine Pappnase - User und Knoten, bitte.')
  229. return
  230. user = str(user)
  231. print('NodeACL ' + cmd + ' \'' + value + '\' for user \'' + user + '\'')
  232. uid = [ x for x in nodeaccess if x == user or x.lower() == user ]
  233. if cmd == 'add':
  234. uid = uid[0] if len(uid) > 0 else user
  235. if not uid in nodeaccess:
  236. nodeaccess[uid] = []
  237. if not value in nodeaccess[uid]:
  238. nodeaccess[uid].append(value)
  239. bot.say('201 nodeACL \'{0}\' +\'{1}\''.format(uid, value))
  240. else:
  241. bot.say('304 nodeACL \'{0}\' contains \'{1}\''.format(uid, value))
  242. elif cmd == 'del':
  243. if len(uid) == 0:
  244. bot.say('404 nodeACL \'{0}\''.format(uid))
  245. return
  246. if value in nodeaccess[uid]:
  247. nodeaccess[uid].remove(value)
  248. bot.say('200 nodeACL \'{0}\' -\'{1}\''.format(uid, value))
  249. else:
  250. bot.say('404 nodeACL \'{0}\' does not contain \'{1}\''.format(uid, value))
  251. elif cmd == 'check':
  252. if len(uid) == 0:
  253. bot.say('Nope, keine ACL gesetzt.')
  254. return
  255. node = ffpb_findnode(value)
  256. if node is None:
  257. bot.say('Nope, kein Plan was für ein Knoten das ist.')
  258. return
  259. result = playitsafe(bot, trigger, debug_user=uid[0], debug_ignorebotadmin=True, node=node, reply_directly=False)
  260. if result == True:
  261. bot.say('Jupp.')
  262. elif result == False:
  263. bot.say('Nope.')
  264. else:
  265. bot.say('Huh? result=' + str(result))
  266. return
  267. bot.say('Unbekanntes Kommando. Probier "list [user]", "add user value" oder "del user value". Value kann node_id oder hostname-Maske sein.')
  268. def ffpb_ensurenodeid(nodedata):
  269. if 'node_id' in nodedata:
  270. return nodedata
  271. # derive node's id
  272. nodeid = node['network']['mac'].replace(':','') if 'network' in node and 'mac' in node['network'] else None
  273. # assemble extended data
  274. result = { 'node_id': nodeid }
  275. for key in nodedata: result[key] = nodedata[key]
  276. return result
  277. def ffpb_findnode(name):
  278. """helper: try to identify the node the user meant by the given name"""
  279. # no name, no node
  280. if name is None or len(name) == 0:
  281. return None
  282. name = str(name).strip()
  283. names = {}
  284. # try to match MAC
  285. m = re.search("^([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]$", name)
  286. if (not m is None):
  287. mac = m.group(0).lower()
  288. if mac in alfred_data:
  289. return ffpb_ensurenodeid(alfred_data[mac])
  290. # try to find alias MAC in ALFRED data
  291. for nodeid in alfred_data:
  292. node = alfred_data[nodeid]
  293. if "network" in node:
  294. if "mac" in node["network"] and node["network"]["mac"].lower() == mac:
  295. return ffpb_ensurenodeid(node)
  296. if "mesh_interfaces" in node["network"]:
  297. for mim in node["network"]["mesh_interfaces"]:
  298. if mim.lower() == mac:
  299. return ffpb_ensurenodeid(node)
  300. nodeid = mac.replace(':','').lower()
  301. return {
  302. 'nodeid': nodeid,
  303. 'hostname': '?-' + nodeid,
  304. 'network': { 'addresses': [ mac2ipv6(mac, 'fdca:ffee:ff12:132:') ], 'mac': mac, },
  305. 'hardware': { 'model': 'derived-from-mac' },
  306. }
  307. # look through the ALFRED peers
  308. for nodeid in alfred_data:
  309. node = alfred_data[nodeid]
  310. if 'hostname' in node:
  311. h = node['hostname']
  312. if h.lower() == name.lower():
  313. return node
  314. else:
  315. names[h] = nodeid
  316. # not found in ALFRED data -> try peers_repo
  317. if not peers_repo is None:
  318. peer_name = None
  319. peer_mac = None
  320. peer_file = None
  321. for b in peers_repo.heads.master.commit.tree.blobs:
  322. if b.name.lower() == name.lower():
  323. peer_name = b.name
  324. peer_file = b.abspath
  325. break
  326. if (not peer_file is None) and os.path.exists(peer_file):
  327. peerfile = open(peer_file, "r")
  328. for line in peerfile:
  329. if line.startswith("# MAC:"):
  330. peer_mac = line[6:].strip()
  331. peerfile.close()
  332. if not (peer_mac is None):
  333. return {
  334. 'node_id': peer_mac.replace(':', ''),
  335. 'hostname': peer_name,
  336. 'network': { 'addresses': [ mac2ipv6(peer_mac, 'fdca:ffee:ff12:132:') ], 'mac': peer_mac },
  337. 'hardware': { 'model': 'derived-from-vpnkeys' },
  338. }
  339. # do a similar name lookup in the ALFRED data
  340. possibilities = difflib.get_close_matches(name, [ x for x in names ], cutoff=0.75)
  341. print('findnode: Fuzzy matching \'{0}\' got {1} entries: {2}'.format(name, len(possibilities), ', '.join(possibilities)))
  342. if len(possibilities) == 1:
  343. # if we got exactly one candidate that might be it
  344. return ffpb_ensurenodeid(alfred_data[names[possibilities[0]]])
  345. # none of the above was able to identify the requested node
  346. return None
  347. def ffpb_findnode_from_botparam(bot, name, ensure_recent_alfreddata = True):
  348. """helper: call ffpb_findnode() and give common answers via bot if nothing has been found"""
  349. if (name is None or len(name) == 0):
  350. if not bot is None: bot.reply("Grün.")
  351. return None
  352. if ensure_recent_alfreddata and alfred_data is None:
  353. if not bot is None: bot.say("Informationen sind ausverkauft, kommen erst morgen wieder rein.")
  354. return None
  355. if ensure_recent_alfreddata and ffpb_alfred_data_outdated():
  356. if not bot is None: bot.say("Ich habe gerade keine aktuellen Informationen, daher sage ich mal lieber nichts zu '" + name + "'.")
  357. return None
  358. node = ffpb_findnode(name)
  359. if node is None:
  360. if not bot is None: bot.say("Kein Plan wer oder was mit '" + name + "' gemeint ist :(")
  361. return node
  362. def mac2ipv6(mac, prefix=None):
  363. """Calculate IPv6 address from given MAC,
  364. optionally replacing the fe80:: prefix with a given one."""
  365. result = str(netaddr.EUI(mac).ipv6_link_local())
  366. if (not prefix is None) and (result.startswith("fe80::")):
  367. result = prefix + result[6:]
  368. return result
  369. @willie.module.interval(30)
  370. def ffpb_updatealfred(bot):
  371. """Aktualisiere ALFRED-Daten"""
  372. global alfred_data, alfred_update
  373. if alfred_method is None or alfred_method == "None":
  374. return
  375. updated = None
  376. if alfred_method == "exec":
  377. rawdata = subprocess.check_output(['alfred-json', '-z', '-r', '158'])
  378. updated = datetime.datetime.now()
  379. elif alfred_method.startswith("http"):
  380. try:
  381. rawdata = urllib2.urlopen(alfred_method)
  382. except:
  383. print("Failed to download ALFRED data.")
  384. return
  385. updated = datetime.datetime.fromtimestamp(mktime_tz(rawdata.info().getdate_tz("Last-Modified")))
  386. else:
  387. print("Unknown ALFRED data method '", alfred_method, "', cannot load new data.", sep="")
  388. alfred_data = None
  389. return
  390. try:
  391. alfred_data = json.load(rawdata)
  392. #print("Fetched new ALFRED data:", len(alfred_data), "entries")
  393. alfred_update = updated
  394. except ValueError as e:
  395. print("Failed to parse ALFRED data: " + str(e))
  396. return
  397. seen_nodes = bot.memory['seen_nodes'] if 'seen_nodes' in bot.memory else None
  398. if not seen_nodes is None:
  399. new = []
  400. for nodeid in alfred_data:
  401. nodeid = str(nodeid)
  402. if not nodeid in seen_nodes:
  403. seen_nodes[nodeid] = updated
  404. new.append((nodeid,alfred_data[nodeid]['hostname']))
  405. print('First time seen: ' + str(nodeid))
  406. if len(new) > 0 and not bot.memory['ffpb_in_setup']:
  407. action_msg = None
  408. if len(new) == 1:
  409. action_msg = random.choice((
  410. 'bemerkt den neuen Knoten {0}',
  411. 'entdeckt {0}',
  412. 'reibt sich die Augen und erblickt einen verpackungsfrischen Knoten {0}',
  413. u'heißt {0} im Mesh willkommen',
  414. 'freut sich, dass {0} aufgetaucht ist',
  415. 'traut seinen Augen kaum. {0} sagt zum ersten Mal: Hallo Freifunk Paderborn',
  416. u'sieht die ersten Herzschläge von {0}',
  417. u'stellt einen großen Pott Heißgetränk zu {0} und fragt ob es hier Meshpartner gibt.',
  418. )).format('\'' + str(new[0][1]) + '\'')
  419. else:
  420. action_msg = random.choice((
  421. 'bemerkt die neuen Knoten {0} und {1}',
  422. 'hat {0} und {1} entdeckt',
  423. 'bewundert {0} sowie {1}',
  424. 'freut sich, dass {0} und {1} nun auch online sind',
  425. u'heißt {0} und {1} im Mesh willkommen',
  426. 'fragt sich ob die noch jungen Herzen von {0} und {1} synchron schlagen',
  427. )).format('\'' + '\', \''.join([ str(x[1]) for x in new[0:-1] ]) + '\'', '\'' + str(new[-1][1]) + '\'')
  428. action_target = bot.config.ffpb.msg_target
  429. bot.msg(action_target, '\x01ACTION %s\x01' % action_msg)
  430. def ffpb_alfred_data_outdated():
  431. timeout = datetime.datetime.now() - datetime.timedelta(minutes=5)
  432. is_outdated = timeout > alfred_update
  433. #print("ALFRED outdated? {0} (timeout={1} vs. lastupdate={2})".format(is_outdated, timeout, alfred_update))
  434. return is_outdated
  435. def ffpb_get_batcave_nodefield(nodeid, field):
  436. raw_data = None
  437. try:
  438. # query BATCAVE for node's field
  439. raw_data = urllib2.urlopen('http://[fdca:ffee:ff12:a255::253]:8888/node/{0}/{1}'.format(nodeid, field))
  440. except Exception as err:
  441. print('Failed to contact BATCAVE for \'{0}\'->\'{1}\': {2}'.format(nodeid, field, err))
  442. return None
  443. try:
  444. return json.load(raw_data)
  445. except:
  446. print('Could not parse BATCAVE\'s response as JSON for \'{0}\'->\'{1}\''.format(nodeid, field))
  447. return None
  448. @willie.module.commands('debug-alfred')
  449. def ffpb_debug_alfred(bot, trigger):
  450. """Show statistics of available ALFRED data."""
  451. if alfred_data is None:
  452. bot.say("Keine ALFRED-Daten vorhanden.")
  453. else:
  454. bot.say("ALFRED Daten: count={0} lastupdate={1}".format(len(alfred_data), alfred_update))
  455. @willie.module.commands('alfred-data')
  456. def ffpb_peerdata(bot, trigger):
  457. """Show ALFRED data of the given node."""
  458. # identify node or bail out
  459. target_name = trigger.group(2)
  460. node = ffpb_findnode_from_botparam(bot, target_name)
  461. if node is None: return
  462. # query must be a PM or as OP in the channel
  463. if not playitsafe(bot, trigger, need_op=True, node=node):
  464. # the check function already gives a bot reply, just exit here
  465. return
  466. # reply each key in the node's data
  467. for key in node:
  468. if key in [ 'hostname' ]: continue
  469. bot.say("{0}.{1} = {2}".format(node['hostname'], key, str(node[key])))
  470. @willie.module.commands('info')
  471. def ffpb_peerinfo(bot, trigger):
  472. """Show information of the given node."""
  473. # identify node or bail out
  474. target_name = trigger.group(2)
  475. node = ffpb_findnode_from_botparam(bot, target_name)
  476. if node is None: return
  477. # read node information
  478. info_mac = node['network']['mac'] if 'network' in node and 'mac' in node['network'] else '??:??:??:??:??:??'
  479. info_id = node['node_id'] if 'node_id' in node else info_mac.replace(':','')
  480. info_name = node['hostname'] if 'hostname' in node else '?-' + info_id
  481. info_hw = ""
  482. if "hardware" in node:
  483. if "model" in node["hardware"]:
  484. model = node["hardware"]["model"]
  485. info_hw = " model='" + model + "'"
  486. info_fw = ""
  487. info_update = ""
  488. if "software" in node:
  489. if "firmware" in node["software"]:
  490. fwinfo = str(node["software"]["firmware"]["release"]) if "release" in node["software"]["firmware"] else "unknown"
  491. info_fw = " firmware=" + fwinfo
  492. if "autoupdater" in node["software"]:
  493. autoupdater = node["software"]["autoupdater"]["branch"] if node["software"]["autoupdater"]["enabled"] else "off"
  494. info_update = " (autoupdater="+autoupdater+")"
  495. info_uptime = ""
  496. u = -1
  497. if "statistics" in node and "uptime" in node["statistics"]:
  498. u = int(float(node["statistics"]["uptime"]))
  499. elif 'uptime' in node:
  500. u = int(float(node['uptime']))
  501. if u > 0:
  502. d, r1 = divmod(u, 86400)
  503. h, r2 = divmod(r1, 3600)
  504. m, s = divmod(r2, 60)
  505. if d > 0:
  506. info_uptime = ' up {0}d {1}h'.format(d,h)
  507. elif h > 0:
  508. info_uptime = ' up {0}h {1}m'.format(h,m)
  509. else:
  510. info_uptime = ' up {0}m'.format(m)
  511. info_clients = ""
  512. clientcount = ffpb_get_batcave_nodefield(info_id, 'clientcount')
  513. if not clientcount is None:
  514. clientcount = int(clientcount)
  515. info_clients = ' clients={0}'.format(clientcount)
  516. bot.say('[{1}]{2}{3}{4}{5}{6}'.format(info_mac, info_name, info_hw, info_fw, info_update, info_uptime, info_clients))
  517. @willie.module.commands('uptime')
  518. def ffpb_peeruptime(bot, trigger):
  519. """Display the uptime of the given node."""
  520. # identify node or bail out
  521. target_name = trigger.group(2)
  522. node = ffpb_findnode_from_botparam(bot, target_name)
  523. if node is None: return
  524. # get name and raw uptime from node
  525. info_name = node["hostname"]
  526. info_uptime = ''
  527. u_raw = None
  528. if 'statistics' in node and 'uptime' in node['statistics']:
  529. u_raw = node['statistics']['uptime']
  530. elif 'uptime' in node:
  531. u_raw = node['uptime']
  532. # pretty print uptime
  533. if not u_raw is None:
  534. u = int(float(u_raw))
  535. d, r1 = divmod(u, 86400)
  536. h, r2 = divmod(r1, 3600)
  537. m, s = divmod(r2, 60)
  538. if d > 0:
  539. info_uptime += '{0}d '.format(d)
  540. if h > 0:
  541. info_uptime += '{0}h '.format(h)
  542. info_uptime += '{0}m'.format(m)
  543. info_uptime += ' # raw: \'{0}\''.format(u_raw)
  544. else:
  545. info_uptime += '?'
  546. # reply to user
  547. bot.say('uptime(\'{0}\') = {1}'.format(info_name, info_uptime))
  548. @willie.module.commands('link')
  549. def ffpb_peerlink(bot, trigger):
  550. """Display MAC and link to statuspage for the given node."""
  551. # identify node or bail out
  552. target_name = trigger.group(2)
  553. node = ffpb_findnode_from_botparam(bot, target_name)
  554. if node is None: return
  555. # get node's MAC
  556. info_mac = node["network"]["mac"]
  557. info_name = node["hostname"]
  558. # get node's v6 address in the mesh (derived from MAC address)
  559. info_v6 = mac2ipv6(info_mac, 'fdca:ffee:ff12:132:')
  560. # reply to user
  561. bot.say('[{1}] mac {0} -> http://[{2}]/'.format(info_mac, info_name, info_v6))
  562. @willie.module.interval(60)
  563. def ffpb_updatepeers(bot):
  564. """Refresh list of peers and message the diff."""
  565. if peers_repo is None:
  566. print('WARNING: peers_repo is None')
  567. return
  568. old_head = peers_repo.head.commit
  569. peers_repo.remotes.origin.pull()
  570. new_head = peers_repo.head.commit
  571. if new_head != old_head:
  572. print('git pull: from ' + str(old_head) + ' to ' + str(new_head))
  573. added = []
  574. changed = []
  575. renamed = []
  576. deleted = []
  577. for f in old_head.diff(new_head):
  578. if f.new_file:
  579. added.append(f.b_blob.name)
  580. elif f.deleted_file:
  581. deleted.append(f.a_blob.name)
  582. elif f.renamed:
  583. renamed.append([f.rename_from, f.rename_to])
  584. else:
  585. changed.append(f.a_blob.name)
  586. response = "Knoten-Update (VPN +{0} %{1} -{2}): ".format(len(added), len(renamed)+len(changed), len(deleted))
  587. for f in added:
  588. response += " +'{}'".format(f)
  589. for f in changed:
  590. response += " %'{}'".format(f)
  591. for f in renamed:
  592. response += " '{}'->'{}'".format(f[0],f[1])
  593. for f in deleted:
  594. response += " -'{}'".format(f)
  595. bot.msg(bot.config.ffpb.msg_target, response)
  596. def ffpb_fetch_stats(bot, url, memoryid):
  597. """Fetch a ffmap-style nodes.json from the given URL and
  598. store it in the bot's memory."""
  599. response = urllib2.urlopen(url)
  600. data = json.load(response)
  601. nodes_active = 0
  602. nodes_total = 0
  603. clients_count = 0
  604. for node in data['nodes']:
  605. if node['flags']['gateway'] or node['flags']['client']:
  606. continue
  607. nodes_total += 1
  608. if node['flags']['online']:
  609. nodes_active += 1
  610. if 'legacy' in node['flags'] and node['flags']['legacy']:
  611. clients_count -= 1
  612. for link in data['links']:
  613. if link['type'] == 'client':
  614. clients_count += 1
  615. if not memoryid in bot.memory:
  616. bot.memory[memoryid] = { }
  617. stats = bot.memory[memoryid]
  618. stats["fetchtime"] = time.time()
  619. stats["nodes_active"] = nodes_active
  620. stats["nodes_total"] = nodes_total
  621. stats["clients"] = clients_count
  622. return (nodes_active, nodes_total, clients_count)
  623. @willie.module.interval(15)
  624. def ffpb_get_stats(bot):
  625. """Fetch current statistics, if the highscore changes signal this."""
  626. (nodes_active, nodes_total, clients_count) = ffpb_fetch_stats(bot, 'http://map.paderborn.freifunk.net/nodes.json', 'ffpb_stats')
  627. highscore_changed = False
  628. if nodes_active > highscores['nodes']:
  629. highscores['nodes'] = nodes_active
  630. highscores['nodes_ts'] = time.time()
  631. highscore_changed = True
  632. if clients_count > highscores['clients']:
  633. highscores['clients'] = clients_count
  634. highscores['clients_ts'] = time.time()
  635. highscore_changed = True
  636. if highscore_changed:
  637. print('HIGHSCORE changed: {0} nodes ({1}), {2} clients ({3})'.format(highscores['nodes'], highscores['nodes_ts'], highscores['clients'], highscores['clients_ts']))
  638. if not (bot.config.ffpb.msg_target is None):
  639. 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'])))
  640. action_target = bot.config.ffpb.msg_target
  641. if (not bot.config.ffpb.msg_target_public is None):
  642. action_target = bot.config.ffpb.msg_target_public
  643. bot.msg(action_target, '\x01ACTION %s\x01' % action_msg)
  644. @willie.module.commands('status')
  645. def ffpb_status(bot, trigger):
  646. """State of the network: count of nodes + clients"""
  647. stats = bot.memory['ffpb_stats'] if 'ffpb_stats' in bot.memory else None
  648. if stats is None:
  649. bot.say('Uff, kein Plan wo der Zettel ist. Fragst du später nochmal?')
  650. return
  651. bot.say('Es sind {0} Knoten und ca. {1} Clients online.'.format(stats["nodes_active"], stats["clients"]))
  652. def pretty_date(time=False):
  653. """
  654. Get a datetime object or a int() Epoch timestamp and return a
  655. pretty string like 'an hour ago', 'Yesterday', '3 months ago',
  656. 'just now', etc
  657. """
  658. from datetime import datetime
  659. now = datetime.now()
  660. compare = None
  661. if type(time) is int:
  662. compare = datetime.fromtimestamp(time)
  663. elif type(time) is float:
  664. compare = datetime.fromtimestamp(int(time))
  665. elif isinstance(time,datetime):
  666. compare = time
  667. elif not time:
  668. compare = now
  669. diff = now - compare
  670. second_diff = diff.seconds
  671. day_diff = diff.days
  672. if day_diff < 0:
  673. return ''
  674. if day_diff == 0:
  675. if second_diff < 10:
  676. return "gerade eben"
  677. if second_diff < 60:
  678. return "vor " + str(second_diff) + " Sekunden"
  679. if second_diff < 120:
  680. return "vor einer Minute"
  681. if second_diff < 3600:
  682. return "vor " + str(second_diff / 60) + " Minuten"
  683. if second_diff < 7200:
  684. return "vor einer Stunde"
  685. if second_diff < 86400:
  686. return "vor " + str(second_diff / 3600) + " Stunden"
  687. if day_diff == 1:
  688. return "gestern"
  689. if day_diff < 7:
  690. return "vor " + str(day_diff) + " Tagen"
  691. return "am " + compare.strftime('%d.%m.%Y um %H:%M Uhr')
  692. @willie.module.commands('highscore')
  693. def ffpb_highscore(bot, trigger):
  694. bot.say('Highscore: {0} Knoten ({1}), {2} Clients ({3})'.format(
  695. highscores['nodes'], pretty_date(int(highscores['nodes_ts'])),
  696. highscores['clients'], pretty_date(int(highscores['clients_ts']))))
  697. @willie.module.commands('rollout-status')
  698. def ffpb_rolloutstatus(bot, trigger):
  699. """Display statistic on how many nodes have installed the given firmware version."""
  700. # initialize results dictionary
  701. result = { }
  702. for branch in [ 'stable', 'testing' ]:
  703. result[branch] = None
  704. skipped = 0
  705. # command is restricted to bot-admins via PM or OPS in the channel
  706. if (not (trigger.admin and trigger.is_privmsg)) and (not trigger.nick in bot.ops[trigger.sender]):
  707. bot.say('Geh zur dunklen Seite, die haben Kekse - ohne Keks kein Rollout-Status.')
  708. return
  709. # read expected firmware version from command arguments
  710. expected_release = trigger.group(2)
  711. if expected_release is None or len(expected_release) == 0:
  712. bot.say('Von welcher Firmware denn?')
  713. return
  714. # check each node in ALFRED data
  715. for nodeid in alfred_data:
  716. item = alfred_data[nodeid]
  717. if (not 'software' in item) or (not 'firmware' in item['software']) or (not 'autoupdater' in item['software']):
  718. skipped+=1
  719. continue
  720. release = item['software']['firmware']['release']
  721. branch = item['software']['autoupdater']['branch']
  722. enabled = item['software']['autoupdater']['enabled']
  723. if not branch in result or result[branch] is None:
  724. result[branch] = { 'auto_count': 0, 'auto_not': 0, 'manual_count': 0, 'manual_not': 0, 'total': 0 }
  725. result[branch]['total'] += 1
  726. match = 'count' if release == expected_release else 'not'
  727. mode = 'auto' if enabled else 'manual'
  728. result[branch][mode+'_'+match] += 1
  729. # respond to user
  730. output = "Rollout von '{0}':".format(expected_release)
  731. for branch in result:
  732. auto_count = result[branch]['auto_count']
  733. auto_total = auto_count + result[branch]['auto_not']
  734. manual_count = result[branch]['manual_count']
  735. manual_total = manual_count + result[branch]['manual_not']
  736. 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))
  737. # output count of nodes for which the autoupdater's branch and/or
  738. # firmware version could not be retrieved
  739. if skipped > 0:
  740. bot.say("Rollout von '{0}': {1} Knoten unklar".format(expected_release, skipped))
  741. @willie.module.commands('ping')
  742. def ffpb_ping(bot, trigger=None, target_name=None):
  743. """Ping the given node"""
  744. # identify node or bail out
  745. if target_name is None: target_name = trigger.group(2)
  746. node = ffpb_findnode_from_botparam(bot, target_name, ensure_recent_alfreddata=False)
  747. if node is None: return None
  748. # get the first non-linklocal address from the node
  749. target = [x for x in node["network"]["addresses"] if not x.lower().startswith("fe80:")][0]
  750. target_alias = node["hostname"]
  751. # execute the actual ping and reply the result
  752. print("pinging '{0}' at {1} ...".format(target_name, target))
  753. result = os.system('ping6 -c 2 -W 1 ' + target + ' >/dev/null')
  754. if result == 0:
  755. print("ping to '{0}' succeeded".format(target_name))
  756. if not bot is None: bot.say('Knoten "' + target_alias + '" antwortet \o/')
  757. return True
  758. elif result == 1 or result == 256:
  759. print("ping to '{0}' failed".format(target_name))
  760. if not bot is None: bot.say('Keine Antwort von "' + target_alias + '" :-(')
  761. return False
  762. else:
  763. print("ping to '{0}' broken: result='{1}'".format(target_name, result))
  764. if not bot is None: bot.say('Uh oh, irgendwas ist kaputt. Chef, ping result = ' + str(result) + ' - darf ich das essen?')
  765. return None
  766. @willie.module.interval(3*60)
  767. def ffpb_monitor_ping(bot):
  768. """Ping each node currently under surveillance."""
  769. # determine where-to to send alerts
  770. notify_target = bot.config.core.owner
  771. if (not bot.config.ffpb.msg_target is None):
  772. notify_target = bot.config.ffpb.msg_target
  773. # check each node under surveillance
  774. for node in monitored_nodes:
  775. mon = monitored_nodes[node]
  776. added = mon['added']
  777. last_status = mon['status']
  778. last_check = mon['last_check']
  779. last_success = mon['last_success']
  780. current_status = ffpb_ping(bot=None, target_name=node)
  781. if current_status is None: current_status = False
  782. mon['status'] = current_status
  783. mon['last_check'] = time.time()
  784. if current_status == True: mon['last_success'] = time.time()
  785. print("Monitoring ({0}) {1} (last: {2} at {3})".format(node, current_status, last_status, time.strftime('%Y-%m-%d %H:%M', time.localtime(last_check))))
  786. if last_status != current_status and (last_status or current_status):
  787. if last_check is None:
  788. # erster Check, keine Ausgabe
  789. continue
  790. if current_status == True:
  791. bot.msg(notify_target, 'Monitoring: Knoten \'{0}\' pingt wieder (zuletzt {1})'.format(node, pretty_date(last_success) if not last_success is None else '[nie]'))
  792. else:
  793. bot.msg(notify_target, 'Monitoring: Knoten \'{0}\' DOWN'.format(node))
  794. @willie.module.commands('monitor')
  795. def ffpb_monitor(bot, trigger):
  796. """Monitoring capability of the bot, try subcommands add, del, info and list."""
  797. # command is restricted to bot admins
  798. if not trigger.admin:
  799. bot.say('Ich ping hier nicht für jeden durch die Weltgeschichte.')
  800. return
  801. # ensure the user gave arguments (group 2 is the concatenation of all following groups)
  802. if trigger.group(2) is None or len(trigger.group(2)) == 0:
  803. bot.say('Das Monitoring sagt du hast doofe Ohren.')
  804. return
  805. # read additional arguments
  806. cmd = trigger.group(3)
  807. node = trigger.group(4)
  808. if not node is None: node = str(node)
  809. # subcommand 'add': add a node to monitoring
  810. if cmd == "add":
  811. if node in monitored_nodes:
  812. bot.say('Knoten \'{0}\' wird bereits gemonitored.'.format(node))
  813. return
  814. monitored_nodes[node] = {
  815. 'added': trigger.sender,
  816. 'status': None,
  817. 'last_check': None,
  818. 'last_success': None,
  819. }
  820. bot.say('Knoten \'{0}\' wird jetzt ganz genau beobachtet.'.format(node))
  821. return
  822. # subcommand 'del': remote a node from monitoring
  823. if cmd == "del":
  824. if not node in monitored_nodes:
  825. bot.say('Knoten \'{0}\' war gar nicht im Monitoring?!?'.format(node))
  826. return
  827. del monitored_nodes[node]
  828. bot.say('Okidoki, \'{0}\' lasse ich jetzt links liegen.'.format(node))
  829. return
  830. # subcommand 'info': monitoring status of a node
  831. if cmd == "info":
  832. if node in monitored_nodes:
  833. info = monitored_nodes[node]
  834. bot.say('Knoten \'{0}\' wurde zuletzt {1} gepingt (Ergebnis: {2})'.format(node, pretty_date(info['last_check']) if not info['last_check'] is None else "^W noch nie", info['status']))
  835. else:
  836. bot.say('Knoten \'{0}\' ist nicht im Monitoring.'.format(node))
  837. return
  838. # subcommand 'list': enumerate all monitored nodes
  839. if cmd == "list":
  840. nodes = ""
  841. for node in monitored_nodes:
  842. nodes = nodes + " " + node
  843. bot.say('Monitoring aktiv für:' + nodes)
  844. return
  845. # subcommand 'help': give some hints what the user can do
  846. if cmd == "help":
  847. bot.say('Entweder "!monitor list" oder "!monitor {add|del|info} <node>"')
  848. return
  849. # no valid subcommand given: complain
  850. bot.say('Mit "' + str(cmd) + '" kann ich nix anfangen, probier doch mal "!monitor help".')
  851. @willie.module.commands('providers')
  852. def ffpb_providers(bot, trigger):
  853. """Fetch the top 5 providers from BATCAVE."""
  854. providers = json.load(urllib2.urlopen('http://[fdca:ffee:ff12:a255::253]:8888/providers?format=json'))
  855. providers.sort(key=lambda x: x['count'], reverse=True)
  856. bot.say('Unsere Top 5 Provider: ' + ', '.join(['{0} ({1:.0f}%)'.format(x['name'], x['percentage']) for x in providers[:5]]))
  857. @willie.module.commands('mesh')
  858. def ffpb_nodemesh(bot, trigger):
  859. """Display mesh partners of the given node."""
  860. # identify node or bail out
  861. target_name = trigger.group(2)
  862. node = ffpb_findnode_from_botparam(bot, target_name, ensure_recent_alfreddata=False)
  863. if node is None: return None
  864. # derive node's id
  865. nodeid = node['node_id'] if 'node_id' in node else None
  866. if nodeid is None:
  867. msg = 'Mist, ich habe gerade den Zettel verlegt auf dem die Node-ID von \'{0}\' steht, bitte frag später noch einmal.'
  868. bot.say(msg.format(node['hostname'] if 'hostname' in node else target_name))
  869. return
  870. # query BATCAVE for node's neighbours (result is a list of MAC addresses)
  871. cave_result = ffpb_get_batcave_nodefield(nodeid, 'neighbours')
  872. # query BATCAVE for neighbour's names
  873. d = '&'.join([ str(n) for n in cave_result ])
  874. req = urllib2.urlopen('http://[fdca:ffee:ff12:a255::253]:8888/idmac2name', d)
  875. # filter out duplicate names
  876. neighbours = set()
  877. for n in req:
  878. ident,name = n.strip().split('=')
  879. neighbours.add(name)
  880. neighbours = [ x for x in neighbours ]
  881. # respond to the user
  882. if len(neighbours) == 0:
  883. bot.say(u'{0} hat keinen Mesh-Partner *schnüff*'.format(node['hostname']))
  884. elif len(neighbours) == 1:
  885. bot.say(u'{0} mesht mit \'{1}\''.format(node['hostname'], neighbours[0]))
  886. else:
  887. bot.say('{0} mesht mit \'{1}\' und \'{2}\''.format(node['hostname'], '\', \''.join(neighbours[:-1]), neighbours[-1]))
  888. @willie.module.commands('exec-on-peer')
  889. def ffpb_remoteexec(bot, trigger):
  890. """Remote execution on the given node"""
  891. bot_params = trigger.group(2).split(' ',1)
  892. if len(bot_params) != 2:
  893. bot.say('Wenn du nicht sagst wo mach ich remote execution bei dir!')
  894. bot.say('Tipp: !exec-on-peer <peer> <cmd>')
  895. return
  896. target_name = bot_params[0]
  897. target_cmd = bot_params[1]
  898. # identify requested node or bail out
  899. node = ffpb_findnode_from_botparam(bot, target_name, ensure_recent_alfreddata=False)
  900. if node is None: return
  901. # check ACL
  902. if not playitsafe(bot, trigger, via_channel=True, node=node):
  903. return
  904. # use the node's first non-linklocal address
  905. target = [x for x in node["network"]["addresses"] if not x.lower().startswith("fe80:")][0]
  906. target_alias = node["hostname"]
  907. # assemble SSH command
  908. cmd = 'ssh -6 -l root ' + target + ' -- "' + target_cmd + '"'
  909. print("REMOTE EXEC = " + cmd)
  910. try:
  911. # call SSH
  912. result = subprocess.check_output(['ssh', '-6n', '-l', 'root', '-o', 'BatchMode=yes', '-o','StrictHostKeyChecking=no', target, target_cmd], stderr=subprocess.STDOUT, shell=False)
  913. # fetch results and sent at most 8 of them as response
  914. lines = str(result).splitlines()
  915. if len(lines) == 0:
  916. bot.say('exec-on-peer(' + target_alias + '): No output')
  917. return
  918. msg = 'exec-on-peer(' + target_alias + '): ' + str(len(lines)) + ' Zeilen'
  919. if len(lines) > 8:
  920. msg += ' (zeige max. 8)'
  921. bot.say(msg + ':')
  922. for line in lines[0:8]:
  923. bot.say(line)
  924. except subprocess.CalledProcessError as e:
  925. bot.say('Fehler '+str(e.returncode)+' bei exec-on-peer('+target_alias+'): ' + e.output)