From a7500dacbc7a6cbd33161e5fb2a750189a4d2bfa Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 18:16:33 -0400 Subject: [PATCH] Add clearnet DNS reachability planning --- README.md | 7 +- ampg/cli.py | 133 ++++++++++++++ ampg/deploy.py | 22 +-- ampg/dns.py | 341 ++++++++++++++++++++++++++++++++++++ docs/architecture.md | 7 + docs/configuration.md | 6 + docs/daemon-management.md | 9 + docs/wownero-integration.md | 1 + tests/test_deploy_plan.py | 4 +- tests/test_dns.py | 121 +++++++++++++ 10 files changed, 632 insertions(+), 19 deletions(-) create mode 100644 ampg/dns.py create mode 100644 tests/test_dns.py diff --git a/README.md b/README.md index e57e117..8ad3a4c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ python3 -m ampg --config gateway.toml init site wownero \ --preset full python3 -m ampg --config gateway.toml deploy plan --profile vps-full +python3 -m ampg --config gateway.toml dns plan --mode dynamic --behind-router python3 -m ampg --config gateway.toml build python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml install-plan --profile vps-full --write-artifacts @@ -60,7 +61,8 @@ python3 -m ampg --config gateway.toml apply --dry-run --profile vps-full `init site` writes a readable `gateway.toml` with selected transports enabled and common transports left as one-line toggles. `deploy plan` condenses the lower-level checks into -clear next steps. The current `apply --dry-run` prints the activation sequence and a +clear next steps. `dns plan` covers static DNS, Dynamic DNS, and behind-router +reachability hints. The current `apply --dry-run` prints the activation sequence and a preflight verdict without changing services. Live apply support will perform only approved changes. @@ -107,6 +109,9 @@ The current implementation is dependency-free Python: ```sh python3 -m ampg --config gateway.toml init site wownero --domain wownero.org --source ../wownero.org-website --preset full python3 -m ampg --config examples/wownero.gateway.toml deploy plan --profile vps-full +python3 -m ampg --config examples/wownero.gateway.toml dns plan --profile vps-full +python3 -m ampg --config examples/wownero.gateway.toml dns plan --profile vps-full --mode dynamic --behind-router +python3 -m ampg --config examples/wownero.gateway.toml dns check --profile vps-full python3 -m ampg --config examples/wownero.gateway.toml plan python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml status diff --git a/ampg/cli.py b/ampg/cli.py index e15fb5b..25eec20 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -36,6 +36,15 @@ from .audit import audit_gateway from .build import build_gateway from .config import load_config from .deploy import DeployPlan, DeployStep, DeployNextStep, deploy_plan +from .dns import ( + ConnectivityHint, + DNSCheckResult, + DNS_MODES, + DNSPlan, + DNSRecordPlan, + dns_check, + dns_plan, +) from .docsgen import generate_docs from .health import HealthCheck, blocked_health_checks, health_plan from .install_plan import ( @@ -134,6 +143,40 @@ def main(argv: list[str] | None = None) -> int: choices=PLATFORM_NAMES, help="Override platform detection for deploy planning.", ) + dns_parser = subcommands.add_parser( + "dns", + help="Plan and check clearnet DNS records.", + ) + dns_subcommands = dns_parser.add_subparsers(dest="dns_command", required=True) + dns_plan_parser = dns_subcommands.add_parser( + "plan", + help="Show DNS and reachability steps for clearnet.", + ) + _add_target_selection(dns_plan_parser) + dns_plan_parser.add_argument( + "--mode", + choices=DNS_MODES, + default="static", + help="Use static A/AAAA records or dynamic DNS guidance.", + ) + dns_plan_parser.add_argument("--ipv4", help="Public IPv4 address for static DNS.") + dns_plan_parser.add_argument("--ipv6", help="Public IPv6 address for static DNS.") + dns_plan_parser.add_argument( + "--dynamic-hostname", + help="Dynamic DNS hostname used in dynamic mode.", + ) + dns_plan_parser.add_argument( + "--behind-router", + action="store_true", + help="Include NAT/router/tunnel reachability hints.", + ) + dns_check_parser = dns_subcommands.add_parser( + "check", + help="Resolve clearnet domains and compare optional expected addresses.", + ) + _add_target_selection(dns_check_parser) + dns_check_parser.add_argument("--ipv4", help="Expected public IPv4 address.") + dns_check_parser.add_argument("--ipv6", help="Expected public IPv6 address.") plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.") _add_target_selection(plan_parser) plan_parser.add_argument( @@ -393,6 +436,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_doctor(config, args) if args.command == "deploy": return _cmd_deploy(config, args) + if args.command == "dns": + return _cmd_dns(config, args) if args.command == "apply": return _cmd_apply(config, args) if args.command == "approvals": @@ -488,6 +533,41 @@ def _cmd_deploy(config, args) -> int: return 1 +def _cmd_dns(config, args) -> int: + if args.dns_command == "plan": + plan = dns_plan( + config, + mode=args.mode, + ipv4=args.ipv4, + ipv6=args.ipv6, + dynamic_hostname=args.dynamic_hostname, + behind_router=args.behind_router, + ) + for record in plan.records: + _print_dns_record(record) + for hint in plan.hints: + _print_connectivity_hint(hint) + _print_dns_summary(plan) + return 1 if plan.status == "blocked" else 0 + + if args.dns_command == "check": + results = dns_check(config, ipv4=args.ipv4, ipv6=args.ipv6) + for result in results: + _print_dns_check(result) + failed = [result for result in results if result.status in {"missing", "mismatch"}] + print( + "AMPG_DNS_CHECK_SUMMARY " + f"checks={len(results)} " + f"matched={sum(1 for result in results if result.status == 'matched')} " + f"resolved={sum(1 for result in results if result.status == 'resolved')} " + f"missing={sum(1 for result in results if result.status == 'missing')} " + f"mismatch={sum(1 for result in results if result.status == 'mismatch')}" + ) + return 1 if failed else 0 + + return 1 + + def _cmd_apply(config, args) -> int: if not args.dry_run: print( @@ -920,6 +1000,59 @@ def _print_deploy_summary(config, plan: DeployPlan) -> None: ) +def _print_dns_record(record: DNSRecordPlan) -> None: + print( + "AMPG_DNS_RECORD " + f"site={record.site_id} " + f"domain={record.domain} " + f"name={record.name} " + f"type={record.type} " + f"value=\"{_quote(record.value)}\" " + f"status={record.status} " + f"message=\"{_quote(record.message)}\"" + ) + + +def _print_connectivity_hint(hint: ConnectivityHint) -> None: + print( + "AMPG_CONNECTIVITY_HINT " + f"method={hint.method} " + f"status={hint.status} " + f"command=\"{_quote(hint.command)}\" " + f"message=\"{_quote(hint.message)}\"" + ) + + +def _print_dns_summary(plan: DNSPlan) -> None: + statuses = [record.status for record in plan.records] + statuses.extend(hint.status for hint in plan.hints) + print( + "AMPG_DNS_SUMMARY " + f"status={plan.status} " + f"records={len(plan.records)} " + f"hints={len(plan.hints)} " + f"todo={statuses.count('todo')} " + f"review={statuses.count('review')} " + f"blocked={statuses.count('blocked')} " + f"skipped={statuses.count('skipped')} " + f"message=\"{_quote(plan.message)}\"" + ) + + +def _print_dns_check(result: DNSCheckResult) -> None: + resolved = ",".join(result.resolved) if result.resolved else "-" + print( + "AMPG_DNS_CHECK " + f"site={result.site_id} " + f"domain={result.domain} " + f"family={result.family} " + f"expected=\"{_quote(result.expected)}\" " + f"resolved=\"{_quote(resolved)}\" " + f"status={result.status} " + f"message=\"{_quote(result.message)}\"" + ) + + def _print_approval_check(check: ApprovalCheck) -> None: candidate = check.candidate print( diff --git a/ampg/deploy.py b/ampg/deploy.py index ee1da81..cabb97e 100644 --- a/ampg/deploy.py +++ b/ampg/deploy.py @@ -14,6 +14,7 @@ from .approvals import ( load_approval_registry, ) from .config import GatewayConfig +from .dns import dns_plan from .install_plan import ( install_artifacts, install_plan, @@ -66,7 +67,7 @@ def deploy_plan( steps = ( _source_step(config), _build_step(config, command_context), - _dns_step(config), + _dns_step(config, command_context), _doctor_step( config, command_context, @@ -143,21 +144,10 @@ def _build_step(config: GatewayConfig, commands: _CommandContext) -> DeployStep: return DeployStep("build", "ready", commands.command("build"), "selected outputs are built") -def _dns_step(config: GatewayConfig) -> DeployStep: - clearnet_sites = [ - site.domain - for site in config.sites - if "clearnet" in site.protocols and site.protocols["clearnet"].enabled - ] - if not clearnet_sites: - return DeployStep("dns", "skipped", "-", "clearnet is not selected") - domains = ", ".join(clearnet_sites) - return DeployStep( - "dns", - "review", - f"set A/AAAA records for {domains} to this server", - "clearnet needs DNS records; provider automation is not implemented yet", - ) +def _dns_step(config: GatewayConfig, commands: _CommandContext) -> DeployStep: + plan = dns_plan(config) + command = "-" if plan.status == "skipped" else commands.command("dns plan") + return DeployStep("dns", plan.status, command, plan.message) def _doctor_step( diff --git a/ampg/dns.py b/ampg/dns.py new file mode 100644 index 0000000..136641b --- /dev/null +++ b/ampg/dns.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from dataclasses import dataclass +import socket + +from .config import GatewayConfig + + +DNS_MODES = ("static", "dynamic") + + +@dataclass(frozen=True) +class DNSRecordPlan: + site_id: str + domain: str + name: str + type: str + value: str + status: str + message: str + + +@dataclass(frozen=True) +class ConnectivityHint: + method: str + status: str + command: str + message: str + + +@dataclass(frozen=True) +class DNSPlan: + status: str + records: tuple[DNSRecordPlan, ...] + hints: tuple[ConnectivityHint, ...] + message: str + + +@dataclass(frozen=True) +class DNSCheckResult: + site_id: str + domain: str + family: str + expected: str + resolved: tuple[str, ...] + status: str + message: str + + +def dns_plan( + config: GatewayConfig, + *, + mode: str = "static", + ipv4: str | None = None, + ipv6: str | None = None, + dynamic_hostname: str | None = None, + behind_router: bool = False, +) -> DNSPlan: + if mode not in DNS_MODES: + raise ValueError(f"unsupported DNS mode {mode!r}") + domains = _clearnet_sites(config) + if not domains: + return DNSPlan( + status="skipped", + records=(), + hints=(), + message="clearnet is not selected", + ) + + records: list[DNSRecordPlan] = [] + for site_id, domain in domains: + if mode == "static": + records.extend(_static_records(site_id, domain, ipv4=ipv4, ipv6=ipv6)) + else: + records.extend(_dynamic_records(site_id, domain, dynamic_hostname=dynamic_hostname)) + hints = _connectivity_hints(behind_router=behind_router) + status = _plan_status([record.status for record in records] + [hint.status for hint in hints]) + return DNSPlan( + status=status, + records=tuple(records), + hints=tuple(hints), + message=_plan_message(status), + ) + + +def dns_check( + config: GatewayConfig, + *, + ipv4: str | None = None, + ipv6: str | None = None, + resolver=None, +) -> list[DNSCheckResult]: + resolver = resolver or _resolve_domain + results: list[DNSCheckResult] = [] + for site_id, domain in _clearnet_sites(config): + resolved = resolver(domain) + if ipv4: + ipv4_values = tuple(value for value in resolved if _looks_ipv4(value)) + results.append(_check_result(site_id, domain, "A", ipv4, ipv4_values)) + if ipv6: + ipv6_values = tuple(value for value in resolved if _looks_ipv6(value)) + results.append(_check_result(site_id, domain, "AAAA", ipv6, ipv6_values)) + if not ipv4 and not ipv6: + results.append( + DNSCheckResult( + site_id=site_id, + domain=domain, + family="any", + expected="-", + resolved=tuple(sorted(resolved)), + status="resolved" if resolved else "missing", + message="domain resolves" if resolved else "domain did not resolve", + ) + ) + return results + + +def _static_records( + site_id: str, + domain: str, + *, + ipv4: str | None, + ipv6: str | None, +) -> list[DNSRecordPlan]: + records: list[DNSRecordPlan] = [] + if ipv4: + records.append( + DNSRecordPlan( + site_id, + domain, + "@", + "A", + ipv4, + "todo", + "create or update apex IPv4 record", + ) + ) + if ipv6: + records.append( + DNSRecordPlan( + site_id, + domain, + "@", + "AAAA", + ipv6, + "todo", + "create or update apex IPv6 record", + ) + ) + if not ipv4 and not ipv6: + records.append( + DNSRecordPlan( + site_id, + domain, + "@", + "A/AAAA", + "", + "review", + "choose a stable public IPv4 or IPv6 address for this server", + ) + ) + records.append( + DNSRecordPlan( + site_id, + domain, + "www", + "CNAME", + domain, + "todo", + "point www at the apex domain", + ) + ) + return records + + +def _dynamic_records( + site_id: str, + domain: str, + *, + dynamic_hostname: str | None, +) -> list[DNSRecordPlan]: + if not dynamic_hostname: + return [ + DNSRecordPlan( + site_id, + domain, + "@", + "dynamic", + "", + "review", + "choose a dynamic DNS hostname or provider API update target", + ) + ] + return [ + DNSRecordPlan( + site_id, + domain, + "www", + "CNAME", + dynamic_hostname, + "todo", + "point www at the dynamic DNS hostname", + ), + DNSRecordPlan( + site_id, + domain, + "@", + "ALIAS/ANAME", + dynamic_hostname, + "review", + "apex dynamic DNS is provider-specific; use ALIAS/ANAME or API-updated A/AAAA", + ), + ] + + +def _connectivity_hints(*, behind_router: bool) -> tuple[ConnectivityHint, ...]: + if not behind_router: + return ( + ConnectivityHint( + "public-ingress", + "review", + "allow inbound TCP 80 and 443 to this host", + "clearnet needs HTTP/HTTPS traffic to reach the server", + ), + ) + return ( + ConnectivityHint( + "port-forward", + "review", + "forward router TCP 80 and 443 to this host", + "best simple option when you control the router", + ), + ConnectivityHint( + "ipv6", + "review", + "allow inbound TCP 80 and 443 to this host IPv6 address", + "public IPv6 can avoid IPv4 NAT when the ISP provides it", + ), + ConnectivityHint( + "pcp-natpmp-upnp", + "review", + "request router port mappings only after explicit approval", + "automatic router mapping can help but must be opt-in", + ), + ConnectivityHint( + "reverse-tunnel", + "review", + "route 80/443 through a VPS or managed tunnel to this host", + "useful when port forwarding is impossible or CGNAT is present", + ), + ConnectivityHint( + "dns-01-acme", + "review", + "use DNS-01 certificate validation when inbound port 80 is unavailable", + "lets HTTPS certificates work even before direct inbound HTTP is reachable", + ), + ) + + +def _check_result( + site_id: str, + domain: str, + family: str, + expected: str, + resolved: tuple[str, ...], +) -> DNSCheckResult: + if expected in resolved: + return DNSCheckResult( + site_id, + domain, + family, + expected, + tuple(sorted(resolved)), + "matched", + "DNS record matches expected value", + ) + if resolved: + return DNSCheckResult( + site_id, + domain, + family, + expected, + tuple(sorted(resolved)), + "mismatch", + "domain resolves, but not to the expected value", + ) + return DNSCheckResult( + site_id, + domain, + family, + expected, + (), + "missing", + "domain did not resolve for this record family", + ) + + +def _clearnet_sites(config: GatewayConfig) -> list[tuple[str, str]]: + return [ + (site.id, site.domain) + for site in config.sites + if "clearnet" in site.protocols and site.protocols["clearnet"].enabled + ] + + +def _resolve_domain(domain: str) -> set[str]: + try: + results = socket.getaddrinfo(domain, None) + except OSError: + return set() + return {item[4][0] for item in results} + + +def _looks_ipv4(value: str) -> bool: + return "." in value + + +def _looks_ipv6(value: str) -> bool: + return ":" in value + + +def _plan_status(statuses: list[str]) -> str: + if "blocked" in statuses: + return "blocked" + if "todo" in statuses: + return "todo" + if "review" in statuses: + return "review" + return "ready" + + +def _plan_message(status: str) -> str: + if status == "blocked": + return "DNS setup has blockers" + if status == "todo": + return "DNS records or connectivity steps are ready to configure" + if status == "review": + return "DNS setup needs operator choices" + if status == "skipped": + return "clearnet DNS is not needed" + return "DNS setup is ready" diff --git a/docs/architecture.md b/docs/architecture.md index d4a4bf9..a384e7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,6 +113,13 @@ render-only environment while making daemon ownership decisions explicit. approvals, address capture, and apply dry-run. It emits stage rows with `ready`, `todo`, `review`, `blocked`, or `skipped` status and short next-step commands. It is read-only. +## Clearnet reachability + +`ampg dns plan` handles the public web edge for clearnet deployments. Static hosts use +A/AAAA records. Dynamic or behind-router hosts can use Dynamic DNS, provider-specific +apex aliases, API-updated records, port forwarding, IPv6, reverse tunnels, or DNS-01 +certificate validation. Provider and router automation should remain opt-in. + ## Activation plan `ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered diff --git a/docs/configuration.md b/docs/configuration.md index 2819b97..3079c32 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,6 +41,12 @@ Then run `deploy plan` for a concise checklist: python3 -m ampg --config gateway.toml deploy plan --profile vps-full ``` +For clearnet on a changing home/laptop address, use Dynamic DNS planning: + +```sh +python3 -m ampg --config gateway.toml dns plan --mode dynamic --behind-router +``` + ## Minimal Wownero pilot config ```toml diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 7e77f35..33d21cf 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -38,6 +38,9 @@ config artifacts. ```sh python3 -m ampg --config gateway.toml deploy plan --profile mobile-i2p +python3 -m ampg --config gateway.toml dns plan --profile vps-full +python3 -m ampg --config gateway.toml dns plan --profile vps-full --mode dynamic --behind-router +python3 -m ampg --config gateway.toml dns check --profile vps-full python3 -m ampg --config gateway.toml status python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p @@ -59,6 +62,12 @@ python3 -m ampg --config gateway.toml doctor --platform android-termux doctor, daemons, artifacts, addresses, and apply preflight. It prints the next commands to run and does not change files or services. +`dns plan` prints clearnet DNS records and reachability hints. Static mode plans A/AAAA +and `www` records. Dynamic mode supports a Dynamic DNS hostname and warns when apex +records require provider-specific ALIAS/ANAME or API-updated A/AAAA support. With +`--behind-router`, it also lists port forwarding, IPv6, explicit router mapping, +reverse-tunnel, and DNS-01 certificate options. + `status` prints one row per enabled protocol with the selected platform provider, adapter, installed/running daemon probe, action, and policy result. diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index c408eb7..baa2048 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -151,6 +151,7 @@ daemon_policy = "auto" ```sh python3 -m ampg --config gateway.toml init site wownero --domain wownero.org --source ../wownero.org-website --preset full python3 -m ampg --config gateway.toml deploy plan --profile vps-full +python3 -m ampg --config gateway.toml dns plan --profile vps-full python3 -m ampg --config examples/wownero.gateway.toml plan python3 -m ampg --config examples/wownero.gateway.toml build python3 -m ampg --config examples/wownero.gateway.toml manifest diff --git a/tests/test_deploy_plan.py b/tests/test_deploy_plan.py index 8999e50..55efa3b 100644 --- a/tests/test_deploy_plan.py +++ b/tests/test_deploy_plan.py @@ -96,8 +96,8 @@ class DeployPlanTest(unittest.TestCase): ] ) - self.assertIn("AMPG_DEPLOY_STEP stage=dns status=review", output) - self.assertIn("set A/AAAA records for example.test", output) + self.assertIn("AMPG_DEPLOY_STEP stage=dns status=todo", output) + self.assertIn("dns plan --profile vps-full", output) def test_deploy_plan_after_build_moves_build_stage_to_ready(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_dns.py b/tests/test_dns.py new file mode 100644 index 0000000..996720f --- /dev/null +++ b/tests/test_dns.py @@ -0,0 +1,121 @@ +import contextlib +import io +import tempfile +import unittest +from pathlib import Path + +from ampg.cli import main +from ampg.config import load_config +from ampg.dns import dns_check + + +def _source(root: Path) -> Path: + source = root / "site" + source.mkdir() + (source / "index.html").write_text("

Hello

", encoding="utf-8") + return source + + +def _run_cli(args: list[str]) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + status = main(args) + return status, stdout.getvalue(), stderr.getvalue() + + +def _init_config(root: Path, *, preset: str = "clearnet-only") -> Path: + config_path = root / "gateway.toml" + status, _, error = _run_cli( + [ + "--config", + str(config_path), + "init", + "site", + "example", + "--domain", + "example.test", + "--source", + str(_source(root)), + "--preset", + preset, + ] + ) + if status != 0: + raise AssertionError(error) + return config_path + + +class DNSTest(unittest.TestCase): + def test_dns_plan_static_records(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _init_config(Path(tmp).resolve()) + + status, output, error = _run_cli( + [ + "--config", + str(config_path), + "dns", + "plan", + "--ipv4", + "203.0.113.10", + ] + ) + + self.assertEqual(0, status, error) + self.assertIn("AMPG_DNS_RECORD site=example domain=example.test name=@ type=A", output) + self.assertIn('value="203.0.113.10"', output) + self.assertIn("AMPG_DNS_RECORD site=example domain=example.test name=www type=CNAME", output) + self.assertIn("AMPG_DNS_SUMMARY status=todo", output) + + def test_dns_plan_dynamic_records_and_router_hints(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _init_config(Path(tmp).resolve()) + + status, output, error = _run_cli( + [ + "--config", + str(config_path), + "dns", + "plan", + "--mode", + "dynamic", + "--dynamic-hostname", + "example.dynamic.test", + "--behind-router", + ] + ) + + self.assertEqual(0, status, error) + self.assertIn("type=CNAME", output) + self.assertIn('value="example.dynamic.test"', output) + self.assertIn("type=ALIAS/ANAME", output) + self.assertIn("AMPG_CONNECTIVITY_HINT method=port-forward", output) + self.assertIn("AMPG_CONNECTIVITY_HINT method=reverse-tunnel", output) + + def test_dns_plan_skips_when_clearnet_is_not_selected(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = _init_config(Path(tmp).resolve(), preset="i2p-only") + + status, output, error = _run_cli(["--config", str(config_path), "dns", "plan"]) + + self.assertEqual(0, status, error) + self.assertIn("AMPG_DNS_SUMMARY status=skipped", output) + + def test_dns_check_compares_expected_records(self): + with tempfile.TemporaryDirectory() as tmp: + config = load_config(_init_config(Path(tmp).resolve())) + + results = dns_check( + config, + ipv4="203.0.113.10", + resolver=lambda domain: {"203.0.113.10", "2001:db8::10"}, + ) + + self.assertEqual(1, len(results)) + self.assertEqual("matched", results[0].status) + self.assertEqual(("203.0.113.10",), results[0].resolved) + + +if __name__ == "__main__": + unittest.main()