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,152 @@
1
+ """Load the ms-cloud-endpoints document from a file, or fetch it by running the ms-cloud-endpoints command."""
2
+
3
+ import json
4
+ import logging
5
+ import shutil
6
+ import subprocess
7
+ from collections.abc import Callable, Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from .config import Config
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ # Labels used inside the collector document -> the collector's --cloud value.
18
+ SERVICE_TAG_CLOUDS = {"public": "public", "china": "china", "azuregovernment": "usgov"}
19
+ M365_INSTANCES = {"worldwide": "public", "china": "china", "usgovdod": "usgov", "usgovgcchigh": "usgov"}
20
+ PRIVATE_DNS_CLOUDS = {"commercial": "public", "china": "china", "government": "usgov"}
21
+
22
+ COLLECTOR_EXIT_PARTIAL = 3
23
+
24
+ type Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
25
+
26
+
27
+ class DataError(RuntimeError):
28
+ """The collector document could not be loaded or fetched."""
29
+
30
+
31
+ class PartialDataError(DataError):
32
+ """The collector reported that one or more sources failed (exit code 3)."""
33
+
34
+ def __init__(self, message: str, document: dict[str, Any]) -> None:
35
+ super().__init__(message)
36
+ self.document = document
37
+
38
+
39
+ def _validate(document: Any, origin: str) -> dict[str, Any]:
40
+ if not isinstance(document, dict) or "metadata" not in document:
41
+ raise DataError(f"{origin} is not an ms-cloud-endpoints document (no 'metadata' key)")
42
+ if not any(key in document for key in ("m365", "azure_service_tags", "m365_additional", "private_dns_zones")):
43
+ raise DataError(f"{origin} contains no endpoint data")
44
+ return document
45
+
46
+
47
+ def load_document(path: Path) -> dict[str, Any]:
48
+ """Read a collector JSON or YAML file (format chosen by suffix, else by content)."""
49
+ try:
50
+ text = path.read_text(encoding="utf-8")
51
+ except OSError as exc:
52
+ raise DataError(f"cannot read {path}: {exc.strerror or exc}") from None
53
+ try:
54
+ if path.suffix.lower() == ".json" or text.lstrip().startswith("{"):
55
+ document = json.loads(text)
56
+ else:
57
+ loader = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
58
+ document = yaml.load(text, Loader=loader)
59
+ except (json.JSONDecodeError, yaml.YAMLError) as exc:
60
+ raise DataError(f"{path}: cannot parse: {exc}") from None
61
+ return _validate(document, str(path))
62
+
63
+
64
+ def fetch_arguments(config: Config) -> list[str]:
65
+ """Collector arguments that fetch only what the configured links need."""
66
+ clouds: set[str] = set()
67
+ sources: set[str] = set()
68
+ tags: set[str] = set()
69
+ ipv6 = False
70
+ for link in config.split_links:
71
+ routes, domains = link.routes, link.domains
72
+ if routes.azure:
73
+ sources.add("service-tags")
74
+ tags.update(routes.azure.service_tags)
75
+ clouds.update(SERVICE_TAG_CLOUDS.get(cloud.lower(), "public") for cloud in routes.azure.clouds)
76
+ if routes.m365 or "m365" in domains.derive_from:
77
+ sources.add("m365")
78
+ instances = routes.m365.instances if routes.m365 else ("Worldwide",)
79
+ clouds.update(M365_INSTANCES.get(instance.lower(), "public") for instance in instances)
80
+ if routes.m365_additional or "m365_additional" in domains.derive_from:
81
+ sources.add("m365-additional")
82
+ if routes.m365_additional:
83
+ clouds.update(routes.m365_additional.clouds)
84
+ if "private_dns" in domains.derive_from:
85
+ sources.add("private-dns")
86
+ clouds.update(PRIVATE_DNS_CLOUDS.get(cloud.lower(), "public") for cloud in domains.private_dns_clouds)
87
+ if routes.ipv6 is not False:
88
+ ipv6 = True
89
+
90
+ args = ["--format", "json", "--no-summary"]
91
+ for cloud in sorted(clouds or {"public"}):
92
+ args += ["--cloud", cloud]
93
+ for source in sorted(sources):
94
+ args += ["--source", source]
95
+ for tag in sorted(tags):
96
+ args += ["--tag", tag]
97
+ if not ipv6:
98
+ args.append("--no-ipv6")
99
+ return args
100
+
101
+
102
+ def _run(command: Sequence[str]) -> subprocess.CompletedProcess[str]:
103
+ return subprocess.run(command, capture_output=True, text=True, check=False)
104
+
105
+
106
+ def fetch_document(config: Config, *, runner: Runner = _run) -> dict[str, Any]:
107
+ """Run the collector and parse its JSON from stdout. Raises ``PartialDataError`` on collector exit code 3."""
108
+ executable = config.data.fetch_command
109
+ if runner is _run and shutil.which(executable) is None:
110
+ raise DataError(
111
+ f"{executable!r} not found on PATH; install ms-cloud-endpoints (pipx install ./ms-cloud-endpoints) "
112
+ "or set data.file / --data"
113
+ )
114
+ command = [executable, *fetch_arguments(config), *config.data.fetch_args]
115
+ log.info("Running %s", " ".join(command))
116
+ result = runner(command)
117
+ if result.stderr:
118
+ for line in result.stderr.splitlines():
119
+ log.info("collector: %s", line)
120
+ if result.returncode not in (0, COLLECTOR_EXIT_PARTIAL):
121
+ raise DataError(f"{executable} exited with code {result.returncode}: {result.stderr.strip()[-500:]}")
122
+ try:
123
+ document = _validate(json.loads(result.stdout), f"{executable} output")
124
+ except json.JSONDecodeError as exc:
125
+ raise DataError(f"{executable} did not print JSON: {exc}") from None
126
+ if result.returncode == COLLECTOR_EXIT_PARTIAL:
127
+ failed = [
128
+ f"{record.get('source')}/{record.get('scope')}"
129
+ for record in document.get("metadata", {}).get("sources", [])
130
+ if record.get("status") != "ok"
131
+ ]
132
+ raise PartialDataError(f"collector reported failed source(s): {', '.join(failed) or 'unknown'}", document)
133
+ return document
134
+
135
+
136
+ def data_versions(document: dict[str, Any]) -> list[str]:
137
+ """Human-readable upstream versions recorded in the document, for file headers."""
138
+ versions = [
139
+ f"Microsoft 365 {name} {data.get('version')}" for name, data in sorted(document.get("m365", {}).items())
140
+ ]
141
+ versions += [
142
+ f"Service Tags {name} change {data.get('change_number')}"
143
+ for name, data in sorted(document.get("azure_service_tags", {}).items())
144
+ ]
145
+ return versions
146
+
147
+
148
+ def collected_ipv6(document: dict[str, Any]) -> bool:
149
+ """False only when the collector recorded that it dropped IPv6 prefixes (``ms-cloud-endpoints --no-ipv6``)."""
150
+ metadata = document.get("metadata")
151
+ options = metadata.get("options") if isinstance(metadata, dict) else None
152
+ return not (isinstance(options, dict) and options.get("ipv6") is False)
@@ -0,0 +1,146 @@
1
+ """Discover mirrored links and their gateways from ``ip -json`` output."""
2
+
3
+ import json
4
+ import logging
5
+ import subprocess
6
+ from collections.abc import Callable, Sequence
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from .config import Match
11
+ from .netutil import IPAddress, parse_address
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ type IpRunner = Callable[[Sequence[str]], str]
16
+
17
+
18
+ class DiscoveryError(RuntimeError):
19
+ """A link or gateway could not be discovered."""
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Gateway:
24
+ address: IPAddress
25
+ onlink: bool
26
+ metric: int
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class LinkInfo:
31
+ ifname: str
32
+ mac: str | None
33
+ altnames: tuple[str, ...]
34
+ operstate: str
35
+ gateway4: Gateway | None = None
36
+ gateway6: Gateway | None = None
37
+ addresses: tuple[str, ...] = field(default_factory=tuple)
38
+
39
+
40
+ def run_ip(args: Sequence[str]) -> str:
41
+ try:
42
+ result = subprocess.run(["ip", "-json", *args], capture_output=True, text=True, check=False)
43
+ except FileNotFoundError:
44
+ raise DiscoveryError("the 'ip' command (iproute2) is not installed") from None
45
+ if result.returncode != 0:
46
+ raise DiscoveryError(f"ip -json {' '.join(args)} failed: {result.stderr.strip()}")
47
+ return result.stdout
48
+
49
+
50
+ def _json(runner: IpRunner, args: Sequence[str]) -> list[dict[str, Any]]:
51
+ output = runner(args).strip()
52
+ if not output:
53
+ return []
54
+ try:
55
+ value = json.loads(output)
56
+ except json.JSONDecodeError as exc:
57
+ raise DiscoveryError(f"cannot parse ip -json {' '.join(args)}: {exc}") from None
58
+ return value if isinstance(value, list) else []
59
+
60
+
61
+ def _default_gateway(routes: list[dict[str, Any]], version: int) -> Gateway | None:
62
+ candidates = []
63
+ for route in routes:
64
+ if route.get("dst") != "default" or "gateway" not in route:
65
+ continue
66
+ try:
67
+ address = parse_address(route["gateway"])
68
+ except ValueError:
69
+ continue
70
+ if address.version == version:
71
+ flags = route.get("flags", [])
72
+ candidates.append(Gateway(address, "onlink" in flags, int(route.get("metric", 0))))
73
+ return min(candidates, key=lambda gateway: gateway.metric, default=None)
74
+
75
+
76
+ class Discovery:
77
+ """Lazily queries ``ip -json`` so that nothing runs unless auto-detection is needed."""
78
+
79
+ def __init__(self, runner: IpRunner | None = None) -> None:
80
+ self._runner = runner or run_ip
81
+ self._links: list[dict[str, Any]] | None = None
82
+ self._cache: dict[str, LinkInfo] = {}
83
+
84
+ def links(self) -> list[dict[str, Any]]:
85
+ if self._links is None:
86
+ self._links = [link for link in _json(self._runner, ["link", "show"]) if link.get("ifname") != "lo"]
87
+ return self._links
88
+
89
+ def resolve(self, match: Match) -> str:
90
+ """Kernel interface name of the link selected by ``match``."""
91
+ found = []
92
+ for link in self.links():
93
+ if match.mac and str(link.get("address", "")).lower() == match.mac:
94
+ found.append(link["ifname"])
95
+ elif match.altname and match.altname in link.get("altnames", []):
96
+ found.append(link["ifname"])
97
+ elif match.name and match.name in (link.get("ifname"), *link.get("altnames", [])):
98
+ found.append(link["ifname"])
99
+ if not found:
100
+ available = ", ".join(f"{link['ifname']} ({link.get('address')})" for link in self.links())
101
+ raise DiscoveryError(f"no link with {match.describe()} (available: {available or 'none'})")
102
+ if len(found) > 1:
103
+ raise DiscoveryError(f"{match.describe()} matches several links: {', '.join(found)}")
104
+ return found[0]
105
+
106
+ def info(self, match: Match) -> LinkInfo:
107
+ ifname = self.resolve(match)
108
+ if ifname not in self._cache:
109
+ link = next(link for link in self.links() if link["ifname"] == ifname)
110
+ routes4 = _json(self._runner, ["-4", "route", "show", "default", "dev", ifname])
111
+ routes6 = _json(self._runner, ["-6", "route", "show", "default", "dev", ifname])
112
+ addresses = [
113
+ f"{address['local']}/{address['prefixlen']}"
114
+ for entry in _json(self._runner, ["address", "show", "dev", ifname])
115
+ for address in entry.get("addr_info", [])
116
+ if address.get("scope") == "global"
117
+ ]
118
+ for route in (*routes4, *routes6):
119
+ route.setdefault("dst", "default")
120
+ self._cache[ifname] = LinkInfo(
121
+ ifname=ifname,
122
+ mac=link.get("address"),
123
+ altnames=tuple(link.get("altnames", [])),
124
+ operstate=str(link.get("operstate", "UNKNOWN")),
125
+ gateway4=_default_gateway(routes4, 4),
126
+ gateway6=_default_gateway(routes6, 6),
127
+ addresses=tuple(addresses),
128
+ )
129
+ return self._cache[ifname]
130
+
131
+
132
+ def describe_links(discovery: Discovery) -> list[str]:
133
+ """One line per link, for ``--show-links``."""
134
+ lines = []
135
+ for link in discovery.links():
136
+ info = discovery.info(Match(name=link["ifname"]))
137
+ gateways = [
138
+ f"gw{gateway.address.version}={gateway.address}{' onlink' if gateway.onlink else ''}"
139
+ for gateway in (info.gateway4, info.gateway6)
140
+ if gateway
141
+ ]
142
+ lines.append(
143
+ f"{info.ifname:<12} {info.operstate:<8} mac={info.mac} altnames={','.join(info.altnames) or '-'} "
144
+ f"addr={','.join(info.addresses) or '-'} {' '.join(gateways) or 'no default route'}"
145
+ )
146
+ return lines
@@ -0,0 +1,117 @@
1
+ """Derive the systemd-resolved routing-only domains (``Domains=~…``) for a split link."""
2
+
3
+ import logging
4
+ from collections.abc import Iterator
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .config import M365Routes, SplitLink
9
+ from .netutil import covered_by, is_valid_domain, minimise_domains, normalise_domain, root_domain, wildcard_suffix
10
+ from .select import matching_endpoint_sets
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ # Certificate authority, OCSP/CRL and CDN domains that Microsoft lists as dependencies but does not operate. Routing
15
+ # them to the split link would send unrelated third-party lookups to the split resolver.
16
+ THIRD_PARTY_DOMAINS = frozenset(
17
+ {
18
+ # Certificate authorities, OCSP and CRL
19
+ "amazontrust.com", "comodoca.com", "digicert.com", "digicert.cn", "entrust.com", "entrust.net",
20
+ "geotrust.com", "globalsign.com", "globalsign.net", "godaddy.com", "identrust.com", "letsencrypt.org",
21
+ "lencr.org", "omniroot.com", "public-trust.com", "quovadisglobal.com", "rapidssl.com", "sectigo.com",
22
+ "ssl.com", "symcb.com", "symcd.com", "thawte.com", "usertrust.com", "verisign.com", "verisign.net",
23
+ "wosign.com",
24
+ # Content delivery networks
25
+ "akadns.net", "akamai.net", "akamaiedge.net", "akamaihd.net", "akamaized.net", "cloudflare.com",
26
+ "cloudflare.net", "cloudfront.net", "edgecastcdn.net", "edgekey.net", "edgesuite.net", "fastly.net",
27
+ "footprint.net", "llnwd.net",
28
+ }
29
+ ) # fmt: skip
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class DomainSelectionResult:
34
+ domains: list[str]
35
+ dropped_third_party: list[str]
36
+
37
+
38
+ def _candidate(value: str) -> str | None:
39
+ """Turn an endpoint URL, wildcard or zone template into a routable suffix."""
40
+ domain = normalise_domain(value)
41
+ domain = wildcard_suffix(domain) or domain
42
+ return domain if is_valid_domain(domain) else None
43
+
44
+
45
+ def _strip_privatelink(zone: str) -> str:
46
+ return zone.removeprefix("privatelink.")
47
+
48
+
49
+ def _m365_domains(document: dict[str, Any], link: SplitLink) -> Iterator[str]:
50
+ selection = link.routes.m365 or M365Routes()
51
+ for endpoint_set in matching_endpoint_sets(document, selection, link.id):
52
+ yield from endpoint_set.get("urls", [])
53
+
54
+
55
+ def _m365_additional_domains(document: dict[str, Any], link: SplitLink) -> Iterator[str]:
56
+ clouds = link.routes.m365_additional.clouds if link.routes.m365_additional else ("public",)
57
+ for row in document.get("m365_additional", {}).get("rows", []):
58
+ for cloud in clouds:
59
+ yield from row.get("by_cloud", {}).get(cloud, {}).get("domains", [])
60
+
61
+
62
+ def _private_dns_domains(document: dict[str, Any], link: SplitLink) -> Iterator[str]:
63
+ zones = document.get("private_dns_zones", {})
64
+ for cloud in link.domains.private_dns_clouds:
65
+ entries = next((value for key, value in zones.items() if key.lower() == cloud.lower()), None)
66
+ if entries is None:
67
+ log.warning("%s: private DNS zones for %s are not in the data", link.id, cloud)
68
+ continue
69
+ for entry in entries:
70
+ for key in ("zones", "zone_templates"):
71
+ yield from (_strip_privatelink(zone) for zone in entry.get(key, []))
72
+ yield from entry.get("forwarders", [])
73
+ yield from entry.get("forwarder_templates", [])
74
+
75
+
76
+ def derive_domains(document: dict[str, Any], link: SplitLink) -> DomainSelectionResult:
77
+ selection = link.domains
78
+ raw: list[str] = []
79
+ if "m365" in selection.derive_from:
80
+ raw.extend(_m365_domains(document, link))
81
+ if "m365_additional" in selection.derive_from:
82
+ raw.extend(_m365_additional_domains(document, link))
83
+ if "private_dns" in selection.derive_from:
84
+ raw.extend(_private_dns_domains(document, link))
85
+
86
+ derived: set[str] = set()
87
+ for value in raw:
88
+ domain = _candidate(value)
89
+ if domain is None:
90
+ log.debug("%s: skipping unusable domain %r", link.id, value)
91
+ continue
92
+ if selection.collapse_to_root:
93
+ domain = root_domain(domain)
94
+ if "." not in domain:
95
+ log.debug("%s: skipping single-label domain %r (from %r)", link.id, domain, value)
96
+ continue
97
+ derived.add(domain)
98
+
99
+ dropped: set[str] = set()
100
+ if selection.exclude_third_party:
101
+ dropped = {domain for domain in derived if covered_by(domain, THIRD_PARTY_DOMAINS)}
102
+ derived -= dropped
103
+
104
+ derived.update(selection.extra)
105
+ for exclude in selection.exclude:
106
+ derived = {domain for domain in derived if not covered_by(domain, [exclude])}
107
+ parents = [domain for domain in derived if covered_by(exclude, [domain])]
108
+ if parents:
109
+ log.warning(
110
+ "%s: excluded domain %s is still inside routed domain %s; give a default link the more specific "
111
+ "domain ~%s to override it",
112
+ link.id,
113
+ exclude,
114
+ ", ".join(sorted(parents)),
115
+ exclude,
116
+ )
117
+ return DomainSelectionResult(minimise_domains(derived), sorted(dropped))
@@ -0,0 +1,81 @@
1
+ # ms-split-router configuration example.
2
+ #
3
+ # Every key is optional unless marked "required". Values shown are the defaults unless noted.
4
+ # Reference: https://github.com/Jayonics/Azure-O365-DNS-UDR-SplitRouter/blob/main/docs/configuration.md
5
+
6
+ data:
7
+ # ms-cloud-endpoints JSON/YAML. Relative paths are resolved against this file's directory. When omitted, the
8
+ # generator runs `ms-cloud-endpoints` and narrows --cloud/--source/--tag/--no-ipv6 to what the links need.
9
+ # file: ms-cloud-endpoints.json # uncomment to use an existing document instead of fetching
10
+ fetch:
11
+ command: ms-cloud-endpoints
12
+ args: [] # extra collector arguments, e.g. [--timeout, "120"]
13
+
14
+ # /etc/systemd/networkd.conf.d/10-ms-split-router.conf
15
+ networkd_conf:
16
+ enabled: true
17
+ manage_foreign_routes: false # keep the routes WSL installs on mirrored links
18
+ manage_foreign_routing_policy_rules: false
19
+ extra: {} # more keys: {Key: value} for [Network], or {Section: {Key: value}}
20
+
21
+ # /etc/systemd/resolved.conf.d/10-ms-split-router.conf
22
+ resolved_conf:
23
+ enabled: true
24
+ dnssec: false # DNSSEC=no; split resolvers often serve unsigned private zones
25
+ extra: {} # e.g. {LLMNR: false, MulticastDNS: false, FallbackDNS: ""}
26
+
27
+ split_links: # required: at least one
28
+ - id: corp-vpn # required: lower-case letters, digits, '-'; used in file names
29
+ match: # exactly one of mac / altname / name
30
+ mac: "00:15:5d:aa:bb:cc" # placeholder: use `ms-split-router --show-links`
31
+ altname_link: # optional: also write a .link file that gives the MAC a stable altname
32
+ name: msvpn
33
+ style: full # full: 10-ms-split-<id>.network | dropin: <base>.d/50-ms-split-router-<id>.conf
34
+ # base: 10-eth1.network # required with style: dropin
35
+ gateway4: auto # auto (from the link's default route) | none | an IPv4 address
36
+ gateway6: auto # auto | none | an IPv6 address
37
+ onlink: auto # GatewayOnLink=; auto copies the detected route's onlink flag
38
+ metric: null # Metric= for every route
39
+ dns: [10.10.0.4, 10.10.0.5] # placeholder; without DNS servers no Domains= are written
40
+ dns_default_route: false
41
+ required_for_online: "no"
42
+ resolve: # per-link resolved settings
43
+ dnssec: false
44
+ # llmnr: false
45
+ # multicast_dns: false
46
+ # dns_over_tls: false
47
+ domains:
48
+ derive_from: [m365, private_dns] # also: m365_additional
49
+ collapse_to_root: true # ~office.com rather than ~outlook.office.com
50
+ exclude_third_party: true # drop CA/CDN domains (digicert.com, akamaihd.net, ...)
51
+ private_dns_clouds: [Commercial]
52
+ extra: [] # e.g. [contoso.com] for your own zones
53
+ exclude: []
54
+ routes:
55
+ azure: # false disables Azure service tags
56
+ clouds: [Public]
57
+ service_tags: [AzureCloud] # case-insensitive globs, e.g. [AzureActiveDirectory, "Storage.*"]
58
+ m365: # false disables Microsoft 365 endpoint sets
59
+ instances: [Worldwide]
60
+ service_areas: [] # empty = all: Common, Exchange, SharePoint, Skype
61
+ categories: [] # empty = all: Optimize, Allow, Default
62
+ required_only: false
63
+ express_route_only: false
64
+ m365_additional: false # true or {clouds: [public]}: prefixes from the "other endpoints" page
65
+ extra_prefixes: [] # e.g. your vNet ranges for Private Endpoints
66
+ exclude_prefixes: []
67
+ ipv6: auto # auto: only with an IPv6 gateway | true: required | false
68
+ # `ms-split-router --ipv6 / --no-ipv6` override this on every split link
69
+ widen_to: # widen longer prefixes to this length before collapsing (fewer routes)
70
+ ipv4: null # e.g. 24
71
+ ipv6: 48 # /48 = smallest globally routed IPv6 block; null keeps prefixes exact
72
+
73
+ default_links: # every other link that should resolve non-Microsoft names
74
+ - id: internet
75
+ match:
76
+ name: eth0
77
+ style: full # 20-ms-default-<id>.network
78
+ dns: [1.1.1.1, 1.0.0.1]
79
+ domains: ["~."]
80
+ dns_default_route: true
81
+ required_for_online: routable