Add DNS provider flow and demo site split

This commit is contained in:
jwinterm
2026-07-07 12:41:52 -04:00
parent 7f3631a624
commit 234a510798
13 changed files with 1729 additions and 20 deletions
+3 -1
View File
@@ -110,7 +110,8 @@ API remains internal even when the public site is available on Tor or I2P.
- [Interactive applications](docs/interactive-apps.md) - [Interactive applications](docs/interactive-apps.md)
- [Wownero integration](docs/wownero-integration.md) - [Wownero integration](docs/wownero-integration.md)
- [Autodocs and drift gates](docs/autodocs.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) - [AMPGateway visual site config](examples/ampgateway-online.gateway.toml)
## Checks ## 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 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
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 --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 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 dns check --profile vps-full
+208
View File
@@ -55,11 +55,20 @@ from .dns import (
ConnectivityHint, ConnectivityHint,
DNSCheckResult, DNSCheckResult,
DNS_MODES, DNS_MODES,
DNS_MAIL_POLICIES,
DNS_PROVIDERS,
DNSApplyChange,
DNSApplyResult,
DNSBackupResult,
DNSPlan, DNSPlan,
DNSProviderRecordPlan,
DNSRecordPlan, DNSRecordPlan,
FreeDomainHint, FreeDomainHint,
dns_apply,
dns_backup,
dns_check, dns_check,
dns_plan, dns_plan,
provider_dns_record_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
@@ -224,6 +233,84 @@ def main(argv: list[str] | None = None) -> int:
_add_target_selection(dns_check_parser) _add_target_selection(dns_check_parser)
dns_check_parser.add_argument("--ipv4", help="Expected public IPv4 address.") dns_check_parser.add_argument("--ipv4", help="Expected public IPv4 address.")
dns_check_parser.add_argument("--ipv6", help="Expected public IPv6 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.") 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(
@@ -746,6 +833,72 @@ def _cmd_dns(config, args) -> int:
) )
return 1 if failed else 0 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 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: def _print_approval_check(check: ApprovalCheck) -> None:
candidate = check.candidate candidate = check.candidate
print( print(
+683 -1
View File
@@ -1,12 +1,36 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import datetime as _datetime
import os
from pathlib import Path
import socket 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_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) @dataclass(frozen=True)
@@ -60,6 +84,70 @@ class DNSCheckResult:
message: str 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( def dns_plan(
config: GatewayConfig, config: GatewayConfig,
*, *,
@@ -187,6 +275,158 @@ def dns_check(
return results 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( def _static_records(
site_id: str, site_id: str,
domain: str, domain: str,
@@ -245,6 +485,113 @@ def _static_records(
return 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( def _dynamic_records(
site_id: str, site_id: str,
domain: str, domain: str,
@@ -411,3 +758,338 @@ def _plan_message(status: str) -> str:
if status == "skipped": if status == "skipped":
return "clearnet DNS is not needed" return "clearnet DNS is not needed"
return "DNS setup is ready" 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
+28 -1
View File
@@ -1,6 +1,6 @@
# Domain Onboarding # 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, 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 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 ```sh
python3 -m ampg --config gateway.toml dns plan 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 --free-domain-hints
python3 -m ampg --config gateway.toml dns plan --mode dynamic --behind-router python3 -m ampg --config gateway.toml dns plan --mode dynamic --behind-router
python3 -m ampg --config gateway.toml dns check 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 `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 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 open-source projects. AMPG does not register these names automatically because each
+15 -1
View File
@@ -147,7 +147,7 @@ flowchart TB
pkg["pkg install<br/>tor, i2pd, nginx"] pkg["pkg install<br/>tor, i2pd, nginx"]
state["Termux state<br/>AMPG config, keys, logs"] state["Termux state<br/>AMPG config, keys, logs"]
services["termux-services<br/>AMPG-named daemons"] services["termux-services<br/>AMPG-named daemons"]
transports["Tor/I2P/Gemini routes"] transports["Tor/I2P/Gemini/Reticulum routes"]
checks["health checks<br/>fixture URLs"] checks["health checks<br/>fixture URLs"]
phone --> ampg 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, repository. A GitHub Pages build can later reuse these Mermaid diagrams with MkDocs,
mdBook, or another static docs generator. Private strategy notes, deployment inventory, 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. 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.
+11 -1
View File
@@ -37,6 +37,7 @@ enabled = true
renderer = "clearnet" renderer = "clearnet"
daemon = "nginx" daemon = "nginx"
daemon_policy = "external" daemon_policy = "external"
url = "https://ampgateway.online/"
[site.protocols.tor] [site.protocols.tor]
enabled = true enabled = true
@@ -46,6 +47,7 @@ daemon_policy = "auto"
local_port = 18180 local_port = 18180
hidden_service_dir = "/var/lib/tor/ampg/ampgateway-online" hidden_service_dir = "/var/lib/tor/ampg/ampgateway-online"
onion_location = "auto" onion_location = "auto"
fixture_url = "http://bn5uppe4zrop2ljsmjtoxtc4bhzs42kj4c6zvme565gz6x6l32ico3id.onion/"
max_asset_bytes = 1048576 max_asset_bytes = 1048576
script_policy = "strip" script_policy = "strip"
@@ -57,6 +59,7 @@ daemon_policy = "auto"
local_port = 18181 local_port = 18181
keys_file = "ampgateway-online-web.dat" keys_file = "ampgateway-online-web.dat"
tunnel_name = "ampgateway-online-web" tunnel_name = "ampgateway-online-web"
i2p_url = "http://vfhcumcxtbysxr6wvbpdytb6jpslcqlo3j6e35w7s63el5vsorca.b32.i2p/"
max_asset_bytes = 1048576 max_asset_bytes = 1048576
script_policy = "strip" script_policy = "strip"
@@ -66,6 +69,7 @@ renderer = "gemtext"
daemon = "agate" daemon = "agate"
daemon_policy = "auto" daemon_policy = "auto"
port = 1965 port = 1965
gemini_url = "gemini://ampgateway.online/"
max_asset_bytes = 1048576 max_asset_bytes = 1048576
[site.protocols.reticulum] [site.protocols.reticulum]
@@ -74,6 +78,7 @@ renderer = "micron"
daemon = "rnsd" daemon = "rnsd"
daemon_policy = "auto" daemon_policy = "auto"
aspect = "web" aspect = "web"
rns_url = "rns://04c455aa45c455a7478c9e90b63f448e/"
max_asset_bytes = 524288 max_asset_bytes = 524288
[[site]] [[site]]
@@ -82,7 +87,7 @@ domain = "ampgateway.site"
[site.source] [site.source]
kind = "static-html" kind = "static-html"
path = "../sites/ampgateway-online" path = "../sites/ampgateway-site"
canonical_url = "https://ampgateway.site" canonical_url = "https://ampgateway.site"
[site.outputs] [site.outputs]
@@ -98,6 +103,7 @@ enabled = true
renderer = "clearnet" renderer = "clearnet"
daemon = "nginx" daemon = "nginx"
daemon_policy = "external" daemon_policy = "external"
url = "https://ampgateway.site/"
[site.protocols.tor] [site.protocols.tor]
enabled = true enabled = true
@@ -107,6 +113,7 @@ daemon_policy = "auto"
local_port = 18280 local_port = 18280
hidden_service_dir = "/var/lib/tor/ampg/ampgateway-site" hidden_service_dir = "/var/lib/tor/ampg/ampgateway-site"
onion_location = "auto" onion_location = "auto"
fixture_url = "http://fy2zosxp4oqqcacxcegvwshtmsiiyztnwoinbm46yoig4o3kveidotyd.onion/"
max_asset_bytes = 1048576 max_asset_bytes = 1048576
script_policy = "strip" script_policy = "strip"
@@ -118,6 +125,7 @@ daemon_policy = "auto"
local_port = 18281 local_port = 18281
keys_file = "ampgateway-site-web.dat" keys_file = "ampgateway-site-web.dat"
tunnel_name = "ampgateway-site-web" tunnel_name = "ampgateway-site-web"
i2p_url = "http://zajoo4f5dso64tvtazas27se2galkihgpfgoy636uxm7loo42cdq.b32.i2p/"
max_asset_bytes = 1048576 max_asset_bytes = 1048576
script_policy = "strip" script_policy = "strip"
@@ -127,6 +135,7 @@ renderer = "gemtext"
daemon = "agate" daemon = "agate"
daemon_policy = "auto" daemon_policy = "auto"
port = 1966 port = 1966
gemini_url = "gemini://ampgateway.site:1966/"
max_asset_bytes = 1048576 max_asset_bytes = 1048576
[site.protocols.reticulum] [site.protocols.reticulum]
@@ -135,4 +144,5 @@ renderer = "micron"
daemon = "rnsd" daemon = "rnsd"
daemon_policy = "auto" daemon_policy = "auto"
aspect = "web" aspect = "web"
rns_url = "rns://39bf05d72fc8bc3b34519e62112e9abf/"
max_asset_bytes = 524288 max_asset_bytes = 524288
+11 -6
View File
@@ -25,7 +25,7 @@
const topologies = { const topologies = {
vps: { 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: [ nodes: [
["source", "source site", 0.16, 0.42, "amber"], ["source", "source site", 0.16, 0.42, "amber"],
["ampg", "AMPG", 0.34, 0.42, "teal"], ["ampg", "AMPG", 0.34, 0.42, "teal"],
@@ -33,7 +33,8 @@
["web", "clearnet", 0.72, 0.22, "teal"], ["web", "clearnet", 0.72, 0.22, "teal"],
["tor", "Tor", 0.78, 0.39, "coral"], ["tor", "Tor", 0.78, 0.39, "coral"],
["i2p", "I2P", 0.76, 0.56, "amber"], ["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: [ links: [
["source", "ampg"], ["source", "ampg"],
@@ -41,11 +42,12 @@
["vps", "web"], ["vps", "web"],
["vps", "tor"], ["vps", "tor"],
["vps", "i2p"], ["vps", "i2p"],
["vps", "gemini"] ["vps", "gemini"],
["vps", "reticulum"]
] ]
}, },
router: { 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: [ nodes: [
["visitor", "visitor", 0.13, 0.33, "green"], ["visitor", "visitor", 0.13, 0.33, "green"],
["dns", "DNS/DDNS", 0.3, 0.24, "amber"], ["dns", "DNS/DDNS", 0.3, 0.24, "amber"],
@@ -53,7 +55,8 @@
["host", "home host", 0.61, 0.38, "teal"], ["host", "home host", 0.61, 0.38, "teal"],
["ampg", "AMPG", 0.76, 0.38, "teal"], ["ampg", "AMPG", 0.76, 0.38, "teal"],
["tor", "Tor", 0.64, 0.66, "coral"], ["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: [ links: [
["visitor", "dns"], ["visitor", "dns"],
@@ -62,8 +65,10 @@
["host", "ampg"], ["host", "ampg"],
["ampg", "tor"], ["ampg", "tor"],
["ampg", "i2p"], ["ampg", "i2p"],
["ampg", "reticulum"],
["visitor", "tor"], ["visitor", "tor"],
["visitor", "i2p"] ["visitor", "i2p"],
["visitor", "reticulum"]
] ]
}, },
phone: { phone: {
+65 -3
View File
@@ -19,6 +19,7 @@
<span>AMPGateway</span> <span>AMPGateway</span>
</a> </a>
<nav class="top-nav" aria-label="Primary"> <nav class="top-nav" aria-label="Primary">
<a href="#transport-flows">Transports</a>
<a href="#topologies">Topologies</a> <a href="#topologies">Topologies</a>
<a href="#domains">Domains</a> <a href="#domains">Domains</a>
<a href="#flow">Flow</a> <a href="#flow">Flow</a>
@@ -42,7 +43,7 @@
without losing the map. without losing the map.
</p> </p>
<div class="hero-actions" aria-label="Primary sections"> <div class="hero-actions" aria-label="Primary sections">
<a class="button primary" href="#topologies">Explore topologies</a> <a class="button primary" href="#transport-flows">Explore transports</a>
<a class="button secondary" href="#domains">See example domains</a> <a class="button secondary" href="#domains">See example domains</a>
</div> </div>
</div> </div>
@@ -55,11 +56,72 @@
<button class="mode-button" type="button" data-topology="browser">Browser</button> <button class="mode-button" type="button" data-topology="browser">Browser</button>
</div> </div>
<p id="topologyCaption" class="topology-caption" aria-live="polite"> <p id="topologyCaption" class="topology-caption" aria-live="polite">
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.
</p> </p>
</div> </div>
</section> </section>
<section id="transport-flows" class="section transport-section" aria-labelledby="transport-flows-title">
<div class="section-heading">
<p class="eyebrow">Traffic flows</p>
<h2 id="transport-flows-title">Same source, different paths through the network.</h2>
<p>
AMPG keeps the authoring path consistent while each transport uses the shape
that fits its network, daemon, and client expectations.
</p>
</div>
<div class="transport-flow-grid">
<article class="transport-flow">
<h3>Clearnet</h3>
<ol>
<li>Visitor resolves DNS for the site domain.</li>
<li>HTTP or HTTPS reaches the public web server.</li>
<li>The server reads AMPG clearnet output.</li>
</ol>
</article>
<article class="transport-flow">
<h3>Tor onion</h3>
<ol>
<li>Tor client resolves the onion service descriptor.</li>
<li>Tor circuits connect to the hidden service.</li>
<li>The local service serves AMPG privacy HTML.</li>
</ol>
</article>
<article class="transport-flow">
<h3>I2P tunnel</h3>
<ol>
<li>I2P client resolves the b32 destination.</li>
<li>Inbound and outbound tunnels carry HTTP.</li>
<li>i2pd forwards to the local AMPG output.</li>
</ol>
</article>
<article class="transport-flow">
<h3>Gemini</h3>
<ol>
<li>Client opens a TLS connection to the capsule.</li>
<li>Gemini server maps the URL to Gemtext.</li>
<li>The rendered page keeps links and headings simple.</li>
</ol>
</article>
<article class="transport-flow">
<h3>Reticulum</h3>
<ol>
<li>Client reaches an RNS destination.</li>
<li>NomadNet-style pages request compact Micron content.</li>
<li>AMPG output keeps media and layout intentionally small.</li>
</ol>
</article>
<article class="transport-flow">
<h3>Single transport</h3>
<ol>
<li>Operator enables only the target protocol.</li>
<li>AMPG builds only the selected output.</li>
<li>Deploy stages skip unrelated daemons and DNS.</li>
</ol>
</article>
</div>
</section>
<section id="topologies" class="section split" aria-labelledby="topologies-title"> <section id="topologies" class="section split" aria-labelledby="topologies-title">
<div> <div>
<p class="eyebrow">Network topologies</p> <p class="eyebrow">Network topologies</p>
@@ -70,7 +132,7 @@
<h3>Full VPS</h3> <h3>Full VPS</h3>
<p> <p>
<code>ampgateway.site</code> is the target for a full public example with <code>ampgateway.site</code> 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.
</p> </p>
</article> </article>
<article> <article>
+30 -5
View File
@@ -298,14 +298,16 @@ h3 {
} }
.topology-grid, .topology-grid,
.domain-grid { .domain-grid,
.transport-flow-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 14px;
} }
.topology-grid article, .topology-grid article,
.domain-card { .domain-card,
.transport-flow {
min-height: 190px; min-height: 190px;
padding: 20px; padding: 20px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -314,14 +316,17 @@ h3 {
} }
.topology-grid article:nth-child(2), .topology-grid article:nth-child(2),
.domain-card.featured { .domain-card.featured,
.transport-flow:nth-child(2n) {
background: var(--panel-strong); background: var(--panel-strong);
} }
.topology-grid p, .topology-grid p,
.domain-card p, .domain-card p,
.docs-section p, .docs-section p,
.flow-list p { .flow-list p,
.transport-flow li,
.section-heading p {
color: var(--muted); color: var(--muted);
} }
@@ -329,6 +334,25 @@ h3 {
margin-bottom: 28px; 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 { .domain-grid {
grid-template-columns: 1.2fr 1fr 1fr; grid-template-columns: 1.2fr 1fr 1fr;
} }
@@ -454,7 +478,8 @@ h3 {
} }
.domain-grid, .domain-grid,
.topology-grid { .topology-grid,
.transport-flow-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+41
View File
@@ -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");
})();
+169
View File
@@ -0,0 +1,169 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field Kit Supply</title>
<meta
name="description"
content="AMPGateway demo site showing a small catalog, contact form, and transport-aware static fallbacks."
>
<link rel="stylesheet" href="styles.css">
<script defer src="app.js"></script>
</head>
<body>
<a class="skip-link" href="#content">Skip to content</a>
<header class="site-header">
<a class="brand" href="#content" aria-label="Field Kit Supply home">
<span class="brand-mark" aria-hidden="true">F</span>
<span>Field Kit Supply</span>
</a>
<nav class="top-nav" aria-label="Primary">
<a href="#catalog">Catalog</a>
<a href="#order">Order</a>
<a href="#transport-notes">Transport Notes</a>
</nav>
</header>
<!--
AMPG demo note:
The page starts with ordinary h1 -> h2 -> h3 structure and useful paragraphs.
Clearnet can enhance this with CSS and JavaScript, while Tor/I2P privacy HTML,
Gemini Gemtext, and Reticulum Micron still get a readable catalog.
-->
<main id="content">
<section class="hero" aria-labelledby="hero-title">
<div>
<p class="eyebrow">Demo shop</p>
<h1 id="hero-title">Small gear, published everywhere.</h1>
<p>
This is a fictional static storefront used to test AMPGateway outputs across
clearnet, Tor, I2P, Gemini, and Reticulum.
</p>
<a class="button" href="#catalog">Browse kits</a>
</div>
<aside class="status-board" aria-labelledby="status-title">
<h2 id="status-title">Transport behavior</h2>
<ul>
<li><strong>Clearnet:</strong> enhanced filters and order estimate.</li>
<li><strong>Tor/I2P:</strong> local assets, no scripts, same readable form.</li>
<li><strong>Gemini:</strong> catalog and links become Gemtext.</li>
<li><strong>Reticulum:</strong> compact Micron page for NomadNet-style reading.</li>
</ul>
</aside>
</section>
<section id="catalog" class="section" aria-labelledby="catalog-title">
<div class="section-heading">
<p class="eyebrow">Catalog</p>
<h2 id="catalog-title">Static inventory with optional enhancement.</h2>
<p>
The cards are plain HTML articles. JavaScript adds filtering on clearnet; the
rendered privacy and text outputs keep the same source order.
</p>
</div>
<div class="filters" aria-label="Catalog filters">
<button class="filter active" type="button" data-filter="all">All</button>
<button class="filter" type="button" data-filter="field">Field</button>
<button class="filter" type="button" data-filter="power">Power</button>
<button class="filter" type="button" data-filter="signals">Signals</button>
</div>
<div class="catalog-grid">
<article class="product" data-category="field" data-price="24">
<p class="tag">Field</p>
<h3>Pocket repair roll</h3>
<p>Compact thread, cord, patches, clips, and a tiny notebook for field fixes.</p>
<p class="price">$24</p>
</article>
<article class="product" data-category="power" data-price="38">
<p class="tag">Power</p>
<h3>Low-draw light kit</h3>
<p>Warm LEDs, diffuser caps, and a short cable set for off-grid worktables.</p>
<p class="price">$38</p>
</article>
<article class="product" data-category="signals" data-price="31">
<p class="tag">Signals</p>
<h3>Marker stack</h3>
<p>Weatherproof tags, reflective strips, and write-on tabs for route labeling.</p>
<p class="price">$31</p>
</article>
<article class="product" data-category="field" data-price="19">
<p class="tag">Field</p>
<h3>Dry pocket ledger</h3>
<p>Small waterproof pages for notes, receipts, map points, and handoff lists.</p>
<p class="price">$19</p>
</article>
</div>
</section>
<!--
AMPG demo note:
The form action is deliberately a normal HTTP path, not a JavaScript-only
checkout. Future AMPG app adapters can map this route to sessions or payments,
while static-only transports can still publish the request shape.
-->
<section id="order" class="section order-section" aria-labelledby="order-title">
<div>
<p class="eyebrow">Order request</p>
<h2 id="order-title">A boring form is a portable interface.</h2>
<p>
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.
</p>
</div>
<form class="order-form" method="post" action="/orders/request">
<label>
Contact
<input name="contact" type="text" autocomplete="email" placeholder="name@example.test">
</label>
<label>
Kit
<select name="kit">
<option>Pocket repair roll</option>
<option>Low-draw light kit</option>
<option>Marker stack</option>
<option>Dry pocket ledger</option>
</select>
</label>
<label>
Notes
<textarea name="notes" rows="4" placeholder="Transport, delivery, or payment notes"></textarea>
</label>
<button type="submit">Request order</button>
<p id="estimate" class="estimate" aria-live="polite">Select catalog items to estimate a bundle.</p>
</form>
</section>
<section id="transport-notes" class="section notes-section" aria-labelledby="notes-title">
<p class="eyebrow">Cookbook</p>
<h2 id="notes-title">What this demo is proving.</h2>
<div class="notes-grid">
<article>
<h3>Keep content first</h3>
<p>Use headings, paragraphs, lists, and forms before decorative layout.</p>
</article>
<article>
<h3>Enhance gently</h3>
<p>JavaScript may improve clearnet, but the source page must remain useful without it.</p>
</article>
<article>
<h3>Declare route intent</h3>
<p>Static routes can publish everywhere; sessions and payments need transport policy.</p>
</article>
<article>
<h3>Publish fixtures</h3>
<p>Every transport output should have a health-checkable public URL.</p>
</article>
</div>
</section>
</main>
<footer class="site-footer">
<p>Fictional demo content for AMPGateway.</p>
<a href="https://ampgateway.online">System guide</a>
</footer>
</body>
</html>
+370
View File
@@ -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;
}
}
+95 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from ampg.cli import main from ampg.cli import main
from ampg.config import load_config 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: def _source(root: Path) -> Path:
@@ -137,6 +137,100 @@ class DNSTest(unittest.TestCase):
self.assertEqual("matched", results[0].status) self.assertEqual("matched", results[0].status)
self.assertEqual(("203.0.113.10",), results[0].resolved) 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__": if __name__ == "__main__":
unittest.main() unittest.main()