1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
|
#!/usr/bin/env python3
"""A caching layer around the kernel's network mapping between IPs and MACs"""
import datetime
import logging
import os
from typing import Any, Optional
from overrides import overrides
import argparse_utils
from collect.bidict import BiDict
import config
import exec_utils
import file_utils
import persistent
import string_utils
import site_config
logger = logging.getLogger(__name__)
cfg = config.add_commandline_args(
f'MAC <--> IP Address mapping table cache ({__file__})',
'Commandline args related to MAC <--> IP Address mapping',
)
cfg.add_argument(
'--arper_cache_location',
default=f'{os.environ["HOME"]}/cache/.arp_table_cache',
metavar='FILENAME',
help='Where to cache the kernel ARP table',
)
cfg.add_argument(
'--arper_cache_max_staleness',
type=argparse_utils.valid_duration,
default=datetime.timedelta(seconds=60 * 60),
metavar='DURATION',
help='Max acceptable age of the kernel arp table cache'
)
cfg.add_argument(
'--arper_min_entries_to_be_valid',
type=int,
default=site_config.get_config().arper_minimum_device_count,
help='Min number of arp entries to bother persisting.'
)
@persistent.persistent_autoloaded_singleton()
class Arper(persistent.Persistent):
def __init__(
self, cached_state: Optional[BiDict[str, str]] = None
) -> None:
self.state = BiDict()
if cached_state is not None:
logger.debug('Loading Arper map from cached state.')
self.state = cached_state
else:
logger.debug('No usable cached state; calling /usr/sbin/arp')
self.update()
def update(self):
output = exec_utils.cmd(
'/usr/sbin/arp -a',
timeout_seconds=5.0
)
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:
mac = mac.lower()
logger.debug(f' {mac} => {ip}')
self.state[mac] = ip
def get_ip_by_mac(self, mac: str) -> Optional[str]:
mac = mac.lower()
return self.state.get(mac, None)
def get_mac_by_ip(self, ip: str) -> Optional[str]:
return self.state.inverse.get(ip, 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"]}'
)
with file_utils.FileWriter(config.config['arper_cache_location']) as wf:
for (mac, ip) in self.state.items():
mac = mac.lower()
print(f'{mac}, {ip}', file=wf)
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.'
)
return False
@classmethod
@overrides
def load(cls) -> Any:
cache_file = config.config['arper_cache_location']
if persistent.was_file_written_within_n_seconds(
cache_file,
config.config['arper_cache_max_staleness'].total_seconds(),
):
logger.debug(f'Loading state from {cache_file}')
cached_state = BiDict()
with open(cache_file, 'r') as rf:
contents = rf.readlines()
for line in contents:
line = line[:-1]
logger.debug(f'ARPER:{cache_file}> {line}')
(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:
logger.warning(
f'{cache_file} sucks, only {len(cached_state)} entries. Deleting it.'
)
os.remove(cache_file)
logger.debug('No usable saved state found')
return None
|