ffpb.py 32 KB

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