Add clearnet DNS reachability planning

This commit is contained in:
jwinterm
2026-07-06 18:16:33 -04:00
parent f4a1355212
commit a7500dacbc
10 changed files with 632 additions and 19 deletions
+6 -1
View File
@@ -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
+133
View File
@@ -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(
+6 -16
View File
@@ -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(
+341
View File
@@ -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",
"<server-public-ip>",
"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",
"<dynamic-hostname>",
"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"
+7
View File
@@ -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
+6
View File
@@ -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
+9
View File
@@ -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.
+1
View File
@@ -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
+2 -2
View File
@@ -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:
+121
View File
@@ -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("<h1>Hello</h1>", 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()