ip-reporter 0.1.1__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,85 @@
1
+ """WAN/LAN IP discovery and Cloudflare DNS synchronization."""
2
+
3
+ from .cloudflare import (
4
+ CloudflareAPIError,
5
+ CloudflareClient,
6
+ CloudflareError,
7
+ HTTPResponse,
8
+ HTTPTransport,
9
+ RecordNotFoundError,
10
+ UrllibTransport,
11
+ )
12
+ from .curl import (
13
+ CurlClient,
14
+ CurlError,
15
+ CurlFeatureError,
16
+ CurlProbeError,
17
+ CurlUnavailableError,
18
+ CurlVersion,
19
+ find_curl,
20
+ )
21
+ from .discovery import (
22
+ DEFAULT_WAN_ENDPOINTS,
23
+ AddressDiscovery,
24
+ DiscoveryFailedError,
25
+ LocalAddressProvider,
26
+ RemoteEndpoint,
27
+ SocketLocalAddressProvider,
28
+ WanProbe,
29
+ )
30
+ from .models import (
31
+ AddressFamily,
32
+ DiscoveredAddress,
33
+ DiscoveryError,
34
+ DiscoveryResult,
35
+ DNSUpdate,
36
+ DNSUpdateResult,
37
+ ProbeProtocol,
38
+ Scope,
39
+ normalize_families,
40
+ )
41
+ from .reporter import (
42
+ IPReporter,
43
+ discover,
44
+ get_lan,
45
+ get_wan,
46
+ update_cloudflare_dns,
47
+ )
48
+
49
+ __all__ = [
50
+ "AddressDiscovery",
51
+ "AddressFamily",
52
+ "CloudflareAPIError",
53
+ "CloudflareClient",
54
+ "CloudflareError",
55
+ "CurlClient",
56
+ "CurlError",
57
+ "CurlFeatureError",
58
+ "CurlProbeError",
59
+ "CurlUnavailableError",
60
+ "CurlVersion",
61
+ "DEFAULT_WAN_ENDPOINTS",
62
+ "DNSUpdate",
63
+ "DNSUpdateResult",
64
+ "DiscoveryError",
65
+ "DiscoveryFailedError",
66
+ "DiscoveryResult",
67
+ "DiscoveredAddress",
68
+ "HTTPResponse",
69
+ "HTTPTransport",
70
+ "IPReporter",
71
+ "LocalAddressProvider",
72
+ "ProbeProtocol",
73
+ "RecordNotFoundError",
74
+ "RemoteEndpoint",
75
+ "Scope",
76
+ "SocketLocalAddressProvider",
77
+ "UrllibTransport",
78
+ "WanProbe",
79
+ "discover",
80
+ "find_curl",
81
+ "get_lan",
82
+ "get_wan",
83
+ "normalize_families",
84
+ "update_cloudflare_dns",
85
+ ]
ip_reporter/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ """Command-line interface for ``ip-reporter``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Iterable
9
+
10
+ from .cloudflare import CloudflareClient
11
+ from .curl import CurlClient
12
+ from .discovery import AddressDiscovery, RemoteEndpoint
13
+ from .models import AddressFamily, Scope, coerce_scope
14
+ from .reporter import IPReporter
15
+
16
+
17
+ def _add_discovery_options(parser: argparse.ArgumentParser) -> None:
18
+ parser.add_argument(
19
+ "--scope",
20
+ choices=[scope.value for scope in Scope],
21
+ default=Scope.BOTH.value,
22
+ help="report wan, lan, or both (default: both)",
23
+ )
24
+ parser.add_argument(
25
+ "--family",
26
+ action="append",
27
+ metavar="FAMILY",
28
+ help="ipv4, ipv6, h3ipv4, h3ipv6, or all; repeat for multiple families (default: ipv4)",
29
+ )
30
+ parser.add_argument(
31
+ "--format",
32
+ choices=("text", "values", "json"),
33
+ default="text",
34
+ help="output format (default: text)",
35
+ )
36
+ parser.add_argument(
37
+ "--curl-path",
38
+ help="curl executable; defaults to Homebrew curl on macOS or IP_REPORTER_CURL",
39
+ )
40
+ parser.add_argument("--timeout", type=float, default=10.0, help="probe timeout in seconds")
41
+ parser.add_argument(
42
+ "--wan-endpoint",
43
+ action="append",
44
+ help="override WAN endpoint; repeat for failover endpoints",
45
+ )
46
+ parser.add_argument(
47
+ "--strict",
48
+ action="store_true",
49
+ help="fail if any requested family cannot be discovered",
50
+ )
51
+
52
+
53
+ def _add_dns_options(parser: argparse.ArgumentParser) -> None:
54
+ parser.add_argument(
55
+ "--record", action="append", required=True, help="DNS name; repeat for many"
56
+ )
57
+ parser.add_argument("--zone-id", help="Cloudflare zone ID; or use CLOUDFLARE_ZONE_ID")
58
+ parser.add_argument(
59
+ "--zone-name",
60
+ help="zone name, useful with --zone-id for relative names or for zone lookup",
61
+ )
62
+ parser.add_argument(
63
+ "--scope",
64
+ choices=[scope.value for scope in Scope],
65
+ default=Scope.WAN.value,
66
+ help="address scope to write (default: wan)",
67
+ )
68
+ parser.add_argument(
69
+ "--family",
70
+ action="append",
71
+ metavar="FAMILY",
72
+ help="ipv4, ipv6, h3ipv4, h3ipv6, or all; repeat for multiple families (default: ipv4)",
73
+ )
74
+ parser.add_argument("--ttl", type=int, default=1, help="TTL in seconds; 1 means automatic")
75
+ proxy = parser.add_mutually_exclusive_group()
76
+ proxy.add_argument("--proxied", dest="proxied", action="store_true")
77
+ proxy.add_argument("--not-proxied", dest="proxied", action="store_false")
78
+ parser.set_defaults(proxied=None)
79
+ parser.add_argument("--comment", help="Cloudflare record comment")
80
+ parser.add_argument(
81
+ "--create-missing",
82
+ action="store_true",
83
+ help="create a missing A/AAAA record; otherwise only existing records are changed",
84
+ )
85
+ parser.add_argument("--curl-path", help="curl executable for WAN probes")
86
+ parser.add_argument("--timeout", type=float, default=10.0, help="probe/API timeout in seconds")
87
+ parser.add_argument("--api-token", help="Cloudflare API token; prefer CLOUDFLARE_API_TOKEN")
88
+ parser.add_argument("--api-email", help="legacy Cloudflare API email")
89
+ parser.add_argument("--api-key", help="legacy Cloudflare Global API key")
90
+ parser.add_argument(
91
+ "--format", choices=("text", "json"), default="text", help="output format (default: text)"
92
+ )
93
+ parser.add_argument(
94
+ "--dry-run", action="store_true", help="discover and print without changing DNS"
95
+ )
96
+
97
+
98
+ def _parse_families(raw: Iterable[str] | None) -> tuple[AddressFamily, ...]:
99
+ if not raw:
100
+ return (AddressFamily.IPV4,)
101
+ values: list[AddressFamily] = []
102
+ all_families = tuple(AddressFamily)
103
+ for item in raw:
104
+ for value in item.split(","):
105
+ normalized = value.strip().lower()
106
+ if normalized == "all":
107
+ candidates = all_families
108
+ else:
109
+ try:
110
+ candidates = (AddressFamily(normalized),)
111
+ except ValueError as exc:
112
+ raise ValueError(
113
+ f"unknown family {value!r}; use ipv4, ipv6, h3ipv4, h3ipv6, or all"
114
+ ) from exc
115
+ for family in candidates:
116
+ if family not in values:
117
+ values.append(family)
118
+ if not values:
119
+ raise ValueError("at least one family is required")
120
+ return tuple(values)
121
+
122
+
123
+ def _make_reporter(args: argparse.Namespace) -> IPReporter:
124
+ scope = coerce_scope(args.scope)
125
+ wan_probe = None
126
+ if scope in (Scope.WAN, Scope.BOTH):
127
+ wan_probe = CurlClient(args.curl_path, timeout=args.timeout)
128
+ endpoint_values = getattr(args, "wan_endpoint", None)
129
+ endpoints = None
130
+ if endpoint_values:
131
+ endpoints = tuple(RemoteEndpoint(value) for value in endpoint_values)
132
+ discovery = (
133
+ AddressDiscovery(wan_probe=wan_probe, endpoints=endpoints or ())
134
+ if endpoints
135
+ else AddressDiscovery(wan_probe=wan_probe)
136
+ )
137
+ return IPReporter(discovery)
138
+
139
+
140
+ def _print_discovery(result, output_format: str) -> None:
141
+ if output_format == "json":
142
+ print(result.to_json())
143
+ return
144
+ if output_format == "values":
145
+ for address in result.wan + result.lan:
146
+ print(address.value)
147
+ else:
148
+ for scope, addresses in (("wan", result.wan), ("lan", result.lan)):
149
+ for address in addresses:
150
+ print(f"{scope} {address.family.value} {address.value} [{address.protocol.value}]")
151
+ for error in result.errors:
152
+ print(
153
+ f"error {error.scope.value} {error.family.value}: {error.message}",
154
+ file=sys.stderr,
155
+ )
156
+
157
+
158
+ def _run_discover(args: argparse.Namespace) -> int:
159
+ reporter = _make_reporter(args)
160
+ result = reporter.discover(
161
+ scope=args.scope,
162
+ families=_parse_families(args.family),
163
+ strict=args.strict,
164
+ )
165
+ _print_discovery(result, args.format)
166
+ return 0 if result.wan or result.lan else 1
167
+
168
+
169
+ def _run_dns_update(args: argparse.Namespace) -> int:
170
+ reporter = _make_reporter(args)
171
+ families = _parse_families(args.family)
172
+ result = reporter.discover(scope=args.scope, families=families, strict=True)
173
+ addresses = result.addresses(scope=args.scope, families=families)
174
+ if not addresses:
175
+ raise RuntimeError("no addresses were discovered; DNS was not changed")
176
+ if args.dry_run:
177
+ _print_discovery(result, "json" if args.format == "json" else "text")
178
+ return 0
179
+
180
+ if args.api_token or args.api_email or args.api_key:
181
+ cloudflare = CloudflareClient(
182
+ api_token=args.api_token,
183
+ api_email=args.api_email,
184
+ api_key=args.api_key,
185
+ timeout=args.timeout,
186
+ )
187
+ else:
188
+ cloudflare = CloudflareClient.from_env(timeout=args.timeout)
189
+ zone_id = args.zone_id
190
+ if not zone_id:
191
+ import os
192
+
193
+ zone_id = os.environ.get("CLOUDFLARE_ZONE_ID")
194
+ update = cloudflare.sync_addresses(
195
+ zone_id,
196
+ args.record,
197
+ addresses,
198
+ zone_name=args.zone_name,
199
+ ttl=args.ttl,
200
+ proxied=args.proxied,
201
+ comment=args.comment,
202
+ create_missing=args.create_missing,
203
+ )
204
+ if args.format == "json":
205
+ print(json.dumps(update.as_dict(), indent=2, sort_keys=True))
206
+ else:
207
+ print(f"created={update.created} updated={update.updated} unchanged={update.unchanged}")
208
+ for item in update.updates:
209
+ print(f"{item.action} {item.record_type} {item.name} {item.address}")
210
+ return 0
211
+
212
+
213
+ def build_parser() -> argparse.ArgumentParser:
214
+ parser = argparse.ArgumentParser(
215
+ prog="ip-reporter",
216
+ description="Discover WAN/LAN IPv4, IPv6, HTTP/3 IPv4, and HTTP/3 IPv6 addresses.",
217
+ )
218
+ _add_discovery_options(parser)
219
+ subparsers = parser.add_subparsers(dest="command")
220
+
221
+ discover_parser = subparsers.add_parser("discover", help="discover addresses")
222
+ _add_discovery_options(discover_parser)
223
+
224
+ dns_parser = subparsers.add_parser("dns", help="Cloudflare DNS operations")
225
+ dns_subparsers = dns_parser.add_subparsers(dest="dns_command", required=True)
226
+ update_parser = dns_subparsers.add_parser("update", help="sync discovered addresses to records")
227
+ _add_dns_options(update_parser)
228
+ return parser
229
+
230
+
231
+ def main(argv: list[str] | None = None) -> int:
232
+ parser = build_parser()
233
+ args = parser.parse_args(argv)
234
+ try:
235
+ if args.command == "dns":
236
+ if args.dns_command != "update":
237
+ parser.error("a DNS operation is required")
238
+ return _run_dns_update(args)
239
+ return _run_discover(args)
240
+ except Exception as exc: # noqa: BLE001 - CLI must produce a clean one-line error
241
+ print(f"ip-reporter: {exc}", file=sys.stderr)
242
+ return 2
243
+
244
+
245
+ if __name__ == "__main__": # pragma: no cover
246
+ raise SystemExit(main())