ffpi-tools/alfred-announce.py

427 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Anounce-Daemon für Gateways und Server
inspiriert von ffnord-alfred-announce
Bestandteil der Freifunk ffmap-Werkzeuge
Ein Meßwert wird in Anlehnung an Zabbix als Item bezeichnet.
Die Daten werden regelmäßig und häufig, z.B. einmal je Minute an Alfred
übertragen. Das Datensammeln muß aber nicht für jedes Item so häufig
geschehen.
Zu beachten ist, daß Alfred die Daten nach einer bestimmten Zeit
automatisch vergißt, es muß also rechtzeitig erneuert werden.
Es werden ermittelt:
- Nodeinfo
- Statistics
Hinweis(e):
- https://github.com/ffnord/ffnord-alfred-announce
- http://www.open-mesh.org/projects/alfred/wiki
- ifstat ansehen
- dstat ansehen (Python)
- Konfigurationsverzeichnis: /etc/alfred
/etc/alfred/statics.json
TODO
- Items aus Redis holen
- Ausgabe von "batctl if" auswerten
Änderungsprotokoll
==================
Version Datum Änderung(en) von
-------- ----------- ------------------------------------------------------ ----
0.1 2015-10-18 Änderungsprotokoll eingebaut tho
0.2 2016-08-30 Automatisierung Land des Exit-VPNs tho
0.3 2016-12-04 DHCPD-Leases integrieren zur Anzeige der Clientanz. tho
0.4 2017-02-10 Communityunabhängig durch sitecode als Einstellung tho
0.5 2022-10-05 Umbau auf python3 für Einsatz unter Debian 11 tho
0.6 2023-01-02 -m bei batctl deprecated: durch meshif ersetzt tho
"""
import os
import sys
import glob
import platform
import getopt
import signal
import daemon
from collections import defaultdict
from collections.abc import Mapping
import json
import subprocess
import socket
import zlib
import time
import logging
import re
import datetime
import functools
__author__ = "Thomas Hooge"
__copyright__ = "Public Domain"
__version__ = "0.6"
__email__ = "thomas@hoogi.de"
__status__ = "Development"
cfg = {
'logfile': '/var/log/alfred-announced.log',
'loglevel': 2,
'pidfile': '/var/run/alfred-announced.pid',
'daemon': False,
'user': '',
'group': 'zabbix',
'interface': 'bat0',
'sitecode': 'ffpi'
}
# Definition der auszulesenden Meßwerte
#
# TODO Zusätzliche Werte
# - Wie wird der Traffic ausgeleitet?
# - direkt über Netzwerkinterface
# - über VPN-Tunnel und ExitVPN
# - Status des VPN Tunnels (node.vpn.provider)
# - Anbieter: None, Mullvad, EarthVPN, oVPN.to, ...
# - Traffic
#
def call(cmdnargs):
output = subprocess.check_output(cmdnargs)
lines = [line.decode("utf-8") for line in output.splitlines()]
return lines
def fn_dummy():
return 'n/a'
def fn_node_hostname():
# Bei Gateways ist der "Hostname" der beschreibende Freitextname
# Dieser wird durch die statics.json überschrieben
return socket.gethostname()
def fn_node_vpn():
return True
def fn_node_net_mac():
return open('/sys/class/net/{0}/address'.format(cfg['interface'])).read().strip()
def fn_node_net_mesh_ifaces():
# TODO!
# Eigentlich:
# "network": { "mesh": { "bat0": { "interfaces": { "tunnel": [ ...
# Die Stelle mit "bat0" müßte dynamisch aufgrund der Interfaces
# zusammengebaut werden
return [open('/sys/class/net/' + iface + '/address').read().strip()
for iface in map(lambda line: line.split(':')[0], call(['batctl', 'meshif', cfg['interface'], 'if']))]
def fn_exitvpn_provider():
"""
Wir arbeiten mit der Standardkonfigurationsdatei von OpenVPN.
Dort ist immer unser aktuell verwendeter Exit-Tunnel eingetragen.
Wenn OpenVPN konfiguriert ist, aber kein Tunnel verwendet wird,
steht in der Konfiguration 'none'. Zur Unterscheidung wird im
Fehlerfall 'n/a' zurückgeliefert. Das kommt z.B. vor, wenn
gar kein OpenVPN installiert ist.
"""
try:
for line in open('/etc/default/openvpn'):
if line.startswith('AUTOSTART='):
k, v = line.split("=")
return v.strip('"\n')
except IOError:
pass
return 'n/a'
def fn_exitvpn_country():
"""
ISO 3166 Country Code
"""
provider = fn_exitvpn_provider()
if provider in ('none', 'n/a'):
return 'n/a'
for line in open('/etc/openvpn/' + provider.lower() + '.conf'):
if line.startswith('## ExitCountry = '):
k, v = line.split(" = ")
return v.strip('\n')
return '??'
def fn_batman_version():
return open('/sys/module/batman_adv/version').read().strip()
def fn_fastd_enabled():
"""
Prüfe, ob das init-script existiert und in rc.d aktiviert ist
- aktuellen Runlevel ermitteln
- suche einen passenden Link im RC-Verzeichnis
- prüfe, ob das Script tatsächlich existiert
"""
runlevel = int(call(['runlevel'])[0].split(' ')[1])
#fname = glob.glob("/etc/rc%d.d/S??fastd" % runlevel)
fname = glob.glob("/etc/rc{:02d}.d/S??fastd".format(runlevel))
if not fname:
return False
return os.path.isfile(fname[0])
def fn_fastd_version():
return call(['fastd', '-v'])[0].split(' ')[1]
def fn_fastd_port():
for line in open('/etc/fastd/{0}/fastd.conf'.format(cfg['sitecode'])):
if line.startswith('bind'):
return line.split(":")[1].rstrip(";\n")
def fn_firmware_base():
return call(['lsb_release','-is'])[0]
def fn_firmware_release():
if fn_firmware_base() == 'Debian':
return call(['cat','/etc/debian_version'])[0]
else:
return call(['lsb_release','-rs'])[0]
def fn_idletime():
return float(open('/proc/uptime').read().split(' ')[1])
def fn_loadavg():
return float(open('/proc/loadavg').read().split(' ')[0])
def fn_memory():
m = dict(
(key.replace('Mem', '').lower(), int(value.split(' ')[0]))
for key, value in map(lambda s: map(str.strip, s.split(': ', 1)), open('/proc/meminfo').readlines())
if key in ('MemTotal', 'MemFree', 'Buffers', 'Cached')
)
return m
def fn_processes():
return dict(zip(('running', 'total'), map(int, open('/proc/loadavg').read().split(' ')[3].split('/'))))
def fn_traffic():
# Ausgabe von ethtool auswerten für bat0
traffic = {'tx': {}, 'rx': {}, 'forward': {}, 'mgmt_tx': {} , 'mgmt_rx': {}}
for data in [line.strip() for line in call(['ethtool', '-S', 'bat0'])[1:]]:
key, value = data.split(':')
if key.split('_')[0] in ['tx', 'rx', 'mgmt', 'forward']:
if not (key.endswith('_bytes') or key.endswith('_dropped')):
key += '_packets'
ix1, ix2 = key.rsplit('_', 1)
traffic[ix1][ix2] = int(value)
return traffic
def fn_uptime():
return float(open('/proc/uptime').read().split(' ')[0])
def fn_hardware_model():
cpuinfo = call(['cat', '/proc/cpuinfo'])
for line in cpuinfo:
try:
key, value = line.split(':')
except:
continue
if key.strip() == "model name":
return ' '.join(value.split())
return ''
def fn_hardware_nproc():
return call(['nproc'])[0]
def fn_fastd_peers():
# TODO fastd-Konfiguration auslesen /etc/fastd/<sitecode>/fastd.conf
# 1. fastd über Socket abfragen
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
client.connect('/var/run/fastd/{0}.sock'.format(cfg['sitecode']))
except socket.error:
return None
data = json.loads(client.makefile('r').read())
client.close()
# 2. Gateways ermitteln (MACs)
# deprecated
#with open('/sys/kernel/debug/batman_adv/{0}/gateways'.format(cfg['interface'])) as f:
# lines = f.readlines()
lines = call(['batctl', 'meshif', cfg['interface'], 'gwl'])
gw_macs = set([gw[3:20] for gw in lines[1:]])
# 3. Ergebnis ermitteln
npeers = 0
for peer in data['peers'].values():
if peer['connection']:
if not set(peer['connection']['mac_addresses']) & gw_macs:
npeers += 1
return npeers
def fn_dhcpd_leases():
regex_leaseblock = re.compile(r"lease (?P<ip>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
regex_properties = re.compile(r"\s+(?P<key>\S+) (?P<value>[\s\S]+?);")
leases = 0
with open("/var/lib/dhcp/dhcpd.leases") as lease_file:
macs = set()
for match in regex_leaseblock.finditer(lease_file.read()):
block = match.groupdict()
properties = {key: value for (key, value) in regex_properties.findall(block['config'])}
if properties['binding'].split(' ')[1] == 'active' and properties['ends'] != 'never':
dt_ends = datetime.datetime.strptime(properties['ends'][2:], "%Y/%m/%d %H:%M:%S")
if dt_ends > datetime.datetime.utcnow() and properties['hardware'].startswith('ethernet'):
macs.add(properties['hardware'][9:])
leases = len(macs)
return leases
# Hinweis: Die durch Punkte getrennten Teilschlüssel müssen gültige
# PHP-Variablennamen sein.
item = {
'node.hostname': { 'interval': 3600, 'exec': fn_node_hostname },
'node.vpn': { 'interval': 3600, 'exec': fn_node_vpn },
'node.network.mac': { 'interval': 3600, 'exec': fn_node_net_mac },
'node.network.mesh_interfaces': { 'interval': 3600, 'exec': fn_node_net_mesh_ifaces },
'node.network.exitvpn.provider': { 'interval': 3600, 'exec': fn_exitvpn_provider },
'node.network.exitvpn.country': { 'interval': 3600, 'exec': fn_exitvpn_country },
'node.software.batman_adv.version': { 'interval': 3600, 'exec': fn_batman_version },
'node.software.fastd.version': { 'interval': 3600, 'exec': fn_fastd_version },
'node.software.fastd.enabled': { 'interval': 60, 'exec': fn_fastd_enabled },
'node.software.fastd.port': { 'interval': 3600, 'exec': fn_fastd_port },
'node.software.firmware.base': { 'interval': 3600, 'exec': fn_firmware_base },
'node.software.firmware.release': { 'interval': 3600, 'exec': fn_firmware_release },
'node.hardware.model': { 'interval': 3600, 'exec': fn_hardware_model },
'node.hardware.nproc': { 'interval': 3600, 'exec': fn_hardware_nproc },
'statistics.idletime': { 'interval': 60, 'exec': fn_idletime },
'statistics.loadavg': { 'interval': 60, 'exec': fn_loadavg },
'statistics.memory': { 'interval': 60, 'exec': fn_memory },
'statistics.processes': { 'interval': 60, 'exec': fn_processes },
'statistics.traffic': { 'interval': 60, 'exec': fn_traffic },
'statistics.uptime': { 'interval': 60, 'exec': fn_uptime },
'statistics.peers': { 'interval': 60, 'exec': fn_fastd_peers },
'statistics.leases': { 'interval': 60, 'exec': fn_dhcpd_leases },
}
# Die Meßwerte nach Intervall gruppieren
#items_by_interval = defaultdict(list)
#for k, v in item.iteritems():
# items_by_interval[v['interval']].append(k)
#print(items_by_interval)
#for i in items_by_interval:
# print(i)
# Datenstruktur zum Übertragen an Alfred.
# Wir nehmen die optimale Variante, ggf. ist das *nicht*
# JSON
def dot_to_json(a):
output = {}
for key, value in a.items():
path = key.split('.')
if path[0] == 'json':
path = path[1:]
target = functools.reduce(lambda d, k: d.setdefault(k, {}), path[:-1], output)
target[path[-1]] = value
return output
def merge_dict(d1, d2):
"""
Modifies d1 in-place to contain values from d2. If any value
in d1 is a dictionary (or dict-like), *and* the corresponding
value in d2 is also a dictionary, then merge them in-place.
"""
for k, v2 in d2.items():
v1 = d1.get(k) # returns None if v1 has no value for this key
if (isinstance(v1, Mapping) and
isinstance(v2, Mapping)):
merge_dict(v1, v2)
else:
d1[k] = v2
def set_loglevel(nr):
# Nummer nach Level umsetzen
levels = [None, logging.CRITICAL, logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
try:
level = levels[nr]
except:
level = logging.INFO
return level
def usage():
print("Alfred Announce Daemon for Gateways")
print("Version {}".format(__version__))
print()
print("Optionen")
print(" -d Programm als Daemon laufen lassen")
print()
if __name__ == "__main__":
# Zeitmessung starten
t0 = time.time()
# Kommandozeilenoptionen verarbeiten
try:
opts, args = getopt.gnu_getopt(sys.argv[1:], "dh", ["daemon", "help"])
except getopt.GetoptError as err:
print(str(err))
sys.exit(2)
for opt, arg in opts:
if opt in ("-h", "--help"):
usage()
sys.exit(1)
elif opt in ("-d", "daemon"):
daemon = true
break
# Protokollierung anschalten
logging.basicConfig(level=logging.ERROR,
format='%(asctime)s %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
filename=cfg['logfile'],
filemode='a')
log = logging.getLogger()
loglevel = set_loglevel(cfg['loglevel'])
if loglevel:
log.setLevel(loglevel)
log.info("{} started on {}".format(sys.argv[0], socket.gethostname()))
else:
log.disabled = True
# Zugeordnete Funktionen je Item ausführen
result = {}
for k, v in item.items():
result[k] = v['exec']()
# Daten für Alfred aufbereiten, wir verwenden gzip
data = dot_to_json(result)
# Zumischen der statischen Daten
try:
with open('/etc/alfred/statics.json', 'r') as fh:
statics = json.load(fh)
except IOError:
statics = {}
except ValueError:
statics = {}
print("Syntax error in statics file, import failed")
merge_dict(data, statics)
# Aufteilen in die jew. Datentypen
nodeinfo = data['node']
statistics = data['statistics']
cnodeinfo = zlib.compress(bytes(json.dumps(nodeinfo), 'UTF-8'))
cstatistics = zlib.compress(bytes(json.dumps(statistics), 'UTF-8'))
# Knoteninfos übertragen
alfred = subprocess.Popen(['alfred', '-s', '158'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
streamdata = alfred.communicate(cnodeinfo)[0]
if alfred.returncode != 0:
print("Communication error with alfred: {}".format(streamdata))
# Statistik übertragen
alfred = subprocess.Popen(['alfred', '-s', '159'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
streamdata = alfred.communicate(cstatistics)[0]
if alfred.returncode != 0:
print("Communication error with alfred: {}".format(streamdata))
# Zeitmessung beenden
tn = time.time()
log.info("Benötigte Zeit: {:.2f} Minuten" .format((tn-t0)/60))