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/cli.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import GENERATOR, __version__
|
|
11
|
+
from .config import Config, ConfigError, load_config, with_ipv6
|
|
12
|
+
from .data import DataError, PartialDataError, fetch_document, load_document
|
|
13
|
+
from .discover import Discovery, DiscoveryError, describe_links
|
|
14
|
+
from .generate import GenerateError, generate
|
|
15
|
+
from .install import apply_changes, diff, plan_changes, reload_commands, run_commands
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(GENERATOR)
|
|
18
|
+
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_ERROR = 1
|
|
21
|
+
EXIT_USAGE = 2
|
|
22
|
+
EXIT_PARTIAL = 3
|
|
23
|
+
EXIT_CHANGES = 4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog=GENERATOR,
|
|
29
|
+
description=(
|
|
30
|
+
"Generate systemd-networkd and systemd-resolved per-link split-tunnel configuration for Microsoft "
|
|
31
|
+
"365, Entra and Azure from ms-cloud-endpoints data. Without -o/--install the files are printed."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("-c", "--config", type=Path, metavar="FILE", help="generator configuration (YAML)")
|
|
35
|
+
|
|
36
|
+
data = parser.add_mutually_exclusive_group()
|
|
37
|
+
data.add_argument("--data", type=Path, metavar="FILE", help="ms-cloud-endpoints JSON/YAML (overrides data.file)")
|
|
38
|
+
data.add_argument("--fetch", action="store_true", help="run ms-cloud-endpoints even if data.file is set")
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--allow-partial",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="continue when the collector reports failed sources (exit code 3)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--ipv6",
|
|
46
|
+
action=argparse.BooleanOptionalAction,
|
|
47
|
+
help="override routes.ipv6 on every split link: --ipv6 requires IPv6 routes (fails without an IPv6 gateway "
|
|
48
|
+
"or IPv6 data), --no-ipv6 leaves them out",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
target = parser.add_mutually_exclusive_group()
|
|
52
|
+
target.add_argument("-o", "--output", type=Path, metavar="DIR", help="write the files as a tree under DIR")
|
|
53
|
+
target.add_argument("--install", action="store_true", help="write the files under --root (default /)")
|
|
54
|
+
parser.add_argument("--root", type=Path, default=Path("/"), metavar="DIR", help="root for --install/--diff/--check")
|
|
55
|
+
parser.add_argument("--prune", action="store_true", help="remove generated files that are no longer rendered")
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--reload", action="store_true", help="after --install, reload networkd/resolved/udev as needed"
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument("--diff", action="store_true", help="print a unified diff against the target tree")
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--check", action="store_true", help=f"write nothing; exit {EXIT_CHANGES} if the target tree is out of date"
|
|
62
|
+
)
|
|
63
|
+
inspection = parser.add_mutually_exclusive_group()
|
|
64
|
+
inspection.add_argument("--show-links", action="store_true", help="list the links seen by 'ip' and exit")
|
|
65
|
+
inspection.add_argument(
|
|
66
|
+
"--print-example-config", action="store_true", help="print the bundled example configuration and exit"
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("-v", "--verbose", action="count", default=0, help="-v info, -vv debug logging on stderr")
|
|
69
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _configure_logging(verbosity: int) -> None:
|
|
74
|
+
level = logging.WARNING if verbosity == 0 else logging.INFO if verbosity == 1 else logging.DEBUG
|
|
75
|
+
logging.basicConfig(level=level, format="%(levelname)s: %(message)s", stream=sys.stderr)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_data(config: Config, args: argparse.Namespace) -> dict[str, Any]:
|
|
79
|
+
if args.data is not None:
|
|
80
|
+
return load_document(args.data)
|
|
81
|
+
if config.data.file is not None and not args.fetch:
|
|
82
|
+
return load_document(config.data.file)
|
|
83
|
+
try:
|
|
84
|
+
return fetch_document(config)
|
|
85
|
+
except PartialDataError as exc:
|
|
86
|
+
if not args.allow_partial:
|
|
87
|
+
raise
|
|
88
|
+
log.warning("%s; continuing (--allow-partial)", exc)
|
|
89
|
+
return exc.document
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_system_root(root: Path) -> bool:
|
|
93
|
+
return root.resolve() == Path("/")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _print_files(files: list[Any]) -> None:
|
|
97
|
+
for index, file in enumerate(files):
|
|
98
|
+
if index:
|
|
99
|
+
sys.stdout.write("\n")
|
|
100
|
+
sys.stdout.write(f"### /{file.path}\n{file.content}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv: list[str] | None = None) -> int:
|
|
104
|
+
parser = build_parser()
|
|
105
|
+
args = parser.parse_args(argv)
|
|
106
|
+
_configure_logging(args.verbose)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
if args.print_example_config:
|
|
110
|
+
try:
|
|
111
|
+
example = resources.files("ms_split_router").joinpath("example_config.yaml").read_text(encoding="utf-8")
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
log.error("cannot read bundled example configuration: %s", exc)
|
|
114
|
+
return EXIT_ERROR
|
|
115
|
+
sys.stdout.write(example)
|
|
116
|
+
return EXIT_OK
|
|
117
|
+
if args.show_links:
|
|
118
|
+
for line in describe_links(Discovery()):
|
|
119
|
+
print(line)
|
|
120
|
+
return EXIT_OK
|
|
121
|
+
if args.config is None:
|
|
122
|
+
parser.error("-c/--config is required")
|
|
123
|
+
if args.reload and not args.install:
|
|
124
|
+
parser.error("--reload requires --install")
|
|
125
|
+
|
|
126
|
+
config = load_config(args.config)
|
|
127
|
+
if args.ipv6 is not None:
|
|
128
|
+
config = with_ipv6(config, args.ipv6)
|
|
129
|
+
document = _load_data(config, args)
|
|
130
|
+
generated = generate(config, document)
|
|
131
|
+
except ConfigError as exc:
|
|
132
|
+
log.error("configuration error: %s", exc)
|
|
133
|
+
return EXIT_USAGE
|
|
134
|
+
except PartialDataError as exc:
|
|
135
|
+
log.error("%s (use --allow-partial to continue)", exc)
|
|
136
|
+
return EXIT_PARTIAL
|
|
137
|
+
except (DataError, DiscoveryError, GenerateError) as exc:
|
|
138
|
+
log.error("%s", exc)
|
|
139
|
+
return EXIT_ERROR
|
|
140
|
+
|
|
141
|
+
for link in generated.links:
|
|
142
|
+
log.info(
|
|
143
|
+
"%s: %d IPv4 routes via %s, %d IPv6 routes via %s, %d routing domains",
|
|
144
|
+
link.id,
|
|
145
|
+
link.ipv4_routes,
|
|
146
|
+
link.gateway4 or "-",
|
|
147
|
+
link.ipv6_routes,
|
|
148
|
+
link.gateway6 or "-",
|
|
149
|
+
len(link.domains),
|
|
150
|
+
)
|
|
151
|
+
if link.dropped_third_party:
|
|
152
|
+
log.info("%s: excluded third-party domains: %s", link.id, " ".join(link.dropped_third_party))
|
|
153
|
+
|
|
154
|
+
writing = args.output is not None or args.install
|
|
155
|
+
if not (writing or args.diff or args.check):
|
|
156
|
+
_print_files(generated.files)
|
|
157
|
+
return EXIT_OK
|
|
158
|
+
|
|
159
|
+
root = args.output if args.output is not None else args.root
|
|
160
|
+
try:
|
|
161
|
+
changes = plan_changes(generated.files, root, prune=args.prune)
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
log.error("cannot read %s: %s", exc.filename or root, exc.strerror or exc)
|
|
164
|
+
return EXIT_ERROR
|
|
165
|
+
if args.diff:
|
|
166
|
+
sys.stdout.write(diff(changes, root))
|
|
167
|
+
if args.check:
|
|
168
|
+
for change in changes:
|
|
169
|
+
print(f"{change.action}: {root / change.path}", file=sys.stderr)
|
|
170
|
+
return EXIT_CHANGES if changes else EXIT_OK
|
|
171
|
+
if not writing:
|
|
172
|
+
return EXIT_OK
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
apply_changes(changes, root)
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
log.error("cannot write %s: %s", exc.filename or root, exc.strerror or exc)
|
|
178
|
+
return EXIT_ERROR
|
|
179
|
+
for change in changes:
|
|
180
|
+
print(f"{change.action}: {root / change.path}", file=sys.stderr)
|
|
181
|
+
if not changes:
|
|
182
|
+
print(f"{root}: up to date", file=sys.stderr)
|
|
183
|
+
|
|
184
|
+
if args.reload and changes:
|
|
185
|
+
if not _is_system_root(root):
|
|
186
|
+
log.warning("--reload skipped: --root is not /")
|
|
187
|
+
else:
|
|
188
|
+
failed = run_commands(reload_commands(changes, config))
|
|
189
|
+
if failed:
|
|
190
|
+
log.error("reload command(s) failed: %s", "; ".join(failed))
|
|
191
|
+
return EXIT_ERROR
|
|
192
|
+
return EXIT_OK
|