umnetdb-utils 0.1.5__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- umnetdb_utils/cli.py +114 -0
- umnetdb_utils/umnetdb.py +304 -8
- umnetdb_utils/utils.py +284 -3
- {umnetdb_utils-0.1.5.dist-info → umnetdb_utils-0.2.0.dist-info}/METADATA +2 -1
- umnetdb_utils-0.2.0.dist-info/RECORD +12 -0
- umnetdb_utils-0.2.0.dist-info/entry_points.txt +3 -0
- umnetdb_utils-0.1.5.dist-info/RECORD +0 -10
- {umnetdb_utils-0.1.5.dist-info → umnetdb_utils-0.2.0.dist-info}/WHEEL +0 -0
umnetdb_utils/cli.py
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
import typer
|
2
|
+
from umnetdb_utils import UMnetdb
|
3
|
+
import inspect
|
4
|
+
from functools import wraps
|
5
|
+
|
6
|
+
from rich.console import Console
|
7
|
+
from rich.table import Table
|
8
|
+
|
9
|
+
from typing import Callable, List
|
10
|
+
from typing_extensions import Annotated
|
11
|
+
|
12
|
+
app = typer.Typer()
|
13
|
+
|
14
|
+
def print_result(result:List[dict]):
|
15
|
+
"""
|
16
|
+
Takes the result of a umnetdb call and prints it as a table
|
17
|
+
"""
|
18
|
+
if len(result) == 0:
|
19
|
+
print("No results found")
|
20
|
+
return
|
21
|
+
|
22
|
+
if isinstance(result, dict):
|
23
|
+
result = [result]
|
24
|
+
|
25
|
+
# instantiate table with columns based on entry dict keys
|
26
|
+
table = Table(*result[0].keys())
|
27
|
+
for row in result:
|
28
|
+
table.add_row(*[str(i) for i in row.values()])
|
29
|
+
|
30
|
+
console = Console()
|
31
|
+
console.print(table)
|
32
|
+
|
33
|
+
|
34
|
+
def command_generator(method_name:str, method:Callable):
|
35
|
+
"""
|
36
|
+
Generates a typer command function for an arbitrary method
|
37
|
+
in the umnetdb class. The generated function opens a connection with
|
38
|
+
the database, executes the method, and prints out the results.
|
39
|
+
|
40
|
+
Note that the docstring of each method is interrogated to generate
|
41
|
+
help text for each typer command.
|
42
|
+
|
43
|
+
:method_name: The name of the method
|
44
|
+
:method: The method itself
|
45
|
+
"""
|
46
|
+
|
47
|
+
# first we're going to tease out the 'help' portions of the method
|
48
|
+
# from the docstring.
|
49
|
+
docstr = method.__doc__
|
50
|
+
docstr_parts = docstr.split("\n:")
|
51
|
+
|
52
|
+
# first section of the docstring is always a generic 'this is what the method does'.
|
53
|
+
cmd_help = docstr_parts.pop(0)
|
54
|
+
|
55
|
+
# next sections are details on the specific arguments that we want to pass to typer as
|
56
|
+
# special annotated type hints
|
57
|
+
arg_help = {}
|
58
|
+
for arg_str in docstr_parts:
|
59
|
+
if ":" in arg_str:
|
60
|
+
arg, help = arg_str.split(":")
|
61
|
+
arg_help[arg] = help.strip()
|
62
|
+
|
63
|
+
sig = inspect.signature(method)
|
64
|
+
|
65
|
+
# going through the method's arguments and augmenting the 'help' section for each one
|
66
|
+
# from the docstring if applicable
|
67
|
+
new_params = []
|
68
|
+
for p_name, p in sig.parameters.items():
|
69
|
+
|
70
|
+
# need to skip self
|
71
|
+
if p_name == "self":
|
72
|
+
continue
|
73
|
+
|
74
|
+
# if there wasn't any helper text then just append the parameter as is
|
75
|
+
if p_name not in arg_help:
|
76
|
+
new_params.append(p)
|
77
|
+
continue
|
78
|
+
|
79
|
+
# params without default values should be typer 'arguments'
|
80
|
+
if p.default == inspect._empty:
|
81
|
+
new_params.append(p.replace(annotation=Annotated[p.annotation, typer.Argument(help=arg_help[p_name])]))
|
82
|
+
continue
|
83
|
+
|
84
|
+
# params with default values should be typer 'options'
|
85
|
+
new_params.append(p.replace(annotation=Annotated[p.annotation, typer.Option(help=arg_help[p_name])]))
|
86
|
+
|
87
|
+
new_sig = sig.replace(parameters=new_params)
|
88
|
+
|
89
|
+
|
90
|
+
# new munged function based on the origional method, with a new signature
|
91
|
+
# and docstring for typer
|
92
|
+
@wraps(method)
|
93
|
+
def wrapper(*args, **kwargs):
|
94
|
+
with UMnetdb() as db:
|
95
|
+
result = getattr(db, method_name)(*args, **kwargs)
|
96
|
+
print_result(result)
|
97
|
+
|
98
|
+
wrapper.__signature__ = new_sig
|
99
|
+
wrapper.__doc__ = cmd_help
|
100
|
+
|
101
|
+
return wrapper
|
102
|
+
|
103
|
+
|
104
|
+
def main():
|
105
|
+
for f_name,f in UMnetdb.__dict__.items():
|
106
|
+
if not(f_name.startswith("_")) and callable(f):
|
107
|
+
app.command()(command_generator(f_name, f))
|
108
|
+
|
109
|
+
app()
|
110
|
+
|
111
|
+
if __name__ == "__main__":
|
112
|
+
main()
|
113
|
+
|
114
|
+
|
umnetdb_utils/umnetdb.py
CHANGED
@@ -1,16 +1,18 @@
|
|
1
|
-
from typing import List
|
1
|
+
from typing import List, Optional
|
2
2
|
import logging
|
3
3
|
import re
|
4
|
+
import ipaddress
|
5
|
+
from copy import deepcopy
|
4
6
|
|
5
|
-
|
6
|
-
from sqlalchemy import text
|
7
7
|
from .base import UMnetdbBase
|
8
|
+
from .utils import is_ip_address, Packet, Hop, Path, LOCAL_PROTOCOLS
|
8
9
|
|
10
|
+
logger = logging.getLogger(__name__)
|
9
11
|
|
10
12
|
class UMnetdb(UMnetdbBase):
|
11
13
|
URL = "postgresql+psycopg://{UMNETDB_USER}:{UMNETDB_PASSWORD}@wintermute.umnet.umich.edu/umnetdb"
|
12
14
|
|
13
|
-
def get_neighbors(self, device: str, known_devices_only: bool = True) -> List[dict]:
|
15
|
+
def get_neighbors(self, device: str, known_devices_only: bool = True, interface:Optional[str]=None) -> List[dict]:
|
14
16
|
"""
|
15
17
|
Gets a list of the neighbors of a particular device. If the port
|
16
18
|
has a parent in the LAG table that is included as well.
|
@@ -20,9 +22,12 @@ class UMnetdb(UMnetdbBase):
|
|
20
22
|
|
21
23
|
Setting 'known_devices_only' to true only returns neighbors that are found
|
22
24
|
in umnet_db's device table. Setting it to false will return all lldp neighbors
|
23
|
-
and will include things like phones and APs
|
25
|
+
and will include things like phones and APs.
|
24
26
|
|
25
27
|
Returns results as a list of dictionary entries keyed on column names.
|
28
|
+
:device: Name of the device
|
29
|
+
:known_devices_only: If set to true, will only return neighbors found in umnetdb's device table.
|
30
|
+
:interface: If supplied, restrict to only find neighbors on a particular interface on the device
|
26
31
|
"""
|
27
32
|
|
28
33
|
if known_devices_only:
|
@@ -55,6 +60,8 @@ class UMnetdb(UMnetdbBase):
|
|
55
60
|
table = "neighbor n"
|
56
61
|
|
57
62
|
where = [f"n.device='{device}'"]
|
63
|
+
if interface:
|
64
|
+
where.append(f"n.port='{interface}'")
|
58
65
|
|
59
66
|
query = self._build_select(select, table, joins, where)
|
60
67
|
|
@@ -64,9 +71,11 @@ class UMnetdb(UMnetdbBase):
|
|
64
71
|
"""
|
65
72
|
Gets all devices within a DL zone based on walking the 'neighbors'
|
66
73
|
table.
|
67
|
-
|
74
|
+
|
68
75
|
For each device, the following attributes are returned:
|
69
76
|
"name", "ip", "version", "vendor", "model", "serial"
|
77
|
+
|
78
|
+
:zone_name: Name of the DL zone
|
70
79
|
"""
|
71
80
|
device_cols = ["name", "ip", "version", "vendor", "model", "serial"]
|
72
81
|
|
@@ -90,13 +99,12 @@ class UMnetdb(UMnetdbBase):
|
|
90
99
|
while len(todo) != 0:
|
91
100
|
device = todo.pop()
|
92
101
|
|
93
|
-
print(f"Processing device {device}")
|
94
102
|
# note that by default this method only returns neighbors in the 'device' table,
|
95
103
|
# any others are ignored
|
96
104
|
neighs = self.get_neighbors(device)
|
97
105
|
devices_by_name[device]["neighbors"] = {}
|
98
106
|
for neigh in neighs:
|
99
|
-
|
107
|
+
|
100
108
|
# only want 'd- or 'dl-' or 's-' devices, and we don't want out of band devices
|
101
109
|
if re.match(r"(dl?-|s-)", neigh["remote_device"]) and not re.match(
|
102
110
|
r"s-oob-", neigh["remote_device"]
|
@@ -120,3 +128,291 @@ class UMnetdb(UMnetdbBase):
|
|
120
128
|
todo.append(neigh_device["name"])
|
121
129
|
|
122
130
|
return list(devices_by_name.values())
|
131
|
+
|
132
|
+
|
133
|
+
def l3info(self, search_str:str, detail:bool=False, num_results:int=10, exact:bool=False)->list[dict]:
|
134
|
+
"""
|
135
|
+
Does a search of the umnetdb ip_interface table.
|
136
|
+
|
137
|
+
:search_str: Can be an IP address, 'VlanX', or a full or partial netname
|
138
|
+
:detail: Adds admin/oper status, primary/secondary, helpers, and timestamps to output.
|
139
|
+
:num_results: Limits number of results printed out.
|
140
|
+
:exact: Only return exact matches, either for IP addresses or for string matches.
|
141
|
+
"""
|
142
|
+
|
143
|
+
cols = ["device", "ip_address", "interface", "description", "vrf"]
|
144
|
+
if detail:
|
145
|
+
cols.extend(["admin_up", "oper_up", "secondary", "helpers", "first_seen", "last_updated"])
|
146
|
+
|
147
|
+
# 'is contained within' IP search - reference:
|
148
|
+
# https://www.postgresql.org/docs/9.3/functions-net.html
|
149
|
+
|
150
|
+
# VlanX based searches are always 'exact'
|
151
|
+
if re.match(r"Vlan\d+$", search_str):
|
152
|
+
where = [f"interface = '{search_str}'"]
|
153
|
+
|
154
|
+
# ip or description based searches can be 'exact' or inexact
|
155
|
+
elif exact:
|
156
|
+
if is_ip_address(search_str):
|
157
|
+
where = [f"host(ip_address) = '{search_str}'"]
|
158
|
+
else:
|
159
|
+
where = [f"description = '{search_str}'"]
|
160
|
+
|
161
|
+
else:
|
162
|
+
if is_ip_address(search_str):
|
163
|
+
where = [f"ip_address >>= '{search_str}'"]
|
164
|
+
else:
|
165
|
+
where = [f"description like '{search_str}%'"]
|
166
|
+
|
167
|
+
# removing IPs assigned to mgmt interfaces
|
168
|
+
where.append("vrf != 'management'")
|
169
|
+
|
170
|
+
query = self._build_select(
|
171
|
+
select=cols,
|
172
|
+
table="ip_interface",
|
173
|
+
where=where,
|
174
|
+
limit=num_results,
|
175
|
+
)
|
176
|
+
return self.execute(query)
|
177
|
+
|
178
|
+
|
179
|
+
def route(self, router:str, prefix:str, vrf:str, resolve_nh:bool=True, details:bool=False) -> list[dict]:
|
180
|
+
"""
|
181
|
+
Does an lpm query on a particular router for a particular prefix
|
182
|
+
in a particular VRF.
|
183
|
+
|
184
|
+
:router: Name of the router to query
|
185
|
+
:prefix: Prefix to query for
|
186
|
+
:vrf: Name of the VRF to query against
|
187
|
+
:resolve_nh: If no nh_interface is present in the database, recursively resolve for it.
|
188
|
+
:details: Set to true to get output of all columns in the route table.
|
189
|
+
"""
|
190
|
+
if details:
|
191
|
+
cols = ["*"]
|
192
|
+
else:
|
193
|
+
cols = ["device", "vrf", "prefix", "nh_ip", "nh_table", "nh_interface"]
|
194
|
+
|
195
|
+
lpms = self.lpm_query(router, prefix, vrf, columns=cols)
|
196
|
+
|
197
|
+
if not lpms:
|
198
|
+
return []
|
199
|
+
|
200
|
+
if not resolve_nh:
|
201
|
+
return lpms
|
202
|
+
|
203
|
+
resolved_results = []
|
204
|
+
for route in lpms:
|
205
|
+
if route["nh_interface"]:
|
206
|
+
resolved_results.append(route)
|
207
|
+
else:
|
208
|
+
self._resolve_nh(route, resolved_results, 0)
|
209
|
+
|
210
|
+
return resolved_results
|
211
|
+
|
212
|
+
def lpm_query(self, router:str, prefix:str, vrf:str, columns:Optional[str]=None)->list[dict]:
|
213
|
+
"""
|
214
|
+
Does an lpm query against a particular router, prefix, and vrf. Optionally specify
|
215
|
+
which columns you want to limit the query to.
|
216
|
+
"""
|
217
|
+
|
218
|
+
select = columns if columns else ["*"]
|
219
|
+
|
220
|
+
query = self._build_select(
|
221
|
+
select=select,
|
222
|
+
table="route",
|
223
|
+
where=[f"device='{router}'", f"prefix >>= '{prefix}'", f"vrf='{vrf}'"],
|
224
|
+
order_by="prefix"
|
225
|
+
)
|
226
|
+
|
227
|
+
result = self.execute(query)
|
228
|
+
|
229
|
+
if not result:
|
230
|
+
return None
|
231
|
+
|
232
|
+
if len(result) == 1:
|
233
|
+
return result
|
234
|
+
|
235
|
+
# peeling the longest matching prefix of the end of the results, which
|
236
|
+
# are ordered by ascending prefixlength
|
237
|
+
lpm_results = [result.pop()]
|
238
|
+
|
239
|
+
# finding any other equivalent lpms. As soon as we run into one that
|
240
|
+
# doesn't match we know we're done.
|
241
|
+
result.reverse()
|
242
|
+
for r in result:
|
243
|
+
if r["prefix"] == lpm_results[0]["prefix"]:
|
244
|
+
lpm_results.append(r)
|
245
|
+
else:
|
246
|
+
break
|
247
|
+
|
248
|
+
return lpm_results
|
249
|
+
|
250
|
+
def mpls_label(self, router:str, label:str) -> list[dict]:
|
251
|
+
"""
|
252
|
+
Looks up a particular label for a particular device in the mpls table.
|
253
|
+
:router: device name
|
254
|
+
:label: label value
|
255
|
+
"""
|
256
|
+
query = self._build_select(
|
257
|
+
select=["*"],
|
258
|
+
table="mpls",
|
259
|
+
where=[f"device='{router}'", f"in_label='{label}'"]
|
260
|
+
)
|
261
|
+
return self.execute(query)
|
262
|
+
|
263
|
+
def vni(self, router:str, vni:int) -> dict:
|
264
|
+
"""
|
265
|
+
Looks up a particular vni on the router and returns
|
266
|
+
the VRF or vlan_id it's associated with
|
267
|
+
"""
|
268
|
+
|
269
|
+
query = self._build_select(
|
270
|
+
select=["*"],
|
271
|
+
table="vni",
|
272
|
+
where=[f"device='{router}'", f"vni='{vni}'"]
|
273
|
+
)
|
274
|
+
return self.execute(query, fetch_one=True)
|
275
|
+
|
276
|
+
|
277
|
+
def _resolve_nh(self, route:dict, resolved_routes:list[dict], depth:int) -> dict:
|
278
|
+
"""
|
279
|
+
Recursively resolves next hop of a route till we find a nh interface.
|
280
|
+
If we hit a recursion depth of 4 then an exception is thrown - the max depth on our
|
281
|
+
network I've seen is like 2 (for a static route to an indirect next hop)
|
282
|
+
"""
|
283
|
+
if route["nh_interface"]:
|
284
|
+
return route
|
285
|
+
|
286
|
+
depth += 1
|
287
|
+
if depth == 4:
|
288
|
+
raise RecursionError(f"Reached max recursion depth of 4 trying to resolve route {route}")
|
289
|
+
|
290
|
+
nh_ip = route["nh_ip"]
|
291
|
+
nh_table = route["nh_table"]
|
292
|
+
router = route["device"]
|
293
|
+
|
294
|
+
nh_routes = self.lpm_query(router, nh_ip, nh_table)
|
295
|
+
|
296
|
+
for nh_route in nh_routes:
|
297
|
+
r = self._resolve_nh(nh_route, resolved_routes, depth)
|
298
|
+
resolved_route = deepcopy(route)
|
299
|
+
resolved_route["nh_interface"] = r["nh_interface"]
|
300
|
+
if r["mpls_label"] and resolved_route["mpls_label"] is not None:
|
301
|
+
resolved_route["mpls_label"].extend(r["mpls_label"])
|
302
|
+
elif r["mpls_label"]:
|
303
|
+
resolved_route["mpls_label"] = r["mpls_label"]
|
304
|
+
resolved_route["nh_ip"] = r["nh_ip"]
|
305
|
+
resolved_routes.append(resolved_route)
|
306
|
+
|
307
|
+
|
308
|
+
def get_all_paths(self, src_ip:str, dst_ip:str):
|
309
|
+
"""
|
310
|
+
Traces the path between a particular source and destination IP
|
311
|
+
:src_ip: A source IP address, must be somewhere on our network
|
312
|
+
:dst_ip: A destination IP address, does not have to be on our network.
|
313
|
+
"""
|
314
|
+
for ip_name, ip in [("source", src_ip), ("destination", dst_ip)]:
|
315
|
+
if not is_ip_address(ip):
|
316
|
+
raise ValueError(f"invalid {ip_name} IP address {ip}")
|
317
|
+
|
318
|
+
src_l3info = self.l3info(src_ip, num_results=1)
|
319
|
+
if not src_l3info:
|
320
|
+
raise ValueError(f"Could not find where {src_ip} is routed")
|
321
|
+
|
322
|
+
packet = Packet(dst_ip=ipaddress.ip_address(dst_ip))
|
323
|
+
hop = Hop(src_l3info[0]["device"], src_l3info[0]["vrf"], src_l3info[0]["interface"], packet)
|
324
|
+
path = Path(hop)
|
325
|
+
|
326
|
+
self._walk_path(path, hop, hop.router, hop.vrf, packet)
|
327
|
+
|
328
|
+
return path.get_path()
|
329
|
+
|
330
|
+
|
331
|
+
def _walk_path(self, path:Path, curr_hop:Hop, nh_router:str, nh_table:str, packet:Packet):
|
332
|
+
|
333
|
+
logger.debug(f"\n******* walking path - current hop: {curr_hop}, nh_route: {nh_router}, nh_table {nh_table} *******")
|
334
|
+
logger.debug(f"Known hops: {path.hops.keys()}")
|
335
|
+
|
336
|
+
# mpls-based lookup
|
337
|
+
if packet.label_stack:
|
338
|
+
logger.debug("")
|
339
|
+
routes = self.mpls_label(router=nh_router, label=packet.label_stack[-1])
|
340
|
+
|
341
|
+
# otherwise we want to do an ip based lokup
|
342
|
+
else:
|
343
|
+
routes = self.route(router=nh_router, prefix=packet.dst_ip, vrf=nh_table, details=True)
|
344
|
+
|
345
|
+
if not routes:
|
346
|
+
raise ValueError(f"No route found for {curr_hop}")
|
347
|
+
|
348
|
+
for idx, route in zip(range(1,len(routes)+1), routes):
|
349
|
+
|
350
|
+
logger.debug(f"*** Processing route {idx} of {len(routes)}:{route} ***")
|
351
|
+
nh_router = None
|
352
|
+
nh_table = None
|
353
|
+
new_packet = deepcopy(packet)
|
354
|
+
|
355
|
+
# if the packet is not encapsulated and the route is local, we have reached our destination
|
356
|
+
if not packet.is_encapped() and route.get("protocol") in LOCAL_PROTOCOLS:
|
357
|
+
logger.debug(f"Destination reached at {curr_hop}")
|
358
|
+
final_hop = Hop(route["device"], vrf=route["vrf"], interface=route["nh_interface"], packet=new_packet)
|
359
|
+
path.add_hop(curr_hop, final_hop)
|
360
|
+
continue
|
361
|
+
|
362
|
+
# VXLAN decap - requires local lookup in the vrf that maps to the packet's VNI
|
363
|
+
if route.get("protocol") == "direct" and packet.vni and curr_hop.router == route["device"]:
|
364
|
+
vni = self.vni(router=route["device"], vni=packet.vni)
|
365
|
+
new_packet.vxlan_decap()
|
366
|
+
nh_table = vni["vrf"]
|
367
|
+
nh_router = route["device"]
|
368
|
+
logger.debug(f"vxlan-decapping packet, new packet {new_packet}")
|
369
|
+
|
370
|
+
# MPLS decap - requires local lookup in the vrf indicated by 'nh_interface' field
|
371
|
+
# of this aggregate route
|
372
|
+
elif route.get("aggregate"):
|
373
|
+
new_packet.mpls_pop()
|
374
|
+
nh_table = route["nh_interface"]
|
375
|
+
nh_router = route["device"]
|
376
|
+
logger.debug(f"mpls aggregate route, new packet {new_packet}")
|
377
|
+
|
378
|
+
# VXLAN encap - requires local lookup of encapped packet in the nh table
|
379
|
+
elif route.get("vxlan_vni") and not packet.is_encapped():
|
380
|
+
new_packet.vxlan_encap(route["vxlan_vni"], route["vxlan_endpoint"])
|
381
|
+
logger.debug(f"vxlan-encapping packet, new packet {new_packet}")
|
382
|
+
|
383
|
+
# MPLS encap for an IP route. Resolved routes will have both transport and vrf
|
384
|
+
# labels if applicable - this 'push' will add both to the packet.
|
385
|
+
elif route.get("mpls_label"):
|
386
|
+
new_packet.mpls_push(route["mpls_label"])
|
387
|
+
logger.debug(f"mpls-encapping packet, new packet {new_packet}")
|
388
|
+
|
389
|
+
# MPLS route - note in our environment we don't have anything that requires a
|
390
|
+
# push on an already-labeled packet (!)
|
391
|
+
elif route.get("in_label"):
|
392
|
+
|
393
|
+
if route["out_label"] == ["pop"]:
|
394
|
+
new_packet.mpls_pop()
|
395
|
+
else:
|
396
|
+
new_packet.mpls_swap(route["out_label"])
|
397
|
+
logger.debug(f"mpls push or swap, new packet {new_packet}")
|
398
|
+
|
399
|
+
# if the next hop isn't local we need to figure out which router it's on. In our environment
|
400
|
+
# the easiest way to do that is to use l3info against the nh_ip of the route.
|
401
|
+
if not nh_router and route["nh_ip"]:
|
402
|
+
|
403
|
+
l3i_router = self.l3info(str(route["nh_ip"]), exact=True)
|
404
|
+
if l3i_router:
|
405
|
+
nh_router = l3i_router[0]["device"]
|
406
|
+
nh_table = nh_table if nh_table else l3i_router[0]["vrf"]
|
407
|
+
logger.debug(f"found router {nh_router} for nh ip {route['nh_ip']}")
|
408
|
+
|
409
|
+
if not nh_router:
|
410
|
+
raise ValueError(f"Unknown next hop for {curr_hop} route {route}")
|
411
|
+
|
412
|
+
# add this hop to our path and if it's a new hop, keep waking
|
413
|
+
new_hop = Hop(route["device"], vrf=route.get("nh_table", "default"), interface=route["nh_interface"], packet=new_packet)
|
414
|
+
logger.debug(f"new hop generated: {new_hop}")
|
415
|
+
new_path = path.add_hop(curr_hop, new_hop)
|
416
|
+
if new_path:
|
417
|
+
logger.debug("New path detected - still walking")
|
418
|
+
self._walk_path(path, new_hop, nh_router, nh_table, new_packet)
|
umnetdb_utils/utils.py
CHANGED
@@ -1,8 +1,26 @@
|
|
1
1
|
import ipaddress
|
2
2
|
import re
|
3
3
|
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from copy import copy, deepcopy
|
4
6
|
|
5
|
-
|
7
|
+
from typing import Optional, Union, List, Dict
|
8
|
+
|
9
|
+
type IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
|
10
|
+
|
11
|
+
LOCAL_PROTOCOLS = [
|
12
|
+
"Access-internal",
|
13
|
+
"am",
|
14
|
+
"connected",
|
15
|
+
"direct",
|
16
|
+
"Direct",
|
17
|
+
"hmm",
|
18
|
+
"local",
|
19
|
+
"VPN",
|
20
|
+
"vrrpv3",
|
21
|
+
]
|
22
|
+
|
23
|
+
def is_ip_address(input_str:str, version:Optional[int]=None):
|
6
24
|
try:
|
7
25
|
ip = ipaddress.ip_address(input_str)
|
8
26
|
except ValueError:
|
@@ -14,7 +32,7 @@ def is_ip_address(input_str, version=None):
|
|
14
32
|
return True
|
15
33
|
|
16
34
|
|
17
|
-
def is_ip_network(input_str, version=None):
|
35
|
+
def is_ip_network(input_str:str, version:Optional[int]=None):
|
18
36
|
# First check that this is a valid IP or network
|
19
37
|
try:
|
20
38
|
net = ipaddress.ip_network(input_str)
|
@@ -27,7 +45,7 @@ def is_ip_network(input_str, version=None):
|
|
27
45
|
return True
|
28
46
|
|
29
47
|
|
30
|
-
def is_mac_address(input_str):
|
48
|
+
def is_mac_address(input_str:str):
|
31
49
|
"""
|
32
50
|
Validates the input string as a mac address. Valid formats are
|
33
51
|
XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, XXXX.XXXX.XXXX
|
@@ -40,3 +58,266 @@ def is_mac_address(input_str):
|
|
40
58
|
return True
|
41
59
|
|
42
60
|
return False
|
61
|
+
|
62
|
+
|
63
|
+
@dataclass(order=True)
|
64
|
+
class Packet:
|
65
|
+
"""
|
66
|
+
Modeling a packet as it traverses the network
|
67
|
+
"""
|
68
|
+
|
69
|
+
dst_ip: IPAddress
|
70
|
+
#ttl: int = 255
|
71
|
+
inner_dst_ip: Optional[IPAddress] = None
|
72
|
+
label_stack: Optional[list[int]] = None
|
73
|
+
vni: Optional[int] = None
|
74
|
+
|
75
|
+
def __str__(self):
|
76
|
+
if self.label_stack:
|
77
|
+
return f"MPLS: {self.label_stack}"
|
78
|
+
if self.vni:
|
79
|
+
return f"VXLAN: {self.dst_ip}:{self.vni}"
|
80
|
+
return f"{self.dst_ip}"
|
81
|
+
|
82
|
+
# def decrement_ttl(self):
|
83
|
+
# new_packet = copy(self)
|
84
|
+
# new_packet.ttl -= 1
|
85
|
+
# return new_packet
|
86
|
+
|
87
|
+
def vxlan_encap(self, vni:int, tunnel_destination: ipaddress.IPv4Address) -> "Packet":
|
88
|
+
"""
|
89
|
+
Return a copy of the existing packet, but with a vxlan encap
|
90
|
+
"""
|
91
|
+
if self.vni or self.inner_dst_ip or self.label_stack:
|
92
|
+
raise ValueError(f"Can't encapsulate an already-encapsulated packet: {self}")
|
93
|
+
|
94
|
+
# new_packet = deepcopy(self)
|
95
|
+
# new_packet.inner_dst_ip = self.dst_ip
|
96
|
+
# new_packet.vni = vni
|
97
|
+
# new_packet.dst_ip = tunnel_destination
|
98
|
+
|
99
|
+
# return new_packet
|
100
|
+
self.inner_dst_ip = self.dst_ip
|
101
|
+
self.vni = vni
|
102
|
+
self.dst_ip = tunnel_destination
|
103
|
+
|
104
|
+
def vxlan_decap(self) -> "Packet":
|
105
|
+
"""
|
106
|
+
Return a copy of the existing packet, but with a vxlan decap
|
107
|
+
"""
|
108
|
+
if not self.vni or not self.inner_dst_ip or self.label_stack:
|
109
|
+
raise ValueError(f"Can't decap a non-VXLAN packet: {self}")
|
110
|
+
|
111
|
+
# new_packet = deepcopy(self)
|
112
|
+
# new_packet.dst_ip = self.inner_dst_ip
|
113
|
+
# new_packet.vni = None
|
114
|
+
# new_packet.inner_dst_ip = None
|
115
|
+
|
116
|
+
# return new_packet
|
117
|
+
self.dst_ip = self.inner_dst_ip
|
118
|
+
self.vni = None
|
119
|
+
self.inner_dst_ip = None
|
120
|
+
|
121
|
+
def mpls_push(self, label:Union[int,list[int]]) -> "Packet":
|
122
|
+
"""
|
123
|
+
Retrun a copy of the existing packet but with MPLS labels pushed on the stack
|
124
|
+
"""
|
125
|
+
if self.vni or self.inner_dst_ip:
|
126
|
+
raise ValueError(f"Can't do mpls on a VXLAN packet: {self}")
|
127
|
+
|
128
|
+
# new_packet = deepcopy(self)
|
129
|
+
# if not new_packet.label_stack:
|
130
|
+
# new_packet.label_stack = []
|
131
|
+
|
132
|
+
# if isinstance(label, int):
|
133
|
+
# new_packet.label_stack.append(label)
|
134
|
+
# else:
|
135
|
+
# new_packet.label_stack.extend(label)
|
136
|
+
|
137
|
+
# return new_packet
|
138
|
+
if not self.label_stack:
|
139
|
+
self.label_stack = []
|
140
|
+
|
141
|
+
if isinstance(label, list):
|
142
|
+
self.label_stack.extend(label)
|
143
|
+
else:
|
144
|
+
self.label_stack.append(label)
|
145
|
+
|
146
|
+
def mpls_pop(self, num_pops:int=1) -> "Packet":
|
147
|
+
"""
|
148
|
+
Return a copy of the existing packet but with MPLS label(s) popped
|
149
|
+
"""
|
150
|
+
if not self.label_stack:
|
151
|
+
raise ValueError(f"Can't pop from an empty label stack!: {self}")
|
152
|
+
if len(self.label_stack) < num_pops:
|
153
|
+
raise ValueError(f"Can't pop {num_pops} labels from packet: {self}")
|
154
|
+
|
155
|
+
# new_packet = copy(self)
|
156
|
+
# for _ in range(num_pops):
|
157
|
+
# new_packet.label_stack.pop()
|
158
|
+
|
159
|
+
# return new_packet
|
160
|
+
|
161
|
+
for _ in range(num_pops):
|
162
|
+
self.label_stack.pop()
|
163
|
+
|
164
|
+
|
165
|
+
def mpls_swap(self, label:Union[int,list[int]]) -> "Packet":
|
166
|
+
"""
|
167
|
+
Rerturn a copy of the existing packet but with MPLS label swap
|
168
|
+
"""
|
169
|
+
if not self.label_stack:
|
170
|
+
raise ValueError(f"Can't pop from an empty label stack!: {self}")
|
171
|
+
|
172
|
+
# new_packet = copy(self)
|
173
|
+
# new_packet.label_stack.pop()
|
174
|
+
# if isinstance(label, int):
|
175
|
+
# new_packet.label_stack.append(label)
|
176
|
+
|
177
|
+
# # inconsistency in mpls table where this really should be a 'pop' but
|
178
|
+
# # it shows up as a single blank entry in a list.
|
179
|
+
# elif label == [""]:
|
180
|
+
# new_packet.label_stack = []
|
181
|
+
# else:
|
182
|
+
# new_packet.label_stack.extend(label)
|
183
|
+
|
184
|
+
# return new_packet
|
185
|
+
self.label_stack.pop()
|
186
|
+
if label == [""]:
|
187
|
+
self.lable_stack = []
|
188
|
+
elif isinstance(label, list):
|
189
|
+
self.label_stack.extend(label)
|
190
|
+
else:
|
191
|
+
self.label_stack.append(label)
|
192
|
+
|
193
|
+
def is_encapped(self):
|
194
|
+
return bool(self.vni or self.label_stack)
|
195
|
+
|
196
|
+
|
197
|
+
@dataclass(order=True)
|
198
|
+
class Hop:
|
199
|
+
"""
|
200
|
+
A hop along the path and the corresponding packet at that location
|
201
|
+
"""
|
202
|
+
router: str
|
203
|
+
vrf: str
|
204
|
+
interface: str
|
205
|
+
packet: Packet
|
206
|
+
|
207
|
+
_parent: "Hop" = field(init=False, compare=False, default=None)
|
208
|
+
_children: List["Hop"] = field(init=False, compare=False, default_factory=list)
|
209
|
+
|
210
|
+
def __str__(self):
|
211
|
+
vrf = "" if self.vrf == "default" else f" VRF {self.vrf}"
|
212
|
+
return f"{self.router}{vrf} {self.interface}, Packet: {self.packet}"
|
213
|
+
|
214
|
+
def copy(self):
|
215
|
+
return deepcopy(self)
|
216
|
+
|
217
|
+
@classmethod
|
218
|
+
def unknown(self, packet=Packet):
|
219
|
+
"""
|
220
|
+
Returns an "unknown" hop for a specific packet
|
221
|
+
"""
|
222
|
+
return Hop("","","", packet)
|
223
|
+
|
224
|
+
def is_unknown(self):
|
225
|
+
return self.router == ""
|
226
|
+
|
227
|
+
def __hash__(self):
|
228
|
+
return(hash(str(self)))
|
229
|
+
|
230
|
+
def add_next_hop(self, other:"Hop"):
|
231
|
+
"""
|
232
|
+
creates parent/child relationship between this hop and another one
|
233
|
+
"""
|
234
|
+
self._children.append(other)
|
235
|
+
other._parent = self
|
236
|
+
|
237
|
+
|
238
|
+
|
239
|
+
class Path:
|
240
|
+
"""
|
241
|
+
Keeps track of all our paths
|
242
|
+
"""
|
243
|
+
def __init__(self, first_hop:Hop):
|
244
|
+
|
245
|
+
self.first_hop = first_hop
|
246
|
+
self.hops = {str(first_hop): first_hop}
|
247
|
+
|
248
|
+
def add_hop(self, curr_hop:Hop, next_hop:Hop) -> bool:
|
249
|
+
"""
|
250
|
+
Adds a hop to our current hop. If this is a new hop,
|
251
|
+
which means we're on a new path, returns true. Otherwise
|
252
|
+
returns false to indicate we don't have to walk this path
|
253
|
+
since it's a duplicate of another.
|
254
|
+
"""
|
255
|
+
|
256
|
+
# if we've seen this hop already we will check for loops.
|
257
|
+
if str(next_hop) in self.hops:
|
258
|
+
new_path = False
|
259
|
+
|
260
|
+
# parent = self.curr_hop._parent
|
261
|
+
# while parent:
|
262
|
+
# if parent == next_hop:
|
263
|
+
# raise ValueError(f"Loop detected for {self.curr_hop} -> {next_hop}")
|
264
|
+
# parent = parent._parent
|
265
|
+
|
266
|
+
self.hops[str(next_hop)]
|
267
|
+
|
268
|
+
else:
|
269
|
+
new_path = True
|
270
|
+
self.hops[str(next_hop)] = next_hop
|
271
|
+
|
272
|
+
curr_hop.add_next_hop(next_hop)
|
273
|
+
return new_path
|
274
|
+
|
275
|
+
|
276
|
+
|
277
|
+
def walk_path(self) -> Dict[int, List[Hop]]:
|
278
|
+
"""
|
279
|
+
'Flattens' path from first hop to last into a dict
|
280
|
+
of list of hops keyed on hop number.
|
281
|
+
"""
|
282
|
+
hop_num = 1
|
283
|
+
result = { hop_num: {self.first_hop}}
|
284
|
+
self._walk_path(self.first_hop, hop_num, result)
|
285
|
+
return result
|
286
|
+
|
287
|
+
|
288
|
+
def _walk_path(self, curr_hop, hop_num, result):
|
289
|
+
hop_num +=1
|
290
|
+
for child in curr_hop._children:
|
291
|
+
if hop_num not in result:
|
292
|
+
result[hop_num] = set()
|
293
|
+
result[hop_num].add(child)
|
294
|
+
self._walk_path(child, hop_num, result)
|
295
|
+
|
296
|
+
|
297
|
+
def print_path(self):
|
298
|
+
"""
|
299
|
+
Prints out the path, hop by hop
|
300
|
+
"""
|
301
|
+
for hop_num, hops in self.walk_path().items():
|
302
|
+
hop_list = list(hops)
|
303
|
+
hop_list.sort()
|
304
|
+
print(f"***** hop {hop_num} *****")
|
305
|
+
for hop in hop_list:
|
306
|
+
print(f"\t{hop}")
|
307
|
+
|
308
|
+
|
309
|
+
def get_path(self) -> list[dict]:
|
310
|
+
"""
|
311
|
+
Returns hops as a list of dicts - for very basic displaying in
|
312
|
+
a table. In the future want to beef up the cli script
|
313
|
+
so that it can do nested tables, which is really
|
314
|
+
what we need for this.
|
315
|
+
"""
|
316
|
+
result = []
|
317
|
+
for hop_num, hops in self.walk_path().items():
|
318
|
+
hop_list = list(hops)
|
319
|
+
hop_list.sort()
|
320
|
+
hop_str = "\n".join([str(h) for h in hop_list]) + "\n"
|
321
|
+
result.append({'hop_num':hop_num, 'hops': hop_str})
|
322
|
+
|
323
|
+
return result
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: umnetdb-utils
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.0
|
4
4
|
Summary: Helper classes for querying UMnet databases
|
5
5
|
License: MIT
|
6
6
|
Author: Amy Liebowitz
|
@@ -16,6 +16,7 @@ Requires-Dist: oracledb (>=3.1.0,<4.0.0)
|
|
16
16
|
Requires-Dist: psycopg[binary] (>=3.2.9,<4.0.0)
|
17
17
|
Requires-Dist: python-decouple (>=3.8,<4.0)
|
18
18
|
Requires-Dist: sqlalchemy (>=2.0.41,<3.0.0)
|
19
|
+
Requires-Dist: typer (>=0.16.0,<0.17.0)
|
19
20
|
Description-Content-Type: text/markdown
|
20
21
|
|
21
22
|
# umnetdb-utils
|
@@ -0,0 +1,12 @@
|
|
1
|
+
umnetdb_utils/__init__.py,sha256=YrT7drJ2MlU287z4--8EQI3fKLQZBlVxaCacU1yoDtg,132
|
2
|
+
umnetdb_utils/base.py,sha256=LWVhrbShhuYekXZxwvhhVUkH6DHd_wnJ2QZ_zw3fKqM,6139
|
3
|
+
umnetdb_utils/cli.py,sha256=tq_XxGrCWJacP3tzxTULPHeuvbh1AU_GHXHY1jkI4-U,3349
|
4
|
+
umnetdb_utils/umnetdb.py,sha256=bzMwItOfCgpKhCGwmghqAyp9gKvckGsUIGvSJC1iUho,17122
|
5
|
+
umnetdb_utils/umnetdisco.py,sha256=D9MiF_QrYznYL_ozLUmD1ASzkGDs1jqZ_XksNV7wW3o,8018
|
6
|
+
umnetdb_utils/umnetequip.py,sha256=Nnh_YCcqGpYRp2KsS8qbAGJ9AgTeYB4umeYeP9_B-6A,5440
|
7
|
+
umnetdb_utils/umnetinfo.py,sha256=vqFvLbIu7Wu650RTqzaELpbKhq6y91fgBXM5Q_elRl4,18443
|
8
|
+
umnetdb_utils/utils.py,sha256=kAVLjX4UvN5VdllYPN2t-q386iDjsZZ-Bp8G_ZTchbI,9418
|
9
|
+
umnetdb_utils-0.2.0.dist-info/METADATA,sha256=kkv7rHDme5K2VXBfe24KFg6qBOsezgLZc8XYku5dpwU,1595
|
10
|
+
umnetdb_utils-0.2.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
11
|
+
umnetdb_utils-0.2.0.dist-info/entry_points.txt,sha256=nNmxVRlYLaeepjDKNCyARBF3d8igHtZGTVA_zoglevI,50
|
12
|
+
umnetdb_utils-0.2.0.dist-info/RECORD,,
|
@@ -1,10 +0,0 @@
|
|
1
|
-
umnetdb_utils/__init__.py,sha256=YrT7drJ2MlU287z4--8EQI3fKLQZBlVxaCacU1yoDtg,132
|
2
|
-
umnetdb_utils/base.py,sha256=LWVhrbShhuYekXZxwvhhVUkH6DHd_wnJ2QZ_zw3fKqM,6139
|
3
|
-
umnetdb_utils/umnetdb.py,sha256=jHifl5eJXzye28mjLnfn7A7zwN2Y4YuKgrFvjV5MQBI,4953
|
4
|
-
umnetdb_utils/umnetdisco.py,sha256=D9MiF_QrYznYL_ozLUmD1ASzkGDs1jqZ_XksNV7wW3o,8018
|
5
|
-
umnetdb_utils/umnetequip.py,sha256=Nnh_YCcqGpYRp2KsS8qbAGJ9AgTeYB4umeYeP9_B-6A,5440
|
6
|
-
umnetdb_utils/umnetinfo.py,sha256=vqFvLbIu7Wu650RTqzaELpbKhq6y91fgBXM5Q_elRl4,18443
|
7
|
-
umnetdb_utils/utils.py,sha256=VLxfBKq1iOQVxTuySvxV4goE_jtX4jCshjf9a5ky1vI,989
|
8
|
-
umnetdb_utils-0.1.5.dist-info/METADATA,sha256=fPpx67_VnM0Xp6JGk13mRrxOte8wPAkau0O0PtM9vW0,1555
|
9
|
-
umnetdb_utils-0.1.5.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
10
|
-
umnetdb_utils-0.1.5.dist-info/RECORD,,
|
File without changes
|