Since this thing is on the innerwebs I suppose it should have a
[python_utils.git] / arper.py
index ca5d1d5fa59c6ad75bb811f0664c90e798a4745d..b4c079edcc750307356b60fdba39ca5b43f5c38a 100644 (file)
--- a/arper.py
+++ b/arper.py
@@ -1,24 +1,26 @@
 #!/usr/bin/env python3
 
 #!/usr/bin/env python3
 
+# © Copyright 2021-2022, Scott Gasch
+
 """A caching layer around the kernel's network mapping between IPs and MACs"""
 
 
 import datetime
 import logging
 import os
 """A caching layer around the kernel's network mapping between IPs and MACs"""
 
 
 import datetime
 import logging
 import os
-from typing import Any, Optional
 import warnings
 import warnings
+from typing import Any, Optional
 
 from overrides import overrides
 
 import argparse_utils
 
 from overrides import overrides
 
 import argparse_utils
-from collect.bidict import BiDict
 import config
 import exec_utils
 import file_utils
 import persistent
 import config
 import exec_utils
 import file_utils
 import persistent
-import string_utils
 import site_config
 import site_config
+import string_utils
+from collect.bidict import BiDict
 
 logger = logging.getLogger(__name__)
 
 
 logger = logging.getLogger(__name__)
 
@@ -28,14 +30,20 @@ cfg = config.add_commandline_args(
 )
 cfg.add_argument(
     '--arper_cache_location',
 )
 cfg.add_argument(
     '--arper_cache_location',
-    default=f'{os.environ["HOME"]}/cache/.arp_table_cache',
+    default=site_config.get_config().arper_cache_file,
     metavar='FILENAME',
     help='Where to cache the kernel ARP table',
 )
     metavar='FILENAME',
     help='Where to cache the kernel ARP table',
 )
+cfg.add_argument(
+    '--arper_supplimental_cache_location',
+    default=site_config.get_config(site_config.other_location()).arper_cache_file,
+    metavar='FILENAME',
+    help='Where someone else is caching the kernel ARP table',
+)
 cfg.add_argument(
     '--arper_cache_max_staleness',
     type=argparse_utils.valid_duration,
 cfg.add_argument(
     '--arper_cache_max_staleness',
     type=argparse_utils.valid_duration,
-    default=datetime.timedelta(seconds=60 * 15),
+    default=datetime.timedelta(seconds=60 * 30),
     metavar='DURATION',
     help='Max acceptable age of the kernel arp table cache',
 )
     metavar='DURATION',
     help='Max acceptable age of the kernel arp table cache',
 )
