mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Capture transport addresses in state
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Adaptive Multi-Protocol Gateway
|
# 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
|
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
|
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 --profile tor-i2p
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml build --protocol tor --protocol 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 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 endpoints
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
|
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml routes explain
|
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
|
`install-plan --write-artifacts` is passed. They include reviewable daemon configs and
|
||||||
platform supervisor files, but are not installed or started.
|
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
|
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
|
lets a full site config build or activate only Tor, only I2P, or a selected subset without
|
||||||
unselected transports blocking the run.
|
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,
|
blocked_steps,
|
||||||
write_activation_artifacts,
|
write_activation_artifacts,
|
||||||
)
|
)
|
||||||
|
from .addresses import (
|
||||||
|
AddressCaptureResult,
|
||||||
|
AddressRecord,
|
||||||
|
address_registry_path,
|
||||||
|
capture_addresses,
|
||||||
|
effective_address_records,
|
||||||
|
set_address,
|
||||||
|
)
|
||||||
from .audit import audit_gateway
|
from .audit import audit_gateway
|
||||||
from .build import build_gateway
|
from .build import build_gateway
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
@@ -132,6 +140,41 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
_add_target_selection(build_parser)
|
_add_target_selection(build_parser)
|
||||||
fixture_parser = subcommands.add_parser("manifest", help="Write AMPB fixture manifests.")
|
fixture_parser = subcommands.add_parser("manifest", help="Write AMPB fixture manifests.")
|
||||||
_add_target_selection(fixture_parser)
|
_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_parser = subcommands.add_parser("preview", help="Preview generated outputs locally.")
|
||||||
preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True)
|
preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True)
|
||||||
for preview_command in ("endpoints", "manifest", "serve"):
|
for preview_command in ("endpoints", "manifest", "serve"):
|
||||||
@@ -224,6 +267,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return _cmd_build(config)
|
return _cmd_build(config)
|
||||||
if args.command == "manifest":
|
if args.command == "manifest":
|
||||||
return _cmd_manifest(config)
|
return _cmd_manifest(config)
|
||||||
|
if args.command == "addresses":
|
||||||
|
return _cmd_addresses(config, args)
|
||||||
if args.command == "preview":
|
if args.command == "preview":
|
||||||
return _cmd_preview(config, args)
|
return _cmd_preview(config, args)
|
||||||
if args.command == "routes":
|
if args.command == "routes":
|
||||||
@@ -529,6 +574,79 @@ def _cmd_manifest(config) -> int:
|
|||||||
return 0
|
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:
|
def _cmd_preview(config, args) -> int:
|
||||||
endpoints = preview_endpoints(config, base_port=args.base_port, host=args.host)
|
endpoints = preview_endpoints(config, base_port=args.base_port, host=args.host)
|
||||||
if args.preview_command == "endpoints":
|
if args.preview_command == "endpoints":
|
||||||
|
|||||||
+27
-1
@@ -87,11 +87,20 @@ class ProfileConfig:
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayPaths:
|
||||||
|
state_dir: Path
|
||||||
|
cache_dir: Path
|
||||||
|
run_dir: Path
|
||||||
|
user: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GatewayConfig:
|
class GatewayConfig:
|
||||||
config_path: Path
|
config_path: Path
|
||||||
sites: list[SiteConfig]
|
sites: list[SiteConfig]
|
||||||
profiles: dict[str, ProfileConfig] = field(default_factory=dict)
|
profiles: dict[str, ProfileConfig] = field(default_factory=dict)
|
||||||
|
paths: GatewayPaths | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: Path) -> GatewayConfig:
|
def load_config(path: Path) -> GatewayConfig:
|
||||||
@@ -101,7 +110,24 @@ def load_config(path: Path) -> GatewayConfig:
|
|||||||
if not sites:
|
if not sites:
|
||||||
raise ValueError("config must declare at least one [[site]]")
|
raise ValueError("config must declare at least one [[site]]")
|
||||||
profiles = _parse_profiles(data.get("profiles", {}))
|
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]:
|
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 []
|
endpoints = preview_endpoints(config, base_port=base_port, host=host) if mode == "preview" else []
|
||||||
checks: list[HealthCheck] = []
|
checks: list[HealthCheck] = []
|
||||||
for site in config.sites:
|
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"]:
|
for fixture in manifest["fixtures"]:
|
||||||
checks.append(_health_check(site, fixture, mode=mode))
|
checks.append(_health_check(site, fixture, mode=mode))
|
||||||
return checks
|
return checks
|
||||||
@@ -51,14 +51,15 @@ def blocked_health_checks(checks: list[HealthCheck]) -> list[HealthCheck]:
|
|||||||
|
|
||||||
|
|
||||||
def _manifest_for_mode(
|
def _manifest_for_mode(
|
||||||
|
config: GatewayConfig,
|
||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
*,
|
*,
|
||||||
mode: str,
|
mode: str,
|
||||||
endpoints,
|
endpoints,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if mode == "preview":
|
if mode == "preview":
|
||||||
return preview_fixture_manifest(site, endpoints)
|
return preview_fixture_manifest(config, site, endpoints)
|
||||||
return fixture_manifest(site)
|
return fixture_manifest(config, site)
|
||||||
|
|
||||||
|
|
||||||
def _health_check(site: SiteConfig, fixture: dict[str, Any], *, mode: str) -> HealthCheck:
|
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 pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .addresses import AddressRecord, effective_address
|
||||||
from .config import GatewayConfig, ProtocolConfig, RoutePolicyConfig, SiteConfig
|
from .config import GatewayConfig, ProtocolConfig, RoutePolicyConfig, SiteConfig
|
||||||
from .route_policy import fixture_path, route_exposed
|
from .route_policy import fixture_path, route_exposed
|
||||||
|
|
||||||
@@ -19,14 +20,15 @@ class ManifestWriteResult:
|
|||||||
fixture_count: int
|
fixture_count: int
|
||||||
|
|
||||||
|
|
||||||
def fixture_manifest(site: SiteConfig) -> dict[str, Any]:
|
def fixture_manifest(config: GatewayConfig, site: SiteConfig) -> dict[str, Any]:
|
||||||
fixtures = []
|
fixtures = []
|
||||||
for protocol in site.protocols.values():
|
for protocol in site.protocols.values():
|
||||||
if not protocol.enabled:
|
if not protocol.enabled:
|
||||||
continue
|
continue
|
||||||
fixtures.append(_fixture_entry(site, protocol, None))
|
address = effective_address(config, site, protocol)
|
||||||
|
fixtures.append(_fixture_entry(site, protocol, None, address))
|
||||||
fixtures.extend(
|
fixtures.extend(
|
||||||
_fixture_entry(site, protocol, route_policy)
|
_fixture_entry(site, protocol, route_policy, address)
|
||||||
for route_policy in site.interactions.routes
|
for route_policy in site.interactions.routes
|
||||||
if route_exposed(site, protocol, route_policy)
|
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]:
|
def write_fixture_manifests(config: GatewayConfig) -> list[ManifestWriteResult]:
|
||||||
results: list[ManifestWriteResult] = []
|
results: list[ManifestWriteResult] = []
|
||||||
for site in config.sites:
|
for site in config.sites:
|
||||||
manifest = fixture_manifest(site)
|
manifest = fixture_manifest(config, site)
|
||||||
path = manifest_path(site)
|
path = manifest_path(site)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(
|
path.write_text(
|
||||||
@@ -69,8 +71,9 @@ def _fixture_entry(
|
|||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
protocol: ProtocolConfig,
|
protocol: ProtocolConfig,
|
||||||
route_policy: RoutePolicyConfig | None,
|
route_policy: RoutePolicyConfig | None,
|
||||||
|
address: AddressRecord | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
url, address_status = _fixture_url(site, protocol)
|
url, address_status = _fixture_url(site, protocol, address)
|
||||||
transport = _transport_for(protocol.name)
|
transport = _transport_for(protocol.name)
|
||||||
entry = {
|
entry = {
|
||||||
"protocol": protocol.name,
|
"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)
|
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")
|
configured_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url")
|
||||||
if configured_url:
|
if configured_url:
|
||||||
return _with_trailing_slash(str(configured_url)), "configured"
|
return _with_trailing_slash(str(configured_url)), "configured"
|
||||||
|
if address:
|
||||||
|
return address.url, address.address_status
|
||||||
|
|
||||||
if protocol.name == "clearnet":
|
if protocol.name == "clearnet":
|
||||||
if site.source.canonical_url:
|
if site.source.canonical_url:
|
||||||
|
|||||||
@@ -68,6 +68,38 @@ class TransportInteractionCapability:
|
|||||||
|
|
||||||
|
|
||||||
CONFIG_FIELDS = (
|
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", "id", "string", True, "", "Stable site identifier used in output paths."),
|
||||||
ConfigField("site", "domain", "string", True, "", "Canonical public domain for the site."),
|
ConfigField("site", "domain", "string", True, "", "Canonical public domain for the site."),
|
||||||
ConfigField("site.source", "kind", "enum", True, "static-html", "Source adapter kind."),
|
ConfigField("site.source", "kind", "enum", True, "static-html", "Source adapter kind."),
|
||||||
@@ -179,6 +211,30 @@ CONFIG_FIELDS = (
|
|||||||
"protocol default",
|
"protocol default",
|
||||||
"Whether AMPG adopts, manages, or only renders config.",
|
"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(
|
ConfigField(
|
||||||
"site.protocols.<name>",
|
"site.protocols.<name>",
|
||||||
"max_asset_bytes",
|
"max_asset_bytes",
|
||||||
|
|||||||
+3
-2
@@ -67,6 +67,7 @@ def preview_endpoints(
|
|||||||
|
|
||||||
|
|
||||||
def preview_fixture_manifest(
|
def preview_fixture_manifest(
|
||||||
|
config: GatewayConfig,
|
||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
endpoints: list[PreviewEndpoint],
|
endpoints: list[PreviewEndpoint],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -75,7 +76,7 @@ def preview_fixture_manifest(
|
|||||||
for endpoint in endpoints
|
for endpoint in endpoints
|
||||||
if endpoint.site_id == site.id
|
if endpoint.site_id == site.id
|
||||||
}
|
}
|
||||||
manifest = fixture_manifest(site)
|
manifest = fixture_manifest(config, site)
|
||||||
manifest["mode"] = "preview"
|
manifest["mode"] = "preview"
|
||||||
for fixture in manifest["fixtures"]:
|
for fixture in manifest["fixtures"]:
|
||||||
endpoint = by_protocol[fixture["protocol"]]
|
endpoint = by_protocol[fixture["protocol"]]
|
||||||
@@ -110,7 +111,7 @@ def write_preview_fixture_manifests(
|
|||||||
endpoints = preview_endpoints(config, base_port=base_port, host=host)
|
endpoints = preview_endpoints(config, base_port=base_port, host=host)
|
||||||
results: list[PreviewManifestWriteResult] = []
|
results: list[PreviewManifestWriteResult] = []
|
||||||
for site in config.sites:
|
for site in config.sites:
|
||||||
manifest = preview_fixture_manifest(site, endpoints)
|
manifest = preview_fixture_manifest(config, site, endpoints)
|
||||||
path = preview_manifest_path(site)
|
path = preview_manifest_path(site)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
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,
|
config_path=config.config_path,
|
||||||
sites=sites,
|
sites=sites,
|
||||||
profiles=config.profiles,
|
profiles=config.profiles,
|
||||||
|
paths=config.paths,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -1,6 +1,6 @@
|
|||||||
# Configuration
|
# 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
|
AMPG is configured from one TOML file. The file declares global paths, sites, source
|
||||||
adapters, render targets, and daemon policy.
|
adapters, render targets, and daemon policy.
|
||||||
@@ -107,6 +107,34 @@ workflow.
|
|||||||
| ipfs | `clearnet` | `ipfs` | `auto` |
|
| ipfs | `clearnet` | `ipfs` | `auto` |
|
||||||
| reticulum | `micron` | `rnsd` | `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
|
## Renderer defaults
|
||||||
|
|
||||||
`privacy-html` removes:
|
`privacy-html` removes:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Daemon Management
|
# 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
|
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
|
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 doctor
|
||||||
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p
|
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 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 health-plan --profile mobile-i2p
|
||||||
python3 -m ampg --config gateway.toml apply --dry-run
|
python3 -m ampg --config gateway.toml apply --dry-run
|
||||||
python3 -m ampg --config gateway.toml apply --dry-run --profile mobile-i2p
|
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
|
Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopback nginx
|
||||||
artifacts.
|
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
|
`health-plan` prints fixture-based checks to run after services start. Published checks
|
||||||
target real transport URLs; preview checks target local loopback preview endpoints.
|
target real transport URLs; preview checks target local loopback preview endpoints.
|
||||||
Missing output blocks the plan, while placeholder transport addresses are review items.
|
Missing output blocks the plan, while placeholder transport addresses are review items.
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ or deployment notes.
|
|||||||
```sh
|
```sh
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml build
|
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 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 preview manifest
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml health-plan
|
python3 -m ampg --config examples/wownero.gateway.toml health-plan
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml health-plan --mode preview
|
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
|
cases AMPG uses route-valid placeholders such as `http://wownero.onion/` so AMPB can still
|
||||||
verify transport selection without contacting the network.
|
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
|
## Interaction Policy
|
||||||
|
|
||||||
Fixture interaction policy defaults to:
|
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 |
|
| 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 | `id` | string | yes | - | Stable site identifier used in output paths. |
|
||||||
| site | `domain` | string | yes | - | Canonical public domain for the site. |
|
| site | `domain` | string | yes | - | Canonical public domain for the site. |
|
||||||
| site.source | `kind` | enum | yes | static-html | Source adapter kind. |
|
| 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> | `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` | 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> | `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> | `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> | `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. |
|
| 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 plan
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml build
|
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 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 endpoints
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
|
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
|
||||||
python3 -m ampg --config examples/i2p-only.gateway.toml plan
|
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)
|
config = load_config(config_path)
|
||||||
manifest = fixture_manifest(config.sites[0])
|
manifest = fixture_manifest(config, config.sites[0])
|
||||||
written = write_fixture_manifests(config)
|
written = write_fixture_manifests(config)
|
||||||
|
|
||||||
self.assertEqual("ampg.fixture-manifest.v1", manifest["schema"])
|
self.assertEqual("ampg.fixture-manifest.v1", manifest["schema"])
|
||||||
@@ -116,7 +116,7 @@ payments = "server-invoice"
|
|||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
manifest = fixture_manifest(config.sites[0])
|
manifest = fixture_manifest(config, config.sites[0])
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{
|
{
|
||||||
@@ -178,7 +178,7 @@ renderer = "privacy-html"
|
|||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
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(config.sites[0].interactions.routes))
|
||||||
self.assertEqual(3, len(manifest["fixtures"]))
|
self.assertEqual(3, len(manifest["fixtures"]))
|
||||||
@@ -252,7 +252,7 @@ renderer = "privacy-html"
|
|||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
manifest = fixture_manifest(config.sites[0])
|
manifest = fixture_manifest(config, config.sites[0])
|
||||||
|
|
||||||
site = config.sites[0]
|
site = config.sites[0]
|
||||||
self.assertEqual("interactive-lite", site.interactions.default_tier)
|
self.assertEqual("interactive-lite", site.interactions.default_tier)
|
||||||
@@ -355,7 +355,7 @@ cid = "bafyexample"
|
|||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
manifest = fixture_manifest(config.sites[0])
|
manifest = fixture_manifest(config, config.sites[0])
|
||||||
|
|
||||||
by_protocol = {}
|
by_protocol = {}
|
||||||
for fixture in manifest["fixtures"]:
|
for fixture in manifest["fixtures"]:
|
||||||
|
|||||||
Reference in New Issue
Block a user