check in checkinator into hswaw/checkinator
repository: https://code.hackerspace.pl/checkinator
revision: 713c7e6c1a8fd6147522c1a5e3067898a1d8bf7a
Change-Id: I1bd2975a46ec0d9a89d6594fb4b9d49832001627
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1219
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/hswaw/checkinator/at/dhcp.py b/hswaw/checkinator/at/dhcp.py
new file mode 100644
index 0000000..bee2ace
--- /dev/null
+++ b/hswaw/checkinator/at/dhcp.py
@@ -0,0 +1,234 @@
+import threading
+import os
+import io
+
+import logging
+
+from typing import Tuple, List, Optional, NamedTuple
+from time import sleep, time, mktime
+from datetime import datetime, timezone
+
+logger = logging.getLogger(__name__)
+
+def strfts(ts, format='%d/%m/%Y %H:%M'):
+ return datetime.fromtimestamp(ts).strftime(format)
+
+class DhcpLease(NamedTuple):
+ hwaddr: Optional[str]
+ atime: Optional[float]
+ ip: Optional[str]
+ name: Optional[str]
+
+
+class ActiveDevices:
+ def __init__(self):
+ self._devices = {}
+
+ def purge_stale(self, timeout):
+ now = time()
+ for device in list(self._devices.values()):
+ if now - device.atime > timeout:
+ del self._devices[device.hwaddr]
+
+ def add(self, lease: DhcpLease) -> bool:
+ if lease.atime is None:
+ lease = lease._replace(atime=time())
+ if lease.hwaddr not in self._devices or self._devices[lease.hwaddr].atime < lease.atime:
+ self._devices[lease.hwaddr] = lease
+ return True
+ return False
+
+ def update(self, devices) -> List[str]:
+ '''Add entries from another ActiveDevices instance
+
+ Args:
+ devices: list of entries to be added
+
+ Returns: list of updated enties
+ '''
+
+ updated = []
+ for device in devices._devices.values():
+ if self.add(device):
+ updated.append(device)
+ return updated
+
+class Updater(threading.Thread):
+ def __init__(self, timeout, logger=logger, *a, **kw):
+ self.timeout = timeout
+ self.lock = threading.Lock()
+ self.logger = logger
+ self.active = ActiveDevices()
+ threading.Thread.__init__(self, *a, **kw)
+ self.daemon = True
+
+ def get_active_devices(self):
+ with self.lock:
+ self.active.purge_stale(self.timeout)
+ return dict(self.active._devices)
+
+ def get_device(self, ip):
+ with self.lock:
+ active_devices = iter(self.get_active_devices().values())
+ for device in active_devices:
+ if device.ip == ip:
+ return device.hwaddr, device.name
+ return None, None
+
+ def update(self, devices: ActiveDevices):
+ for device in devices._devices.values():
+ with self.lock:
+ changed = self.active.add(device)
+ if changed:
+ self.logger.info('updated %s with atime %s and ip %s',
+ device.hwaddr, strfts(device.atime), device.ip)
+
+#class CapUpdater(Updater):
+# def __init__(self, cap_file, *a, **kw):
+# self.cap_file = cap_file
+# Updater.__init__(self, *a, **kw)
+#
+# def run(self):
+# while True:
+# try:
+# with open(self.cap_file, 'r', buffering=0) as f:
+# self.logger.info('Updater ready on cap file %s',
+# self.cap_file)
+# lines = [l.strip() for l in f.read().split('\n')]
+# for hwaddr in lines:
+# if hwaddr:
+# self.update(hwaddr)
+# self.logger.warning('Cap file %s closed, reopening',
+# self.cap_file)
+# except Exception as e:
+# self.logger.error('Updater got an exception:\n' +
+# traceback.format_exc(e))
+# sleep(10.0)
+
+
+class MtimeUpdater(Updater):
+ def __init__(self, lease_file, *a, **kw):
+ self.lease_file = lease_file
+ self.position = 0
+ self.last_modified = 0
+ Updater.__init__(self, *a, **kw)
+
+ def file_changed(self, f):
+ """Callback on changed lease file
+
+ Args:
+ f: Lease file. File offset can be used to skip already parsed lines.
+
+ Returns: New byte offset pointing after last parsed byte.
+ """
+ return f.tell()
+
+ def _trigger_update(self):
+ self.logger.info('Lease file changed, updating')
+ with open(self.lease_file, 'r') as f:
+ f.seek(self.position)
+ self.position = self.file_changed(f)
+
+ def run(self):
+ """Periodicaly check if file has changed
+
+ From ISC DHCPD manual:
+
+ New leases are appended to the end of the dhcpd.leases file. In
+ order to prevent the file from becoming arbitrarily large, from
+ time to time dhcpd creates a new dhcpd.leases file from its in-core
+ lease database. Once this file has been written to disk, the old
+ file is renamed dhcpd.leases~, and the new file is renamed
+ dhcpd.leases.
+ """
+ while True:
+ try:
+ stat = os.stat(self.lease_file)
+ mtime = stat.st_mtime
+ size = stat.st_size
+ if size < self.position:
+ self.logger.info('leases file changed - reseting pointer')
+ self.position = 0
+ try:
+ # checking if DHCPD performed cleanup
+ # cleanup during operation seems to be currently broken
+ # on customs so this could never execute
+ purge_time = os.stat(self.lease_file + '~').st_mtime
+ if purge_time > self.last_modified:
+ self.logger.info('leases file purged - reseting pointer')
+ self.position = 0
+ except FileNotFoundError:
+ pass
+ if mtime > self.last_modified:
+ self._trigger_update()
+ self.last_modified = mtime
+ sleep(5.0)
+ except Exception:
+ self.logger.exception('Exception in updater')
+ sleep(10.0)
+
+
+class DnsmasqUpdater(MtimeUpdater):
+ def file_changed(self, f):
+ raise NotImplementedError(
+ "This was not tested after adding differential update")
+ for line in f:
+ ts, hwaddr, ip, name, client_id = line.split(' ')
+ self.update(hwaddr, int(ts), ip, name)
+ return f.tell()
+
+def parse_isc_dhcpd_leases(leases_file: io.TextIOBase) -> Tuple[int, ActiveDevices]:
+ """Parse ISC dhcpd server leases file
+
+ Args:
+ leases_file: opened leases file. To skip already parsed part use seek
+ before calling.
+
+ Returns: Byte offset (as returned by tell()) of last parsed entry and
+ dictionary of parsed leases
+ """
+ leases = ActiveDevices()
+
+ ip: Optional[str] = None
+ hwaddr: Optional[str] = None
+ atime: Optional[float] = None
+ name: Optional[str] = None
+
+ lease = False
+ offset = leases_file.tell()
+ while True:
+ # using readline because iter(file) blocks file.tell usage
+ line = leases_file.readline()
+ if not line:
+ return offset, leases
+ line = line.split('#')[0]
+ cmd = line.strip().split()
+ if not cmd:
+ continue
+ if lease:
+ field = cmd[0]
+ if(field == 'starts'):
+ dt = datetime.strptime(' '.join(cmd[2:]),
+ '%Y/%m/%d %H:%M:%S;')
+ atime = dt.replace(tzinfo=timezone.utc).timestamp()
+ if(field == 'client-hostname'):
+ name = cmd[1][1:-2]
+ if(field == 'hardware'):
+ hwaddr = cmd[2][:-1]
+ if(field.startswith('}')):
+ offset = leases_file.tell()
+ lease = False
+ if hwaddr is not None and atime is not None:
+ leases.add(DhcpLease(hwaddr, atime, ip, name))
+ hwaddr, atime = None, None
+ elif cmd[0] == 'lease':
+ ip = cmd[1]
+ hwaddr, atime, name = None, None, None
+ lease = True
+
+
+class DhcpdUpdater(MtimeUpdater):
+ def file_changed(self, f):
+ offset, devices = parse_isc_dhcpd_leases(f)
+ self.update(devices)
+ return offset