mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add DNS provider flow and demo site split
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user