ffpb.py 28 KB

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