ms-split-router 0.1.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.
- ms_split_router/__init__.py +4 -0
- ms_split_router/__main__.py +4 -0
- ms_split_router/cli.py +192 -0
- ms_split_router/config.py +611 -0
- ms_split_router/data.py +152 -0
- ms_split_router/discover.py +146 -0
- ms_split_router/domains.py +117 -0
- ms_split_router/example_config.yaml +81 -0
- ms_split_router/generate.py +200 -0
- ms_split_router/install.py +163 -0
- ms_split_router/netutil.py +105 -0
- ms_split_router/py.typed +0 -0
- ms_split_router/render.py +221 -0
- ms_split_router/select.py +111 -0
- ms_split_router-0.1.0.dist-info/METADATA +326 -0
- ms_split_router-0.1.0.dist-info/RECORD +19 -0
- ms_split_router-0.1.0.dist-info/WHEEL +4 -0
- ms_split_router-0.1.0.dist-info/entry_points.txt +2 -0
- ms_split_router-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""Generator configuration: YAML file -> validated, frozen dataclasses."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .netutil import MAC_RE, IPAddress, IPNetwork, is_valid_domain, normalise_domain, parse_address, parse_network
|
|
13
|
+
|
|
14
|
+
ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62})$")
|
|
15
|
+
IFNAME_RE = re.compile(r"^[^\s/]{1,128}$")
|
|
16
|
+
|
|
17
|
+
DERIVE_SOURCES = ("m365", "m365_additional", "private_dns")
|
|
18
|
+
M365_AREAS = ("Common", "Exchange", "SharePoint", "Skype")
|
|
19
|
+
M365_CATEGORIES = ("Optimize", "Allow", "Default")
|
|
20
|
+
|
|
21
|
+
type Style = Literal["full", "dropin"]
|
|
22
|
+
type Tristate = Literal["auto"] | bool
|
|
23
|
+
# section -> key -> values (one line per value)
|
|
24
|
+
type ExtraSections = dict[str, dict[str, tuple[str, ...]]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigError(ValueError):
|
|
28
|
+
"""Invalid configuration; the message names the offending key."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Match:
|
|
33
|
+
mac: str | None = None
|
|
34
|
+
altname: str | None = None
|
|
35
|
+
name: str | None = None
|
|
36
|
+
|
|
37
|
+
def describe(self) -> str:
|
|
38
|
+
if self.mac:
|
|
39
|
+
return f"MAC {self.mac}"
|
|
40
|
+
return f"altname {self.altname}" if self.altname else f"name {self.name}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ResolveOptions:
|
|
45
|
+
"""Per-link systemd-resolved settings written to the ``[Network]`` section."""
|
|
46
|
+
|
|
47
|
+
dnssec: str | None = None
|
|
48
|
+
llmnr: str | None = None
|
|
49
|
+
multicast_dns: str | None = None
|
|
50
|
+
dns_over_tls: str | None = None
|
|
51
|
+
|
|
52
|
+
def keys(self) -> list[tuple[str, str]]:
|
|
53
|
+
pairs = [
|
|
54
|
+
("DNSSEC", self.dnssec),
|
|
55
|
+
("LLMNR", self.llmnr),
|
|
56
|
+
("MulticastDNS", self.multicast_dns),
|
|
57
|
+
("DNSOverTLS", self.dns_over_tls),
|
|
58
|
+
]
|
|
59
|
+
return [(key, value) for key, value in pairs if value is not None]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class DomainSelection:
|
|
64
|
+
derive_from: tuple[str, ...] = ("m365", "private_dns")
|
|
65
|
+
collapse_to_root: bool = True
|
|
66
|
+
exclude_third_party: bool = True
|
|
67
|
+
private_dns_clouds: tuple[str, ...] = ("Commercial",)
|
|
68
|
+
extra: tuple[str, ...] = ()
|
|
69
|
+
exclude: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class AzureRoutes:
|
|
74
|
+
clouds: tuple[str, ...] = ("Public",)
|
|
75
|
+
service_tags: tuple[str, ...] = ("AzureCloud",)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class M365Routes:
|
|
80
|
+
instances: tuple[str, ...] = ("Worldwide",)
|
|
81
|
+
service_areas: tuple[str, ...] = () # empty = all
|
|
82
|
+
categories: tuple[str, ...] = () # empty = all
|
|
83
|
+
required_only: bool = False
|
|
84
|
+
express_route_only: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class M365AdditionalRoutes:
|
|
89
|
+
clouds: tuple[str, ...] = ("public",)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class RouteSelection:
|
|
94
|
+
azure: AzureRoutes | None = field(default_factory=AzureRoutes)
|
|
95
|
+
m365: M365Routes | None = field(default_factory=M365Routes)
|
|
96
|
+
m365_additional: M365AdditionalRoutes | None = None
|
|
97
|
+
extra_prefixes: tuple[IPNetwork, ...] = ()
|
|
98
|
+
exclude_prefixes: tuple[IPNetwork, ...] = ()
|
|
99
|
+
ipv6: Tristate = "auto"
|
|
100
|
+
# Prefixes longer than this are widened to it before collapsing. /48 is the smallest globally routed IPv6 block,
|
|
101
|
+
# so widening to it never reaches another network's space and keeps AzureCloud's IPv6 within networkd's limits.
|
|
102
|
+
widen_to_ipv4: int | None = None
|
|
103
|
+
widen_to_ipv6: int | None = 48
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class SplitLink:
|
|
108
|
+
id: str
|
|
109
|
+
match: Match | None
|
|
110
|
+
dns: tuple[str, ...]
|
|
111
|
+
style: Style = "full"
|
|
112
|
+
base: str | None = None
|
|
113
|
+
altname_link: str | None = None
|
|
114
|
+
gateway4: IPAddress | Literal["auto"] | None = "auto"
|
|
115
|
+
gateway6: IPAddress | Literal["auto"] | None = "auto"
|
|
116
|
+
onlink: Tristate = "auto"
|
|
117
|
+
metric: int | None = None
|
|
118
|
+
dns_default_route: bool = False
|
|
119
|
+
required_for_online: str = "no"
|
|
120
|
+
resolve: ResolveOptions = field(default_factory=ResolveOptions)
|
|
121
|
+
domains: DomainSelection = field(default_factory=DomainSelection)
|
|
122
|
+
routes: RouteSelection = field(default_factory=RouteSelection)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class DefaultLink:
|
|
127
|
+
id: str
|
|
128
|
+
match: Match | None
|
|
129
|
+
dns: tuple[str, ...] = ()
|
|
130
|
+
style: Style = "full"
|
|
131
|
+
base: str | None = None
|
|
132
|
+
domains: tuple[str, ...] = ("~.",)
|
|
133
|
+
dns_default_route: bool = True
|
|
134
|
+
required_for_online: str = "routable"
|
|
135
|
+
resolve: ResolveOptions = field(default_factory=ResolveOptions)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class DataConfig:
|
|
140
|
+
file: Path | None = None
|
|
141
|
+
fetch_command: str = "ms-cloud-endpoints"
|
|
142
|
+
fetch_args: tuple[str, ...] = ()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class NetworkdConf:
|
|
147
|
+
enabled: bool = True
|
|
148
|
+
manage_foreign_routes: bool = False
|
|
149
|
+
manage_foreign_routing_policy_rules: bool = False
|
|
150
|
+
extra: ExtraSections = field(default_factory=dict)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class ResolvedConf:
|
|
155
|
+
enabled: bool = True
|
|
156
|
+
dnssec: str | None = "no"
|
|
157
|
+
extra: ExtraSections = field(default_factory=dict)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True)
|
|
161
|
+
class Config:
|
|
162
|
+
data: DataConfig = field(default_factory=DataConfig)
|
|
163
|
+
networkd_conf: NetworkdConf = field(default_factory=NetworkdConf)
|
|
164
|
+
resolved_conf: ResolvedConf = field(default_factory=ResolvedConf)
|
|
165
|
+
split_links: tuple[SplitLink, ...] = ()
|
|
166
|
+
default_links: tuple[DefaultLink, ...] = ()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- parsing helpers --------------------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _fail(path: str, message: str) -> ConfigError:
|
|
173
|
+
return ConfigError(f"{path}: {message}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _mapping(value: Any, path: str, allowed: tuple[str, ...]) -> Mapping[str, Any]:
|
|
177
|
+
if value is None:
|
|
178
|
+
return {}
|
|
179
|
+
if not isinstance(value, Mapping):
|
|
180
|
+
raise _fail(path, f"expected a mapping, got {type(value).__name__}")
|
|
181
|
+
unknown = sorted(set(value) - set(allowed))
|
|
182
|
+
if unknown:
|
|
183
|
+
raise _fail(path, f"unknown key(s) {', '.join(map(str, unknown))}; allowed: {', '.join(allowed)}")
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _bool(value: Any, path: str) -> bool:
|
|
188
|
+
if not isinstance(value, bool):
|
|
189
|
+
raise _fail(path, f"expected true/false, got {value!r}")
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _str(value: Any, path: str) -> str:
|
|
194
|
+
if not isinstance(value, str) or not value.strip():
|
|
195
|
+
raise _fail(path, f"expected a non-empty string, got {value!r}")
|
|
196
|
+
return value.strip()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _str_list(value: Any, path: str) -> tuple[str, ...]:
|
|
200
|
+
if value is None:
|
|
201
|
+
return ()
|
|
202
|
+
if isinstance(value, str):
|
|
203
|
+
value = [value]
|
|
204
|
+
if not isinstance(value, list):
|
|
205
|
+
raise _fail(path, f"expected a list, got {type(value).__name__}")
|
|
206
|
+
return tuple(_str(item, f"{path}[{index}]") for index, item in enumerate(value))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _tristate(value: Any, path: str) -> Tristate:
|
|
210
|
+
if value == "auto" or isinstance(value, bool):
|
|
211
|
+
return value
|
|
212
|
+
raise _fail(path, f"expected auto, true or false, got {value!r}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _systemd_value(value: Any) -> str:
|
|
216
|
+
"""Render a YAML scalar as a systemd setting value (booleans become yes/no)."""
|
|
217
|
+
if isinstance(value, bool):
|
|
218
|
+
return "yes" if value else "no"
|
|
219
|
+
return str(value)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _optional_setting(value: Any, path: str) -> str | None:
|
|
223
|
+
if value is None:
|
|
224
|
+
return None
|
|
225
|
+
if isinstance(value, bool | str | int):
|
|
226
|
+
return _systemd_value(value)
|
|
227
|
+
raise _fail(path, f"expected a boolean or string, got {value!r}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _choices(values: tuple[str, ...], allowed: tuple[str, ...], path: str) -> tuple[str, ...]:
|
|
231
|
+
lookup = {choice.lower(): choice for choice in allowed}
|
|
232
|
+
result = []
|
|
233
|
+
for index, value in enumerate(values):
|
|
234
|
+
if value.lower() not in lookup:
|
|
235
|
+
raise _fail(f"{path}[{index}]", f"unknown value {value!r}; allowed: {', '.join(allowed)}")
|
|
236
|
+
result.append(lookup[value.lower()])
|
|
237
|
+
return tuple(dict.fromkeys(result))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _networks(value: Any, path: str) -> tuple[IPNetwork, ...]:
|
|
241
|
+
result = []
|
|
242
|
+
for index, item in enumerate(_str_list(value, path)):
|
|
243
|
+
try:
|
|
244
|
+
network = parse_network(item)
|
|
245
|
+
except ValueError as exc:
|
|
246
|
+
raise _fail(f"{path}[{index}]", str(exc)) from None
|
|
247
|
+
if network.prefixlen == 0:
|
|
248
|
+
raise _fail(f"{path}[{index}]", f"{network} would replace the default route; refusing")
|
|
249
|
+
result.append(network)
|
|
250
|
+
return tuple(result)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _domains(value: Any, path: str) -> tuple[str, ...]:
|
|
254
|
+
result = []
|
|
255
|
+
for index, item in enumerate(_str_list(value, path)):
|
|
256
|
+
domain = normalise_domain(item.removeprefix("~"))
|
|
257
|
+
if not is_valid_domain(domain):
|
|
258
|
+
raise _fail(f"{path}[{index}]", f"invalid domain {item!r}")
|
|
259
|
+
result.append(domain)
|
|
260
|
+
return tuple(result)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _dns_servers(value: Any, path: str) -> tuple[str, ...]:
|
|
264
|
+
servers = _str_list(value, path)
|
|
265
|
+
for index, server in enumerate(servers):
|
|
266
|
+
# systemd accepts "address[:port][%ifname][#server-name]"; validate the address part.
|
|
267
|
+
address = server.split("#", 1)[0].split("%", 1)[0]
|
|
268
|
+
if address.startswith("["):
|
|
269
|
+
address = address[1:].split("]", 1)[0]
|
|
270
|
+
elif address.count(":") == 1:
|
|
271
|
+
address = address.split(":", 1)[0]
|
|
272
|
+
try:
|
|
273
|
+
parse_address(address)
|
|
274
|
+
except ValueError:
|
|
275
|
+
raise _fail(f"{path}[{index}]", f"invalid DNS server address {server!r}") from None
|
|
276
|
+
return servers
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _extra_sections(value: Any, path: str, default_section: str) -> ExtraSections:
|
|
280
|
+
if value is None:
|
|
281
|
+
return {}
|
|
282
|
+
if not isinstance(value, Mapping):
|
|
283
|
+
raise _fail(path, "expected a mapping of keys (or of sections to keys)")
|
|
284
|
+
sections: ExtraSections = {}
|
|
285
|
+
|
|
286
|
+
def add(section: str, key: Any, raw: Any, key_path: str) -> None:
|
|
287
|
+
if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", key):
|
|
288
|
+
raise _fail(key_path, f"invalid setting name {key!r}")
|
|
289
|
+
values = raw if isinstance(raw, list) else [raw]
|
|
290
|
+
if not all(isinstance(item, bool | str | int | float) for item in values):
|
|
291
|
+
raise _fail(key_path, "expected a scalar or a list of scalars")
|
|
292
|
+
sections.setdefault(section, {})[key] = tuple(_systemd_value(item) for item in values)
|
|
293
|
+
|
|
294
|
+
for key, raw in value.items():
|
|
295
|
+
if isinstance(raw, Mapping):
|
|
296
|
+
for sub_key, sub_raw in raw.items():
|
|
297
|
+
add(str(key), sub_key, sub_raw, f"{path}.{key}.{sub_key}")
|
|
298
|
+
else:
|
|
299
|
+
add(default_section, key, raw, f"{path}.{key}")
|
|
300
|
+
return sections
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _match(value: Any, path: str) -> Match | None:
|
|
304
|
+
if value is None:
|
|
305
|
+
return None
|
|
306
|
+
raw = _mapping(value, path, ("mac", "altname", "name"))
|
|
307
|
+
present = [key for key in ("mac", "altname", "name") if raw.get(key) is not None]
|
|
308
|
+
if len(present) != 1:
|
|
309
|
+
raise _fail(path, "set exactly one of mac, altname or name")
|
|
310
|
+
key = present[0]
|
|
311
|
+
text = _str(raw[key], f"{path}.{key}")
|
|
312
|
+
if key == "mac":
|
|
313
|
+
text = text.lower().replace("-", ":")
|
|
314
|
+
if not MAC_RE.match(text):
|
|
315
|
+
raise _fail(f"{path}.mac", f"invalid MAC address {raw[key]!r}")
|
|
316
|
+
elif not IFNAME_RE.match(text):
|
|
317
|
+
raise _fail(f"{path}.{key}", f"invalid interface name {text!r}")
|
|
318
|
+
return Match(**{key: text})
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _gateway(value: Any, path: str) -> IPAddress | Literal["auto"] | None:
|
|
322
|
+
if value is None or value == "none" or value is False:
|
|
323
|
+
return None
|
|
324
|
+
if value == "auto":
|
|
325
|
+
return "auto"
|
|
326
|
+
try:
|
|
327
|
+
return parse_address(_str(value, path))
|
|
328
|
+
except ValueError:
|
|
329
|
+
raise _fail(path, f"expected auto, none or an IP address, got {value!r}") from None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _resolve(value: Any, path: str) -> ResolveOptions:
|
|
333
|
+
raw = _mapping(value, path, ("dnssec", "llmnr", "multicast_dns", "dns_over_tls"))
|
|
334
|
+
return ResolveOptions(**{key: _optional_setting(raw.get(key), f"{path}.{key}") for key in raw})
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _style(raw: Mapping[str, Any], path: str) -> tuple[Style, str | None]:
|
|
338
|
+
style = raw.get("style", "full")
|
|
339
|
+
if style not in ("full", "dropin"):
|
|
340
|
+
raise _fail(f"{path}.style", f"expected full or dropin, got {style!r}")
|
|
341
|
+
base = raw.get("base")
|
|
342
|
+
if style == "dropin":
|
|
343
|
+
base = _str(base, f"{path}.base")
|
|
344
|
+
if "/" in base or not base.endswith(".network"):
|
|
345
|
+
raise _fail(f"{path}.base", "expected the file name of a .network file, e.g. 10-eth1.network")
|
|
346
|
+
elif base is not None:
|
|
347
|
+
raise _fail(f"{path}.base", "only valid with style: dropin")
|
|
348
|
+
return style, base
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _link_id(raw: Mapping[str, Any], path: str) -> str:
|
|
352
|
+
link_id = _str(raw.get("id"), f"{path}.id")
|
|
353
|
+
if not ID_RE.match(link_id):
|
|
354
|
+
raise _fail(f"{path}.id", f"{link_id!r} must be lower-case letters, digits and '-'")
|
|
355
|
+
return link_id
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _domain_selection(value: Any, path: str) -> DomainSelection:
|
|
359
|
+
keys = ("derive_from", "collapse_to_root", "exclude_third_party", "private_dns_clouds", "extra", "exclude")
|
|
360
|
+
raw = _mapping(value, path, keys)
|
|
361
|
+
default = DomainSelection()
|
|
362
|
+
derive = raw.get("derive_from", list(default.derive_from))
|
|
363
|
+
return DomainSelection(
|
|
364
|
+
derive_from=_choices(_str_list(derive, f"{path}.derive_from"), DERIVE_SOURCES, f"{path}.derive_from"),
|
|
365
|
+
collapse_to_root=_bool(raw.get("collapse_to_root", default.collapse_to_root), f"{path}.collapse_to_root"),
|
|
366
|
+
exclude_third_party=_bool(
|
|
367
|
+
raw.get("exclude_third_party", default.exclude_third_party), f"{path}.exclude_third_party"
|
|
368
|
+
),
|
|
369
|
+
private_dns_clouds=_str_list(raw.get("private_dns_clouds", ["Commercial"]), f"{path}.private_dns_clouds"),
|
|
370
|
+
extra=_domains(raw.get("extra"), f"{path}.extra"),
|
|
371
|
+
exclude=_domains(raw.get("exclude"), f"{path}.exclude"),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _routes(value: Any, path: str) -> RouteSelection:
|
|
376
|
+
raw = _mapping(
|
|
377
|
+
value, path, ("azure", "m365", "m365_additional", "extra_prefixes", "exclude_prefixes", "ipv6", "widen_to")
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
azure: AzureRoutes | None = AzureRoutes()
|
|
381
|
+
if "azure" in raw:
|
|
382
|
+
if raw["azure"] in (None, False):
|
|
383
|
+
azure = None
|
|
384
|
+
else:
|
|
385
|
+
sub = _mapping(raw["azure"], f"{path}.azure", ("clouds", "service_tags"))
|
|
386
|
+
azure = AzureRoutes(
|
|
387
|
+
clouds=_str_list(sub.get("clouds", ["Public"]), f"{path}.azure.clouds"),
|
|
388
|
+
service_tags=_str_list(sub.get("service_tags", ["AzureCloud"]), f"{path}.azure.service_tags"),
|
|
389
|
+
)
|
|
390
|
+
if not azure.service_tags:
|
|
391
|
+
azure = None
|
|
392
|
+
|
|
393
|
+
m365: M365Routes | None = M365Routes()
|
|
394
|
+
if "m365" in raw:
|
|
395
|
+
if raw["m365"] in (None, False):
|
|
396
|
+
m365 = None
|
|
397
|
+
else:
|
|
398
|
+
sub_path = f"{path}.m365"
|
|
399
|
+
sub = _mapping(
|
|
400
|
+
raw["m365"],
|
|
401
|
+
sub_path,
|
|
402
|
+
("instances", "service_areas", "categories", "required_only", "express_route_only"),
|
|
403
|
+
)
|
|
404
|
+
m365 = M365Routes(
|
|
405
|
+
instances=_str_list(sub.get("instances", ["Worldwide"]), f"{sub_path}.instances"),
|
|
406
|
+
service_areas=_choices(
|
|
407
|
+
_str_list(sub.get("service_areas"), f"{sub_path}.service_areas"),
|
|
408
|
+
M365_AREAS,
|
|
409
|
+
f"{sub_path}.service_areas",
|
|
410
|
+
),
|
|
411
|
+
categories=_choices(
|
|
412
|
+
_str_list(sub.get("categories"), f"{sub_path}.categories"),
|
|
413
|
+
M365_CATEGORIES,
|
|
414
|
+
f"{sub_path}.categories",
|
|
415
|
+
),
|
|
416
|
+
required_only=_bool(sub.get("required_only", False), f"{sub_path}.required_only"),
|
|
417
|
+
express_route_only=_bool(sub.get("express_route_only", False), f"{sub_path}.express_route_only"),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
m365_additional: M365AdditionalRoutes | None = None
|
|
421
|
+
additional = raw.get("m365_additional")
|
|
422
|
+
if additional is True:
|
|
423
|
+
m365_additional = M365AdditionalRoutes()
|
|
424
|
+
elif isinstance(additional, Mapping):
|
|
425
|
+
sub = _mapping(additional, f"{path}.m365_additional", ("clouds",))
|
|
426
|
+
clouds = _str_list(sub.get("clouds", ["public"]), f"{path}.m365_additional.clouds")
|
|
427
|
+
m365_additional = M365AdditionalRoutes(
|
|
428
|
+
clouds=_choices(clouds, ("public", "usgov", "china"), f"{path}.m365_additional.clouds")
|
|
429
|
+
)
|
|
430
|
+
elif additional not in (None, False):
|
|
431
|
+
raise _fail(f"{path}.m365_additional", "expected true, false or a mapping")
|
|
432
|
+
|
|
433
|
+
widen = _mapping(raw.get("widen_to"), f"{path}.widen_to", ("ipv4", "ipv6"))
|
|
434
|
+
widen_to: dict[str, int | None] = {"ipv4": None, "ipv6": 48}
|
|
435
|
+
for family, (low, high) in (("ipv4", (8, 32)), ("ipv6", (16, 128))):
|
|
436
|
+
if family in widen:
|
|
437
|
+
length = widen[family]
|
|
438
|
+
if length is not None and (not isinstance(length, int) or isinstance(length, bool)
|
|
439
|
+
or not low <= length <= high): # fmt: skip
|
|
440
|
+
raise _fail(f"{path}.widen_to.{family}", f"expected null or a prefix length {low}-{high}")
|
|
441
|
+
widen_to[family] = length
|
|
442
|
+
|
|
443
|
+
return RouteSelection(
|
|
444
|
+
azure=azure,
|
|
445
|
+
m365=m365,
|
|
446
|
+
m365_additional=m365_additional,
|
|
447
|
+
extra_prefixes=_networks(raw.get("extra_prefixes"), f"{path}.extra_prefixes"),
|
|
448
|
+
exclude_prefixes=_networks(raw.get("exclude_prefixes"), f"{path}.exclude_prefixes"),
|
|
449
|
+
ipv6=_tristate(raw.get("ipv6", "auto"), f"{path}.ipv6"),
|
|
450
|
+
widen_to_ipv4=widen_to["ipv4"],
|
|
451
|
+
widen_to_ipv6=widen_to["ipv6"],
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _split_link(value: Any, path: str) -> SplitLink:
|
|
456
|
+
keys = (
|
|
457
|
+
"id", "match", "altname_link", "style", "base", "gateway4", "gateway6", "onlink", "metric", "dns",
|
|
458
|
+
"dns_default_route", "required_for_online", "resolve", "domains", "routes",
|
|
459
|
+
) # fmt: skip
|
|
460
|
+
raw = _mapping(value, path, keys)
|
|
461
|
+
link_id = _link_id(raw, path)
|
|
462
|
+
style, base = _style(raw, path)
|
|
463
|
+
match = _match(raw.get("match"), f"{path}.match")
|
|
464
|
+
gateway4 = _gateway(raw.get("gateway4", "auto"), f"{path}.gateway4")
|
|
465
|
+
gateway6 = _gateway(raw.get("gateway6", "auto"), f"{path}.gateway6")
|
|
466
|
+
for name, gateway in (("gateway4", gateway4), ("gateway6", gateway6)):
|
|
467
|
+
expected = 4 if name == "gateway4" else 6
|
|
468
|
+
if isinstance(gateway, ipaddress.IPv4Address | ipaddress.IPv6Address) and gateway.version != expected:
|
|
469
|
+
raise _fail(f"{path}.{name}", f"expected an IPv{expected} address")
|
|
470
|
+
needs_discovery = "auto" in (gateway4, gateway6) or raw.get("onlink", "auto") == "auto"
|
|
471
|
+
if match is None and (style == "full" or needs_discovery):
|
|
472
|
+
raise _fail(f"{path}.match", "required for style: full and for gateway/onlink auto-detection")
|
|
473
|
+
|
|
474
|
+
altname_link = None
|
|
475
|
+
if raw.get("altname_link") is not None:
|
|
476
|
+
sub = _mapping(raw["altname_link"], f"{path}.altname_link", ("name",))
|
|
477
|
+
altname_link = _str(sub.get("name"), f"{path}.altname_link.name")
|
|
478
|
+
if not IFNAME_RE.match(altname_link):
|
|
479
|
+
raise _fail(f"{path}.altname_link.name", f"invalid interface name {altname_link!r}")
|
|
480
|
+
if match is None or match.mac is None:
|
|
481
|
+
raise _fail(f"{path}.altname_link", "requires match.mac (the .link file matches on the MAC address)")
|
|
482
|
+
|
|
483
|
+
metric = raw.get("metric")
|
|
484
|
+
if metric is not None and (not isinstance(metric, int) or isinstance(metric, bool) or metric < 0):
|
|
485
|
+
raise _fail(f"{path}.metric", f"expected a non-negative integer, got {metric!r}")
|
|
486
|
+
|
|
487
|
+
return SplitLink(
|
|
488
|
+
id=link_id,
|
|
489
|
+
match=match,
|
|
490
|
+
dns=_dns_servers(raw.get("dns"), f"{path}.dns"),
|
|
491
|
+
style=style,
|
|
492
|
+
base=base,
|
|
493
|
+
altname_link=altname_link,
|
|
494
|
+
gateway4=gateway4,
|
|
495
|
+
gateway6=gateway6,
|
|
496
|
+
onlink=_tristate(raw.get("onlink", "auto"), f"{path}.onlink"),
|
|
497
|
+
metric=metric,
|
|
498
|
+
dns_default_route=_bool(raw.get("dns_default_route", False), f"{path}.dns_default_route"),
|
|
499
|
+
required_for_online=_systemd_value(raw.get("required_for_online", "no")),
|
|
500
|
+
resolve=_resolve(raw.get("resolve"), f"{path}.resolve"),
|
|
501
|
+
domains=_domain_selection(raw.get("domains"), f"{path}.domains"),
|
|
502
|
+
routes=_routes(raw.get("routes"), f"{path}.routes"),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _default_link(value: Any, path: str) -> DefaultLink:
|
|
507
|
+
keys = ("id", "match", "style", "base", "dns", "domains", "dns_default_route", "required_for_online", "resolve")
|
|
508
|
+
raw = _mapping(value, path, keys)
|
|
509
|
+
style, base = _style(raw, path)
|
|
510
|
+
match = _match(raw.get("match"), f"{path}.match")
|
|
511
|
+
if match is None and style == "full":
|
|
512
|
+
raise _fail(f"{path}.match", "required for style: full")
|
|
513
|
+
domains = []
|
|
514
|
+
for index, item in enumerate(_str_list(raw.get("domains", ["~."]), f"{path}.domains")):
|
|
515
|
+
routing_only = item.startswith("~")
|
|
516
|
+
domain = normalise_domain(item.removeprefix("~"))
|
|
517
|
+
if domain in ("", "."):
|
|
518
|
+
if not routing_only:
|
|
519
|
+
raise _fail(f"{path}.domains[{index}]", "the catch-all domain must be written ~.")
|
|
520
|
+
domains.append("~.")
|
|
521
|
+
continue
|
|
522
|
+
if not is_valid_domain(domain):
|
|
523
|
+
raise _fail(f"{path}.domains[{index}]", f"invalid domain {item!r}")
|
|
524
|
+
domains.append(("~" if routing_only else "") + domain)
|
|
525
|
+
return DefaultLink(
|
|
526
|
+
id=_link_id(raw, path),
|
|
527
|
+
match=match,
|
|
528
|
+
dns=_dns_servers(raw.get("dns"), f"{path}.dns"),
|
|
529
|
+
style=style,
|
|
530
|
+
base=base,
|
|
531
|
+
domains=tuple(domains),
|
|
532
|
+
dns_default_route=_bool(raw.get("dns_default_route", True), f"{path}.dns_default_route"),
|
|
533
|
+
required_for_online=_systemd_value(raw.get("required_for_online", "routable")),
|
|
534
|
+
resolve=_resolve(raw.get("resolve"), f"{path}.resolve"),
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def parse_config(raw: Any, base_dir: Path | None = None) -> Config:
|
|
539
|
+
"""Validate a parsed YAML document. Relative ``data.file`` paths are resolved against ``base_dir``."""
|
|
540
|
+
top = _mapping(raw, "config", ("data", "networkd_conf", "resolved_conf", "split_links", "default_links"))
|
|
541
|
+
|
|
542
|
+
data_raw = _mapping(top.get("data"), "data", ("file", "fetch"))
|
|
543
|
+
fetch_raw = _mapping(data_raw.get("fetch"), "data.fetch", ("command", "args"))
|
|
544
|
+
file = None
|
|
545
|
+
if data_raw.get("file") is not None:
|
|
546
|
+
file = Path(_str(data_raw["file"], "data.file")).expanduser()
|
|
547
|
+
if base_dir is not None and not file.is_absolute():
|
|
548
|
+
file = base_dir / file
|
|
549
|
+
data = DataConfig(
|
|
550
|
+
file=file,
|
|
551
|
+
fetch_command=_str(fetch_raw.get("command", "ms-cloud-endpoints"), "data.fetch.command"),
|
|
552
|
+
fetch_args=_str_list(fetch_raw.get("args"), "data.fetch.args"),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
nd_raw = _mapping(
|
|
556
|
+
top.get("networkd_conf"),
|
|
557
|
+
"networkd_conf",
|
|
558
|
+
("enabled", "manage_foreign_routes", "manage_foreign_routing_policy_rules", "extra"),
|
|
559
|
+
)
|
|
560
|
+
networkd_conf = NetworkdConf(
|
|
561
|
+
enabled=_bool(nd_raw.get("enabled", True), "networkd_conf.enabled"),
|
|
562
|
+
manage_foreign_routes=_bool(nd_raw.get("manage_foreign_routes", False), "networkd_conf.manage_foreign_routes"),
|
|
563
|
+
manage_foreign_routing_policy_rules=_bool(
|
|
564
|
+
nd_raw.get("manage_foreign_routing_policy_rules", False),
|
|
565
|
+
"networkd_conf.manage_foreign_routing_policy_rules",
|
|
566
|
+
),
|
|
567
|
+
extra=_extra_sections(nd_raw.get("extra"), "networkd_conf.extra", "Network"),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
rc_raw = _mapping(top.get("resolved_conf"), "resolved_conf", ("enabled", "dnssec", "extra"))
|
|
571
|
+
resolved_conf = ResolvedConf(
|
|
572
|
+
enabled=_bool(rc_raw.get("enabled", True), "resolved_conf.enabled"),
|
|
573
|
+
dnssec=_optional_setting(rc_raw.get("dnssec", False), "resolved_conf.dnssec"),
|
|
574
|
+
extra=_extra_sections(rc_raw.get("extra"), "resolved_conf.extra", "Resolve"),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
split_raw = top.get("split_links") or []
|
|
578
|
+
default_raw = top.get("default_links") or []
|
|
579
|
+
for key, value in (("split_links", split_raw), ("default_links", default_raw)):
|
|
580
|
+
if not isinstance(value, list):
|
|
581
|
+
raise _fail(key, "expected a list")
|
|
582
|
+
split_links = tuple(_split_link(item, f"split_links[{index}]") for index, item in enumerate(split_raw))
|
|
583
|
+
default_links = tuple(_default_link(item, f"default_links[{index}]") for index, item in enumerate(default_raw))
|
|
584
|
+
if not split_links:
|
|
585
|
+
raise _fail("split_links", "at least one split link is required")
|
|
586
|
+
|
|
587
|
+
ids = [link.id for link in (*split_links, *default_links)]
|
|
588
|
+
duplicates = sorted({link_id for link_id in ids if ids.count(link_id) > 1})
|
|
589
|
+
if duplicates:
|
|
590
|
+
raise _fail("config", f"duplicate link id(s): {', '.join(duplicates)}")
|
|
591
|
+
bases = [link.base for link in (*split_links, *default_links) if link.base]
|
|
592
|
+
if len(bases) != len(set(bases)):
|
|
593
|
+
raise _fail("config", "two links use style: dropin with the same base file")
|
|
594
|
+
|
|
595
|
+
return Config(data, networkd_conf, resolved_conf, split_links, default_links)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def load_config(path: Path) -> Config:
|
|
599
|
+
try:
|
|
600
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
601
|
+
except OSError as exc:
|
|
602
|
+
raise ConfigError(f"cannot read {path}: {exc.strerror or exc}") from None
|
|
603
|
+
except yaml.YAMLError as exc:
|
|
604
|
+
raise ConfigError(f"{path}: invalid YAML: {exc}") from None
|
|
605
|
+
return parse_config(raw, path.parent)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def with_ipv6(config: Config, ipv6: Tristate) -> Config:
|
|
609
|
+
"""Return ``config`` with ``routes.ipv6`` replaced on every split link (``--ipv6`` / ``--no-ipv6``)."""
|
|
610
|
+
links = tuple(replace(link, routes=replace(link.routes, ipv6=ipv6)) for link in config.split_links)
|
|
611
|
+
return replace(config, split_links=links)
|