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,200 @@
1
+ """Combine configuration, collector data and link discovery into the set of files to write."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from .config import Config, SplitLink
8
+ from .data import collected_ipv6, data_versions
9
+ from .discover import Discovery, DiscoveryError, Gateway
10
+ from .domains import derive_domains
11
+ from .netutil import IPAddress, IPNetwork
12
+ from .render import (
13
+ RenderedFile,
14
+ gateway_conflicts,
15
+ render_altname_link,
16
+ render_default_network,
17
+ render_networkd_conf,
18
+ render_resolved_conf,
19
+ render_split_network,
20
+ route_specs,
21
+ )
22
+ from .select import select_prefixes
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ # systemd-networkd ignores [Route] sections beyond this per .network file, including its drop-ins
27
+ # (STATIC_ROUTES_PER_NETWORK_MAX; it logs "Failed to allocate route ... Argument list too long").
28
+ NETWORKD_MAX_ROUTES = 8192
29
+
30
+
31
+ class GenerateError(RuntimeError):
32
+ """The configuration cannot be rendered (for example a gateway is missing)."""
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class LinkSummary:
37
+ id: str
38
+ ipv4_routes: int
39
+ ipv6_routes: int
40
+ domains: list[str]
41
+ gateway4: str | None
42
+ gateway6: str | None
43
+ dropped_third_party: list[str] = field(default_factory=list)
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class Generated:
48
+ files: list[RenderedFile]
49
+ links: list[LinkSummary]
50
+
51
+
52
+ def _resolve_gateway(link: SplitLink, version: int, discovery: Discovery) -> tuple[IPAddress | None, Gateway | None]:
53
+ """Return the configured or detected gateway, plus the detected route (if discovery ran)."""
54
+ configured = link.gateway4 if version == 4 else link.gateway6
55
+ detected: Gateway | None = None
56
+ if (configured == "auto" or link.onlink == "auto") and link.match is not None:
57
+ try:
58
+ info = discovery.info(link.match)
59
+ except DiscoveryError as exc:
60
+ if configured == "auto":
61
+ raise GenerateError(f"{link.id}: cannot auto-detect gateway{version}: {exc}") from None
62
+ log.warning("%s: %s; assuming the gateway is not on-link", link.id, exc)
63
+ else:
64
+ detected = info.gateway4 if version == 4 else info.gateway6
65
+ if configured == "auto":
66
+ return (detected.address if detected else None), detected
67
+ return configured, detected # type: ignore[return-value]
68
+
69
+
70
+ def _onlink(link: SplitLink, gateway: IPAddress, detected: Gateway | None) -> bool:
71
+ if link.onlink != "auto":
72
+ return bool(link.onlink)
73
+ return detected is not None and detected.address == gateway and detected.onlink
74
+
75
+
76
+ def _split_link_files(link: SplitLink, document: dict[str, Any], discovery: Discovery, versions: list[str]):
77
+ prefixes = select_prefixes(document, link.routes, link.id)
78
+
79
+ gateway4, detected4 = _resolve_gateway(link, 4, discovery)
80
+ gateway6, detected6 = _resolve_gateway(link, 6, discovery)
81
+ if gateway4 is None and link.gateway4 == "auto":
82
+ raise GenerateError(
83
+ f"{link.id}: no IPv4 default route found on the link; set gateway4 explicitly (or gateway4: none)"
84
+ )
85
+
86
+ routes = []
87
+ if gateway4 is not None:
88
+ routes += route_specs(prefixes.ipv4, gateway4, _onlink(link, gateway4, detected4), link.metric)
89
+ elif prefixes.ipv4:
90
+ log.warning("%s: gateway4 is none; skipping %d IPv4 prefixes", link.id, len(prefixes.ipv4))
91
+
92
+ ipv6_wanted = link.routes.ipv6
93
+ ipv6_in_data = collected_ipv6(document)
94
+ if ipv6_wanted is True:
95
+ required = f"{link.id}: IPv6 routes are required (routes.ipv6: true or --ipv6)"
96
+ if gateway6 is None:
97
+ raise GenerateError(f"{required} but no IPv6 gateway is configured or detected")
98
+ if not ipv6_in_data:
99
+ raise GenerateError(f"{required} but the collector data was gathered with --no-ipv6")
100
+ if ipv6_wanted is not False and gateway6 is not None:
101
+ if not ipv6_in_data:
102
+ log.info("%s: no Microsoft IPv6 routes: the collector data was gathered with --no-ipv6", link.id)
103
+ routes += route_specs(prefixes.ipv6, gateway6, _onlink(link, gateway6, detected6), link.metric)
104
+ elif prefixes.ipv6:
105
+ reason = "routes.ipv6: false or --no-ipv6" if ipv6_wanted is False else "the link has no IPv6 gateway"
106
+ log.info("%s: skipping %d IPv6 prefixes (%s)", link.id, len(prefixes.ipv6), reason)
107
+
108
+ for gateway in (gateway4, gateway6):
109
+ conflicts = gateway_conflicts((route.destination for route in routes), gateway)
110
+ if conflicts:
111
+ log.warning(
112
+ "%s: prefix(es) %s contain the gateway %s; consider routes.exclude_prefixes",
113
+ link.id,
114
+ ", ".join(map(str, conflicts)),
115
+ gateway,
116
+ )
117
+
118
+ domain_result = derive_domains(document, link)
119
+ domains = domain_result.domains
120
+ if not link.dns:
121
+ log.warning("%s: no dns servers configured; the DNS layer (Domains=) is not generated", link.id)
122
+ domains = []
123
+ elif not domains:
124
+ log.warning("%s: no routing domains selected; the split link will receive no DNS queries", link.id)
125
+
126
+ ipv4_count = sum(1 for route in routes if route.destination.version == 4)
127
+ ipv6_count = len(routes) - ipv4_count
128
+ if len(routes) > NETWORKD_MAX_ROUTES:
129
+ raise GenerateError(
130
+ f"{link.id}: {len(routes)} routes ({ipv4_count} IPv4, {ipv6_count} IPv6) exceed the "
131
+ f"{NETWORKD_MAX_ROUTES} static routes systemd-networkd accepts per .network file; the rest would be "
132
+ "silently ignored. Lower routes.widen_to, narrow routes.azure.service_tags or set routes.ipv6: false "
133
+ "(or --no-ipv6)"
134
+ )
135
+ details = [
136
+ *versions,
137
+ f"{ipv4_count} IPv4 and {ipv6_count} IPv6 routes (from {prefixes.input_count} prefixes), "
138
+ f"{len(domains)} routing domains",
139
+ ]
140
+ files = [render_split_network(link, link.dns, domains, routes, details)]
141
+ if link.altname_link:
142
+ files.append(render_altname_link(link))
143
+ summary = LinkSummary(
144
+ id=link.id,
145
+ ipv4_routes=ipv4_count,
146
+ ipv6_routes=ipv6_count,
147
+ domains=domains,
148
+ gateway4=str(gateway4) if gateway4 and ipv4_count else None,
149
+ gateway6=str(gateway6) if gateway6 and ipv6_count else None,
150
+ dropped_third_party=domain_result.dropped_third_party,
151
+ )
152
+ routed: set[IPNetwork] = {route.destination for route in routes}
153
+ return files, summary, routed
154
+
155
+
156
+ def _log_overlaps(summaries: list[LinkSummary], routed: dict[str, set[IPNetwork]]) -> None:
157
+ for index, first in enumerate(summaries):
158
+ for second in summaries[index + 1 :]:
159
+ shared_prefixes = routed[first.id] & routed[second.id]
160
+ shared_domains = set(first.domains) & set(second.domains)
161
+ if shared_prefixes or shared_domains:
162
+ log.info(
163
+ "Links %s and %s share %d prefixes and %d domains; the kernel (metric) and resolved (both "
164
+ "links queried) break the ties",
165
+ first.id,
166
+ second.id,
167
+ len(shared_prefixes),
168
+ len(shared_domains),
169
+ )
170
+
171
+
172
+ def generate(config: Config, document: dict[str, Any], discovery: Discovery | None = None) -> Generated:
173
+ discovery = discovery or Discovery()
174
+ versions = data_versions(document)
175
+ files: list[RenderedFile] = []
176
+ summaries: list[LinkSummary] = []
177
+ routed: dict[str, set[IPNetwork]] = {}
178
+
179
+ for link in config.split_links:
180
+ link_files, summary, link_routes = _split_link_files(link, document, discovery, versions)
181
+ files += link_files
182
+ summaries.append(summary)
183
+ routed[link.id] = link_routes
184
+ _log_overlaps(summaries, routed)
185
+
186
+ for default_link in config.default_links:
187
+ if not default_link.dns:
188
+ log.warning("%s: default link has no dns servers; its Domains= will not resolve anything", default_link.id)
189
+ files.append(render_default_network(default_link))
190
+
191
+ if config.networkd_conf.enabled:
192
+ files.append(render_networkd_conf(config.networkd_conf))
193
+ if config.resolved_conf.enabled and (resolved := render_resolved_conf(config.resolved_conf)) is not None:
194
+ files.append(resolved)
195
+
196
+ paths = [file.path for file in files]
197
+ duplicates = sorted({path for path in paths if paths.count(path) > 1})
198
+ if duplicates:
199
+ raise GenerateError(f"two links render the same file: {', '.join(duplicates)}")
200
+ return Generated(files=files, links=summaries)
@@ -0,0 +1,163 @@
1
+ """Compare rendered files with a target tree, write changes atomically, prune stale files and reload services."""
2
+
3
+ import difflib
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ from collections.abc import Callable, Iterable, Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .config import Config
13
+ from .render import MARKER, NETWORK_DIR, NETWORKD_CONF_PATH, RESOLVED_CONF_PATH, Kind, RenderedFile
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ type CommandRunner = Callable[[Sequence[str]], int]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Change:
22
+ path: str
23
+ kind: Kind
24
+ old: str | None # None: the file does not exist yet
25
+ new: str | None # None: the file is removed (prune)
26
+
27
+ @property
28
+ def action(self) -> str:
29
+ if self.new is None:
30
+ return "remove"
31
+ return "create" if self.old is None else "update"
32
+
33
+
34
+ def _read(path: Path) -> str | None:
35
+ try:
36
+ return path.read_text(encoding="utf-8")
37
+ except (FileNotFoundError, NotADirectoryError):
38
+ return None
39
+
40
+
41
+ def _kind_of(relative: str) -> Kind:
42
+ if relative.startswith(NETWORKD_CONF_PATH.rsplit("/", 1)[0]):
43
+ return "networkd_conf"
44
+ if relative.startswith(RESOLVED_CONF_PATH.rsplit("/", 1)[0]):
45
+ return "resolved_conf"
46
+ return "link" if relative.endswith(".link") else "network"
47
+
48
+
49
+ def generated_files_in(root: Path) -> list[str]:
50
+ """Relative paths of files under ``root`` that carry this generator's marker."""
51
+ patterns = [
52
+ f"{NETWORK_DIR}/*.network",
53
+ f"{NETWORK_DIR}/*.link",
54
+ f"{NETWORK_DIR}/*.network.d/*.conf",
55
+ f"{NETWORKD_CONF_PATH.rsplit('/', 1)[0]}/*.conf",
56
+ f"{RESOLVED_CONF_PATH.rsplit('/', 1)[0]}/*.conf",
57
+ ]
58
+ found = []
59
+ for pattern in patterns:
60
+ for path in root.glob(pattern):
61
+ try:
62
+ with path.open(encoding="utf-8") as stream:
63
+ first_line = stream.readline()
64
+ except (OSError, UnicodeDecodeError):
65
+ continue
66
+ if first_line.startswith(MARKER):
67
+ found.append(path.relative_to(root).as_posix())
68
+ return sorted(found)
69
+
70
+
71
+ def plan_changes(files: Iterable[RenderedFile], root: Path, *, prune: bool = False) -> list[Change]:
72
+ changes = []
73
+ rendered = {file.path: file for file in files}
74
+ for path, file in sorted(rendered.items()):
75
+ old = _read(root / path)
76
+ if old != file.content:
77
+ changes.append(Change(path, file.kind, old, file.content))
78
+ if prune:
79
+ for path in generated_files_in(root):
80
+ if path not in rendered:
81
+ changes.append(Change(path, _kind_of(path), _read(root / path), None))
82
+ return changes
83
+
84
+
85
+ def diff(changes: Iterable[Change], root: Path) -> str:
86
+ chunks = []
87
+ for change in changes:
88
+ target = (root / change.path).as_posix()
89
+ chunks.extend(
90
+ difflib.unified_diff(
91
+ (change.old or "").splitlines(keepends=True),
92
+ (change.new or "").splitlines(keepends=True),
93
+ fromfile=target if change.old is not None else "/dev/null",
94
+ tofile=target if change.new is not None else "/dev/null",
95
+ )
96
+ )
97
+ return "".join(chunks)
98
+
99
+
100
+ def _atomic_write(path: Path, content: str) -> None:
101
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
102
+ fd, temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
103
+ try:
104
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
105
+ stream.write(content)
106
+ os.chmod(temp, 0o644)
107
+ os.replace(temp, path)
108
+ except BaseException:
109
+ Path(temp).unlink(missing_ok=True)
110
+ raise
111
+
112
+
113
+ def apply_changes(changes: Iterable[Change], root: Path) -> None:
114
+ for change in changes:
115
+ path = root / change.path
116
+ if change.new is None:
117
+ path.unlink(missing_ok=True)
118
+ if path.parent.name.endswith(".network.d") and not any(path.parent.iterdir()):
119
+ path.parent.rmdir()
120
+ else:
121
+ _atomic_write(path, change.new)
122
+ log.info("%s %s", change.action, path)
123
+
124
+
125
+ def reload_commands(changes: Sequence[Change], config: Config) -> list[list[str]]:
126
+ """Service actions that make systemd pick up ``changes``, in the order they must run."""
127
+ kinds = {change.kind for change in changes}
128
+ commands: list[list[str]] = []
129
+ if "link" in kinds:
130
+ commands.append(["udevadm", "control", "--reload"])
131
+ # .link settings (AlternativeName=) are only applied on "add" uevents.
132
+ for link in config.split_links:
133
+ if link.altname_link and link.match and link.match.mac:
134
+ commands.append(
135
+ ["udevadm", "trigger", "--action=add", "--subsystem-match=net",
136
+ f"--attr-match=address={link.match.mac}"]
137
+ ) # fmt: skip
138
+ commands.append(["udevadm", "settle"])
139
+ if "networkd_conf" in kinds:
140
+ commands.append(["systemctl", "restart", "systemd-networkd"])
141
+ elif kinds & {"network", "link"}:
142
+ commands.append(["networkctl", "reload"])
143
+ if "resolved_conf" in kinds:
144
+ commands.append(["systemctl", "try-reload-or-restart", "systemd-resolved"])
145
+ return commands
146
+
147
+
148
+ def run_command(command: Sequence[str]) -> int:
149
+ try:
150
+ return subprocess.run(command, check=False).returncode
151
+ except FileNotFoundError:
152
+ log.error("%s: command not found", command[0])
153
+ return 127
154
+
155
+
156
+ def run_commands(commands: Iterable[Sequence[str]], runner: CommandRunner = run_command) -> list[str]:
157
+ """Run each command; return the ones that failed."""
158
+ failed = []
159
+ for command in commands:
160
+ log.info("running %s", " ".join(command))
161
+ if runner(command) != 0:
162
+ failed.append(" ".join(command))
163
+ return failed
@@ -0,0 +1,105 @@
1
+ """IP prefix and domain helpers.
2
+
3
+ Partly duplicated from ``ms_cloud_endpoints.netutil`` on purpose: this package only reads the collector's output and
4
+ does not depend on it.
5
+ """
6
+
7
+ import ipaddress
8
+ import re
9
+ from collections.abc import Iterable
10
+
11
+ type IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
12
+ type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
13
+
14
+ PLACEHOLDER_RE = re.compile(r"\{[A-Za-z]+\}")
15
+ # A routing domain as accepted by systemd: dot-separated LDH labels (underscore tolerated), no wildcards.
16
+ DOMAIN_RE = re.compile(
17
+ r"^(?=.{1,253}$)(?:[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?)(?:\.[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?)*$"
18
+ )
19
+ MAC_RE = re.compile(r"^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$")
20
+
21
+ # Public suffixes with more than one label that occur in Microsoft endpoint lists; see ms_cloud_endpoints.netutil.
22
+ MULTI_LABEL_SUFFIXES = frozenset(
23
+ {
24
+ "co.uk", "org.uk", "gov.uk", "ac.uk", "com.cn", "net.cn", "gov.cn", "org.cn", "com.au", "net.au",
25
+ "co.jp", "ne.jp", "co.kr", "co.in", "co.nz", "com.br", "com.mx", "com.tw", "com.hk", "com.sg",
26
+ "co.za", "us.com",
27
+ }
28
+ ) # fmt: skip
29
+
30
+
31
+ def parse_network(value: str) -> IPNetwork:
32
+ """Parse a prefix (host bits are cleared); raises ``ValueError`` for invalid input."""
33
+ return ipaddress.ip_network(str(value).strip(), strict=False)
34
+
35
+
36
+ def parse_address(value: str) -> IPAddress:
37
+ return ipaddress.ip_address(str(value).strip())
38
+
39
+
40
+ def collapse(networks: Iterable[IPNetwork]) -> tuple[list[ipaddress.IPv4Network], list[ipaddress.IPv6Network]]:
41
+ """Collapse overlapping/adjacent prefixes, returning sorted IPv4 and IPv6 lists."""
42
+ ipv4: list[ipaddress.IPv4Network] = []
43
+ ipv6: list[ipaddress.IPv6Network] = []
44
+ for network in networks:
45
+ if isinstance(network, ipaddress.IPv4Network):
46
+ ipv4.append(network)
47
+ else:
48
+ ipv6.append(network)
49
+ return list(ipaddress.collapse_addresses(ipv4)), list(ipaddress.collapse_addresses(ipv6))
50
+
51
+
52
+ def subtract(networks: Iterable[IPNetwork], excludes: Iterable[IPNetwork]) -> list[IPNetwork]:
53
+ """Remove every address in ``excludes`` from ``networks``, splitting prefixes where needed."""
54
+ result = list(networks)
55
+ for exclude in excludes:
56
+ remaining: list[IPNetwork] = []
57
+ for network in result:
58
+ if network.version != exclude.version or not network.overlaps(exclude):
59
+ remaining.append(network)
60
+ elif network.subnet_of(exclude): # type: ignore[arg-type]
61
+ continue
62
+ else:
63
+ remaining.extend(network.address_exclude(exclude)) # type: ignore[arg-type]
64
+ result = remaining
65
+ return result
66
+
67
+
68
+ def normalise_domain(value: str) -> str:
69
+ domain = str(value).strip().lower().rstrip(".")
70
+ for scheme in ("https://", "http://"):
71
+ domain = domain.removeprefix(scheme)
72
+ return domain.split("/", 1)[0].split(":", 1)[0]
73
+
74
+
75
+ def wildcard_suffix(domain: str) -> str | None:
76
+ """``*.foo.com`` -> ``foo.com``; ``autodiscover.*.onmicrosoft.com`` -> ``onmicrosoft.com``; else ``None``."""
77
+ domain = PLACEHOLDER_RE.sub("*", domain)
78
+ if "*" not in domain:
79
+ return None
80
+ labels = domain.split(".")
81
+ last_wildcard = max(index for index, label in enumerate(labels) if "*" in label)
82
+ return ".".join(labels[last_wildcard + 1 :]) or None
83
+
84
+
85
+ def root_domain(domain: str) -> str:
86
+ labels = domain.split(".")
87
+ if len(labels) >= 3 and ".".join(labels[-2:]) in MULTI_LABEL_SUFFIXES:
88
+ return ".".join(labels[-3:])
89
+ return ".".join(labels[-2:])
90
+
91
+
92
+ def covered_by(domain: str, suffixes: Iterable[str]) -> bool:
93
+ """True when ``domain`` equals or is a subdomain of any of ``suffixes``."""
94
+ return any(domain == suffix or domain.endswith("." + suffix) for suffix in suffixes)
95
+
96
+
97
+ def minimise_domains(domains: Iterable[str]) -> list[str]:
98
+ """Drop domains already covered by a shorter routing domain in the same set and sort the rest."""
99
+ unique = set(domains)
100
+ kept = [domain for domain in unique if not covered_by(domain, unique - {domain})]
101
+ return sorted(kept)
102
+
103
+
104
+ def is_valid_domain(domain: str) -> bool:
105
+ return bool(DOMAIN_RE.match(domain))
File without changes
@@ -0,0 +1,221 @@
1
+ """Render systemd unit-style INI files (.network, .network.d drop-ins, .link, networkd.conf.d, resolved.conf.d)."""
2
+
3
+ from collections.abc import Iterable, Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ from . import GENERATOR, __version__
8
+ from .config import DefaultLink, ExtraSections, Match, NetworkdConf, ResolvedConf, SplitLink
9
+ from .netutil import IPAddress, IPNetwork
10
+
11
+ MARKER = f"# Generated by {GENERATOR}"
12
+ NETWORK_DIR = "etc/systemd/network"
13
+ NETWORKD_CONF_PATH = "etc/systemd/networkd.conf.d/10-ms-split-router.conf"
14
+ RESOLVED_CONF_PATH = "etc/systemd/resolved.conf.d/10-ms-split-router.conf"
15
+ DOMAINS_LINE_WIDTH = 110
16
+
17
+ type Kind = Literal["network", "link", "networkd_conf", "resolved_conf"]
18
+ type Section = tuple[str, list[tuple[str, str]]]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class RenderedFile:
23
+ path: str # relative to the root directory, e.g. "etc/systemd/network/10-ms-split-vpn.network"
24
+ content: str
25
+ kind: Kind
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class RouteSpec:
30
+ destination: IPNetwork
31
+ gateway: IPAddress
32
+ onlink: bool
33
+ metric: int | None
34
+
35
+
36
+ def split_network_path(link: SplitLink) -> str:
37
+ if link.style == "dropin":
38
+ return f"{NETWORK_DIR}/{link.base}.d/50-ms-split-router-{link.id}.conf"
39
+ return f"{NETWORK_DIR}/10-ms-split-{link.id}.network"
40
+
41
+
42
+ def default_network_path(link: DefaultLink) -> str:
43
+ if link.style == "dropin":
44
+ return f"{NETWORK_DIR}/{link.base}.d/50-ms-split-router-{link.id}.conf"
45
+ return f"{NETWORK_DIR}/20-ms-default-{link.id}.network"
46
+
47
+
48
+ def link_file_path(link: SplitLink) -> str:
49
+ return f"{NETWORK_DIR}/10-ms-split-{link.id}.link"
50
+
51
+
52
+ def header(description: str, details: Iterable[str] = ()) -> str:
53
+ lines = [
54
+ f"{MARKER} {__version__}. Do not edit: changes are overwritten on the next run.",
55
+ f"# {description}",
56
+ *(f"# {line}" for line in details),
57
+ ]
58
+ return "\n".join(lines) + "\n"
59
+
60
+
61
+ def format_sections(sections: Sequence[Section]) -> str:
62
+ blocks = []
63
+ for name, entries in sections:
64
+ blocks.append("\n".join([f"[{name}]", *(f"{key}={value}" for key, value in entries)]))
65
+ return "\n\n".join(blocks) + "\n"
66
+
67
+
68
+ def wrap_domains(domains: Sequence[str], width: int = DOMAINS_LINE_WIDTH) -> list[str]:
69
+ """Spread the domain list over several ``Domains=`` lines; systemd appends repeated assignments."""
70
+ lines: list[str] = []
71
+ current: list[str] = []
72
+ for domain in domains:
73
+ if current and len(" ".join([*current, domain])) > width:
74
+ lines.append(" ".join(current))
75
+ current = []
76
+ current.append(domain)
77
+ if current:
78
+ lines.append(" ".join(current))
79
+ return lines
80
+
81
+
82
+ def _match_section(match: Match) -> Section:
83
+ if match.mac:
84
+ return ("Match", [("MACAddress", match.mac)])
85
+ # Name= matches the kernel name and the alternative names.
86
+ return ("Match", [("Name", match.altname or match.name or "")])
87
+
88
+
89
+ def _dns_entries(
90
+ dns: Sequence[str], domains: Sequence[str], dns_default_route: bool, resolve: Iterable[tuple[str, str]]
91
+ ) -> list[tuple[str, str]]:
92
+ entries = [("DNS", server) for server in dns]
93
+ entries += [("Domains", line) for line in wrap_domains(domains)]
94
+ entries.append(("DNSDefaultRoute", "yes" if dns_default_route else "no"))
95
+ entries += list(resolve)
96
+ return entries
97
+
98
+
99
+ # WSL assigns the addresses of mirrored links; networkd must not run its own address configuration.
100
+ _UNMANAGED_ADDRESSING = [
101
+ ("DHCP", "no"),
102
+ ("IPv6AcceptRA", "no"),
103
+ ("LinkLocalAddressing", "no"),
104
+ ("KeepConfiguration", "yes"),
105
+ ]
106
+
107
+
108
+ def _route_sections(routes: Sequence[RouteSpec]) -> list[Section]:
109
+ sections: list[Section] = []
110
+ for route in routes:
111
+ entries = [("Destination", str(route.destination)), ("Gateway", str(route.gateway))]
112
+ if route.onlink:
113
+ entries.append(("GatewayOnLink", "yes"))
114
+ if route.metric is not None:
115
+ entries.append(("Metric", str(route.metric)))
116
+ sections.append(("Route", entries))
117
+ return sections
118
+
119
+
120
+ def render_split_network(
121
+ link: SplitLink, dns: Sequence[str], domains: Sequence[str], routes: Sequence[RouteSpec], details: Sequence[str]
122
+ ) -> RenderedFile:
123
+ network_entries = []
124
+ if link.style == "full":
125
+ network_entries += _UNMANAGED_ADDRESSING
126
+ if dns:
127
+ routing_domains = [f"~{domain}" for domain in domains]
128
+ network_entries += _dns_entries(dns, routing_domains, link.dns_default_route, link.resolve.keys())
129
+ sections: list[Section] = []
130
+ if link.style == "full":
131
+ assert link.match is not None
132
+ sections += [_match_section(link.match), ("Link", [("RequiredForOnline", link.required_for_online)])]
133
+ if network_entries:
134
+ sections.append(("Network", network_entries))
135
+ sections += _route_sections(routes)
136
+ description = f"Split-tunnel link '{link.id}'" + (
137
+ f" ({link.match.describe()})" if link.match else f" (drop-in for {link.base})"
138
+ )
139
+ content = header(description, details) + "\n" + format_sections(sections)
140
+ return RenderedFile(split_network_path(link), content, "network")
141
+
142
+
143
+ def render_default_network(link: DefaultLink) -> RenderedFile:
144
+ network_entries = []
145
+ if link.style == "full":
146
+ network_entries += _UNMANAGED_ADDRESSING
147
+ network_entries += _dns_entries(link.dns, link.domains, link.dns_default_route, link.resolve.keys())
148
+ sections: list[Section] = []
149
+ if link.style == "full":
150
+ assert link.match is not None
151
+ sections += [_match_section(link.match), ("Link", [("RequiredForOnline", link.required_for_online)])]
152
+ sections.append(("Network", network_entries))
153
+ description = f"Default link '{link.id}'" + (
154
+ f" ({link.match.describe()})" if link.match else f" (drop-in for {link.base})"
155
+ )
156
+ return RenderedFile(default_network_path(link), header(description) + "\n" + format_sections(sections), "network")
157
+
158
+
159
+ def render_altname_link(link: SplitLink) -> RenderedFile:
160
+ assert link.match is not None and link.match.mac and link.altname_link
161
+ sections: list[Section] = [
162
+ ("Match", [("MACAddress", link.match.mac)]),
163
+ (
164
+ "Link",
165
+ [
166
+ # Same naming behaviour as systemd's 99-default.link, which this file supersedes for the device.
167
+ ("NamePolicy", "keep kernel database onboard slot path"),
168
+ ("AlternativeNamesPolicy", "database onboard slot path mac"),
169
+ ("AlternativeName", link.altname_link),
170
+ # Never rewrite the MAC that WSL mirrors from the Windows adapter.
171
+ ("MACAddressPolicy", "none"),
172
+ ],
173
+ ),
174
+ ]
175
+ description = f"Stable alternative name '{link.altname_link}' for split-tunnel link '{link.id}'"
176
+ return RenderedFile(link_file_path(link), header(description) + "\n" + format_sections(sections), "link")
177
+
178
+
179
+ def _with_extra(sections: list[Section], extra: ExtraSections) -> list[Section]:
180
+ merged: dict[str, list[tuple[str, str]]] = {name: list(entries) for name, entries in sections}
181
+ for section, keys in extra.items():
182
+ entries = merged.setdefault(section, [])
183
+ for key, values in keys.items():
184
+ entries[:] = [entry for entry in entries if entry[0] != key]
185
+ entries.extend((key, value) for value in values)
186
+ return [(name, entries) for name, entries in merged.items() if entries]
187
+
188
+
189
+ def render_networkd_conf(conf: NetworkdConf) -> RenderedFile:
190
+ sections: list[Section] = [
191
+ (
192
+ "Network",
193
+ [
194
+ ("ManageForeignRoutes", "yes" if conf.manage_foreign_routes else "no"),
195
+ ("ManageForeignRoutingPolicyRules", "yes" if conf.manage_foreign_routing_policy_rules else "no"),
196
+ ],
197
+ )
198
+ ]
199
+ description = "Keep the addresses, routes and policy rules that WSL installs on mirrored links"
200
+ content = header(description) + "\n" + format_sections(_with_extra(sections, conf.extra))
201
+ return RenderedFile(NETWORKD_CONF_PATH, content, "networkd_conf")
202
+
203
+
204
+ def render_resolved_conf(conf: ResolvedConf) -> RenderedFile | None:
205
+ entries = [("DNSSEC", conf.dnssec)] if conf.dnssec is not None else []
206
+ sections = _with_extra([("Resolve", entries)], conf.extra)
207
+ if not sections:
208
+ return None
209
+ description = "Global systemd-resolved settings for the split-tunnel setup"
210
+ return RenderedFile(RESOLVED_CONF_PATH, header(description) + "\n" + format_sections(sections), "resolved_conf")
211
+
212
+
213
+ def gateway_conflicts(prefixes: Iterable[IPNetwork], gateway: IPAddress | None) -> list[IPNetwork]:
214
+ """Prefixes that contain the gateway itself (the gateway would be routed via itself)."""
215
+ if gateway is None:
216
+ return []
217
+ return [prefix for prefix in prefixes if prefix.version == gateway.version and gateway in prefix]
218
+
219
+
220
+ def route_specs(prefixes: Iterable[IPNetwork], gateway: IPAddress, onlink: bool, metric: int | None) -> list[RouteSpec]:
221
+ return [RouteSpec(prefix, gateway, onlink, metric) for prefix in prefixes]