From 234a510798f099cfe9b1e96651672b7de3b9b13d Mon Sep 17 00:00:00 2001
From: jwinterm
Date: Tue, 7 Jul 2026 12:41:52 -0400
Subject: [PATCH] Add DNS provider flow and demo site split
---
README.md | 4 +-
ampg/cli.py | 208 +++++++
ampg/dns.py | 684 +++++++++++++++++++++++-
docs/domain-onboarding.md | 29 +-
docs/visual-guide.md | 16 +-
examples/ampgateway-online.gateway.toml | 12 +-
sites/ampgateway-online/app.js | 17 +-
sites/ampgateway-online/index.html | 68 ++-
sites/ampgateway-online/styles.css | 35 +-
sites/ampgateway-site/app.js | 41 ++
sites/ampgateway-site/index.html | 169 ++++++
sites/ampgateway-site/styles.css | 370 +++++++++++++
tests/test_dns.py | 96 +++-
13 files changed, 1729 insertions(+), 20 deletions(-)
create mode 100644 sites/ampgateway-site/app.js
create mode 100644 sites/ampgateway-site/index.html
create mode 100644 sites/ampgateway-site/styles.css
diff --git a/README.md b/README.md
index bccf8a3..c1c7b44 100644
--- a/README.md
+++ b/README.md
@@ -110,7 +110,8 @@ API remains internal even when the public site is available on Tor or I2P.
- [Interactive applications](docs/interactive-apps.md)
- [Wownero integration](docs/wownero-integration.md)
- [Autodocs and drift gates](docs/autodocs.md)
-- [AMPGateway visual site source](sites/ampgateway-online/index.html)
+- [AMPGateway visual guide source](sites/ampgateway-online/index.html)
+- [AMPGateway demo site source](sites/ampgateway-site/index.html)
- [AMPGateway visual site config](examples/ampgateway-online.gateway.toml)
## Checks
@@ -128,6 +129,7 @@ The current implementation is dependency-free Python:
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 records --profile vps-full --ipv4 203.0.113.10
python3 -m ampg --config examples/wownero.gateway.toml dns plan --profile vps-full --free-domain-hints
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
diff --git a/ampg/cli.py b/ampg/cli.py
index 2a00c4b..2d3747a 100644
--- a/ampg/cli.py
+++ b/ampg/cli.py
@@ -55,11 +55,20 @@ from .dns import (
ConnectivityHint,
DNSCheckResult,
DNS_MODES,
+ DNS_MAIL_POLICIES,
+ DNS_PROVIDERS,
+ DNSApplyChange,
+ DNSApplyResult,
+ DNSBackupResult,
DNSPlan,
+ DNSProviderRecordPlan,
DNSRecordPlan,
FreeDomainHint,
+ dns_apply,
+ dns_backup,
dns_check,
dns_plan,
+ provider_dns_record_plan,
)
from .docsgen import generate_docs
from .health import HealthCheck, blocked_health_checks, health_plan
@@ -224,6 +233,84 @@ def main(argv: list[str] | None = None) -> int:
_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.")
+ dns_records_parser = dns_subcommands.add_parser(
+ "records",
+ help="Show provider-ready DNS records, including optional transport discovery hints.",
+ )
+ _add_target_selection(dns_records_parser)
+ dns_records_parser.add_argument("--ipv4", help="Public IPv4 address for A records.")
+ dns_records_parser.add_argument("--ipv6", help="Public IPv6 address for AAAA records.")
+ dns_records_parser.add_argument(
+ "--mail-policy",
+ choices=DNS_MAIL_POLICIES,
+ default="preserve",
+ help="Preserve existing mail records or mark the domain as non-mail.",
+ )
+ dns_records_parser.add_argument(
+ "--no-discovery",
+ action="store_true",
+ help="Do not include Tor/I2P/Reticulum TXT discovery hints.",
+ )
+ dns_backup_parser = dns_subcommands.add_parser(
+ "backup",
+ help="Back up DNS records from a supported provider.",
+ )
+ _add_target_selection(dns_backup_parser)
+ dns_backup_parser.add_argument("--provider", choices=DNS_PROVIDERS, required=True)
+ dns_backup_parser.add_argument(
+ "--credentials",
+ type=Path,
+ required=True,
+ help="Provider credential file. Secrets are read locally and never printed.",
+ )
+ dns_backup_parser.add_argument(
+ "--backup-dir",
+ type=Path,
+ help="Backup root. Defaults to gateway state_dir/dns-backups.",
+ )
+ dns_backup_parser.add_argument(
+ "--client-ip",
+ help="Provider API caller IP when required, such as Namecheap ClientIp.",
+ )
+ dns_apply_parser = dns_subcommands.add_parser(
+ "apply",
+ help="Preview or apply provider DNS records with a pre-change backup.",
+ )
+ _add_target_selection(dns_apply_parser)
+ dns_apply_parser.add_argument("--provider", choices=DNS_PROVIDERS, required=True)
+ dns_apply_parser.add_argument(
+ "--credentials",
+ type=Path,
+ required=True,
+ help="Provider credential file. Secrets are read locally and never printed.",
+ )
+ dns_apply_parser.add_argument("--ipv4", help="Public IPv4 address for A records.")
+ dns_apply_parser.add_argument("--ipv6", help="Public IPv6 address for AAAA records.")
+ dns_apply_parser.add_argument(
+ "--mail-policy",
+ choices=DNS_MAIL_POLICIES,
+ default="preserve",
+ help="Preserve existing mail records or mark the domain as non-mail.",
+ )
+ dns_apply_parser.add_argument(
+ "--no-discovery",
+ action="store_true",
+ help="Do not include Tor/I2P/Reticulum TXT discovery hints.",
+ )
+ dns_apply_parser.add_argument(
+ "--backup-dir",
+ type=Path,
+ help="Backup root. Defaults to gateway state_dir/dns-backups.",
+ )
+ dns_apply_parser.add_argument(
+ "--client-ip",
+ help="Provider API caller IP when required, such as Namecheap ClientIp.",
+ )
+ dns_apply_parser.add_argument(
+ "--yes",
+ action="store_true",
+ help="Apply changes to the provider. Omit for dry-run preview.",
+ )
plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.")
_add_target_selection(plan_parser)
plan_parser.add_argument(
@@ -746,6 +833,72 @@ def _cmd_dns(config, args) -> int:
)
return 1 if failed else 0
+ if args.dns_command == "records":
+ records = provider_dns_record_plan(
+ config,
+ ipv4=args.ipv4,
+ ipv6=args.ipv6,
+ mail_policy=args.mail_policy,
+ discovery=not args.no_discovery,
+ )
+ for record in records:
+ _print_dns_provider_record(record)
+ print(
+ "AMPG_DNS_RECORDS_SUMMARY "
+ f"records={len([record for record in records if record.status != 'skipped'])} "
+ f"mail_policy={args.mail_policy} "
+ f"discovery={_bool(not args.no_discovery)}"
+ )
+ return 0
+
+ if args.dns_command == "backup":
+ results = dns_backup(
+ config,
+ provider=args.provider,
+ credentials=args.credentials,
+ backup_dir=args.backup_dir,
+ client_ip=args.client_ip,
+ )
+ for result in results:
+ _print_dns_backup(result)
+ print(
+ "AMPG_DNS_BACKUP_SUMMARY "
+ f"provider={args.provider} "
+ f"domains={len(results)} "
+ f"ok={sum(1 for result in results if result.status == 'ok')} "
+ f"errors={sum(1 for result in results if result.status != 'ok')}"
+ )
+ return 1 if any(result.status != "ok" for result in results) else 0
+
+ if args.dns_command == "apply":
+ results = dns_apply(
+ config,
+ provider=args.provider,
+ credentials=args.credentials,
+ ipv4=args.ipv4,
+ ipv6=args.ipv6,
+ mail_policy=args.mail_policy,
+ discovery=not args.no_discovery,
+ backup_dir=args.backup_dir,
+ dry_run=not args.yes,
+ client_ip=args.client_ip,
+ )
+ for result in results:
+ for change in result.changes:
+ _print_dns_apply_change(change)
+ _print_dns_apply_result(result)
+ print(
+ "AMPG_DNS_APPLY_SUMMARY "
+ f"provider={args.provider} "
+ f"mode={'live' if args.yes else 'dry-run'} "
+ f"domains={len(results)} "
+ f"changes={sum(len(result.changes) for result in results)} "
+ f"applied={sum(1 for result in results if result.status == 'applied')} "
+ f"ready={sum(1 for result in results if result.status == 'ready')} "
+ f"todo={sum(1 for result in results if result.status == 'todo')}"
+ )
+ return 0
+
return 1
@@ -1348,6 +1501,61 @@ def _print_dns_check(result: DNSCheckResult) -> None:
)
+def _print_dns_provider_record(record: DNSProviderRecordPlan) -> None:
+ print(
+ "AMPG_DNS_PROVIDER_RECORD "
+ f"site={record.site_id} "
+ f"domain={record.domain} "
+ f"name={record.name} "
+ f"type={record.type} "
+ f"value=\"{_quote(record.value)}\" "
+ f"ttl={record.ttl} "
+ f"status={record.status} "
+ f"message=\"{_quote(record.message)}\""
+ )
+
+
+def _print_dns_backup(result: DNSBackupResult) -> None:
+ path = str(result.path) if result.path else "-"
+ print(
+ "AMPG_DNS_BACKUP "
+ f"provider={result.provider} "
+ f"domain={result.domain} "
+ f"status={result.status} "
+ f"records={result.records} "
+ f"path=\"{_quote(path)}\" "
+ f"message=\"{_quote(result.message)}\""
+ )
+
+
+def _print_dns_apply_change(change: DNSApplyChange) -> None:
+ print(
+ "AMPG_DNS_APPLY_CHANGE "
+ f"domain={change.domain} "
+ f"name={change.name} "
+ f"type={change.type} "
+ f"action={change.action} "
+ f"current=\"{_quote(change.current)}\" "
+ f"desired=\"{_quote(change.desired)}\" "
+ f"status={change.status} "
+ f"message=\"{_quote(change.message)}\""
+ )
+
+
+def _print_dns_apply_result(result: DNSApplyResult) -> None:
+ backup_path = str(result.backup_path) if result.backup_path else "-"
+ print(
+ "AMPG_DNS_APPLY "
+ f"provider={result.provider} "
+ f"domain={result.domain} "
+ f"mode={result.mode} "
+ f"status={result.status} "
+ f"changes={len(result.changes)} "
+ f"backup=\"{_quote(backup_path)}\" "
+ f"message=\"{_quote(result.message)}\""
+ )
+
+
def _print_approval_check(check: ApprovalCheck) -> None:
candidate = check.candidate
print(
diff --git a/ampg/dns.py b/ampg/dns.py
index 2318e9b..b6e696d 100644
--- a/ampg/dns.py
+++ b/ampg/dns.py
@@ -1,12 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
+import datetime as _datetime
+import os
+from pathlib import Path
import socket
+import urllib.parse
+import urllib.request
+import xml.etree.ElementTree as ET
-from .config import GatewayConfig
+from .config import GatewayConfig, SiteConfig
+from .addresses import effective_address_records
DNS_MODES = ("static", "dynamic")
+DNS_PROVIDERS = ("namecheap",)
+DNS_MAIL_POLICIES = ("preserve", "disabled")
+DEFAULT_TTL = 1800
+NAMECHEAP_SUPPORTED_RECORD_TYPES = {
+ "A",
+ "AAAA",
+ "ALIAS",
+ "CAA",
+ "CNAME",
+ "MX",
+ "MXE",
+ "NS",
+ "TXT",
+ "URL",
+ "URL301",
+ "FRAME",
+}
@dataclass(frozen=True)
@@ -60,6 +84,70 @@ class DNSCheckResult:
message: str
+@dataclass(frozen=True)
+class ProviderDNSRecord:
+ name: str
+ type: str
+ value: str
+ ttl: int = DEFAULT_TTL
+ mx_pref: int | None = None
+
+
+@dataclass(frozen=True)
+class DNSProviderRecordPlan:
+ site_id: str
+ domain: str
+ name: str
+ type: str
+ value: str
+ ttl: int
+ status: str
+ message: str
+
+
+@dataclass(frozen=True)
+class DNSZoneSnapshot:
+ provider: str
+ domain: str
+ raw: str
+ records: tuple[ProviderDNSRecord, ...]
+ status: str
+ message: str
+
+
+@dataclass(frozen=True)
+class DNSBackupResult:
+ provider: str
+ domain: str
+ status: str
+ path: Path | None
+ records: int
+ message: str
+
+
+@dataclass(frozen=True)
+class DNSApplyChange:
+ domain: str
+ name: str
+ type: str
+ action: str
+ current: str
+ desired: str
+ status: str
+ message: str
+
+
+@dataclass(frozen=True)
+class DNSApplyResult:
+ provider: str
+ domain: str
+ mode: str
+ status: str
+ backup_path: Path | None
+ changes: tuple[DNSApplyChange, ...]
+ message: str
+
+
def dns_plan(
config: GatewayConfig,
*,
@@ -187,6 +275,158 @@ def dns_check(
return results
+def provider_dns_record_plan(
+ config: GatewayConfig,
+ *,
+ ipv4: str | None = None,
+ ipv6: str | None = None,
+ mail_policy: str = "preserve",
+ discovery: bool = True,
+) -> tuple[DNSProviderRecordPlan, ...]:
+ if mail_policy not in DNS_MAIL_POLICIES:
+ raise ValueError(f"unsupported DNS mail policy {mail_policy!r}")
+ records: list[DNSProviderRecordPlan] = []
+ for site, record in _desired_dns_records(
+ config,
+ ipv4=ipv4,
+ ipv6=ipv6,
+ mail_policy=mail_policy,
+ discovery=discovery,
+ ):
+ records.append(
+ DNSProviderRecordPlan(
+ site.id,
+ site.domain,
+ record.name,
+ record.type,
+ record.value,
+ record.ttl,
+ "todo",
+ _provider_record_message(record),
+ )
+ )
+ if not records:
+ return (
+ DNSProviderRecordPlan(
+ "-",
+ "-",
+ "-",
+ "-",
+ "-",
+ DEFAULT_TTL,
+ "skipped",
+ "no enabled DNS-backed protocols need provider records",
+ ),
+ )
+ return tuple(records)
+
+
+def dns_backup(
+ config: GatewayConfig,
+ *,
+ provider: str,
+ credentials: Path,
+ backup_dir: Path | None = None,
+ client_ip: str | None = None,
+) -> list[DNSBackupResult]:
+ dns_provider = _dns_provider(provider, credentials=credentials, client_ip=client_ip)
+ target_backup_dir = backup_dir or _default_backup_dir(config)
+ return [_backup_domain(dns_provider, domain, target_backup_dir) for domain in _dns_domains(config)]
+
+
+def dns_apply(
+ config: GatewayConfig,
+ *,
+ provider: str,
+ credentials: Path,
+ ipv4: str | None = None,
+ ipv6: str | None = None,
+ mail_policy: str = "preserve",
+ discovery: bool = True,
+ backup_dir: Path | None = None,
+ dry_run: bool = True,
+ client_ip: str | None = None,
+) -> list[DNSApplyResult]:
+ if mail_policy not in DNS_MAIL_POLICIES:
+ raise ValueError(f"unsupported DNS mail policy {mail_policy!r}")
+ if not ipv4 and not ipv6:
+ raise ValueError("dns apply requires --ipv4, --ipv6, or both")
+
+ dns_provider = _dns_provider(provider, credentials=credentials, client_ip=client_ip)
+ target_backup_dir = backup_dir or _default_backup_dir(config)
+ desired_by_domain: dict[str, list[ProviderDNSRecord]] = {}
+ for site, record in _desired_dns_records(
+ config,
+ ipv4=ipv4,
+ ipv6=ipv6,
+ mail_policy=mail_policy,
+ discovery=discovery,
+ ):
+ desired_by_domain.setdefault(site.domain, []).append(record)
+
+ results: list[DNSApplyResult] = []
+ for domain in _dns_domains(config):
+ snapshot = dns_provider.get_hosts(domain)
+ if snapshot.status != "ok":
+ results.append(
+ DNSApplyResult(
+ provider=dns_provider.name,
+ domain=domain,
+ mode="dry-run" if dry_run else "live",
+ status="blocked",
+ backup_path=None,
+ changes=(),
+ message=f"provider fetch failed: {snapshot.message}",
+ )
+ )
+ continue
+
+ backup_path: Path | None = None
+ raw_desired = tuple(desired_by_domain.get(domain, ()))
+ desired = tuple(_records_supported_by_provider(dns_provider.name, raw_desired))
+ changes = _unsupported_record_changes(domain, dns_provider.name, raw_desired)
+ changes += _record_changes(domain, snapshot.records, desired, mail_policy=mail_policy)
+ next_records = merge_dns_records(snapshot.records, desired, mail_policy=mail_policy)
+ status = _apply_status(changes)
+ message = "dry-run; provider was not changed"
+ write_changes = [change for change in changes if change.status != "skipped"]
+ if not dry_run and write_changes:
+ backup_path = _write_dns_backup(dns_provider.name, domain, snapshot.raw, target_backup_dir)
+ dns_provider.set_hosts(domain, next_records, mail_policy=mail_policy)
+ message = "provider zone updated after backup"
+ status = "applied"
+ elif not dry_run:
+ message = "no supported provider changes to apply"
+ results.append(
+ DNSApplyResult(
+ provider=dns_provider.name,
+ domain=domain,
+ mode="dry-run" if dry_run else "live",
+ status=status,
+ backup_path=backup_path,
+ changes=changes,
+ message=message,
+ )
+ )
+ return results
+
+
+def merge_dns_records(
+ existing: tuple[ProviderDNSRecord, ...] | list[ProviderDNSRecord],
+ desired: tuple[ProviderDNSRecord, ...] | list[ProviderDNSRecord],
+ *,
+ mail_policy: str = "preserve",
+) -> tuple[ProviderDNSRecord, ...]:
+ desired_records = tuple(_normalize_provider_record(record) for record in desired)
+ managed_keys = _managed_record_keys(desired_records)
+ retained = [
+ _normalize_provider_record(record)
+ for record in existing
+ if not _is_managed_record(record, managed_keys, mail_policy=mail_policy)
+ ]
+ return tuple(sorted(retained + list(desired_records), key=_record_sort_key))
+
+
def _static_records(
site_id: str,
domain: str,
@@ -245,6 +485,113 @@ def _static_records(
return records
+def _desired_dns_records(
+ config: GatewayConfig,
+ *,
+ ipv4: str | None,
+ ipv6: str | None,
+ mail_policy: str,
+ discovery: bool,
+) -> list[tuple[SiteConfig, ProviderDNSRecord]]:
+ addresses = {
+ (record.site_id, record.protocol): record
+ for record in effective_address_records(config)
+ if record.address_status in {"configured", "captured"}
+ }
+ desired: list[tuple[SiteConfig, ProviderDNSRecord]] = []
+ for site in config.sites:
+ enabled = {name for name, protocol in site.protocols.items() if protocol.enabled}
+ if not enabled.intersection({"clearnet", "gemini", "reticulum"}):
+ continue
+ target_names = ["@"]
+ if "clearnet" in enabled:
+ target_names.append("www")
+ if "gemini" in enabled:
+ target_names.append("gemini")
+ if "reticulum" in enabled:
+ target_names.append("reticulum")
+
+ for name in target_names:
+ if ipv4:
+ desired.append((site, ProviderDNSRecord(name, "A", ipv4)))
+ if ipv6:
+ desired.append((site, ProviderDNSRecord(name, "AAAA", ipv6)))
+
+ if "clearnet" in enabled:
+ desired.append((site, ProviderDNSRecord("@", "CAA", '0 issue "letsencrypt.org"')))
+
+ if mail_policy == "disabled":
+ desired.append((site, ProviderDNSRecord("@", "TXT", "v=spf1 -all")))
+ desired.append(
+ (
+ site,
+ ProviderDNSRecord(
+ "_dmarc",
+ "TXT",
+ "v=DMARC1; p=reject; adkim=s; aspf=s",
+ ),
+ )
+ )
+
+ if "gemini" in enabled:
+ port = int(site.protocols["gemini"].options.get("port", 1965))
+ desired.append(
+ (
+ site,
+ ProviderDNSRecord(
+ "_gemini._tcp",
+ "SRV",
+ f"0 0 {port} gemini.{site.domain}.",
+ ),
+ )
+ )
+
+ if "reticulum" in enabled:
+ port = int(site.protocols["reticulum"].options.get("port", 4242))
+ desired.append(
+ (
+ site,
+ ProviderDNSRecord(
+ "_reticulum._tcp",
+ "SRV",
+ f"0 0 {port} reticulum.{site.domain}.",
+ ),
+ )
+ )
+ reticulum_address = addresses.get((site.id, "reticulum"))
+ reticulum_hint = f"transport=reticulum.{site.domain}:{port}"
+ if reticulum_address:
+ host = _url_host(reticulum_address.url)
+ if host:
+ reticulum_hint = f"rns={host}; {reticulum_hint}"
+ desired.append((site, ProviderDNSRecord("_reticulum", "TXT", reticulum_hint)))
+
+ if discovery:
+ tor_address = addresses.get((site.id, "tor"))
+ tor_host = _url_host(tor_address.url) if tor_address else ""
+ if tor_host:
+ desired.append((site, ProviderDNSRecord("_tor", "TXT", f"onion={tor_host}")))
+ i2p_address = addresses.get((site.id, "i2p"))
+ i2p_host = _url_host(i2p_address.url) if i2p_address else ""
+ if i2p_host:
+ desired.append((site, ProviderDNSRecord("_i2p", "TXT", f"b32={i2p_host}")))
+ return desired
+
+
+def _provider_record_message(record: ProviderDNSRecord) -> str:
+ if record.type == "SRV":
+ return "service discovery record; provider support varies"
+ if record.name in {"_tor", "_i2p", "_reticulum"}:
+ return "AMPG transport discovery hint for compatible browsers"
+ if record.type == "CAA":
+ return "allow Let's Encrypt certificate issuance"
+ if record.type == "TXT" and record.value.startswith("v=spf1"):
+ return "mark this non-mail domain as not sending mail"
+ if record.name == "_dmarc":
+ return "reject spoofed mail for this non-mail domain"
+ return "create or update AMPG-managed provider record"
+
+
def _dynamic_records(
site_id: str,
domain: str,
@@ -411,3 +758,338 @@ def _plan_message(status: str) -> str:
if status == "skipped":
return "clearnet DNS is not needed"
return "DNS setup is ready"
+
+
+def _dns_domains(config: GatewayConfig) -> tuple[str, ...]:
+ domains = []
+ for site in config.sites:
+ if site.domain not in domains and any(protocol.enabled for protocol in site.protocols.values()):
+ domains.append(site.domain)
+ return tuple(domains)
+
+
+def _default_backup_dir(config: GatewayConfig) -> Path:
+ if config.paths:
+ return config.paths.state_dir / "dns-backups"
+ return config.config_path.parent / ".ampg/state/dns-backups"
+
+
+def _backup_domain(provider, domain: str, backup_dir: Path) -> DNSBackupResult:
+ snapshot = provider.get_hosts(domain)
+ path = _write_dns_backup(provider.name, domain, snapshot.raw, backup_dir)
+ return DNSBackupResult(
+ provider=provider.name,
+ domain=domain,
+ status=snapshot.status,
+ path=path,
+ records=len(snapshot.records),
+ message=snapshot.message,
+ )
+
+
+def _write_dns_backup(provider: str, domain: str, raw: str, backup_dir: Path) -> Path:
+ stamp = _datetime.datetime.now(_datetime.UTC).strftime("%Y%m%d_%H%M%S")
+ path = backup_dir / provider / f"{domain}.{stamp}.xml"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(raw, encoding="utf-8")
+ return path
+
+
+def _records_supported_by_provider(
+ provider: str,
+ records: tuple[ProviderDNSRecord, ...] | list[ProviderDNSRecord],
+) -> tuple[ProviderDNSRecord, ...]:
+ if provider != "namecheap":
+ return tuple(records)
+ return tuple(record for record in records if record.type in NAMECHEAP_SUPPORTED_RECORD_TYPES)
+
+
+def _unsupported_record_changes(
+ domain: str,
+ provider: str,
+ desired: tuple[ProviderDNSRecord, ...],
+) -> tuple[DNSApplyChange, ...]:
+ if provider != "namecheap":
+ return ()
+ changes = []
+ for record in desired:
+ if record.type in NAMECHEAP_SUPPORTED_RECORD_TYPES:
+ continue
+ changes.append(
+ DNSApplyChange(
+ domain,
+ record.name,
+ record.type,
+ "skip",
+ "-",
+ record.value,
+ "skipped",
+ f"{provider} API does not support this record type",
+ )
+ )
+ return tuple(changes)
+
+
+def _record_changes(
+ domain: str,
+ existing: tuple[ProviderDNSRecord, ...],
+ desired: tuple[ProviderDNSRecord, ...],
+ *,
+ mail_policy: str,
+) -> tuple[DNSApplyChange, ...]:
+ next_records = merge_dns_records(existing, desired, mail_policy=mail_policy)
+ existing_set = {_record_identity(record) for record in existing}
+ next_set = {_record_identity(record) for record in next_records}
+ changes: list[DNSApplyChange] = []
+ for identity in sorted(next_set - existing_set):
+ name, record_type, value = identity
+ changes.append(
+ DNSApplyChange(
+ domain,
+ name,
+ record_type,
+ "add",
+ "-",
+ value,
+ "todo",
+ "record will be added",
+ )
+ )
+ for identity in sorted(existing_set - next_set):
+ name, record_type, value = identity
+ changes.append(
+ DNSApplyChange(
+ domain,
+ name,
+ record_type,
+ "remove",
+ value,
+ "-",
+ "todo",
+ "AMPG-managed old record will be removed",
+ )
+ )
+ return tuple(changes)
+
+
+def _apply_status(changes: tuple[DNSApplyChange, ...]) -> str:
+ return "todo" if any(change.status == "todo" for change in changes) else "ready"
+
+
+def _managed_record_keys(
+ desired: tuple[ProviderDNSRecord, ...],
+) -> dict[str, set[str]]:
+ keys: dict[str, set[str]] = {}
+ for record in desired:
+ keys.setdefault(_record_name(record.name), set()).add(record.type)
+ return keys
+
+
+def _is_managed_record(
+ record: ProviderDNSRecord,
+ managed_keys: dict[str, set[str]],
+ *,
+ mail_policy: str,
+) -> bool:
+ normalized = _normalize_provider_record(record)
+ name = _record_name(normalized.name)
+ record_type = normalized.type
+ desired_types = managed_keys.get(name, set())
+ if record_type in desired_types:
+ if name == "@" and record_type == "TXT":
+ return normalized.value.lower().startswith("v=spf1")
+ if name == "@" and record_type == "CAA":
+ return "letsencrypt.org" in normalized.value.lower()
+ return True
+ if mail_policy == "disabled" and record_type in {"MX", "MXE"}:
+ return True
+ if name in {"www", "gemini", "reticulum"} and desired_types.intersection({"A", "AAAA"}):
+ return record_type in {"A", "AAAA", "CNAME", "ALIAS"}
+ return False
+
+
+def _normalize_provider_record(record: ProviderDNSRecord) -> ProviderDNSRecord:
+ return ProviderDNSRecord(
+ name=_record_name(record.name),
+ type=record.type.upper(),
+ value=record.value.strip(),
+ ttl=int(record.ttl or DEFAULT_TTL),
+ mx_pref=record.mx_pref,
+ )
+
+
+def _record_name(name: str) -> str:
+ normalized = name.strip().rstrip(".").lower()
+ return normalized or "@"
+
+
+def _record_identity(record: ProviderDNSRecord) -> tuple[str, str, str]:
+ normalized = _normalize_provider_record(record)
+ return normalized.name, normalized.type, normalized.value
+
+
+def _record_sort_key(record: ProviderDNSRecord) -> tuple[str, str, str]:
+ normalized = _normalize_provider_record(record)
+ return normalized.name, normalized.type, normalized.value
+
+
+def _url_host(value: str) -> str:
+ parsed = urllib.parse.urlparse(value)
+ host = parsed.hostname
+ if host:
+ return host
+ if "://" in value:
+ return ""
+ return value.strip().strip("/")
+
+
+def _dns_provider(provider: str, *, credentials: Path, client_ip: str | None):
+ if provider == "namecheap":
+ return NamecheapDNSProvider(credentials=credentials, client_ip=client_ip)
+ raise ValueError(f"unsupported DNS provider {provider!r}")
+
+
+class NamecheapDNSProvider:
+ name = "namecheap"
+
+ def __init__(self, *, credentials: Path, client_ip: str | None = None):
+ self.credentials = credentials
+ self.config = _read_key_value_credentials(credentials)
+ self.username = (
+ self.config.get("dns_namecheap_username")
+ or self.config.get("namecheap_username")
+ or self.config.get("username")
+ or self.config.get("api_user")
+ )
+ self.api_key = (
+ self.config.get("dns_namecheap_api_key")
+ or self.config.get("namecheap_api_key")
+ or self.config.get("api_key")
+ )
+ self.client_ip = (
+ client_ip
+ or self.config.get("dns_namecheap_client_ip")
+ or os.environ.get("AMPG_NAMECHEAP_CLIENT_IP")
+ or os.environ.get("SUCH_HQ_PUBLIC_IP")
+ )
+ if not self.username:
+ raise ValueError(f"missing Namecheap username in {credentials}")
+ if not self.api_key:
+ raise ValueError(f"missing Namecheap API key in {credentials}")
+ if not self.client_ip:
+ raise ValueError("Namecheap API requires --client-ip or AMPG_NAMECHEAP_CLIENT_IP")
+
+ def get_hosts(self, domain: str) -> DNSZoneSnapshot:
+ raw = self._request("namecheap.domains.dns.getHosts", self._domain_params(domain))
+ root = ET.fromstring(raw)
+ errors = _xml_errors(root)
+ if root.get("Status") != "OK" or errors:
+ message = "; ".join(errors) or root.get("Status") or "unknown Namecheap error"
+ return DNSZoneSnapshot(self.name, domain, raw, (), "error", message)
+ records = []
+ for element in root.iter():
+ if _xml_localname(element.tag) != "host":
+ continue
+ records.append(
+ ProviderDNSRecord(
+ name=element.get("Name") or "@",
+ type=(element.get("Type") or "").upper(),
+ value=element.get("Address") or "",
+ ttl=int(element.get("TTL") or DEFAULT_TTL),
+ mx_pref=_optional_int(element.get("MXPref")),
+ )
+ )
+ return DNSZoneSnapshot(
+ self.name,
+ domain,
+ raw,
+ tuple(records),
+ "ok",
+ "zone fetched from Namecheap",
+ )
+
+ def set_hosts(
+ self,
+ domain: str,
+ records: tuple[ProviderDNSRecord, ...],
+ *,
+ mail_policy: str,
+ ) -> None:
+ params = self._domain_params(domain)
+ if mail_policy == "disabled":
+ params["EmailType"] = "MX"
+ for index, record in enumerate(records, start=1):
+ params[f"HostName{index}"] = record.name
+ params[f"RecordType{index}"] = record.type
+ params[f"Address{index}"] = record.value
+ params[f"TTL{index}"] = str(record.ttl)
+ if record.mx_pref is not None:
+ params[f"MXPref{index}"] = str(record.mx_pref)
+ raw = self._request("namecheap.domains.dns.setHosts", params)
+ root = ET.fromstring(raw)
+ errors = _xml_errors(root)
+ success = any(
+ element.get("IsSuccess") == "true"
+ for element in root.iter()
+ if _xml_localname(element.tag) == "DomainDNSSetHostsResult"
+ )
+ if root.get("Status") != "OK" or errors or not success:
+ raise RuntimeError("; ".join(errors) or "Namecheap setHosts did not report success")
+
+ def _domain_params(self, domain: str) -> dict[str, str]:
+ sld, tld = _split_namecheap_domain(domain)
+ return {"SLD": sld, "TLD": tld}
+
+ def _request(self, command: str, params: dict[str, str]) -> str:
+ request_params = {
+ "ApiUser": self.username,
+ "ApiKey": self.api_key,
+ "UserName": self.username,
+ "ClientIp": self.client_ip,
+ "Command": command,
+ }
+ request_params.update(params)
+ url = "https://api.namecheap.com/xml.response?" + urllib.parse.urlencode(request_params)
+ with urllib.request.urlopen(url, timeout=30) as response:
+ return response.read().decode()
+
+
+def _read_key_value_credentials(path: Path) -> dict[str, str]:
+ if not path.exists():
+ raise FileNotFoundError(path)
+ values: dict[str, str] = {}
+ for line in path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ values[key.strip()] = value.strip()
+ return values
+
+
+def _split_namecheap_domain(domain: str) -> tuple[str, str]:
+ sld, dot, tld = domain.partition(".")
+ if not dot:
+ raise ValueError(f"domain must contain a dot: {domain}")
+ return sld, tld
+
+
+def _xml_localname(tag: str) -> str:
+ return tag.split("}", 1)[-1]
+
+
+def _xml_errors(root: ET.Element) -> list[str]:
+ return [
+ element.text or ""
+ for element in root.iter()
+ if _xml_localname(element.tag) == "Error" and element.text
+ ]
+
+
+def _optional_int(value: str | None) -> int | None:
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError:
+ return None
diff --git a/docs/domain-onboarding.md b/docs/domain-onboarding.md
index 16679e7..0808308 100644
--- a/docs/domain-onboarding.md
+++ b/docs/domain-onboarding.md
@@ -1,6 +1,6 @@
# Domain Onboarding
-> Status: draft | Updated 2026-07-06 | Applies to: clearnet DNS setup
+> Status: draft | Updated 2026-07-07 | Applies to: clearnet DNS setup
AMPG treats clearnet naming as an operator choice. A site can use a normal owned domain,
a community free subdomain, a Dynamic DNS name, or no clearnet name at all when only
@@ -10,11 +10,38 @@ private transports are selected.
```sh
python3 -m ampg --config gateway.toml dns plan
+python3 -m ampg --config gateway.toml dns records --ipv4 203.0.113.10
python3 -m ampg --config gateway.toml dns plan --free-domain-hints
python3 -m ampg --config gateway.toml dns plan --mode dynamic --behind-router
python3 -m ampg --config gateway.toml dns check
```
+`dns records` prints provider-ready records for enabled public transports. It can include
+`A`/`AAAA` host records, CAA, optional non-mail SPF/DMARC records, and AMPG TXT discovery
+hints for transport-aware clients.
+
+## Provider Writes
+
+Provider writes must be previewable and backed up. AMPG keeps the generic record plan
+separate from provider mutation:
+
+```sh
+python3 -m ampg --config gateway.toml dns records --ipv4 203.0.113.10 --mail-policy disabled
+python3 -m ampg --config gateway.toml dns backup --provider namecheap --credentials /etc/ampg/namecheap.ini
+python3 -m ampg --config gateway.toml dns apply --provider namecheap --credentials /etc/ampg/namecheap.ini --ipv4 203.0.113.10
+python3 -m ampg --config gateway.toml dns apply --provider namecheap --credentials /etc/ampg/namecheap.ini --ipv4 203.0.113.10 --yes
+```
+
+`dns apply` is a dry run unless `--yes` is present. Live apply reads the current zone,
+writes a backup under `gateway.state_dir/dns-backups`, merges only AMPG-managed records,
+and preserves unrelated records such as domain verification TXT entries.
+
+Namecheap's XML API uses replace-all `setHosts`, so AMPG always fetches and merges the
+whole zone before applying. The documented Namecheap API record types do not include
+`SRV`; AMPG can show SRV records in the generic plan, but the Namecheap writer skips
+unsupported record types and relies on TXT discovery hints unless another provider path
+is configured.
+
`dns plan --free-domain-hints` prints `AMPG_FREE_DOMAIN_HINT` rows for optional
third-party services that may provide free subdomains for personal sites, hobby apps, or
open-source projects. AMPG does not register these names automatically because each
diff --git a/docs/visual-guide.md b/docs/visual-guide.md
index c59c93f..9a3ae10 100644
--- a/docs/visual-guide.md
+++ b/docs/visual-guide.md
@@ -147,7 +147,7 @@ flowchart TB
pkg["pkg install tor, i2pd, nginx"]
state["Termux state AMPG config, keys, logs"]
services["termux-services AMPG-named daemons"]
- transports["Tor/I2P/Gemini routes"]
+ transports["Tor/I2P/Gemini/Reticulum routes"]
checks["health checks fixture URLs"]
phone --> ampg
@@ -201,3 +201,17 @@ The first public docs site should be generated from the public Markdown docs in
repository. A GitHub Pages build can later reuse these Mermaid diagrams with MkDocs,
mdBook, or another static docs generator. Private strategy notes, deployment inventory,
and host-specific material should stay under private docs and outside any Pages build.
+
+## Example Site Roles
+
+`ampgateway.online`
+: Public system guide. It explains AMPG concepts, transport flows, topologies, domain
+ onboarding, and deploy stages. It should stay concise, visual, and operator-facing.
+
+`ampgateway.site`
+: Public demo site. It should behave like a real small site someone might deploy: static
+ catalog, plain form fallback, optional clearnet enhancement, and comments in source
+ that explain how the same content survives privacy HTML, Gemtext, and Micron outputs.
+
+Both sites are source inputs consumed by AMPG. Clearnet output can remain visually rich;
+Tor and I2P use privacy HTML; Gemini uses Gemtext; Reticulum uses Micron.
diff --git a/examples/ampgateway-online.gateway.toml b/examples/ampgateway-online.gateway.toml
index cfff877..08406dd 100644
--- a/examples/ampgateway-online.gateway.toml
+++ b/examples/ampgateway-online.gateway.toml
@@ -37,6 +37,7 @@ enabled = true
renderer = "clearnet"
daemon = "nginx"
daemon_policy = "external"
+url = "https://ampgateway.online/"
[site.protocols.tor]
enabled = true
@@ -46,6 +47,7 @@ daemon_policy = "auto"
local_port = 18180
hidden_service_dir = "/var/lib/tor/ampg/ampgateway-online"
onion_location = "auto"
+fixture_url = "http://bn5uppe4zrop2ljsmjtoxtc4bhzs42kj4c6zvme565gz6x6l32ico3id.onion/"
max_asset_bytes = 1048576
script_policy = "strip"
@@ -57,6 +59,7 @@ daemon_policy = "auto"
local_port = 18181
keys_file = "ampgateway-online-web.dat"
tunnel_name = "ampgateway-online-web"
+i2p_url = "http://vfhcumcxtbysxr6wvbpdytb6jpslcqlo3j6e35w7s63el5vsorca.b32.i2p/"
max_asset_bytes = 1048576
script_policy = "strip"
@@ -66,6 +69,7 @@ renderer = "gemtext"
daemon = "agate"
daemon_policy = "auto"
port = 1965
+gemini_url = "gemini://ampgateway.online/"
max_asset_bytes = 1048576
[site.protocols.reticulum]
@@ -74,6 +78,7 @@ renderer = "micron"
daemon = "rnsd"
daemon_policy = "auto"
aspect = "web"
+rns_url = "rns://04c455aa45c455a7478c9e90b63f448e/"
max_asset_bytes = 524288
[[site]]
@@ -82,7 +87,7 @@ domain = "ampgateway.site"
[site.source]
kind = "static-html"
-path = "../sites/ampgateway-online"
+path = "../sites/ampgateway-site"
canonical_url = "https://ampgateway.site"
[site.outputs]
@@ -98,6 +103,7 @@ enabled = true
renderer = "clearnet"
daemon = "nginx"
daemon_policy = "external"
+url = "https://ampgateway.site/"
[site.protocols.tor]
enabled = true
@@ -107,6 +113,7 @@ daemon_policy = "auto"
local_port = 18280
hidden_service_dir = "/var/lib/tor/ampg/ampgateway-site"
onion_location = "auto"
+fixture_url = "http://fy2zosxp4oqqcacxcegvwshtmsiiyztnwoinbm46yoig4o3kveidotyd.onion/"
max_asset_bytes = 1048576
script_policy = "strip"
@@ -118,6 +125,7 @@ daemon_policy = "auto"
local_port = 18281
keys_file = "ampgateway-site-web.dat"
tunnel_name = "ampgateway-site-web"
+i2p_url = "http://zajoo4f5dso64tvtazas27se2galkihgpfgoy636uxm7loo42cdq.b32.i2p/"
max_asset_bytes = 1048576
script_policy = "strip"
@@ -127,6 +135,7 @@ renderer = "gemtext"
daemon = "agate"
daemon_policy = "auto"
port = 1966
+gemini_url = "gemini://ampgateway.site:1966/"
max_asset_bytes = 1048576
[site.protocols.reticulum]
@@ -135,4 +144,5 @@ renderer = "micron"
daemon = "rnsd"
daemon_policy = "auto"
aspect = "web"
+rns_url = "rns://39bf05d72fc8bc3b34519e62112e9abf/"
max_asset_bytes = 524288
diff --git a/sites/ampgateway-online/app.js b/sites/ampgateway-online/app.js
index 23ecc06..c4e469a 100644
--- a/sites/ampgateway-online/app.js
+++ b/sites/ampgateway-online/app.js
@@ -25,7 +25,7 @@
const topologies = {
vps: {
- caption: "A VPS can publish clearnet, Tor, I2P, and Gemini outputs from one AMPG plan.",
+ caption: "A VPS can publish clearnet, Tor, I2P, Gemini, and Reticulum outputs from one AMPG plan.",
nodes: [
["source", "source site", 0.16, 0.42, "amber"],
["ampg", "AMPG", 0.34, 0.42, "teal"],
@@ -33,7 +33,8 @@
["web", "clearnet", 0.72, 0.22, "teal"],
["tor", "Tor", 0.78, 0.39, "coral"],
["i2p", "I2P", 0.76, 0.56, "amber"],
- ["gemini", "Gemini", 0.68, 0.72, "green"]
+ ["gemini", "Gemini", 0.66, 0.72, "green"],
+ ["reticulum", "Reticulum", 0.84, 0.72, "coral"]
],
links: [
["source", "ampg"],
@@ -41,11 +42,12 @@
["vps", "web"],
["vps", "tor"],
["vps", "i2p"],
- ["vps", "gemini"]
+ ["vps", "gemini"],
+ ["vps", "reticulum"]
]
},
router: {
- caption: "Behind a router, clearnet may need DNS and port choices; Tor and I2P can avoid inbound port forwarding.",
+ caption: "Behind a router, clearnet may need DNS and port choices; Tor, I2P, and Reticulum can avoid depending on inbound web ports.",
nodes: [
["visitor", "visitor", 0.13, 0.33, "green"],
["dns", "DNS/DDNS", 0.3, 0.24, "amber"],
@@ -53,7 +55,8 @@
["host", "home host", 0.61, 0.38, "teal"],
["ampg", "AMPG", 0.76, 0.38, "teal"],
["tor", "Tor", 0.64, 0.66, "coral"],
- ["i2p", "I2P", 0.82, 0.66, "amber"]
+ ["i2p", "I2P", 0.78, 0.66, "amber"],
+ ["reticulum", "Reticulum", 0.9, 0.54, "green"]
],
links: [
["visitor", "dns"],
@@ -62,8 +65,10 @@
["host", "ampg"],
["ampg", "tor"],
["ampg", "i2p"],
+ ["ampg", "reticulum"],
["visitor", "tor"],
- ["visitor", "i2p"]
+ ["visitor", "i2p"],
+ ["visitor", "reticulum"]
]
},
phone: {
diff --git a/sites/ampgateway-online/index.html b/sites/ampgateway-online/index.html
index ee0c47f..35c596b 100644
--- a/sites/ampgateway-online/index.html
+++ b/sites/ampgateway-online/index.html
@@ -19,6 +19,7 @@
AMPGateway
+ Transports
Topologies
Domains
Flow
@@ -42,7 +43,7 @@
without losing the map.
@@ -55,11 +56,72 @@
Browser
- A VPS can publish clearnet, Tor, I2P, and Gemini outputs from one AMPG plan.
+ A VPS can publish clearnet, Tor, I2P, Gemini, and Reticulum outputs from one AMPG plan.
+
+
+
Traffic flows
+
Same source, different paths through the network.
+
+ AMPG keeps the authoring path consistent while each transport uses the shape
+ that fits its network, daemon, and client expectations.
+
+
+
+
+ Clearnet
+
+ Visitor resolves DNS for the site domain.
+ HTTP or HTTPS reaches the public web server.
+ The server reads AMPG clearnet output.
+
+
+
+ Tor onion
+
+ Tor client resolves the onion service descriptor.
+ Tor circuits connect to the hidden service.
+ The local service serves AMPG privacy HTML.
+
+
+
+ I2P tunnel
+
+ I2P client resolves the b32 destination.
+ Inbound and outbound tunnels carry HTTP.
+ i2pd forwards to the local AMPG output.
+
+
+
+ Gemini
+
+ Client opens a TLS connection to the capsule.
+ Gemini server maps the URL to Gemtext.
+ The rendered page keeps links and headings simple.
+
+
+
+ Reticulum
+
+ Client reaches an RNS destination.
+ NomadNet-style pages request compact Micron content.
+ AMPG output keeps media and layout intentionally small.
+
+
+
+ Single transport
+
+ Operator enables only the target protocol.
+ AMPG builds only the selected output.
+ Deploy stages skip unrelated daemons and DNS.
+
+
+
+
+
Network topologies
@@ -70,7 +132,7 @@
Full VPS
ampgateway.site is the target for a full public example with
- clearnet, Tor, I2P, and Gemini routes managed from one deploy plan.
+ clearnet, Tor, I2P, Gemini, and Reticulum routes managed from one deploy plan.
diff --git a/sites/ampgateway-online/styles.css b/sites/ampgateway-online/styles.css
index 1768b1e..42ee615 100644
--- a/sites/ampgateway-online/styles.css
+++ b/sites/ampgateway-online/styles.css
@@ -298,14 +298,16 @@ h3 {
}
.topology-grid,
-.domain-grid {
+.domain-grid,
+.transport-flow-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.topology-grid article,
-.domain-card {
+.domain-card,
+.transport-flow {
min-height: 190px;
padding: 20px;
border: 1px solid var(--line);
@@ -314,14 +316,17 @@ h3 {
}
.topology-grid article:nth-child(2),
-.domain-card.featured {
+.domain-card.featured,
+.transport-flow:nth-child(2n) {
background: var(--panel-strong);
}
.topology-grid p,
.domain-card p,
.docs-section p,
-.flow-list p {
+.flow-list p,
+.transport-flow li,
+.section-heading p {
color: var(--muted);
}
@@ -329,6 +334,25 @@ h3 {
margin-bottom: 28px;
}
+.transport-flow-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.transport-flow {
+ min-height: 250px;
+}
+
+.transport-flow h3 {
+ font-size: 1.35rem;
+}
+
+.transport-flow ol {
+ display: grid;
+ gap: 10px;
+ margin: 16px 0 0;
+ padding-left: 20px;
+}
+
.domain-grid {
grid-template-columns: 1.2fr 1fr 1fr;
}
@@ -454,7 +478,8 @@ h3 {
}
.domain-grid,
- .topology-grid {
+ .topology-grid,
+ .transport-flow-grid {
grid-template-columns: 1fr;
}
diff --git a/sites/ampgateway-site/app.js b/sites/ampgateway-site/app.js
new file mode 100644
index 0000000..30d3f35
--- /dev/null
+++ b/sites/ampgateway-site/app.js
@@ -0,0 +1,41 @@
+(function () {
+ const filters = Array.from(document.querySelectorAll("[data-filter]"));
+ const products = Array.from(document.querySelectorAll("[data-category]"));
+ const estimate = document.getElementById("estimate");
+
+ if (filters.length === 0 || products.length === 0) {
+ return;
+ }
+
+ function setFilter(category) {
+ filters.forEach((button) => {
+ const selected = button.dataset.filter === category;
+ button.classList.toggle("active", selected);
+ button.setAttribute("aria-pressed", selected ? "true" : "false");
+ });
+
+ let total = 0;
+ let count = 0;
+ products.forEach((product) => {
+ const visible = category === "all" || product.dataset.category === category;
+ product.hidden = !visible;
+ if (visible) {
+ count += 1;
+ total += Number(product.dataset.price || 0);
+ }
+ });
+
+ if (estimate) {
+ estimate.textContent =
+ count === products.length
+ ? `All ${count} kits total $${total}.`
+ : `${count} matching kits total $${total}.`;
+ }
+ }
+
+ filters.forEach((button) => {
+ button.addEventListener("click", () => setFilter(button.dataset.filter || "all"));
+ });
+
+ setFilter("all");
+})();
diff --git a/sites/ampgateway-site/index.html b/sites/ampgateway-site/index.html
new file mode 100644
index 0000000..7043983
--- /dev/null
+++ b/sites/ampgateway-site/index.html
@@ -0,0 +1,169 @@
+
+
+
+
+
+ Field Kit Supply
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
Demo shop
+
Small gear, published everywhere.
+
+ This is a fictional static storefront used to test AMPGateway outputs across
+ clearnet, Tor, I2P, Gemini, and Reticulum.
+
+
Browse kits
+
+
+ Transport behavior
+
+ Clearnet: enhanced filters and order estimate.
+ Tor/I2P: local assets, no scripts, same readable form.
+ Gemini: catalog and links become Gemtext.
+ Reticulum: compact Micron page for NomadNet-style reading.
+
+
+
+
+
+
+
Catalog
+
Static inventory with optional enhancement.
+
+ The cards are plain HTML articles. JavaScript adds filtering on clearnet; the
+ rendered privacy and text outputs keep the same source order.
+
+
+
+
+ All
+ Field
+ Power
+ Signals
+
+
+
+
+ Field
+ Pocket repair roll
+ Compact thread, cord, patches, clips, and a tiny notebook for field fixes.
+ $24
+
+
+ Power
+ Low-draw light kit
+ Warm LEDs, diffuser caps, and a short cable set for off-grid worktables.
+ $38
+
+
+ Signals
+ Marker stack
+ Weatherproof tags, reflective strips, and write-on tabs for route labeling.
+ $31
+
+
+ Field
+ Dry pocket ledger
+ Small waterproof pages for notes, receipts, map points, and handoff lists.
+ $19
+
+
+
+
+
+
+
+
Order request
+
A boring form is a portable interface.
+
+ This demo does not submit real payments. It models the shape AMPG should
+ support later: static catalog, optional identity, optional payment, and a
+ server action only where the selected transport can safely handle it.
+
+
+
+
+
+
+ Cookbook
+ What this demo is proving.
+
+
+ Keep content first
+ Use headings, paragraphs, lists, and forms before decorative layout.
+
+
+ Enhance gently
+ JavaScript may improve clearnet, but the source page must remain useful without it.
+
+
+ Declare route intent
+ Static routes can publish everywhere; sessions and payments need transport policy.
+
+
+ Publish fixtures
+ Every transport output should have a health-checkable public URL.
+
+
+
+
+
+
+
+
diff --git a/sites/ampgateway-site/styles.css b/sites/ampgateway-site/styles.css
new file mode 100644
index 0000000..03258ab
--- /dev/null
+++ b/sites/ampgateway-site/styles.css
@@ -0,0 +1,370 @@
+:root {
+ color-scheme: light;
+ --paper: #f7f4ec;
+ --ink: #151615;
+ --muted: #60645f;
+ --line: #d8d0c0;
+ --panel: #fffdf7;
+ --field: #2f8f83;
+ --power: #b24f38;
+ --signal: #445fb0;
+ --gold: #c48a24;
+ --max: 1120px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ background:
+ linear-gradient(rgba(21, 22, 21, 0.035) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(21, 22, 21, 0.03) 1px, transparent 1px),
+ var(--paper);
+ background-size: 32px 32px;
+ color: var(--ink);
+ font-family:
+ ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+ line-height: 1.55;
+}
+
+a {
+ color: inherit;
+}
+
+.skip-link {
+ position: fixed;
+ top: 12px;
+ left: 12px;
+ z-index: 20;
+ padding: 8px 10px;
+ background: var(--ink);
+ color: var(--paper);
+ transform: translateY(-140%);
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+}
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 14px clamp(18px, 4vw, 46px);
+ background: rgba(247, 244, 236, 0.9);
+ border-bottom: 1px solid var(--line);
+ backdrop-filter: blur(14px);
+}
+
+.brand,
+.top-nav {
+ display: flex;
+ align-items: center;
+}
+
+.brand {
+ gap: 10px;
+ font-weight: 900;
+ text-decoration: none;
+}
+
+.brand-mark {
+ display: inline-grid;
+ width: 26px;
+ height: 26px;
+ place-items: center;
+ background: var(--field);
+ color: white;
+ font-weight: 900;
+}
+
+.top-nav {
+ gap: clamp(10px, 2vw, 22px);
+ color: var(--muted);
+ font-size: 0.94rem;
+}
+
+.top-nav a {
+ text-decoration: none;
+}
+
+.top-nav a:hover,
+.top-nav a:focus-visible {
+ color: var(--ink);
+}
+
+.hero,
+.section {
+ width: min(var(--max), calc(100% - 36px));
+ margin: 0 auto;
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
+ gap: clamp(28px, 5vw, 64px);
+ align-items: end;
+ min-height: 74vh;
+ padding: clamp(70px, 12vw, 140px) 0 clamp(52px, 8vw, 92px);
+}
+
+.eyebrow,
+.tag {
+ margin: 0 0 10px;
+ color: var(--gold);
+ font-size: 0.76rem;
+ font-weight: 900;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ max-width: 760px;
+ margin-bottom: 18px;
+ font-size: clamp(3.2rem, 8vw, 7rem);
+ line-height: 0.94;
+ letter-spacing: 0;
+}
+
+h2 {
+ max-width: 720px;
+ margin-bottom: 16px;
+ font-size: clamp(1.8rem, 3.4vw, 3.8rem);
+ line-height: 1.04;
+ letter-spacing: 0;
+}
+
+h3 {
+ margin-bottom: 10px;
+ font-size: 1.15rem;
+ letter-spacing: 0;
+}
+
+.hero p,
+.section-heading p,
+.order-section p,
+.notes-grid p,
+.status-board li,
+.product p {
+ color: var(--muted);
+}
+
+.button,
+.order-form button,
+.filter {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ border: 1px solid var(--ink);
+ border-radius: 6px;
+ font: inherit;
+ font-weight: 900;
+ text-decoration: none;
+}
+
+.button,
+.order-form button {
+ margin-top: 16px;
+ padding: 10px 16px;
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.status-board,
+.product,
+.order-form,
+.notes-grid article {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.status-board {
+ padding: 22px;
+}
+
+.status-board h2 {
+ font-size: 1.2rem;
+}
+
+.status-board ul {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding-left: 18px;
+}
+
+.section {
+ padding: clamp(58px, 9vw, 104px) 0;
+ border-top: 1px solid var(--line);
+}
+
+.section-heading {
+ max-width: 720px;
+ margin-bottom: 26px;
+}
+
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.filter {
+ padding: 8px 14px;
+ background: transparent;
+ color: var(--ink);
+ cursor: pointer;
+}
+
+.filter.active,
+.filter:focus-visible {
+ background: var(--signal);
+ color: white;
+ border-color: var(--signal);
+}
+
+.catalog-grid,
+.notes-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.product,
+.notes-grid article {
+ padding: 20px;
+}
+
+.product {
+ display: flex;
+ min-height: 230px;
+ flex-direction: column;
+}
+
+.product[hidden] {
+ display: none;
+}
+
+.price {
+ margin-top: auto;
+ color: var(--power) !important;
+ font-size: 1.8rem;
+ font-weight: 900;
+}
+
+.order-section {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(320px, 0.7fr);
+ gap: clamp(28px, 6vw, 72px);
+ align-items: start;
+}
+
+.order-form {
+ display: grid;
+ gap: 14px;
+ padding: 20px;
+}
+
+.order-form label {
+ display: grid;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 0.92rem;
+ font-weight: 800;
+}
+
+.order-form input,
+.order-form select,
+.order-form textarea {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: white;
+ color: var(--ink);
+ font: inherit;
+ padding: 10px 12px;
+}
+
+.estimate {
+ min-height: 24px;
+ margin: 0;
+ color: var(--field) !important;
+ font-weight: 800;
+}
+
+.site-footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 28px clamp(18px, 4vw, 46px);
+ border-top: 1px solid var(--line);
+ color: var(--muted);
+}
+
+.site-footer p {
+ margin: 0;
+}
+
+.site-footer a {
+ color: var(--signal);
+ font-weight: 900;
+ text-decoration: none;
+}
+
+@media (max-width: 900px) {
+ .top-nav {
+ display: none;
+ }
+
+ .hero,
+ .order-section {
+ grid-template-columns: 1fr;
+ }
+
+ .catalog-grid,
+ .notes-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 620px) {
+ h1 {
+ font-size: clamp(2.9rem, 17vw, 4.5rem);
+ }
+
+ .catalog-grid,
+ .notes-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .button,
+ .filter,
+ .order-form button {
+ width: 100%;
+ }
+
+ .site-footer {
+ display: grid;
+ }
+}
diff --git a/tests/test_dns.py b/tests/test_dns.py
index af5224b..8e3c4a4 100644
--- a/tests/test_dns.py
+++ b/tests/test_dns.py
@@ -6,7 +6,7 @@ from pathlib import Path
from ampg.cli import main
from ampg.config import load_config
-from ampg.dns import dns_check
+from ampg.dns import ProviderDNSRecord, dns_check, merge_dns_records
def _source(root: Path) -> Path:
@@ -137,6 +137,100 @@ class DNSTest(unittest.TestCase):
self.assertEqual("matched", results[0].status)
self.assertEqual(("203.0.113.10",), results[0].resolved)
+ def test_dns_records_include_service_and_transport_discovery(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp).resolve()
+ source = _source(root)
+ config_path = root / "gateway.toml"
+ config_path.write_text(
+ f"""
+[[site]]
+id = "example"
+domain = "example.test"
+
+[site.source]
+kind = "static-html"
+path = "{source}"
+
+[site.outputs]
+root = "{root / 'dist'}"
+
+[site.protocols.clearnet]
+enabled = true
+
+[site.protocols.tor]
+enabled = true
+fixture_url = "http://exampleabcdefghijklmnop.onion/"
+
+[site.protocols.i2p]
+enabled = true
+i2p_url = "exampleabcdefghijklmnop.b32.i2p"
+
+[site.protocols.gemini]
+enabled = true
+port = 1966
+
+[site.protocols.reticulum]
+enabled = true
+rns_url = "rns://0123456789abcdef/"
+port = 4242
+""",
+ encoding="utf-8",
+ )
+
+ status, output, error = _run_cli(
+ [
+ "--config",
+ str(config_path),
+ "dns",
+ "records",
+ "--ipv4",
+ "203.0.113.10",
+ "--mail-policy",
+ "disabled",
+ ]
+ )
+
+ self.assertEqual(0, status, error)
+ self.assertIn("AMPG_DNS_PROVIDER_RECORD site=example domain=example.test name=@", output)
+ self.assertIn("name=gemini type=A", output)
+ self.assertIn("name=reticulum type=A", output)
+ self.assertIn("name=_gemini._tcp type=SRV", output)
+ self.assertIn('value="0 0 1966 gemini.example.test."', output)
+ self.assertIn("name=_reticulum._tcp type=SRV", output)
+ self.assertIn("name=_tor type=TXT", output)
+ self.assertIn("name=_i2p type=TXT", output)
+ self.assertIn("name=_reticulum type=TXT", output)
+ self.assertIn("name=_dmarc type=TXT", output)
+
+ def test_merge_dns_records_preserves_unmanaged_records(self):
+ existing = (
+ ProviderDNSRecord("@", "A", "198.51.100.9"),
+ ProviderDNSRecord("@", "TXT", "google-site-verification=keep"),
+ ProviderDNSRecord("@", "TXT", "v=spf1 include:old.example -all"),
+ ProviderDNSRecord("@", "MX", "mail.example.test.", mx_pref=10),
+ ProviderDNSRecord("www", "CNAME", "parking.example.test."),
+ ProviderDNSRecord("notes", "TXT", "keep me"),
+ )
+ desired = (
+ ProviderDNSRecord("@", "A", "203.0.113.10"),
+ ProviderDNSRecord("www", "A", "203.0.113.10"),
+ ProviderDNSRecord("@", "TXT", "v=spf1 -all"),
+ ProviderDNSRecord("_dmarc", "TXT", "v=DMARC1; p=reject"),
+ )
+
+ merged = merge_dns_records(existing, desired, mail_policy="disabled")
+ identities = {(record.name, record.type, record.value) for record in merged}
+
+ self.assertIn(("@", "A", "203.0.113.10"), identities)
+ self.assertIn(("www", "A", "203.0.113.10"), identities)
+ self.assertIn(("@", "TXT", "google-site-verification=keep"), identities)
+ self.assertIn(("notes", "TXT", "keep me"), identities)
+ self.assertNotIn(("@", "A", "198.51.100.9"), identities)
+ self.assertNotIn(("@", "TXT", "v=spf1 include:old.example -all"), identities)
+ self.assertNotIn(("@", "MX", "mail.example.test."), identities)
+ self.assertNotIn(("www", "CNAME", "parking.example.test."), identities)
+
if __name__ == "__main__":
unittest.main()