route-explain 0.4.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.
- route_explain/__init__.py +3 -0
- route_explain/__main__.py +3 -0
- route_explain/analyze.py +459 -0
- route_explain/cli.py +390 -0
- route_explain/collect.py +123 -0
- route_explain/context.py +87 -0
- route_explain/correlation.py +240 -0
- route_explain/diffing.py +164 -0
- route_explain/doctor.py +169 -0
- route_explain/model.py +85 -0
- route_explain/nfttrace.py +148 -0
- route_explain/overlay.py +120 -0
- route_explain/render.py +152 -0
- route_explain/snapshot.py +103 -0
- route_explain/why.py +39 -0
- route_explain-0.4.0.dist-info/METADATA +413 -0
- route_explain-0.4.0.dist-info/RECORD +20 -0
- route_explain-0.4.0.dist-info/WHEEL +4 -0
- route_explain-0.4.0.dist-info/entry_points.txt +2 -0
- route_explain-0.4.0.dist-info/licenses/LICENSE +21 -0
route_explain/analyze.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .model import Evidence, Flow, PolicyRule, Report, Route, RouteDecision
|
|
8
|
+
|
|
9
|
+
TABLE_NAMES = {
|
|
10
|
+
253: "default",
|
|
11
|
+
254: "main",
|
|
12
|
+
255: "local",
|
|
13
|
+
}
|
|
14
|
+
OVERLAY_PREFIXES = ("tailscale", "wg", "wireguard", "tun", "nebula", "zt", "headscale")
|
|
15
|
+
PROTOCOL_NUMBERS = {
|
|
16
|
+
"icmp": 1,
|
|
17
|
+
"tcp": 6,
|
|
18
|
+
"udp": 17,
|
|
19
|
+
"icmpv6": 58,
|
|
20
|
+
}
|
|
21
|
+
SELECTOR_KEYS = {
|
|
22
|
+
"tos",
|
|
23
|
+
"fwmark",
|
|
24
|
+
"fwmask",
|
|
25
|
+
"iif",
|
|
26
|
+
"oif",
|
|
27
|
+
"uidrange",
|
|
28
|
+
"ipproto",
|
|
29
|
+
"sport",
|
|
30
|
+
"dport",
|
|
31
|
+
"tun_id",
|
|
32
|
+
"l3mdev",
|
|
33
|
+
"not",
|
|
34
|
+
}
|
|
35
|
+
MODIFIER_KEYS = {"suppress_prefixlen", "suppress_prefixlength", "suppress_ifgroup", "goto", "nat", "realms", "nop"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def table_name(value: Any, *, default: str = "main") -> str:
|
|
39
|
+
if value is None:
|
|
40
|
+
return default
|
|
41
|
+
if isinstance(value, int):
|
|
42
|
+
return TABLE_NAMES.get(value, str(value))
|
|
43
|
+
text = str(value)
|
|
44
|
+
if text.isdigit():
|
|
45
|
+
return TABLE_NAMES.get(int(text), text)
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _network(value: str, version: int) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
|
|
50
|
+
if value in {"all", "default"}:
|
|
51
|
+
return ipaddress.ip_network("0.0.0.0/0" if version == 4 else "::/0")
|
|
52
|
+
return ipaddress.ip_network(value, strict=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _address_matches(selector: str, address: str | None, version: int) -> bool:
|
|
56
|
+
if selector in {"all", "default"}:
|
|
57
|
+
return True
|
|
58
|
+
if address is None:
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
return ipaddress.ip_address(address) in _network(selector, version)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _rule_prefix(item: dict[str, Any], key: str, version: int) -> str:
|
|
67
|
+
raw = str(item.get(key, "all"))
|
|
68
|
+
if raw in {"all", "default"} or "/" in raw:
|
|
69
|
+
return raw
|
|
70
|
+
length = item.get(f"{key}len")
|
|
71
|
+
if length is None:
|
|
72
|
+
return raw
|
|
73
|
+
try:
|
|
74
|
+
prefixlen = int(length)
|
|
75
|
+
return str(ipaddress.ip_network(f"{raw}/{prefixlen}", strict=False))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return raw
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _as_int(value: Any) -> int | None:
|
|
81
|
+
if value is None:
|
|
82
|
+
return None
|
|
83
|
+
if isinstance(value, bool):
|
|
84
|
+
return int(value)
|
|
85
|
+
if isinstance(value, int):
|
|
86
|
+
return value
|
|
87
|
+
text = str(value).strip()
|
|
88
|
+
try:
|
|
89
|
+
return int(text, 0)
|
|
90
|
+
except ValueError:
|
|
91
|
+
try:
|
|
92
|
+
return int(text)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _protocol_number(value: Any) -> int | None:
|
|
98
|
+
if value is None:
|
|
99
|
+
return None
|
|
100
|
+
number = _as_int(value)
|
|
101
|
+
if number is not None:
|
|
102
|
+
return number
|
|
103
|
+
return PROTOCOL_NUMBERS.get(str(value).casefold())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _range_matches(selector: Any, value: int | None) -> bool | None:
|
|
107
|
+
if value is None:
|
|
108
|
+
return None
|
|
109
|
+
if isinstance(selector, int):
|
|
110
|
+
return value == selector
|
|
111
|
+
text = str(selector).strip()
|
|
112
|
+
if "-" not in text:
|
|
113
|
+
parsed = _as_int(text)
|
|
114
|
+
return None if parsed is None else value == parsed
|
|
115
|
+
start_text, end_text = text.split("-", 1)
|
|
116
|
+
start = _as_int(start_text)
|
|
117
|
+
end = _as_int(end_text)
|
|
118
|
+
if start is None or end is None:
|
|
119
|
+
return None
|
|
120
|
+
return start <= value <= end
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _selector_result(item: dict[str, Any], flow: Flow) -> tuple[bool | None, tuple[str, ...]]:
|
|
124
|
+
"""Return selector truth and selectors that could not be evaluated.
|
|
125
|
+
|
|
126
|
+
True means every known selector matches. False means at least one known selector
|
|
127
|
+
definitely does not match. None means no known selector mismatched, but one or more
|
|
128
|
+
selectors could not be evaluated from the provided flow metadata.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
version = ipaddress.ip_address(flow.destination).version
|
|
132
|
+
unknown: list[str] = []
|
|
133
|
+
source_selector = _rule_prefix(item, "src", version)
|
|
134
|
+
destination_selector = _rule_prefix(item, "dst", version)
|
|
135
|
+
results: list[bool] = [
|
|
136
|
+
_address_matches(destination_selector, flow.destination, version),
|
|
137
|
+
]
|
|
138
|
+
if source_selector not in {"all", "default"} and flow.source is None:
|
|
139
|
+
unknown.append("src")
|
|
140
|
+
else:
|
|
141
|
+
results.append(_address_matches(source_selector, flow.source, version))
|
|
142
|
+
|
|
143
|
+
if "iif" in item:
|
|
144
|
+
if flow.iif is None:
|
|
145
|
+
unknown.append("iif")
|
|
146
|
+
else:
|
|
147
|
+
results.append(str(item["iif"]) == flow.iif)
|
|
148
|
+
|
|
149
|
+
if "oif" in item:
|
|
150
|
+
if flow.oif is None:
|
|
151
|
+
unknown.append("oif")
|
|
152
|
+
else:
|
|
153
|
+
results.append(str(item["oif"]) == flow.oif)
|
|
154
|
+
|
|
155
|
+
if "fwmark" in item:
|
|
156
|
+
rule_mark = _as_int(item.get("fwmark"))
|
|
157
|
+
mask = _as_int(item.get("fwmask"))
|
|
158
|
+
if mask is None:
|
|
159
|
+
mask = 0xFFFFFFFF
|
|
160
|
+
if flow.mark is None or rule_mark is None:
|
|
161
|
+
unknown.append("fwmark")
|
|
162
|
+
else:
|
|
163
|
+
results.append((flow.mark & mask) == (rule_mark & mask))
|
|
164
|
+
|
|
165
|
+
if "tos" in item:
|
|
166
|
+
rule_tos = _as_int(item.get("tos"))
|
|
167
|
+
if flow.tos is None or rule_tos is None:
|
|
168
|
+
unknown.append("tos")
|
|
169
|
+
else:
|
|
170
|
+
results.append(flow.tos == rule_tos)
|
|
171
|
+
|
|
172
|
+
if "ipproto" in item:
|
|
173
|
+
rule_proto = _protocol_number(item.get("ipproto"))
|
|
174
|
+
flow_proto = _protocol_number(flow.protocol)
|
|
175
|
+
if rule_proto is None or flow_proto is None:
|
|
176
|
+
unknown.append("ipproto")
|
|
177
|
+
else:
|
|
178
|
+
results.append(rule_proto == flow_proto)
|
|
179
|
+
|
|
180
|
+
for key, value in (("sport", flow.source_port), ("dport", flow.destination_port)):
|
|
181
|
+
if key in item:
|
|
182
|
+
result = _range_matches(item[key], value)
|
|
183
|
+
if result is None:
|
|
184
|
+
unknown.append(key)
|
|
185
|
+
else:
|
|
186
|
+
results.append(result)
|
|
187
|
+
|
|
188
|
+
# These selectors need state we intentionally do not synthesize yet.
|
|
189
|
+
for key in ("uidrange", "tun_id", "l3mdev"):
|
|
190
|
+
if key in item:
|
|
191
|
+
unknown.append(key)
|
|
192
|
+
|
|
193
|
+
if any(result is False for result in results):
|
|
194
|
+
base: bool | None = False
|
|
195
|
+
elif unknown:
|
|
196
|
+
base = None
|
|
197
|
+
else:
|
|
198
|
+
base = True
|
|
199
|
+
|
|
200
|
+
# iproute2 JSON represents `not` as a key with a null value, so presence matters.
|
|
201
|
+
if "not" in item and base is not None:
|
|
202
|
+
base = not base
|
|
203
|
+
|
|
204
|
+
unresolved = tuple(sorted(set(unknown))) if base is None else ()
|
|
205
|
+
return base, unresolved
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_decision(
|
|
209
|
+
flow: Flow,
|
|
210
|
+
route_get: list[dict[str, Any]],
|
|
211
|
+
fibmatch: list[dict[str, Any]] | None = None,
|
|
212
|
+
) -> RouteDecision:
|
|
213
|
+
if not route_get:
|
|
214
|
+
raise ValueError("kernel returned no route for the destination")
|
|
215
|
+
|
|
216
|
+
item = route_get[0]
|
|
217
|
+
fib = fibmatch[0] if fibmatch else {}
|
|
218
|
+
route_type = str(fib.get("type") or item.get("type") or "unicast")
|
|
219
|
+
return RouteDecision(
|
|
220
|
+
destination=str(item.get("dst", flow.destination)),
|
|
221
|
+
gateway=item.get("gateway"),
|
|
222
|
+
dev=item.get("dev"),
|
|
223
|
+
source=item.get("prefsrc") or item.get("src") or flow.source,
|
|
224
|
+
table=table_name(item.get("table") if "table" in item else fib.get("table")),
|
|
225
|
+
matched_prefix=str(fib["dst"]) if fib.get("dst") is not None else None,
|
|
226
|
+
route_type=route_type,
|
|
227
|
+
metric=fib.get("metric") if fib.get("metric") is not None else item.get("metric"),
|
|
228
|
+
raw=item,
|
|
229
|
+
fibmatch_raw=fib,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_rules(flow: Flow, rules: list[dict[str, Any]]) -> list[PolicyRule]:
|
|
234
|
+
version = ipaddress.ip_address(flow.destination).version
|
|
235
|
+
candidates: list[PolicyRule] = []
|
|
236
|
+
|
|
237
|
+
for item in rules:
|
|
238
|
+
source = _rule_prefix(item, "src", version)
|
|
239
|
+
destination = _rule_prefix(item, "dst", version)
|
|
240
|
+
selector_result, unknown = _selector_result(item, flow)
|
|
241
|
+
if selector_result is False:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
selectors = {key: item[key] for key in SELECTOR_KEYS if key in item and key != "not"}
|
|
245
|
+
modifiers = {key: item[key] for key in MODIFIER_KEYS if key in item}
|
|
246
|
+
if item.get("table") is not None:
|
|
247
|
+
action = "lookup"
|
|
248
|
+
elif "goto" in item:
|
|
249
|
+
action = f"goto {item['goto']}"
|
|
250
|
+
elif "nop" in item:
|
|
251
|
+
action = "nop"
|
|
252
|
+
else:
|
|
253
|
+
action = str(item.get("action") or "unspecified")
|
|
254
|
+
|
|
255
|
+
candidates.append(
|
|
256
|
+
PolicyRule(
|
|
257
|
+
priority=int(item.get("priority", 0)),
|
|
258
|
+
source=source,
|
|
259
|
+
destination=destination,
|
|
260
|
+
table=table_name(item.get("table"), default="unspecified"),
|
|
261
|
+
action=action,
|
|
262
|
+
certainty="match" if selector_result is True else "indeterminate",
|
|
263
|
+
inverted="not" in item,
|
|
264
|
+
selectors=selectors,
|
|
265
|
+
unknown_selectors=unknown,
|
|
266
|
+
modifiers=modifiers,
|
|
267
|
+
raw=item,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return sorted(candidates, key=lambda rule: rule.priority)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def parse_matching_routes(flow: Flow, routes: list[dict[str, Any]]) -> list[Route]:
|
|
275
|
+
destination = ipaddress.ip_address(flow.destination)
|
|
276
|
+
matches: list[tuple[int, Route]] = []
|
|
277
|
+
|
|
278
|
+
for item in routes:
|
|
279
|
+
raw_dst = str(item.get("dst", "default"))
|
|
280
|
+
try:
|
|
281
|
+
network = _network(raw_dst, destination.version)
|
|
282
|
+
except ValueError:
|
|
283
|
+
continue
|
|
284
|
+
if destination not in network:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
route = Route(
|
|
288
|
+
destination=raw_dst,
|
|
289
|
+
gateway=item.get("gateway"),
|
|
290
|
+
dev=item.get("dev"),
|
|
291
|
+
table=table_name(item.get("table")),
|
|
292
|
+
metric=item.get("metric"),
|
|
293
|
+
route_type=str(item.get("type", "unicast")),
|
|
294
|
+
protocol=str(item["protocol"]) if item.get("protocol") is not None else None,
|
|
295
|
+
scope=str(item["scope"]) if item.get("scope") is not None else None,
|
|
296
|
+
raw=item,
|
|
297
|
+
)
|
|
298
|
+
matches.append((network.prefixlen, route))
|
|
299
|
+
|
|
300
|
+
matches.sort(key=lambda pair: (-pair[0], pair[1].table, pair[1].metric or 0))
|
|
301
|
+
return [route for _, route in matches]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def detect_overlays(links: list[dict[str, Any]]) -> list[str]:
|
|
305
|
+
names: list[str] = []
|
|
306
|
+
for item in links:
|
|
307
|
+
name = str(item.get("ifname", ""))
|
|
308
|
+
lowered = name.casefold()
|
|
309
|
+
link_kind = str((item.get("linkinfo") or {}).get("info_kind", "")).casefold()
|
|
310
|
+
if any(lowered.startswith(prefix) for prefix in OVERLAY_PREFIXES) or link_kind in {
|
|
311
|
+
"wireguard",
|
|
312
|
+
"tun",
|
|
313
|
+
}:
|
|
314
|
+
names.append(name)
|
|
315
|
+
return sorted({name for name in names if name})
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _route_prefixlen(route: Route, version: int) -> int:
|
|
319
|
+
try:
|
|
320
|
+
return _network(route.destination, version).prefixlen
|
|
321
|
+
except ValueError:
|
|
322
|
+
return -1
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _build_evidence(
|
|
326
|
+
flow: Flow,
|
|
327
|
+
decision: RouteDecision,
|
|
328
|
+
candidate_rules: list[PolicyRule],
|
|
329
|
+
matching_routes: list[Route],
|
|
330
|
+
overlays: list[str],
|
|
331
|
+
) -> list[Evidence]:
|
|
332
|
+
evidence = [
|
|
333
|
+
Evidence(
|
|
334
|
+
"kernel",
|
|
335
|
+
f"kernel resolved the flow through table {decision.table}"
|
|
336
|
+
+ (f" on {decision.dev}" if decision.dev else ""),
|
|
337
|
+
)
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
if decision.matched_prefix:
|
|
341
|
+
evidence.append(
|
|
342
|
+
Evidence(
|
|
343
|
+
"kernel",
|
|
344
|
+
f"fibmatch selected prefix {decision.matched_prefix} in table {decision.table}",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
selected_table_rules = [
|
|
349
|
+
rule for rule in candidate_rules if rule.table == decision.table and rule.action == "lookup"
|
|
350
|
+
]
|
|
351
|
+
certain = [rule for rule in selected_table_rules if rule.certainty == "match"]
|
|
352
|
+
uncertain = [rule for rule in selected_table_rules if rule.certainty == "indeterminate"]
|
|
353
|
+
if certain:
|
|
354
|
+
priorities = ", ".join(str(rule.priority) for rule in certain)
|
|
355
|
+
evidence.append(
|
|
356
|
+
Evidence(
|
|
357
|
+
"derived",
|
|
358
|
+
f"policy rule selector(s) at priority {priorities} match and reference the selected table",
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
elif uncertain:
|
|
362
|
+
priorities = ", ".join(str(rule.priority) for rule in uncertain)
|
|
363
|
+
evidence.append(
|
|
364
|
+
Evidence(
|
|
365
|
+
"caution",
|
|
366
|
+
f"rule(s) at priority {priorities} reference the selected table but need missing selector context",
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
version = ipaddress.ip_address(flow.destination).version
|
|
371
|
+
selected_prefixlen = -1
|
|
372
|
+
if decision.matched_prefix:
|
|
373
|
+
with suppress(ValueError):
|
|
374
|
+
selected_prefixlen = _network(decision.matched_prefix, version).prefixlen
|
|
375
|
+
|
|
376
|
+
competitors = [
|
|
377
|
+
route
|
|
378
|
+
for route in matching_routes
|
|
379
|
+
if route.table != decision.table and _route_prefixlen(route, version) > selected_prefixlen
|
|
380
|
+
]
|
|
381
|
+
if competitors:
|
|
382
|
+
best = max(competitors, key=lambda route: _route_prefixlen(route, version))
|
|
383
|
+
evidence.append(
|
|
384
|
+
Evidence(
|
|
385
|
+
"caution",
|
|
386
|
+
f"a more-specific route exists in table {best.table}: {best.destination}"
|
|
387
|
+
+ (f" via {best.dev}" if best.dev else "")
|
|
388
|
+
+ "; policy routing kept it out of the selected path",
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
other_tables = sorted({route.table for route in matching_routes if route.table != decision.table})
|
|
393
|
+
if other_tables:
|
|
394
|
+
evidence.append(
|
|
395
|
+
Evidence(
|
|
396
|
+
"derived",
|
|
397
|
+
f"matching route context also exists in table(s): {', '.join(other_tables)}",
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
unused_overlays = [name for name in overlays if name != decision.dev]
|
|
402
|
+
if unused_overlays:
|
|
403
|
+
evidence.append(
|
|
404
|
+
Evidence(
|
|
405
|
+
"derived",
|
|
406
|
+
f"overlay interface(s) are present but not selected: {', '.join(unused_overlays)}",
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
indeterminate = [rule for rule in candidate_rules if rule.certainty == "indeterminate"]
|
|
411
|
+
if indeterminate:
|
|
412
|
+
details = sorted({selector for rule in indeterminate for selector in rule.unknown_selectors})
|
|
413
|
+
evidence.append(
|
|
414
|
+
Evidence(
|
|
415
|
+
"caution",
|
|
416
|
+
"some policy-rule candidates remain indeterminate because the flow lacks: "
|
|
417
|
+
+ ", ".join(details),
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
evidence.append(
|
|
422
|
+
Evidence(
|
|
423
|
+
"caution",
|
|
424
|
+
"firewall, NAT, conntrack, and packet-mark mutation are not traced; routing evidence stops at the FIB/RPDB boundary",
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
return evidence
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def build_report(
|
|
431
|
+
flow: Flow,
|
|
432
|
+
*,
|
|
433
|
+
route_get: list[dict[str, Any]],
|
|
434
|
+
fibmatch: list[dict[str, Any]] | None = None,
|
|
435
|
+
rules: list[dict[str, Any]],
|
|
436
|
+
routes: list[dict[str, Any]],
|
|
437
|
+
links: list[dict[str, Any]],
|
|
438
|
+
) -> Report:
|
|
439
|
+
decision = parse_decision(flow, route_get, fibmatch)
|
|
440
|
+
candidate_rules = parse_rules(flow, rules)
|
|
441
|
+
matching_routes = parse_matching_routes(flow, routes)
|
|
442
|
+
overlays = detect_overlays(links)
|
|
443
|
+
evidence = _build_evidence(flow, decision, candidate_rules, matching_routes, overlays)
|
|
444
|
+
|
|
445
|
+
notes: list[str] = []
|
|
446
|
+
if not fibmatch:
|
|
447
|
+
notes.append("fibmatch evidence was unavailable; exact selected route prefix is unknown")
|
|
448
|
+
if any(rule.modifiers for rule in candidate_rules):
|
|
449
|
+
notes.append("one or more matching rules use RPDB action modifiers; read them as context, not a simulated rule trace")
|
|
450
|
+
|
|
451
|
+
return Report(
|
|
452
|
+
flow=flow,
|
|
453
|
+
decision=decision,
|
|
454
|
+
candidate_rules=candidate_rules,
|
|
455
|
+
matching_routes=matching_routes,
|
|
456
|
+
overlays=overlays,
|
|
457
|
+
evidence=evidence,
|
|
458
|
+
notes=notes,
|
|
459
|
+
)
|