mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 15:41:41 +02:00
Add clearnet DNS reachability planning
This commit is contained in:
@@ -51,6 +51,7 @@ python3 -m ampg --config gateway.toml init site wownero \
|
|||||||
--preset full
|
--preset full
|
||||||
|
|
||||||
python3 -m ampg --config gateway.toml deploy plan --profile vps-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 build
|
||||||
python3 -m ampg --config gateway.toml doctor
|
python3 -m ampg --config gateway.toml doctor
|
||||||
python3 -m ampg --config gateway.toml install-plan --profile vps-full --write-artifacts
|
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
|
`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
|
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
|
preflight verdict without changing services. Live apply support will perform only
|
||||||
approved changes.
|
approved changes.
|
||||||
|
|
||||||
@@ -107,6 +109,9 @@ The current implementation is dependency-free Python:
|
|||||||
```sh
|
```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 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 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
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts
|
python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml status
|
python3 -m ampg --config examples/wownero.gateway.toml status
|
||||||
|
|||||||
+133
@@ -36,6 +36,15 @@ from .audit import audit_gateway
|
|||||||
from .build import build_gateway
|
from .build import build_gateway
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .deploy import DeployPlan, DeployStep, DeployNextStep, deploy_plan
|
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 .docsgen import generate_docs
|
||||||
from .health import HealthCheck, blocked_health_checks, health_plan
|
from .health import HealthCheck, blocked_health_checks, health_plan
|
||||||
from .install_plan import (
|
from .install_plan import (
|
||||||
@@ -134,6 +143,40 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
choices=PLATFORM_NAMES,
|
choices=PLATFORM_NAMES,
|
||||||
help="Override platform detection for deploy planning.",
|
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.")
|
plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.")
|
||||||
_add_target_selection(plan_parser)
|
_add_target_selection(plan_parser)
|
||||||
plan_parser.add_argument(
|
plan_parser.add_argument(
|
||||||
@@ -393,6 +436,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return _cmd_doctor(config, args)
|
return _cmd_doctor(config, args)
|
||||||
if args.command == "deploy":
|
if args.command == "deploy":
|
||||||
return _cmd_deploy(config, args)
|
return _cmd_deploy(config, args)
|
||||||
|
if args.command == "dns":
|
||||||
|
return _cmd_dns(config, args)
|
||||||
if args.command == "apply":
|
if args.command == "apply":
|
||||||
return _cmd_apply(config, args)
|
return _cmd_apply(config, args)
|
||||||
if args.command == "approvals":
|
if args.command == "approvals":
|
||||||
@@ -488,6 +533,41 @@ def _cmd_deploy(config, args) -> int:
|
|||||||
return 1
|
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:
|
def _cmd_apply(config, args) -> int:
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
print(
|
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:
|
def _print_approval_check(check: ApprovalCheck) -> None:
|
||||||
candidate = check.candidate
|
candidate = check.candidate
|
||||||
print(
|
print(
|
||||||
|
|||||||
+6
-16
@@ -14,6 +14,7 @@ from .approvals import (
|
|||||||
load_approval_registry,
|
load_approval_registry,
|
||||||
)
|
)
|
||||||
from .config import GatewayConfig
|
from .config import GatewayConfig
|
||||||
|
from .dns import dns_plan
|
||||||
from .install_plan import (
|
from .install_plan import (
|
||||||
install_artifacts,
|
install_artifacts,
|
||||||
install_plan,
|
install_plan,
|
||||||
@@ -66,7 +67,7 @@ def deploy_plan(
|
|||||||
steps = (
|
steps = (
|
||||||
_source_step(config),
|
_source_step(config),
|
||||||
_build_step(config, command_context),
|
_build_step(config, command_context),
|
||||||
_dns_step(config),
|
_dns_step(config, command_context),
|
||||||
_doctor_step(
|
_doctor_step(
|
||||||
config,
|
config,
|
||||||
command_context,
|
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")
|
return DeployStep("build", "ready", commands.command("build"), "selected outputs are built")
|
||||||
|
|
||||||
|
|
||||||
def _dns_step(config: GatewayConfig) -> DeployStep:
|
def _dns_step(config: GatewayConfig, commands: _CommandContext) -> DeployStep:
|
||||||
clearnet_sites = [
|
plan = dns_plan(config)
|
||||||
site.domain
|
command = "-" if plan.status == "skipped" else commands.command("dns plan")
|
||||||
for site in config.sites
|
return DeployStep("dns", plan.status, command, plan.message)
|
||||||
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 _doctor_step(
|
def _doctor_step(
|
||||||
|
|||||||
+341
@@ -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"
|
||||||
@@ -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`,
|
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.
|
`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
|
## Activation plan
|
||||||
|
|
||||||
`ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered
|
`ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ Then run `deploy plan` for a concise checklist:
|
|||||||
python3 -m ampg --config gateway.toml deploy plan --profile vps-full
|
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
|
## Minimal Wownero pilot config
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ config artifacts.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m ampg --config gateway.toml deploy plan --profile mobile-i2p
|
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 status
|
||||||
python3 -m ampg --config gateway.toml doctor
|
python3 -m ampg --config gateway.toml doctor
|
||||||
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p
|
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
|
doctor, daemons, artifacts, addresses, and apply preflight. It prints the next commands
|
||||||
to run and does not change files or services.
|
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,
|
`status` prints one row per enabled protocol with the selected platform provider,
|
||||||
adapter, installed/running daemon probe, action, and policy result.
|
adapter, installed/running daemon probe, action, and policy result.
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ daemon_policy = "auto"
|
|||||||
```sh
|
```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 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 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 plan
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml build
|
python3 -m ampg --config examples/wownero.gateway.toml build
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml manifest
|
python3 -m ampg --config examples/wownero.gateway.toml manifest
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ class DeployPlanTest(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("AMPG_DEPLOY_STEP stage=dns status=review", output)
|
self.assertIn("AMPG_DEPLOY_STEP stage=dns status=todo", output)
|
||||||
self.assertIn("set A/AAAA records for example.test", output)
|
self.assertIn("dns plan --profile vps-full", output)
|
||||||
|
|
||||||
def test_deploy_plan_after_build_moves_build_stage_to_ready(self):
|
def test_deploy_plan_after_build_moves_build_stage_to_ready(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user