ffpb.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963
  1. # -*- coding: utf-8 -*-
  2. from __future__ import print_function
  3. import willie
  4. from datetime import datetime, timedelta
  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 sys
  18. import time
  19. import dns.resolver, dns.reversename
  20. import socket
  21. import SocketServer
  22. import threading
  23. # ensure our directory is on path (in order to load batcave module)
  24. __my_dir = os.path.dirname(__file__)
  25. if __my_dir not in sys.path:
  26. sys.path.append(__my_dir)
  27. from batcave import BatcaveClient
  28. msgserver = None
  29. peers_repo = None
  30. nodeaccess = None
  31. __batcave = None
  32. ffpb_resolver = dns.resolver.Resolver()
  33. ffpb_resolver.nameservers = ['10.132.251.53']
  34. msg_cache = {}
  35. msg_cache_time = 10
  36. class MsgHandler(SocketServer.BaseRequestHandler):
  37. """Reads line from TCP stream and forwards it to configured IRC channels."""
  38. def handle(self):
  39. msg = str (self.request.recv(2048).strip())
  40. sender = self.resolve_name(self.client_address[0])
  41. bot = self.server.bot
  42. if bot is None:
  43. print("ERROR: No bot in handle() :-(")
  44. return
  45. target = bot.config.core.owner
  46. if bot.config.has_section('ffpb'):
  47. is_public = msg.lstrip().startswith("PUBLIC:")
  48. if is_public and not bot.config.ffpb.msg_target_public is None:
  49. msg = msg[7:].lstrip()
  50. target = bot.config.ffpb.msg_target_public
  51. elif not bot.config.ffpb.msg_target is None:
  52. target = bot.config.ffpb.msg_target
  53. # Try to aggregate peer updates and print them in batch
  54. if msg.startswith ("Peers updated:"):
  55. if msg not in msg_cache:
  56. msg_cache[msg] = {
  57. 'time' : time.time (),
  58. 'nodes' : []
  59. }
  60. # Save message in message cache, will be delivered later
  61. msg_cache[msg]['nodes'].append (sender)
  62. return
  63. # Aggregate identical salt changes reported from different hosts
  64. match = re.search ("^(\S+): (.*)$", msg)
  65. if sender == "salt" and match:
  66. salt_msg = match.group (2)
  67. if salt_msg not in msg_cache:
  68. msg_cache[salt_msg] = {
  69. 'time' : time.time (),
  70. 'nodes' : []
  71. }
  72. msg_cache[salt_msg]['nodes'].append (match.group (1))
  73. return
  74. bot.msg(target, "[{0}] {1}".format(sender, msg))
  75. def resolve_name(self, ipaddr):
  76. """
  77. Resolves the host name of the given IP address
  78. and strips away the suffix ((.srv)?.in)?.ffho.net.
  79. """
  80. if ipaddr.startswith("127."):
  81. return "localhost"
  82. try:
  83. addr = dns.reversename.from_address(ipaddr)
  84. ptr = str(ffpb_resolver.query(addr, "PTR")[0])
  85. ptr = re.sub("^bb.", "", ptr)
  86. ptr = re.sub("((.srv)?.in)?.ffho.net.", "", ptr)
  87. ptr = re.sub("((.srv)?.infra)?.ffpb.", "", ptr)
  88. return ptr
  89. except dns.resolver.NXDOMAIN:
  90. return ipaddr
  91. except Exception:
  92. return "E: " + ipaddr
  93. class ThreadingTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
  94. """Defines a threaded TCP socket server."""
  95. bot = None
  96. def __init__(self, endpoint, handler):
  97. if ':' in endpoint[0]:
  98. self.address_family = socket.AF_INET6
  99. SocketServer.TCPServer.__init__(self, endpoint, handler)
  100. def setup(bot):
  101. """Called by willie upon loading this plugin."""
  102. global __batcave, msgserver, peers_repo, nodeaccess
  103. # signal begin of setup routine
  104. bot.memory['ffpb_in_setup'] = True
  105. # load list of seen nodes from disk
  106. seen_nodes = shelve.open('nodes.seen', writeback=True)
  107. bot.memory['seen_nodes'] = seen_nodes
  108. # load list of node ACL from disk (used in playitsafe())
  109. nodeaccess = shelve.open('nodes.acl', writeback=True)
  110. # no need to configure anything else if the ffpb config section is missing
  111. if not bot.config.has_section('ffpb'):
  112. bot.memory['ffpb_in_setup'] = False
  113. return
  114. # open the git repository containing the peers files
  115. if not bot.config.ffpb.peers_directory is None:
  116. peers_repo = git.Repo(bot.config.ffpb.peers_directory)
  117. assert peers_repo.bare is False
  118. # if configured, start the messaging server
  119. if int(bot.config.ffpb.msg_enable) == 1:
  120. host = "localhost"
  121. port = 2342
  122. if not bot.config.ffpb.msg_host is None:
  123. host = bot.config.ffpb.msg_host
  124. if not bot.config.ffpb.msg_port is None:
  125. port = int(bot.config.ffpb.msg_port)
  126. msgserver = ThreadingTCPServer((host, port), MsgHandler)
  127. msgserver.bot = bot
  128. print("Messaging server listening on {}:{}".format(host, port))
  129. msgserver_thread = threading.Thread(target=msgserver.serve_forever)
  130. msgserver_thread.daemon = True
  131. msgserver_thread.start()
  132. # initialize BATCAVE
  133. if bot.config.ffpb.batcave_url is None:
  134. raise Exception("You did not specify [ffpb].batcave_url ...")
  135. else:
  136. print('using BATCAVE at ' + bot.config.ffpb.batcave_url)
  137. __batcave = BatcaveClient(bot.config.ffpb.batcave_url)
  138. # signal end of setup routine
  139. bot.memory['ffpb_in_setup'] = False
  140. def shutdown(bot):
  141. global msgserver, nodeaccess
  142. # Print cached message before shutdown, if any
  143. ffpb_print_cached_messages (bot)
  144. # store node acl
  145. if not nodeaccess is None:
  146. nodeaccess.sync()
  147. nodeaccess.close()
  148. nodeaccess = None
  149. # store seen nodes
  150. if 'seen_nodes' in bot.memory and bot.memory['seen_nodes'] != None:
  151. bot.memory['seen_nodes'].close()
  152. bot.memory['seen_nodes'] = None
  153. del bot.memory['seen_nodes']
  154. # shutdown messaging server
  155. if not msgserver is None:
  156. msgserver.shutdown()
  157. print("Closed messaging server.")
  158. msgserver = None
  159. @willie.module.commands("help")
  160. @willie.module.commands("hilfe")
  161. @willie.module.commands("man")
  162. def ffpb_help(bot, trigger):
  163. """Display commony ulsed functions."""
  164. functions = {
  165. "!ping <knoten>": "Prüfe ob der Knoten erreichbar ist.",
  166. "!status": "Aktuellen Status des Netzwerks (insb. Anzahl Knoten und Clients) ausgegeben.",
  167. "!highscore": "Höchstwerte von Anzahl Knoten und Clients",
  168. "!info <knoten>": "Allgemeine Information zu dem Knoten anzeigen.",
  169. "!link <knoten>": "MAC-Adresse und Link zur Status-Seite des Knotens anzeigen.",
  170. "!mesh <knoten>": "Zeige Mesh-Partner eines Knotens",
  171. "!lastseen <knoten>": "Zeitpunkt an dem der Knoten zuletzt gesehen wurde",
  172. }
  173. param = trigger.group(2)
  174. if param is None:
  175. bot.say("Funktionen: " + str.join(", ", sorted(functions.keys())))
  176. return
  177. if param.startswith("!"):
  178. param = param[1:]
  179. for fun in functions.keys():
  180. if fun.startswith("!" + param + " "):
  181. bot.say("Hilfe zu '" + fun + "': " + functions[fun])
  182. return
  183. bot.say("Allgemeine Hilfe gibt's mit !help - ohne Parameter.")
  184. def playitsafe(bot, trigger,
  185. botadmin=False, admin_channel=False,
  186. via_channel=False, via_privmsg=False, need_op=False,
  187. node=None, reply_directly=True,
  188. debug_user=None, debug_ignorebotadmin=False):
  189. """
  190. helper: checks that the triggering user has the necessary rights
  191. Returns true if everything is okay.
  192. If it's not, a reply is send via the bot and false is returned.
  193. """
  194. if via_channel and via_privmsg:
  195. raise Exception('Der Entwickler ist ein dummer, dummer Junge. ' +
  196. '(playitsafe: via_channel && via_privmsg).')
  197. user = trigger.nick if debug_user is None else debug_user
  198. user = user.lower()
  199. # botadmin: you need to be configured as a bot admin
  200. if botadmin and not trigger.admin:
  201. if reply_directly:
  202. bot.say('Du brauchst Super-Kuh-Kräfte um dieses Kommando auszuführen.')
  203. return False
  204. # via_channel: the request must not be a private conversation
  205. if via_channel and trigger.is_privmsg:
  206. if reply_directly:
  207. bot.say('Bitte per Channel - mehr Transparenz wagen und so!')
  208. return False
  209. # via_privmsg: the request must be a private conversation
  210. if via_privmsg and not trigger.is_privmsg:
  211. if reply_directly:
  212. bot.say('Solche Informationen gibt es nur per PM, ' +
  213. 'da bin ich ja schon ein klein wenig sensibel ...')
  214. return False
  215. # need_op: if the message is in a channel, check that the user has OP there
  216. if need_op and (not trigger.is_privmsg) and \
  217. (not user in bot.ops[trigger.sender]):
  218. if reply_directly:
  219. bot.say('Keine Zimtschnecke, keine Kekse.')
  220. return False
  221. # node: check that the user is whitelisted (or is admin)
  222. if not node is None and (debug_ignorebotadmin or not trigger.admin):
  223. acluser = [x for x in nodeaccess if x.lower() == user]
  224. acluser = acluser[0] if len(acluser) == 1 else None
  225. if nodeaccess is None or acluser is None:
  226. if reply_directly:
  227. bot.reply('You! Shall! Not! Access!')
  228. return False
  229. nodeid = node['node_id'] if 'node_id' in node else None
  230. matched = False
  231. for x in nodeaccess[acluser]:
  232. if x == nodeid or fnmatch(node['hostname'], x):
  233. matched = True
  234. break
  235. if not matched:
  236. if reply_directly:
  237. bot.reply('Mach das doch bitte auf deinen Knoten, kthxbye.')
  238. return False
  239. return True
  240. @willie.module.commands('nodeacl')
  241. def ffpb_nodeacl(bot, trigger):
  242. """Configure ACL for nodes."""
  243. if not playitsafe(bot, trigger, botadmin=True):
  244. # the check function already gives a bot reply, just exit here
  245. return
  246. # ensure the user gave arguments
  247. if trigger.group(2) is None or len(trigger.group(2)) == 0:
  248. bot.say('Sag doch was du willst ... einmal mit Profis arbeiten, ey -.-')
  249. return
  250. # read additional arguments
  251. cmd = trigger.group(3).lower()
  252. if cmd == 'list':
  253. user = trigger.group(4)
  254. if user is None:
  255. usernames = [x for x in nodeaccess]
  256. bot.say('ACLs gesetzt für die User: ' + ', '.join(usernames))
  257. return
  258. user = user.lower()
  259. uid = [x for x in nodeaccess if x.lower() == user]
  260. if len(uid) == 0:
  261. bot.say('Für \'{0}\' ist keine Node ACL gesetzt.'.format(user))
  262. return
  263. bot.say('Node ACL für \'{0}\' = \'{1}\''.format(
  264. uid[0],
  265. '\', \''.join(nodeaccess[uid[0]]))
  266. )
  267. return
  268. if cmd in ['add', 'del', 'check']:
  269. user = trigger.group(4)
  270. value = trigger.group(5)
  271. if user is None or value is None:
  272. bot.say('Du bist eine Pappnase - User und Knoten, bitte.')
  273. return
  274. user = str(user)
  275. print('NodeACL ' + cmd + ' \'' + value + '\' for user \'' + user + '\'')
  276. uid = [x for x in nodeaccess if x == user or x.lower() == user]
  277. if cmd == 'add':
  278. uid = uid[0] if len(uid) > 0 else user
  279. if not uid in nodeaccess:
  280. nodeaccess[uid] = []
  281. if not value in nodeaccess[uid]:
  282. nodeaccess[uid].append(value)
  283. bot.say("201 nodeACL '{0}' +'{1}'".format(uid, value))
  284. else:
  285. bot.say("304 nodeACL '{0}' contains '{1}'".format(uid, value))
  286. elif cmd == 'del':
  287. if len(uid) == 0:
  288. bot.say("404 nodeACL '{0}'".format(uid))
  289. return
  290. if value in nodeaccess[uid]:
  291. nodeaccess[uid].remove(value)
  292. bot.say("200 nodeACL '{0}' -'{1}'".format(uid, value))
  293. else:
  294. bot.say("404 nodeACL '{0}' has no '{1}'".format(uid, value))
  295. elif cmd == 'check':
  296. if len(uid) == 0:
  297. bot.say('Nope, keine ACL gesetzt.')
  298. return
  299. node = ffpb_findnode(value)
  300. if node is None:
  301. bot.say('Nope, kein Plan was für ein Knoten das ist.')
  302. return
  303. result = playitsafe(bot, trigger,
  304. debug_user=uid[0], debug_ignorebotadmin=True,
  305. node=node, reply_directly=False)
  306. if result is True:
  307. bot.say('Jupp.')
  308. elif result is False:
  309. bot.say('Nope.')
  310. else:
  311. bot.say('Huh? result=' + str(result))
  312. return
  313. bot.say('Unbekanntes Kommando. Probier ' +
  314. '"list [user]", "add user value" oder "del user value". ' +
  315. 'Value kann node_id oder hostname-Maske sein.')
  316. def ffpb_ensurenodeid(nodedata):
  317. """Makes sure that the given dict has a 'node_id' field."""
  318. if 'node_id' in nodedata:
  319. return nodedata
  320. # derive node's id
  321. nodeid = None
  322. if 'network' in nodedata and 'mac' in nodedata['network']:
  323. nodeid = nodedata['network']['mac'].replace(':', '')
  324. # assemble extended data
  325. result = {'node_id': nodeid}
  326. for key in nodedata:
  327. result[key] = nodedata[key]
  328. return result
  329. def ffpb_findnode(name, allow_fuzzymatching=True):
  330. """helper: try to identify the node the user meant by the given name"""
  331. # no name, no node
  332. if name is None or len(name) == 0:
  333. return None
  334. name = str(name).strip()
  335. # disable fuzzy matching if name is enclosed in quotes
  336. if name.startswith('\'') and name.endswith('\'') or \
  337. name.startswith('"') and name.endswith('"'):
  338. name = name[1:-1]
  339. allow_fuzzymatching = False
  340. # try to match MAC
  341. m = re.search("^([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]$", name)
  342. if not m is None:
  343. mac = m.group(0).lower()
  344. node = __batcave.find_node_by_mac(mac)
  345. if node is not None:
  346. return __batcave.get_node(node['id'])
  347. else:
  348. nodeid = mac.replace(':', '').lower()
  349. return {
  350. 'node_id': nodeid,
  351. 'hostname': '?-' + nodeid,
  352. 'mac': mac,
  353. 'hardware': 'derived-from-mac',
  354. }
  355. # try to find by NAME
  356. node = __batcave.find_node_by_name(name, fuzzymatch=allow_fuzzymatching)
  357. if node is not None:
  358. return __batcave.get_node(node['id'])
  359. # not found in ALFRED data -> try peers_repo
  360. if not peers_repo is None:
  361. peer_name = None
  362. peer_mac = None
  363. peer_file = None
  364. for b in peers_repo.heads.master.commit.tree.blobs:
  365. if b.name.lower() == name.lower():
  366. peer_name = b.name
  367. peer_file = b.abspath
  368. break
  369. if (not peer_file is None) and os.path.exists(peer_file):
  370. peerfile = open(peer_file, "r")
  371. for line in peerfile:
  372. if line.startswith("# MAC:"):
  373. peer_mac = line[6:].strip()
  374. peerfile.close()
  375. if not peer_mac is None:
  376. return {
  377. 'node_id': peer_mac.replace(':', ''),
  378. 'hostname': peer_name,
  379. 'mac': peer_mac,
  380. 'hardware': 'derived-from-vpnkeys',
  381. }
  382. # none of the above was able to identify the requested node
  383. return None
  384. def ffpb_findnode_from_botparam(bot, name, ensure_recent_alfreddata=True):
  385. """helper: call ffpb_findnode() and give common answers via bot on error"""
  386. if name is None or len(name) == 0:
  387. if not bot is None:
  388. bot.reply("Grün.")
  389. return None
  390. node = ffpb_findnode(name)
  391. if node is None:
  392. if not bot is None:
  393. bot.say("Kein Plan wer oder was mit '" + name + "' gemeint ist :(")
  394. return node
  395. def mac2ipv6(mac, prefix=None):
  396. """Calculate IPv6 address from given MAC,
  397. optionally replacing the fe80:: prefix with a given one."""
  398. result = str(netaddr.EUI(mac).ipv6_link_local())
  399. if (not prefix is None) and (result.startswith("fe80::")):
  400. result = prefix + result[6:]
  401. return result
  402. def ffpb_notify_newly_seen_nodes(bot, new):
  403. if not isinstance(bot, dict):
  404. return
  405. if len(new) == 0 or bot.memory['ffpb_in_setup']:
  406. return
  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. # try to fetch location from BATCAVE in order to add a geomap URL
  420. location = __batcave.get_nodefield(str.replace(new[0][0], ':', ''), 'location')
  421. if not location is None:
  422. action_msg += ' http://map.paderborn.freifunk.net/geomap.html?lat=' + location['latitude'] + '&lon=' + location['longitude']
  423. else:
  424. action_msg = random.choice((
  425. 'bemerkt die neuen Knoten {0} und {1}',
  426. 'hat {0} und {1} entdeckt',
  427. 'bewundert {0} sowie {1}',
  428. 'freut sich, dass {0} und {1} nun auch online sind',
  429. u'heißt {0} und {1} im Mesh willkommen',
  430. 'fragt sich ob die noch jungen Herzen von {0} und {1} synchron schlagen',
  431. ))
  432. all_but_last = [str(x[1]) for x in new[0:-1]]
  433. last = str(new[-1][1])
  434. action_msg = action_msg.format(
  435. '\'' + '\', \''.join(all_but_last) + '\'',
  436. '\'' + last + '\''
  437. )
  438. action_target = bot.config.ffpb.msg_target
  439. if not bot.config.ffpb.msg_target_public is None:
  440. action_target = bot.config.ffpb.msg_target_public
  441. bot.msg(action_target, '\x01ACTION %s\x01' % action_msg)
  442. @willie.module.interval(60)
  443. def ffpb_updatepeers(bot):
  444. """Refresh list of peers and message the diff."""
  445. if peers_repo is None:
  446. print('WARNING: peers_repo is None')
  447. return
  448. old_head = peers_repo.head.commit
  449. peers_repo.remotes.origin.pull()
  450. new_head = peers_repo.head.commit
  451. if new_head != old_head:
  452. print('git pull: from ' + str(old_head) + ' to ' + str(new_head))
  453. added = []
  454. changed = []
  455. renamed = []
  456. deleted = []
  457. for f in old_head.diff(new_head):
  458. if f.new_file:
  459. added.append(f.b_blob.name)
  460. elif f.deleted_file:
  461. deleted.append(f.a_blob.name)
  462. elif f.renamed:
  463. renamed.append([f.rename_from, f.rename_to])
  464. else:
  465. changed.append(f.a_blob.name)
  466. response = "Knoten-Update (VPN +{0} %{1} -{2}): ".format(
  467. len(added), len(renamed)+len(changed), len(deleted))
  468. for f in added:
  469. response += " +'{}'".format(f)
  470. for f in changed:
  471. response += " %'{}'".format(f)
  472. for f in renamed:
  473. response += " '{}'->'{}'".format(f[0], f[1])
  474. for f in deleted:
  475. response += " -'{}'".format(f)
  476. bot.msg(bot.config.ffpb.msg_target, response)
  477. @willie.module.interval(15)
  478. def ffpb_print_cached_messages (bot):
  479. for msg, m_info in msg_cache.items ():
  480. if time.time () - m_info['time'] > msg_cache_time:
  481. sender = _ffpb_aggregate_node_ids (bot, m_info['nodes'])
  482. bot.msg (bot.config.ffpb.msg_target, "[{0}] {1}".format (sender, msg))
  483. del msg_cache[msg]
  484. def _ffpb_aggregate_node_ids (bot, nodes):
  485. """Aggregate given list of node IDs as far as possbile (read: combine gw*, lnx*, ...)
  486. and return aggregated and ordered list."""
  487. aggr_patterns = ('gw', 'lnx')
  488. nodes_temp = {}
  489. nodes_aggr = []
  490. for node in nodes:
  491. match = re.search ("^([a-z-]+(\d+))(\.([a-z]+)\.?)?.*", node, re.I)
  492. if match:
  493. site = match.group (4) if match.group (4) else None
  494. expr = match.group (1)
  495. for pattern in aggr_patterns:
  496. expr = re.sub ("^%s(\d+)" % pattern, "%s##" % pattern, expr)
  497. if site not in nodes_temp:
  498. nodes_temp[site] = {}
  499. if expr not in nodes_temp[site]:
  500. nodes_temp[site][expr] = []
  501. nodes_temp[site][expr].append (match.group (2))
  502. continue
  503. nodes_aggr.append (node)
  504. for site, hosts in nodes_temp.items ():
  505. for node, n_list in hosts.items ():
  506. if len (n_list) == 1:
  507. node = re.sub ("##", "%s" % n_list[0], node)
  508. else:
  509. node = re.sub ("##", "{%s}" % ",".join (sorted (n_list)), node)
  510. if site:
  511. node += "." + site
  512. nodes_aggr.append (node)
  513. return ", ".join (sorted (nodes_aggr))
  514. def ffpb_fetch_stats(bot, url, memoryid):
  515. """Fetch a ffmap-style nodes.json from the given URL and
  516. store it in the bot's memory."""
  517. response = urllib2.urlopen(url)
  518. data = json.load(response)
  519. nodes_active = 0
  520. nodes_total = 0
  521. clients_count = 0
  522. for key,node in data['nodes'].iteritems():
  523. if node['flags']['gateway'] or (node['flags'].has_key('client') and node['flags']['client']):
  524. continue
  525. nodes_total += 1
  526. if node['flags']['online']:
  527. nodes_active += 1
  528. if 'legacy' in node['flags'] and node['flags']['legacy']:
  529. clients_count -= 1
  530. clients_count += node['statistics']['clients']
  531. if not memoryid in bot.memory:
  532. bot.memory[memoryid] = {}
  533. stats = bot.memory[memoryid]
  534. stats["fetchtime"] = time.time()
  535. stats["nodes_active"] = nodes_active
  536. stats["nodes_total"] = nodes_total
  537. stats["clients"] = clients_count
  538. return (nodes_active, nodes_total, clients_count)
  539. def pretty_date(timestamp=False):
  540. """
  541. Get a datetime object or a int() Epoch timestamp and return a
  542. pretty string like 'an hour ago', 'Yesterday', '3 months ago',
  543. 'just now', etc
  544. """
  545. now = datetime.now()
  546. compare = None
  547. if type(timestamp) is int:
  548. compare = datetime.fromtimestamp(timestamp)
  549. elif type(timestamp) is float:
  550. compare = datetime.fromtimestamp(int(timestamp))
  551. elif isinstance(timestamp, datetime):
  552. compare = timestamp
  553. elif not timestamp:
  554. compare = now
  555. diff = now - compare
  556. second_diff = diff.seconds
  557. day_diff = diff.days
  558. if day_diff < 0:
  559. return ''
  560. if day_diff == 0:
  561. if second_diff < 10:
  562. return "gerade eben"
  563. if second_diff < 60:
  564. return "vor " + str(second_diff) + " Sekunden"
  565. if second_diff < 120:
  566. return "vor einer Minute"
  567. if second_diff < 3600:
  568. return "vor " + str(second_diff / 60) + " Minuten"
  569. if second_diff < 7200:
  570. return "vor einer Stunde"
  571. if second_diff < 86400:
  572. return "vor " + str(second_diff / 3600) + " Stunden"
  573. if day_diff == 1:
  574. return "gestern"
  575. if day_diff < 7:
  576. return "vor " + str(day_diff) + " Tagen"
  577. return "am " + compare.strftime('%d.%m.%Y um %H:%M Uhr')
  578. @willie.module.commands('ping')
  579. def ffpb_ping(bot, trigger=None, target_name=None, reply_directly=True):
  580. """Ping the given node"""
  581. # identify node or bail out
  582. if target_name is None:
  583. target_name = trigger.group(2)
  584. node = ffpb_findnode_from_botparam(bot, target_name,
  585. ensure_recent_alfreddata=False)
  586. if node is None:
  587. return None
  588. # derive node address from MAC
  589. node_mac = node.get('mac')
  590. target = mac2ipv6(node_mac, 'fdca:ffee:ff12:132:')
  591. target_alias = node["hostname"]
  592. # execute the actual ping and reply the result
  593. print("pinging '{0}' at {1} ...".format(target_name, target))
  594. result = os.system('ping6 -c 2 -W 1 ' + target + ' >/dev/null')
  595. if result == 0:
  596. print("ping to '{0}' succeeded".format(target_name))
  597. if reply_directly:
  598. bot.say('Knoten "' + target_alias + '" antwortet \\o/')
  599. return True
  600. elif result == 1 or result == 256:
  601. print("ping to '{0}' failed".format(target_name))
  602. if reply_directly:
  603. bot.say('Keine Antwort von "' + target_alias + '" :-(')
  604. return False
  605. else:
  606. print("ping to '{0}' broken: result='{1}'".format(target_name, result))
  607. if reply_directly:
  608. bot.say('Uh oh, irgendwas ist kaputt. Chef, ping result = ' + str(result) + ' - darf ich das essen?')
  609. return None
  610. @willie.module.commands('mesh')
  611. def ffpb_nodemesh(bot, trigger):
  612. """Display mesh partners of the given node."""
  613. # identify node or bail out
  614. target_name = trigger.group(2)
  615. node = ffpb_findnode_from_botparam(bot, target_name,
  616. ensure_recent_alfreddata=False)
  617. if node is None:
  618. return None
  619. # derive node's id
  620. nodeid = node['node_id'] if 'node_id' in node else None
  621. if nodeid is None:
  622. msg = 'Mist, ich habe gerade den Zettel verlegt auf dem die Node-ID' + \
  623. ' von \'{0}\' steht, bitte frag später noch einmal.'
  624. bot.say(msg.format(node.get('hostname', target_name)))
  625. return
  626. # query BATCAVE for node's neighbours (result is a list of MAC addresses)
  627. cave_result = node.get('neighbours')
  628. if cave_result is None:
  629. msg = 'Hm, scheinbar liegen zu \'{0}\' keine Daten vor. ' + \
  630. 'Klingt komisch, ist aber so.'
  631. bot.say(msg.format(node.get('hostname', target_name)))
  632. return
  633. # query BATCAVE for neighbour's names
  634. data = '&'.join([str(n) for n in cave_result])
  635. req = urllib2.urlopen(bot.config.ffpb.batcave_url + 'idmac2name', data)
  636. # filter out duplicate names
  637. neighbours = set()
  638. gateways = set()
  639. for line in req:
  640. ident, name = line.strip().split('=')
  641. if ident == name and ident.startswith('c0:ff:ee:ba:be:'):
  642. gateways.add('Gateway ' + ident[len('c0:ff:ee:ba:be:'):])
  643. else:
  644. neighbours.add(name)
  645. neighbours = [x for x in neighbours]
  646. gateways = sorted([x for x in gateways])
  647. # respond to the user
  648. reply = node['hostname']
  649. if len(neighbours) == 0:
  650. reply += ' hat keinen Mesh-Partner'
  651. elif len(neighbours) == 1:
  652. reply += u' mesht mit \'{0}\''.format(neighbours[0])
  653. else:
  654. all_except_last = '\', \''.join(neighbours[:-1])
  655. last = neighbours[-1]
  656. reply += ' mesht mit \'{0}\' und \'{1}\''.format(all_except_last, last)
  657. if len(gateways) > 0:
  658. if len(neighbours) == 0:
  659. reply += ', aber hat eine Verbindung zu '
  660. else:
  661. reply += ' und hat zudem eine Verbindung zu '
  662. if len(gateways) == 1:
  663. reply += gateways[0]
  664. else:
  665. all_except_last = '\', \''.join(gateways[:-1])
  666. last = gateways[-1]
  667. reply += '{0} und {1}'.format(all_except_last, last)
  668. bot.say(reply)
  669. @willie.module.commands('exec-on-peer')
  670. def ffpb_remoteexec(bot, trigger):
  671. """Remote execution on the given node"""
  672. bot_params = trigger.group(2).split(' ', 1) if trigger.group(2) is not None else []
  673. if len(bot_params) != 2:
  674. bot.say('Wenn du nicht sagst wo mach ich remote execution bei dir!')
  675. bot.say('Tipp: !exec-on-peer <peer> <cmd>')
  676. return
  677. target_name = bot_params[0]
  678. target_cmd = bot_params[1]
  679. # identify requested node or bail out
  680. node = ffpb_findnode_from_botparam(bot, target_name,
  681. ensure_recent_alfreddata=False)
  682. if node is None:
  683. return
  684. # check ACL
  685. if not playitsafe(bot, trigger, via_channel=True, node=node):
  686. return
  687. # derive target from node's MAC
  688. node_mac = node.get('mac')
  689. target = mac2ipv6(node_mac, 'fdca:ffee:ff12:132:')
  690. target_alias = node["hostname"]
  691. # assemble SSH command
  692. cmd = [
  693. 'ssh',
  694. '-6n',
  695. '-l', 'root',
  696. '-o', 'BatchMode=yes',
  697. '-o', 'StrictHostKeyChecking=no',
  698. target,
  699. target_cmd,
  700. ]
  701. print("REMOTE EXEC = " + str(cmd))
  702. try:
  703. # call SSH
  704. result = subprocess.check_output(
  705. cmd,
  706. stderr=subprocess.STDOUT,
  707. shell=False,
  708. )
  709. # fetch results and sent at most 8 of them as response
  710. lines = str(result).splitlines()
  711. if len(lines) == 0:
  712. bot.say('exec-on-peer(' + target_alias + '): No output')
  713. return
  714. msg = 'exec-on-peer({0}): {1} Zeilen'.format(target_alias, len(lines))
  715. if len(lines) > 8:
  716. msg += ' (zeige max. 8)'
  717. bot.say(msg + ':')
  718. for line in lines[0:8]:
  719. bot.say(line)
  720. except subprocess.CalledProcessError as err:
  721. bot.say('Fehler {0} bei exec-on-peer({1}): {2}'.format(
  722. err.returncode,
  723. target_alias,
  724. err.output
  725. ))
  726. @willie.module.commands('forget-peer-sshhostkey')
  727. def ffpb_clearsshhostkey(bot, trigger):
  728. """Forget given node's SSH host key"""
  729. target_name = trigger.group(2) or ''
  730. if len(target_name.strip()) == 0:
  731. bot.say('Lasst uns einfach mal ' + trigger.nick + ' löschen!')
  732. bot.say('Tipp: !forget-peer-sshhostkey <peer>')
  733. return
  734. # identify requested node or bail out
  735. node = ffpb_findnode_from_botparam(bot, target_name,
  736. ensure_recent_alfreddata=False)
  737. if node is None:
  738. return
  739. # check ACL
  740. if not playitsafe(bot, trigger, via_channel=True, node=node):
  741. return
  742. # derive target from node's MAC
  743. node_mac = node.get('mac')
  744. target = mac2ipv6(node_mac, 'fdca:ffee:ff12:132:')
  745. target_alias = node["hostname"]
  746. # assemble command
  747. cmd = [
  748. 'ssh-keygen',
  749. '-f', '.ssh/known_hosts',
  750. '-R',
  751. target,
  752. ]
  753. try:
  754. # call command
  755. result = subprocess.check_output(
  756. cmd,
  757. stderr=subprocess.STDOUT,
  758. shell=False,
  759. )
  760. bot.reply('Ich mache den Weg frei ... also den zu "' + target + '" per SSH zumindest.')
  761. except subprocess.CalledProcessError as err:
  762. bot.say('Fehler {0} bei remove-sshhostkey-of-peer({1}): {2}'.format(
  763. err.returncode,
  764. target_alias,
  765. err.output
  766. ))