mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 15:41:41 +02:00
Capture transport addresses in state
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 + "/"
|
||||
+118
@@ -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":
|
||||
|
||||
+27
-1
@@ -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]:
|
||||
|
||||
+4
-3
@@ -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:
|
||||
|
||||
+15
-6
@@ -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:
|
||||
|
||||
@@ -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.<name>",
|
||||
"fixture_url",
|
||||
"url",
|
||||
False,
|
||||
"",
|
||||
"Published URL used in fixture manifests and health plans.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.protocols.<name>",
|
||||
"browser_url",
|
||||
"url",
|
||||
False,
|
||||
"",
|
||||
"Browser-facing URL alias used when fixture_url is not set.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.protocols.<name>",
|
||||
"address_file",
|
||||
"path",
|
||||
False,
|
||||
"",
|
||||
"Daemon-written public address file consumed by addresses capture.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.protocols.<name>",
|
||||
"max_asset_bytes",
|
||||
|
||||
+3
-2
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+29
-1
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.<name> | `renderer` | enum | no | <protocol> | Renderer profile to use for this protocol. |
|
||||
| site.protocols.<name> | `daemon` | enum | no | protocol default | Ingress daemon adapter. |
|
||||
| site.protocols.<name> | `daemon_policy` | enum | no | protocol default | Whether AMPG adopts, manages, or only renders config. |
|
||||
| site.protocols.<name> | `fixture_url` | url | no | - | Published URL used in fixture manifests and health plans. |
|
||||
| site.protocols.<name> | `browser_url` | url | no | - | Browser-facing URL alias used when fixture_url is not set. |
|
||||
| site.protocols.<name> | `address_file` | path | no | - | Daemon-written public address file consumed by addresses capture. |
|
||||
| site.protocols.<name> | `max_asset_bytes` | integer | no | 1048576 | Maximum asset size copied by privacy-html render targets. |
|
||||
| site.protocols.<name> | `script_policy` | enum | no | strip | Script handling policy for privacy-html render targets. |
|
||||
| site.protocols.<name> | `max_tier` | enum | no | transport default | Maximum interaction tier this protocol target may expose. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<h1>Hello</h1>", 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()
|
||||
@@ -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"]:
|
||||
|
||||
Reference in New Issue
Block a user