diff --git a/README.md b/README.md index c6a8123..e1ca8bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Adaptive Multi-Protocol Gateway -> Status: draft | Updated 2026-07-05 | Applies to: AMPG contributors and operators +> Status: draft | Updated 2026-07-06 | Applies to: AMPG contributors and operators Adaptive Multi-Protocol Gateway, or AMPG, turns one canonical site into transport-appropriate published versions for clearnet web, Tor onion services, I2P, Gemini, IPFS, and @@ -116,6 +116,9 @@ python3 -m ampg --config examples/wownero.gateway.toml build python3 -m ampg --config examples/wownero.gateway.toml build --profile tor-i2p python3 -m ampg --config examples/wownero.gateway.toml build --protocol tor --protocol i2p python3 -m ampg --config examples/wownero.gateway.toml manifest +python3 -m ampg --config examples/wownero.gateway.toml addresses list +python3 -m ampg --config examples/wownero.gateway.toml addresses capture --profile mobile-i2p +python3 -m ampg --config examples/wownero.gateway.toml addresses set --site wownero --protocol i2p --url example.b32.i2p python3 -m ampg --config examples/wownero.gateway.toml preview endpoints python3 -m ampg --config examples/wownero.gateway.toml preview manifest python3 -m ampg --config examples/wownero.gateway.toml routes explain @@ -139,6 +142,10 @@ Managed install artifacts are also written under `dist/ampg-plan/` when `install-plan --write-artifacts` is passed. They include reviewable daemon configs and platform supervisor files, but are not installed or started. +Captured transport addresses are written under `gateway.state_dir` by +`addresses capture` or `addresses set`. Fixture manifests and health plans use explicit +config first, then captured addresses, then deterministic placeholders. + Use `--protocol` to scope operational commands to one or more enabled protocols. This lets a full site config build or activate only Tor, only I2P, or a selected subset without unselected transports blocking the run. diff --git a/ampg/addresses.py b/ampg/addresses.py new file mode 100644 index 0000000..262503b --- /dev/null +++ b/ampg/addresses.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +from .config import GatewayConfig, ProtocolConfig, SiteConfig + + +SCHEMA = "ampg.address-registry.v1" +CAPTURE_PROTOCOLS = {"tor", "i2p", "reticulum", "ipfs"} + + +@dataclass(frozen=True) +class AddressRecord: + site_id: str + protocol: str + url: str + address_status: str + source: str + + +@dataclass(frozen=True) +class AddressCaptureResult: + site_id: str + protocol: str + status: str + url: str + source: str + path: Path | None + message: str + + +def address_registry_path(config: GatewayConfig) -> Path: + if config.paths: + return config.paths.state_dir / "addresses.json" + return config.config_path.parent / ".ampg/state/addresses.json" + + +def load_address_registry(config: GatewayConfig) -> dict[tuple[str, str], AddressRecord]: + path = address_registry_path(config) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid address registry {path}: {exc}") from exc + if data.get("schema") != SCHEMA: + raise ValueError(f"invalid address registry schema in {path}") + records: dict[tuple[str, str], AddressRecord] = {} + for raw_record in data.get("addresses", []): + if not isinstance(raw_record, dict): + continue + record = AddressRecord( + site_id=str(raw_record.get("site", "")), + protocol=str(raw_record.get("protocol", "")), + url=str(raw_record.get("url", "")), + address_status=str(raw_record.get("address_status", "captured")), + source=str(raw_record.get("source", "registry")), + ) + if record.site_id and record.protocol and record.url: + records[(record.site_id, record.protocol)] = record + return records + + +def write_address_registry( + config: GatewayConfig, + records: dict[tuple[str, str], AddressRecord], +) -> Path: + path = address_registry_path(config) + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "schema": SCHEMA, + "addresses": [ + { + "site": record.site_id, + "protocol": record.protocol, + "url": record.url, + "address_status": record.address_status, + "source": record.source, + } + for record in sorted(records.values(), key=lambda item: (item.site_id, item.protocol)) + ], + } + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path + + +def set_address( + config: GatewayConfig, + *, + site_id: str, + protocol: str, + url: str, + source: str = "manual", +) -> AddressRecord: + _require_declared_protocol(config, site_id, protocol) + records = load_address_registry(config) + record = AddressRecord( + site_id=site_id, + protocol=protocol, + url=normalize_address_url(protocol, url), + address_status="captured", + source=source, + ) + records[(site_id, protocol)] = record + write_address_registry(config, records) + return record + + +def effective_address_records(config: GatewayConfig) -> list[AddressRecord]: + registry = load_address_registry(config) + records: list[AddressRecord] = [] + for site in config.sites: + for protocol in site.protocols.values(): + if not protocol.enabled: + continue + configured = configured_address(site, protocol) + if configured: + records.append(configured) + continue + captured = registry.get((site.id, protocol.name)) + if captured: + records.append(captured) + continue + records.append(_placeholder_or_derived_address(site, protocol)) + return records + + +def effective_address( + config: GatewayConfig, + site: SiteConfig, + protocol: ProtocolConfig, +) -> AddressRecord | None: + configured = configured_address(site, protocol) + if configured: + return configured + return load_address_registry(config).get((site.id, protocol.name)) + + +def configured_address(site: SiteConfig, protocol: ProtocolConfig) -> AddressRecord | None: + raw_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url") + if raw_url is None: + raw_url = _protocol_specific_configured_url(protocol) + if raw_url: + return AddressRecord( + site_id=site.id, + protocol=protocol.name, + url=normalize_address_url(protocol.name, str(raw_url)), + address_status="configured", + source="config", + ) + if protocol.name == "ipfs" and protocol.options.get("cid"): + return AddressRecord( + site_id=site.id, + protocol=protocol.name, + url=f"ipfs://{protocol.options['cid']}", + address_status="configured", + source="config", + ) + return None + + +def _protocol_specific_configured_url(protocol: ProtocolConfig) -> Any: + if protocol.name == "tor": + onion_url = protocol.options.get("onion_url") + if onion_url: + return onion_url + onion_location = protocol.options.get("onion_location") + if onion_location and onion_location != "auto": + return onion_location + return None + if protocol.name == "i2p": + return protocol.options.get("i2p_url") or protocol.options.get("i2p_hostname") + if protocol.name == "gemini": + return protocol.options.get("gemini_url") + if protocol.name == "reticulum": + return protocol.options.get("rns_url") + if protocol.name == "ipfs": + return protocol.options.get("ipfs_url") + if protocol.name == "clearnet": + return protocol.options.get("url") + return None + + +def capture_addresses(config: GatewayConfig) -> list[AddressCaptureResult]: + records = load_address_registry(config) + results: list[AddressCaptureResult] = [] + changed = False + for site in config.sites: + for protocol in site.protocols.values(): + if not protocol.enabled: + continue + result = _capture_protocol_address(config, site, protocol) + results.append(result) + if result.status == "captured": + records[(site.id, protocol.name)] = AddressRecord( + site_id=site.id, + protocol=protocol.name, + url=result.url, + address_status="captured", + source=result.source, + ) + changed = True + if changed: + write_address_registry(config, records) + return results + + +def normalize_address_url(protocol: str, value: str) -> str: + address = value.strip() + if protocol == "tor": + return _with_scheme(address, "http") + if protocol == "i2p": + return _with_scheme(address, "http") + if protocol == "gemini": + return _with_scheme(address, "gemini") + if protocol == "reticulum": + return _with_scheme(address, "rns") + if protocol == "ipfs": + if address.startswith(("/ipfs/", "/ipns/")): + return address + return _with_scheme(address, "ipfs") + if protocol == "clearnet": + return _with_scheme(address, "https") + return _with_trailing_slash(address) + + +def _capture_protocol_address( + config: GatewayConfig, + site: SiteConfig, + protocol: ProtocolConfig, +) -> AddressCaptureResult: + configured = configured_address(site, protocol) + if configured: + return AddressCaptureResult( + site_id=site.id, + protocol=protocol.name, + status="configured", + url=configured.url, + source=configured.source, + path=None, + message="address is set in config; registry unchanged", + ) + + candidates = _address_file_candidates(config, site, protocol) + if protocol.name not in CAPTURE_PROTOCOLS and not protocol.options.get("address_file"): + return AddressCaptureResult( + site_id=site.id, + protocol=protocol.name, + status="skipped", + url="", + source="-", + path=None, + message="protocol address is derived from site config", + ) + + for path in candidates: + if not path.exists(): + continue + raw_value = _first_nonempty_line(path) + if not raw_value: + continue + url = normalize_address_url(protocol.name, raw_value) + return AddressCaptureResult( + site_id=site.id, + protocol=protocol.name, + status="captured", + url=url, + source=f"file:{path}", + path=path, + message="captured public transport address", + ) + candidate_text = ", ".join(str(path) for path in candidates) + return AddressCaptureResult( + site_id=site.id, + protocol=protocol.name, + status="missing", + url="", + source="-", + path=None, + message=f"no address file found; checked {candidate_text}", + ) + + +def _address_file_candidates( + config: GatewayConfig, + site: SiteConfig, + protocol: ProtocolConfig, +) -> tuple[Path, ...]: + configured_file = protocol.options.get("address_file") + candidates: list[Path] = [] + if configured_file: + path = Path(str(configured_file)).expanduser() + if not path.is_absolute(): + path = config.config_path.parent / path + candidates.append(path.resolve()) + + state_dir = _state_dir(config, site, protocol) + if protocol.name == "tor": + candidates.extend( + [ + state_dir / "hidden-service/hostname", + state_dir / "hostname", + state_dir / "address.txt", + ] + ) + elif protocol.name == "i2p": + candidates.extend( + [ + state_dir / "hostname.txt", + state_dir / "b32.txt", + state_dir / "address.txt", + ] + ) + else: + candidates.extend([state_dir / "address.txt", state_dir / "hostname"]) + return tuple(candidates) + + +def _placeholder_or_derived_address(site: SiteConfig, protocol: ProtocolConfig) -> AddressRecord: + if protocol.name == "clearnet": + url = site.source.canonical_url or f"https://{site.domain}/" + return AddressRecord(site.id, protocol.name, _with_trailing_slash(url), "derived", "site") + if protocol.name == "tor": + return AddressRecord( + site.id, + protocol.name, + f"http://{site.id}.onion/", + "placeholder", + "placeholder", + ) + if protocol.name == "i2p": + return AddressRecord( + site.id, + protocol.name, + f"http://{site.id}.i2p/", + "placeholder", + "placeholder", + ) + if protocol.name == "gemini": + return AddressRecord(site.id, protocol.name, f"gemini://{site.domain}/", "derived", "site") + if protocol.name == "reticulum": + return AddressRecord(site.id, protocol.name, f"rns://{site.id}", "placeholder", "placeholder") + if protocol.name == "ipfs": + return AddressRecord(site.id, protocol.name, f"/ipfs/{site.id}", "placeholder", "placeholder") + return AddressRecord(site.id, protocol.name, f"https://{site.domain}/", "derived", "site") + + +def _require_declared_protocol(config: GatewayConfig, site_id: str, protocol: str) -> None: + for site in config.sites: + if site.id != site_id: + continue + if protocol not in site.protocols: + raise ValueError(f"{site_id}: protocol {protocol!r} is not declared") + return + raise ValueError(f"unknown site {site_id!r}") + + +def _state_dir(config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig) -> Path: + root = config.paths.state_dir if config.paths else config.config_path.parent / ".ampg/state" + return root / site.id / protocol.name + + +def _first_nonempty_line(path: Path) -> str: + for line in path.read_text(encoding="utf-8").splitlines(): + value = line.strip() + if value: + return value + return "" + + +def _with_scheme(value: str, scheme: str) -> str: + if "://" in value: + return _with_trailing_slash(value) + return _with_trailing_slash(f"{scheme}://{value}") + + +def _with_trailing_slash(value: str) -> str: + if value.startswith(("rns://", "lxmf://", "nomad://", "ipfs://", "ipns://")): + return value + if value.endswith("/"): + return value + return value + "/" diff --git a/ampg/cli.py b/ampg/cli.py index 70c4e56..a8703b9 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -10,6 +10,14 @@ from .activation import ( blocked_steps, write_activation_artifacts, ) +from .addresses import ( + AddressCaptureResult, + AddressRecord, + address_registry_path, + capture_addresses, + effective_address_records, + set_address, +) from .audit import audit_gateway from .build import build_gateway from .config import load_config @@ -132,6 +140,41 @@ def main(argv: list[str] | None = None) -> int: _add_target_selection(build_parser) fixture_parser = subcommands.add_parser("manifest", help="Write AMPB fixture manifests.") _add_target_selection(fixture_parser) + addresses_parser = subcommands.add_parser( + "addresses", + help="Inspect or update captured transport addresses.", + ) + addresses_subcommands = addresses_parser.add_subparsers( + dest="addresses_command", + required=True, + ) + addresses_list = addresses_subcommands.add_parser( + "list", + help="Print effective configured, captured, derived, and placeholder addresses.", + ) + _add_target_selection(addresses_list) + addresses_capture = addresses_subcommands.add_parser( + "capture", + help="Capture generated transport addresses from daemon state files.", + ) + _add_target_selection(addresses_capture) + addresses_set = addresses_subcommands.add_parser( + "set", + help="Manually store a generated transport address in AMPG state.", + ) + addresses_set.add_argument("--site", required=True, help="Site id to update.") + addresses_set.add_argument( + "--protocol", + dest="address_protocol", + required=True, + help="Protocol name to update.", + ) + addresses_set.add_argument("--url", required=True, help="Public transport URL or hostname.") + addresses_set.add_argument( + "--source", + default="manual", + help="Source label stored with the captured address.", + ) preview_parser = subcommands.add_parser("preview", help="Preview generated outputs locally.") preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True) for preview_command in ("endpoints", "manifest", "serve"): @@ -224,6 +267,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_build(config) if args.command == "manifest": return _cmd_manifest(config) + if args.command == "addresses": + return _cmd_addresses(config, args) if args.command == "preview": return _cmd_preview(config, args) if args.command == "routes": @@ -529,6 +574,79 @@ def _cmd_manifest(config) -> int: return 0 +def _cmd_addresses(config, args) -> int: + if args.addresses_command == "list": + records = effective_address_records(config) + for record in records: + _print_address(record) + print( + "AMPG_ADDRESS_SUMMARY " + f"sites={len(config.sites)} " + f"addresses={len(records)} " + f"configured={sum(1 for record in records if record.address_status == 'configured')} " + f"captured={sum(1 for record in records if record.address_status == 'captured')} " + f"derived={sum(1 for record in records if record.address_status == 'derived')} " + f"placeholder={sum(1 for record in records if record.address_status == 'placeholder')} " + f"registry={address_registry_path(config)}" + ) + return 0 + + if args.addresses_command == "capture": + results = capture_addresses(config) + for result in results: + _print_address_capture(result) + print( + "AMPG_ADDRESS_CAPTURE_SUMMARY " + f"sites={len(config.sites)} " + f"results={len(results)} " + f"captured={sum(1 for result in results if result.status == 'captured')} " + f"configured={sum(1 for result in results if result.status == 'configured')} " + f"skipped={sum(1 for result in results if result.status == 'skipped')} " + f"missing={sum(1 for result in results if result.status == 'missing')} " + f"registry={address_registry_path(config)}" + ) + return 0 + + if args.addresses_command == "set": + record = set_address( + config, + site_id=args.site, + protocol=args.address_protocol, + url=args.url, + source=args.source, + ) + _print_address(record) + print(f"AMPG_ADDRESS_SET status=written registry={address_registry_path(config)}") + return 0 + + return 1 + + +def _print_address(record: AddressRecord) -> None: + print( + "AMPG_ADDRESS " + f"site={record.site_id} " + f"protocol={record.protocol} " + f"url=\"{_quote(record.url)}\" " + f"address_status={record.address_status} " + f"source=\"{_quote(record.source)}\"" + ) + + +def _print_address_capture(result: AddressCaptureResult) -> None: + path = str(result.path) if result.path else "-" + print( + "AMPG_ADDRESS_CAPTURE " + f"site={result.site_id} " + f"protocol={result.protocol} " + f"status={result.status} " + f"url=\"{_quote(result.url)}\" " + f"source=\"{_quote(result.source)}\" " + f"path=\"{_quote(path)}\" " + f"message=\"{_quote(result.message)}\"" + ) + + def _cmd_preview(config, args) -> int: endpoints = preview_endpoints(config, base_port=args.base_port, host=args.host) if args.preview_command == "endpoints": diff --git a/ampg/config.py b/ampg/config.py index ec34a6d..d032184 100644 --- a/ampg/config.py +++ b/ampg/config.py @@ -87,11 +87,20 @@ class ProfileConfig: description: str = "" +@dataclass(frozen=True) +class GatewayPaths: + state_dir: Path + cache_dir: Path + run_dir: Path + user: str | None = None + + @dataclass(frozen=True) class GatewayConfig: config_path: Path sites: list[SiteConfig] profiles: dict[str, ProfileConfig] = field(default_factory=dict) + paths: GatewayPaths | None = None def load_config(path: Path) -> GatewayConfig: @@ -101,7 +110,24 @@ def load_config(path: Path) -> GatewayConfig: if not sites: raise ValueError("config must declare at least one [[site]]") profiles = _parse_profiles(data.get("profiles", {})) - return GatewayConfig(config_path=config_path, sites=sites, profiles=profiles) + paths = _parse_gateway_paths(data.get("gateway", {}), config_path.parent) + return GatewayConfig(config_path=config_path, sites=sites, profiles=profiles, paths=paths) + + +def _parse_gateway_paths(raw_gateway: Any, base_dir: Path) -> GatewayPaths: + if raw_gateway is None: + raw_gateway = {} + if not isinstance(raw_gateway, dict): + raise ValueError("gateway must be a table") + user = raw_gateway.get("user") + if user is not None and not isinstance(user, str): + raise ValueError("gateway.user must be a string") + return GatewayPaths( + state_dir=_resolve_path(base_dir, str(raw_gateway.get("state_dir", "./.ampg/state"))), + cache_dir=_resolve_path(base_dir, str(raw_gateway.get("cache_dir", "./.ampg/cache"))), + run_dir=_resolve_path(base_dir, str(raw_gateway.get("run_dir", "./.ampg/run"))), + user=user, + ) def _parse_profiles(raw_profiles: Any) -> dict[str, ProfileConfig]: diff --git a/ampg/health.py b/ampg/health.py index ce4be0e..ce2befe 100644 --- a/ampg/health.py +++ b/ampg/health.py @@ -40,7 +40,7 @@ def health_plan( endpoints = preview_endpoints(config, base_port=base_port, host=host) if mode == "preview" else [] checks: list[HealthCheck] = [] for site in config.sites: - manifest = _manifest_for_mode(site, mode=mode, endpoints=endpoints) + manifest = _manifest_for_mode(config, site, mode=mode, endpoints=endpoints) for fixture in manifest["fixtures"]: checks.append(_health_check(site, fixture, mode=mode)) return checks @@ -51,14 +51,15 @@ def blocked_health_checks(checks: list[HealthCheck]) -> list[HealthCheck]: def _manifest_for_mode( + config: GatewayConfig, site: SiteConfig, *, mode: str, endpoints, ) -> dict[str, Any]: if mode == "preview": - return preview_fixture_manifest(site, endpoints) - return fixture_manifest(site) + return preview_fixture_manifest(config, site, endpoints) + return fixture_manifest(config, site) def _health_check(site: SiteConfig, fixture: dict[str, Any], *, mode: str) -> HealthCheck: diff --git a/ampg/manifest.py b/ampg/manifest.py index 916442a..bf39248 100644 --- a/ampg/manifest.py +++ b/ampg/manifest.py @@ -5,6 +5,7 @@ import json from pathlib import Path from typing import Any +from .addresses import AddressRecord, effective_address from .config import GatewayConfig, ProtocolConfig, RoutePolicyConfig, SiteConfig from .route_policy import fixture_path, route_exposed @@ -19,14 +20,15 @@ class ManifestWriteResult: fixture_count: int -def fixture_manifest(site: SiteConfig) -> dict[str, Any]: +def fixture_manifest(config: GatewayConfig, site: SiteConfig) -> dict[str, Any]: fixtures = [] for protocol in site.protocols.values(): if not protocol.enabled: continue - fixtures.append(_fixture_entry(site, protocol, None)) + address = effective_address(config, site, protocol) + fixtures.append(_fixture_entry(site, protocol, None, address)) fixtures.extend( - _fixture_entry(site, protocol, route_policy) + _fixture_entry(site, protocol, route_policy, address) for route_policy in site.interactions.routes if route_exposed(site, protocol, route_policy) ) @@ -44,7 +46,7 @@ def fixture_manifest(site: SiteConfig) -> dict[str, Any]: def write_fixture_manifests(config: GatewayConfig) -> list[ManifestWriteResult]: results: list[ManifestWriteResult] = [] for site in config.sites: - manifest = fixture_manifest(site) + manifest = fixture_manifest(config, site) path = manifest_path(site) path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -69,8 +71,9 @@ def _fixture_entry( site: SiteConfig, protocol: ProtocolConfig, route_policy: RoutePolicyConfig | None, + address: AddressRecord | None, ) -> dict[str, Any]: - url, address_status = _fixture_url(site, protocol) + url, address_status = _fixture_url(site, protocol, address) transport = _transport_for(protocol.name) entry = { "protocol": protocol.name, @@ -134,10 +137,16 @@ def _output_path_for_route(protocol_name: str, route_policy: RoutePolicyConfig | return protocol_name + fixture_path(route_policy.match) -def _fixture_url(site: SiteConfig, protocol: ProtocolConfig) -> tuple[str, str]: +def _fixture_url( + site: SiteConfig, + protocol: ProtocolConfig, + address: AddressRecord | None, +) -> tuple[str, str]: configured_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url") if configured_url: return _with_trailing_slash(str(configured_url)), "configured" + if address: + return address.url, address.address_status if protocol.name == "clearnet": if site.source.canonical_url: diff --git a/ampg/metadata.py b/ampg/metadata.py index dae8e77..5b56528 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -68,6 +68,38 @@ class TransportInteractionCapability: CONFIG_FIELDS = ( + ConfigField( + "gateway", + "state_dir", + "path", + False, + "./.ampg/state", + "AMPG-owned daemon state and captured address registry root.", + ), + ConfigField( + "gateway", + "cache_dir", + "path", + False, + "./.ampg/cache", + "AMPG cache root for generated intermediate data.", + ), + ConfigField( + "gateway", + "run_dir", + "path", + False, + "./.ampg/run", + "Runtime socket and process metadata root for managed daemons.", + ), + ConfigField( + "gateway", + "user", + "string", + False, + "", + "Preferred service user for generated managed-daemon artifacts.", + ), ConfigField("site", "id", "string", True, "", "Stable site identifier used in output paths."), ConfigField("site", "domain", "string", True, "", "Canonical public domain for the site."), ConfigField("site.source", "kind", "enum", True, "static-html", "Source adapter kind."), @@ -179,6 +211,30 @@ CONFIG_FIELDS = ( "protocol default", "Whether AMPG adopts, manages, or only renders config.", ), + ConfigField( + "site.protocols.", + "fixture_url", + "url", + False, + "", + "Published URL used in fixture manifests and health plans.", + ), + ConfigField( + "site.protocols.", + "browser_url", + "url", + False, + "", + "Browser-facing URL alias used when fixture_url is not set.", + ), + ConfigField( + "site.protocols.", + "address_file", + "path", + False, + "", + "Daemon-written public address file consumed by addresses capture.", + ), ConfigField( "site.protocols.", "max_asset_bytes", diff --git a/ampg/preview.py b/ampg/preview.py index 827b3ae..516c710 100644 --- a/ampg/preview.py +++ b/ampg/preview.py @@ -67,6 +67,7 @@ def preview_endpoints( def preview_fixture_manifest( + config: GatewayConfig, site: SiteConfig, endpoints: list[PreviewEndpoint], ) -> dict[str, Any]: @@ -75,7 +76,7 @@ def preview_fixture_manifest( for endpoint in endpoints if endpoint.site_id == site.id } - manifest = fixture_manifest(site) + manifest = fixture_manifest(config, site) manifest["mode"] = "preview" for fixture in manifest["fixtures"]: endpoint = by_protocol[fixture["protocol"]] @@ -110,7 +111,7 @@ def write_preview_fixture_manifests( endpoints = preview_endpoints(config, base_port=base_port, host=host) results: list[PreviewManifestWriteResult] = [] for site in config.sites: - manifest = preview_fixture_manifest(site, endpoints) + manifest = preview_fixture_manifest(config, site, endpoints) path = preview_manifest_path(site) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/ampg/selection.py b/ampg/selection.py index bf853b3..019f740 100644 --- a/ampg/selection.py +++ b/ampg/selection.py @@ -60,6 +60,7 @@ def select_protocols(config: GatewayConfig, protocols: tuple[str, ...]) -> Gatew config_path=config.config_path, sites=sites, profiles=config.profiles, + paths=config.paths, ) diff --git a/docs/configuration.md b/docs/configuration.md index 60c1d1e..44728a0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -> Status: draft | Updated 2026-07-05 | Applies to: `gateway.toml` +> Status: draft | Updated 2026-07-06 | Applies to: `gateway.toml` AMPG is configured from one TOML file. The file declares global paths, sites, source adapters, render targets, and daemon policy. @@ -107,6 +107,34 @@ workflow. | ipfs | `clearnet` | `ipfs` | `auto` | | reticulum | `micron` | `rnsd` | `auto` | +## Gateway paths + +`[gateway]` paths are resolved relative to `gateway.toml` when they are not absolute. + +`state_dir` +: AMPG-owned durable state. Managed daemon identities, generated transport state, and + the captured address registry live here. + +`cache_dir` +: Rebuildable intermediate data. + +`run_dir` +: Runtime sockets, pid files, and process metadata for managed daemons. + +`user` +: Preferred service user emitted into generated managed-daemon artifacts. + +Transport URLs are resolved in this order: + +1. Explicit protocol config such as `fixture_url`, `browser_url`, `onion_url`, `i2p_url`, + `gemini_url`, `rns_url`, `ipfs_url`, or `cid`. +2. Captured state from `ampg addresses capture` or `ampg addresses set`. +3. A derived URL for clearnet/Gemini or a deterministic placeholder for identity-based + transports whose final address is not known yet. + +Use `address_file` on a protocol target when an adopted daemon writes its public hostname +outside AMPG's managed state layout. + ## Renderer defaults `privacy-html` removes: diff --git a/docs/daemon-management.md b/docs/daemon-management.md index a0ed5e6..8bd479a 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -1,6 +1,6 @@ # Daemon Management -> Status: draft | Updated 2026-07-05 | Applies to: protocol adapters +> Status: draft | Updated 2026-07-06 | Applies to: protocol adapters AMPG can use daemons that already exist on a server or create AMPG-owned daemons when the selected protocol is not running. This keeps simple deployments simple while avoiding @@ -41,6 +41,8 @@ python3 -m ampg --config gateway.toml status python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p --write-artifacts +python3 -m ampg --config gateway.toml addresses list --profile mobile-i2p +python3 -m ampg --config gateway.toml addresses capture --profile mobile-i2p python3 -m ampg --config gateway.toml health-plan --profile mobile-i2p python3 -m ampg --config gateway.toml apply --dry-run python3 -m ampg --config gateway.toml apply --dry-run --profile mobile-i2p @@ -66,6 +68,11 @@ files under the configured `plan_root`, grouped by site, protocol, and platform. Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopback nginx artifacts. +`addresses list` prints the effective public address for each enabled protocol: +configured, captured, derived, or placeholder. `addresses capture` reads daemon-written +address files from AMPG state or a protocol `address_file` override, then updates the +address registry under `state_dir`. + `health-plan` prints fixture-based checks to run after services start. Published checks target real transport URLs; preview checks target local loopback preview endpoints. Missing output blocks the plan, while placeholder transport addresses are review items. diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index 99eba91..6bddf38 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -32,6 +32,8 @@ or deployment notes. ```sh python3 -m ampg --config examples/wownero.gateway.toml build python3 -m ampg --config examples/wownero.gateway.toml manifest +python3 -m ampg --config examples/wownero.gateway.toml addresses list +python3 -m ampg --config examples/wownero.gateway.toml addresses capture --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml preview manifest python3 -m ampg --config examples/wownero.gateway.toml health-plan python3 -m ampg --config examples/wownero.gateway.toml health-plan --mode preview @@ -60,6 +62,11 @@ Some transports may not have final public addresses when a fixture is generated. cases AMPG uses route-valid placeholders such as `http://wownero.onion/` so AMPB can still verify transport selection without contacting the network. +When a managed or adopted daemon writes an actual address, run `addresses capture`. +Captured addresses are stored under `gateway.state_dir` and replace placeholders in +fixture manifests and published health plans. Operators can also use `addresses set` to +store an address manually. + ## Interaction Policy Fixture interaction policy defaults to: diff --git a/docs/generated/config-schema.md b/docs/generated/config-schema.md index 857859d..d0031db 100644 --- a/docs/generated/config-schema.md +++ b/docs/generated/config-schema.md @@ -6,6 +6,10 @@ This file is generated from code. Do not edit it by hand. | Section | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | +| gateway | `state_dir` | path | no | ./.ampg/state | AMPG-owned daemon state and captured address registry root. | +| gateway | `cache_dir` | path | no | ./.ampg/cache | AMPG cache root for generated intermediate data. | +| gateway | `run_dir` | path | no | ./.ampg/run | Runtime socket and process metadata root for managed daemons. | +| gateway | `user` | string | no | - | Preferred service user for generated managed-daemon artifacts. | | site | `id` | string | yes | - | Stable site identifier used in output paths. | | site | `domain` | string | yes | - | Canonical public domain for the site. | | site.source | `kind` | enum | yes | static-html | Source adapter kind. | @@ -26,6 +30,9 @@ This file is generated from code. Do not edit it by hand. | site.protocols. | `renderer` | enum | no | | Renderer profile to use for this protocol. | | site.protocols. | `daemon` | enum | no | protocol default | Ingress daemon adapter. | | site.protocols. | `daemon_policy` | enum | no | protocol default | Whether AMPG adopts, manages, or only renders config. | +| site.protocols. | `fixture_url` | url | no | - | Published URL used in fixture manifests and health plans. | +| site.protocols. | `browser_url` | url | no | - | Browser-facing URL alias used when fixture_url is not set. | +| site.protocols. | `address_file` | path | no | - | Daemon-written public address file consumed by addresses capture. | | site.protocols. | `max_asset_bytes` | integer | no | 1048576 | Maximum asset size copied by privacy-html render targets. | | site.protocols. | `script_policy` | enum | no | strip | Script handling policy for privacy-html render targets. | | site.protocols. | `max_tier` | enum | no | transport default | Maximum interaction tier this protocol target may expose. | diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index 44b21e7..232dfdd 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -152,6 +152,7 @@ daemon_policy = "auto" python3 -m ampg --config examples/wownero.gateway.toml plan python3 -m ampg --config examples/wownero.gateway.toml build python3 -m ampg --config examples/wownero.gateway.toml manifest +python3 -m ampg --config examples/wownero.gateway.toml addresses list python3 -m ampg --config examples/wownero.gateway.toml preview endpoints python3 -m ampg --config examples/wownero.gateway.toml preview manifest python3 -m ampg --config examples/i2p-only.gateway.toml plan diff --git a/tests/test_addresses.py b/tests/test_addresses.py new file mode 100644 index 0000000..fd176cb --- /dev/null +++ b/tests/test_addresses.py @@ -0,0 +1,233 @@ +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from ampg.addresses import ( + address_registry_path, + capture_addresses, + effective_address_records, + set_address, +) +from ampg.cli import main +from ampg.config import load_config +from ampg.health import health_plan +from ampg.manifest import fixture_manifest + + +def _write_config(root: Path, protocol_block: str) -> Path: + source = root / "site" + source.mkdir() + (source / "index.html").write_text("

Hello

", encoding="utf-8") + config_path = root / "gateway.toml" + config_path.write_text( + f""" +[gateway] +state_dir = "./state" +cache_dir = "./cache" +run_dir = "./run" +user = "ampg" + +[profiles.mobile-i2p] +protocols = ["i2p"] + +[[site]] +id = "example" +domain = "example.test" + +[site.source] +kind = "static-html" +path = "./site" +canonical_url = "https://example.test" + +[site.outputs] +root = "./out" +plan_root = "./plan" + +{protocol_block} +""", + encoding="utf-8", + ) + return config_path + + +def _mark_output(root: Path, protocol: str) -> None: + output = root / f"out/{protocol}" + output.mkdir(parents=True) + (output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8") + + +class AddressRegistryTest(unittest.TestCase): + def test_gateway_paths_are_parsed(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + config = load_config( + _write_config( + root, + """ +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +""", + ) + ) + + self.assertEqual(root / "state", config.paths.state_dir) + self.assertEqual(root / "cache", config.paths.cache_dir) + self.assertEqual(root / "run", config.paths.run_dir) + self.assertEqual("ampg", config.paths.user) + + def test_manual_address_drives_manifest_and_health_plan(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + config = load_config( + _write_config( + root, + """ +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +""", + ) + ) + record = set_address( + config, + site_id="example", + protocol="i2p", + url="manualexample.b32.i2p", + ) + _mark_output(root, "i2p") + + records = effective_address_records(config) + manifest = fixture_manifest(config, config.sites[0]) + checks = health_plan(config) + + self.assertEqual("http://manualexample.b32.i2p/", record.url) + self.assertEqual(root / "state/addresses.json", address_registry_path(config)) + self.assertEqual("http://manualexample.b32.i2p/", records[0].url) + self.assertEqual("captured", records[0].address_status) + self.assertEqual("http://manualexample.b32.i2p/", manifest["fixtures"][0]["url"]) + self.assertEqual("captured", manifest["fixtures"][0]["address_status"]) + self.assertEqual("planned", checks[0].status) + + def test_capture_reads_i2p_address_from_state_file(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + config = load_config( + _write_config( + root, + """ +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +""", + ) + ) + address_file = root / "state/example/i2p/b32.txt" + address_file.parent.mkdir(parents=True) + address_file.write_text("capturedexample.b32.i2p\n", encoding="utf-8") + + results = capture_addresses(config) + registry = json.loads(address_registry_path(config).read_text(encoding="utf-8")) + + self.assertEqual("captured", results[0].status) + self.assertEqual(address_file, results[0].path) + self.assertEqual("http://capturedexample.b32.i2p/", registry["addresses"][0]["url"]) + + def test_addresses_cli_set_and_list_respect_profile_scope(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + config_path = _write_config( + root, + """ +[site.protocols.clearnet] +enabled = true +renderer = "clearnet" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +""", + ) + + set_stdout = io.StringIO() + with contextlib.redirect_stdout(set_stdout): + set_status = main( + [ + "--config", + str(config_path), + "addresses", + "set", + "--site", + "example", + "--protocol", + "i2p", + "--url", + "cliexample.b32.i2p", + ] + ) + + list_stdout = io.StringIO() + with contextlib.redirect_stdout(list_stdout): + list_status = main( + [ + "--config", + str(config_path), + "addresses", + "list", + "--profile", + "mobile-i2p", + ] + ) + + self.assertEqual(0, set_status) + self.assertEqual(0, list_status) + self.assertIn("AMPG_ADDRESS_SET status=written", set_stdout.getvalue()) + self.assertIn("protocol=i2p", list_stdout.getvalue()) + self.assertIn("captured=1", list_stdout.getvalue()) + self.assertNotIn("protocol=clearnet", list_stdout.getvalue()) + + def test_addresses_cli_capture_uses_profile_scope(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + config_path = _write_config( + root, + """ +[site.protocols.clearnet] +enabled = true +renderer = "clearnet" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +""", + ) + address_file = root / "state/example/i2p/hostname.txt" + address_file.parent.mkdir(parents=True) + address_file.write_text("capturecli.b32.i2p\n", encoding="utf-8") + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main( + [ + "--config", + str(config_path), + "addresses", + "capture", + "--profile", + "mobile-i2p", + ] + ) + + output = stdout.getvalue() + self.assertEqual(0, status) + self.assertIn("protocol=i2p", output) + self.assertIn("status=captured", output) + self.assertIn("captured=1", output) + self.assertNotIn("protocol=clearnet", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fixture_manifest.py b/tests/test_fixture_manifest.py index e606545..0c3e70d 100644 --- a/tests/test_fixture_manifest.py +++ b/tests/test_fixture_manifest.py @@ -56,7 +56,7 @@ cid = "bafyexample" ) config = load_config(config_path) - manifest = fixture_manifest(config.sites[0]) + manifest = fixture_manifest(config, config.sites[0]) written = write_fixture_manifests(config) self.assertEqual("ampg.fixture-manifest.v1", manifest["schema"]) @@ -116,7 +116,7 @@ payments = "server-invoice" ) config = load_config(config_path) - manifest = fixture_manifest(config.sites[0]) + manifest = fixture_manifest(config, config.sites[0]) self.assertEqual( { @@ -178,7 +178,7 @@ renderer = "privacy-html" ) config = load_config(config_path) - manifest = fixture_manifest(config.sites[0]) + manifest = fixture_manifest(config, config.sites[0]) self.assertEqual(3, len(config.sites[0].interactions.routes)) self.assertEqual(3, len(manifest["fixtures"])) @@ -252,7 +252,7 @@ renderer = "privacy-html" ) config = load_config(config_path) - manifest = fixture_manifest(config.sites[0]) + manifest = fixture_manifest(config, config.sites[0]) site = config.sites[0] self.assertEqual("interactive-lite", site.interactions.default_tier) @@ -355,7 +355,7 @@ cid = "bafyexample" ) config = load_config(config_path) - manifest = fixture_manifest(config.sites[0]) + manifest = fixture_manifest(config, config.sites[0]) by_protocol = {} for fixture in manifest["fixtures"]: