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.
- ip_reporter/__init__.py +85 -0
- ip_reporter/cli.py +246 -0
- ip_reporter/cloudflare.py +397 -0
- ip_reporter/curl.py +246 -0
- ip_reporter/discovery.py +276 -0
- ip_reporter/models.py +251 -0
- ip_reporter/py.typed +0 -0
- ip_reporter/reporter.py +175 -0
- ip_reporter-0.1.1.dist-info/METADATA +231 -0
- ip_reporter-0.1.1.dist-info/RECORD +13 -0
- ip_reporter-0.1.1.dist-info/WHEEL +4 -0
- ip_reporter-0.1.1.dist-info/entry_points.txt +2 -0
- ip_reporter-0.1.1.dist-info/licenses/LICENSE +21 -0
ip_reporter/__init__.py
ADDED
|
@@ -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())
|