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
ms_split_router/data.py
ADDED
|
@@ -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
|