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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
|
#!/usr/bin/env python3
"""A caching layer around the kernel's network mapping between IPs and MACs"""
import datetime
import logging
import os
import warnings
from typing import Any, Optional
from overrides import overrides
import argparse_utils
import config
import exec_utils
import file_utils
import persistent
import site_config
import string_utils
from collect.bidict import BiDict
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=site_config.get_config().arper_cache_file,
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,
default=datetime.timedelta(seconds=60 * 15),
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() # type: ignore
class Arper(persistent.Persistent):
def __init__(
self,
cached_local_state: Optional[BiDict] = None,
cached_supplimental_state: Optional[BiDict] = None,
) -> None:
self.state = BiDict()
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']:
raise Exception(f'Arper didn\'t find enough entries; only got {len(self.state)}.')
if cached_supplimental_state is not None:
logger.debug(f'Also added {len(cached_supplimental_state)} supplimental entries.')
for mac, ip in cached_supplimental_state.items():
self.state[mac] = ip
for mac, ip in self.state.items():
logger.debug(f'{mac} <-> {ip}')
def update_from_arp_scan(self):
network_spec = site_config.get_config().network
try:
output = exec_utils.cmd(
f'/usr/local/bin/arp-scan --retry=6 --timeout 350 --backoff=1.4 --random --numeric --plain --ignoredups {network_spec}',
timeout_seconds=10.0,
)
except Exception as e:
logger.exception(e)
return
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':
mac = mac.lower()
logger.debug(f'ARPER: {mac} => {ip}')
self.state[mac] = ip
def update_from_arp(self):
try:
output = exec_utils.cmd('/usr/sbin/arp -a', timeout_seconds=10.0)
except Exception as e:
logger.exception(e)
return
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':
mac = mac.lower()
logger.debug(f'ARPER: {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)
@classmethod
def load_state(cls, cache_file: str, freshness_threshold_sec: int, state: BiDict):
if not file_utils.file_is_readable(cache_file):
logger.debug(f'Can\'t read {cache_file}')
return None
if persistent.was_file_written_within_n_seconds(
cache_file,
freshness_threshold_sec,
):
logger.debug(f'Loading state from {cache_file}')
count = 0
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()
state[mac] = ip
count += 1
else:
logger.debug(f'{cache_file} is too stale.')
@classmethod
@overrides
def load(cls) -> Any:
local_state = BiDict()
cache_file = config.config['arper_cache_location']
max_staleness = config.config['arper_cache_max_staleness'].total_seconds()
logger.debug(f'Trying to load main arper cache from {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)
except Exception:
pass
supplimental_state = BiDict()
cache_file = config.config['arper_supplimental_cache_location']
max_staleness = config.config['arper_cache_max_staleness'].total_seconds()
logger.debug(f'Trying to suppliment arper state from {cache_file}...')
cls.load_state(cache_file, max_staleness, supplimental_state)
if len(supplimental_state) == 0:
supplimental_state = None
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']:
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
|