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,4 @@
1
+ """Generate systemd-networkd/systemd-resolved split-tunnel configuration from ms-cloud-endpoints data."""
2
+
3
+ __version__ = "0.1.0"
4
+ GENERATOR = "ms-split-router"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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