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