@@ -49,19 +57,38 @@ cfg.add_argument(
 
 @persistent.persistent_autoloaded_singleton()  # type: ignore
 class Arper(persistent.Persistent):
 
 @persistent.persistent_autoloaded_singleton()  # type: ignore
 class Arper(persistent.Persistent):
-    def __init__(self, cached_state: Optional[BiDict] = None) -> None:
+    """A caching layer around the kernel's network mapping between IPs and
+    MACs.  This class restores persisted state that expires
+    periodically (see --arper_cache_max_staleness) at program startup
+    time.  If it's unable to use the file's contents, it queries the
+    kernel (via arp) and uses an auxillary utility called arp-scan to
+    query the network.  If it has to do this there's a latency hit but
+    it persists the collected data in the cache file.  Either way, the
+    class behaves as a global singleton hosting this data thereafter.
+
+    """
+
+    def __init__(
+        self,
+        cached_local_state: Optional[BiDict] = None,
+        cached_supplimental_state: Optional[BiDict] = None,
+    ) -> None:
         self.state = BiDict()
         self.state = BiDict()
-        if cached_state is not None:
-            logger.debug('Loading Arper map from cached state.')
-            self.state = cached_state
+        if cached_local_state is not None:
+            logger.debug('Loading Arper map from cached local state.')
+            self.state = cached_local_state
         else:
             logger.debug('No usable cached state; calling /usr/sbin/arp')
             self.update_from_arp_scan()
             self.update_from_arp()
         if len(self.state) < config.config['arper_min_entries_to_be_valid']:
         else:
             logger.debug('No usable cached state; calling /usr/sbin/arp')
             self.update_from_arp_scan()
             self.update_from_arp()
         if len(self.state) < config.config['arper_min_entries_to_be_valid']:
-            raise Exception(
-                f'Arper didn\'t find enough entries; only got {len(self.state)}.'
-            )
+            raise Exception(f'Arper didn\'t find enough entries; only got {len(self.state)}.')
+        if cached_supplimental_state is not None:
+            logger.debug('Also added %d supplimental entries.', len(cached_supplimental_state))
+            for mac, ip in cached_supplimental_state.items():
+                self.state[mac] = ip
+        for mac, ip in self.state.items():
+            logger.debug('%s <-> %s', mac, ip)
 
     def update_from_arp_scan(self):
         network_spec = site_config.get_config().network
 
     def update_from_arp_scan(self):
         network_spec = site_config.get_config().network
@@ -76,14 +103,9 @@ class Arper(persistent.Persistent):
         for line in output.split('\n'):
             ip = string_utils.extract_ip_v4(line)
             mac = string_utils.extract_mac_address(line)
         for line in output.split('\n'):
             ip = string_utils.extract_ip_v4(line)
             mac = string_utils.extract_mac_address(line)
-            if (
-                ip is not None
-                and mac is not None
-                and mac != 'UNKNOWN'
-                and ip != 'UNKNOWN'
-            ):
+            if ip is not None and mac is not None and mac != 'UNKNOWN' and ip != 'UNKNOWN':
                 mac = mac.lower()
                 mac = mac.lower()
-                logger.debug(f'ARPER: {mac} => {ip}')
+                logger.debug('ARPER: %s => %s', mac, ip)
                 self.state[mac] = ip
 
     def update_from_arp(self):
                 self.state[mac] = ip
 
     def update_from_arp(self):
@@ -95,14 +117,9 @@ class Arper(persistent.Persistent):
         for line in output.split('\n'):
             ip = string_utils.extract_ip_v4(line)
             mac = string_utils.extract_mac_address(line)
         for line in output.split('\n'):
             ip = string_utils.extract_ip_v4(line)
             mac = string_utils.extract_mac_address(line)
-            if (
-                ip is not None
-                and mac is not None
-                and mac != 'UNKNOWN'
-                and ip != 'UNKNOWN'
-            ):
+            if ip is not None and mac is not None and mac != 'UNKNOWN' and ip != 'UNKNOWN':
                 mac = mac.lower()
                 mac = mac.lower()
-                logger.debug(f'ARPER: {mac} => {ip}')
+                logger.debug('ARPER: %s => %s', mac, ip)
                 self.state[mac] = ip
 
     def get_ip_by_mac(self, mac: str) -> Optional[str]:
                 self.state[mac] = ip
 
     def get_ip_by_mac(self, mac: str) -> Optional[str]:
@@ -113,39 +130,65 @@ class Arper(persistent.Persistent):
         return self.state.inverse.get(ip, None)
 
     @classmethod
         return self.state.inverse.get(ip, None)
 
     @classmethod
-    @overrides
-    def load(cls) -> Any:
-        cache_file = config.config['arper_cache_location']
+    def load_state(
+        cls,
+        cache_file: str,
+        freshness_threshold_sec: int,
+        state: BiDict,
+    ):
+        if not file_utils.file_is_readable(cache_file):
+            logger.debug('Can\'t read %s', cache_file)
+            return
         if persistent.was_file_written_within_n_seconds(
             cache_file,
         if persistent.was_file_written_within_n_seconds(
             cache_file,
-            config.config['arper_cache_max_staleness'].total_seconds(),
+            freshness_threshold_sec,
         ):
         ):
-            logger.debug(f'Loading state from {cache_file}')
-            cached_state = BiDict()
+            logger.debug('Loading state from %s', cache_file)
+            count = 0
             with open(cache_file, 'r') as rf:
                 contents = rf.readlines()
                 for line in contents:
                     line = line[:-1]
             with open(cache_file, 'r') as rf:
                 contents = rf.readlines()
                 for line in contents:
                     line = line[:-1]
-                    logger.debug(f'ARPER:{cache_file}> {line}')
+                    logger.debug('ARPER:%s> %s', cache_file, line)
                     (mac, ip) = line.split(',')
                     mac = mac.strip()
                     mac = mac.lower()
                     ip = ip.strip()
                     (mac, ip) = line.split(',')
                     mac = mac.strip()
                     mac = mac.lower()
                     ip = ip.strip()
-                    cached_state[mac] = ip
-            if len(cached_state) > config.config['arper_min_entries_to_be_valid']:
-                return cls(cached_state)
-            else:
-                msg = f'{cache_file} is invalid: only {len(cached_state)} entries.  Deleting it.'
-                logger.warning(msg)
-                warnings.warn(msg, stacklevel=2)
+                    state[mac] = ip
+                    count += 1
+        else:
+            logger.debug('%s is too stale.', cache_file)
+
+    @classmethod
+    @overrides
+    def load(cls) -> Any:
+        local_state: BiDict = BiDict()
+        cache_file = config.config['arper_cache_location']
+        max_staleness = config.config['arper_cache_max_staleness'].total_seconds()
+        logger.debug('Trying to load main arper cache from %s...', cache_file)
+        cls.load_state(cache_file, max_staleness, local_state)
+        if len(local_state) <= config.config['arper_min_entries_to_be_valid']:
+            msg = f'{cache_file} is invalid: only {len(local_state)} entries.  Deleting it.'
+            logger.warning(msg)
+            warnings.warn(msg, stacklevel=2)
+            try:
                 os.remove(cache_file)
                 os.remove(cache_file)
-        logger.debug('No usable saved state found')
+            except Exception:
+                pass
+
+        supplimental_state: BiDict = BiDict()
+        cache_file = config.config['arper_supplimental_cache_location']
+        max_staleness = config.config['arper_cache_max_staleness'].total_seconds()
+        logger.debug('Trying to suppliment arper state from %s', cache_file)
+        cls.load_state(cache_file, max_staleness, supplimental_state)
+        if len(local_state) > 0:
+            return cls(local_state, supplimental_state)
         return None
 
     @overrides
     def save(self) -> bool:
         if len(self.state) > config.config['arper_min_entries_to_be_valid']:
         return None
 
     @overrides
     def save(self) -> bool:
         if len(self.state) > config.config['arper_min_entries_to_be_valid']:
-            logger.debug(f'Persisting state to {config.config["arper_cache_location"]}')
+            logger.debug('Persisting state to %s', config.config["arper_cache_location"])
             with file_utils.FileWriter(config.config['arper_cache_location']) as wf:
                 for (mac, ip) in self.state.items():
                     mac = mac.lower()
             with file_utils.FileWriter(config.config['arper_cache_location']) as wf:
                 for (mac, ip) in self.state.items():
                     mac = mac.lower()
@@ -153,6 +196,8 @@ class Arper(persistent.Persistent):
             return True
         else:
             logger.warning(
             return True
         else:
             logger.warning(
-                f'Only saw {len(self.state)} entries; needed at least {config.config["arper_min_entries_to_be_valid"]} to bother persisting.'
+                'Only saw %d entries; needed at least %d to bother persisting.',
+                len(self.state),
+                config.config["arper_min_entries_to_be_valid"],
             )
             return False
             )
             return False