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.
@@ -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)