mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add managed state contract
This commit is contained in:
@@ -83,6 +83,7 @@ API remains internal even when the public site is available on Tor or I2P.
|
|||||||
- [Configuration](docs/configuration.md)
|
- [Configuration](docs/configuration.md)
|
||||||
- [Daemon management](docs/daemon-management.md)
|
- [Daemon management](docs/daemon-management.md)
|
||||||
- [Fixture manifests](docs/fixture-manifests.md)
|
- [Fixture manifests](docs/fixture-manifests.md)
|
||||||
|
- [State contract](docs/state-contract.md)
|
||||||
- [Interaction capabilities](docs/interaction-capabilities.md)
|
- [Interaction capabilities](docs/interaction-capabilities.md)
|
||||||
- [Interactive applications](docs/interactive-apps.md)
|
- [Interactive applications](docs/interactive-apps.md)
|
||||||
- [Wownero integration](docs/wownero-integration.md)
|
- [Wownero integration](docs/wownero-integration.md)
|
||||||
@@ -119,6 +120,7 @@ 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 list
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml addresses capture --profile mobile-i2p
|
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 addresses set --site wownero --protocol i2p --url example.b32.i2p
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml state-contract --profile mobile-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
|
||||||
|
|||||||
+2
-41
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||||
|
from .state_contract import address_capture_candidates
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = "ampg.address-registry.v1"
|
SCHEMA = "ampg.address-registry.v1"
|
||||||
@@ -244,7 +245,7 @@ def _capture_protocol_address(
|
|||||||
message="address is set in config; registry unchanged",
|
message="address is set in config; registry unchanged",
|
||||||
)
|
)
|
||||||
|
|
||||||
candidates = _address_file_candidates(config, site, protocol)
|
candidates = address_capture_candidates(config, site, protocol)
|
||||||
if protocol.name not in CAPTURE_PROTOCOLS and not protocol.options.get("address_file"):
|
if protocol.name not in CAPTURE_PROTOCOLS and not protocol.options.get("address_file"):
|
||||||
return AddressCaptureResult(
|
return AddressCaptureResult(
|
||||||
site_id=site.id,
|
site_id=site.id,
|
||||||
@@ -284,41 +285,6 @@ def _capture_protocol_address(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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:
|
def _placeholder_or_derived_address(site: SiteConfig, protocol: ProtocolConfig) -> AddressRecord:
|
||||||
if protocol.name == "clearnet":
|
if protocol.name == "clearnet":
|
||||||
url = site.source.canonical_url or f"https://{site.domain}/"
|
url = site.source.canonical_url or f"https://{site.domain}/"
|
||||||
@@ -358,11 +324,6 @@ def _require_declared_protocol(config: GatewayConfig, site_id: str, protocol: st
|
|||||||
raise ValueError(f"unknown site {site_id!r}")
|
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:
|
def _first_nonempty_line(path: Path) -> str:
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
value = line.strip()
|
value = line.strip()
|
||||||
|
|||||||
+36
@@ -40,6 +40,7 @@ from .route_manifest import (
|
|||||||
)
|
)
|
||||||
from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues
|
from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues
|
||||||
from .selection import protocols_for_selection, select_profile, select_protocols
|
from .selection import protocols_for_selection, select_profile, select_protocols
|
||||||
|
from .state_contract import StatePathContract, state_contract
|
||||||
from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status
|
from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +176,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
default="manual",
|
default="manual",
|
||||||
help="Source label stored with the captured address.",
|
help="Source label stored with the captured address.",
|
||||||
)
|
)
|
||||||
|
state_contract_parser = subcommands.add_parser(
|
||||||
|
"state-contract",
|
||||||
|
help="Print AMPG-owned state paths and daemon-written files.",
|
||||||
|
)
|
||||||
|
_add_target_selection(state_contract_parser)
|
||||||
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"):
|
||||||
@@ -269,6 +275,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return _cmd_manifest(config)
|
return _cmd_manifest(config)
|
||||||
if args.command == "addresses":
|
if args.command == "addresses":
|
||||||
return _cmd_addresses(config, args)
|
return _cmd_addresses(config, args)
|
||||||
|
if args.command == "state-contract":
|
||||||
|
return _cmd_state_contract(config)
|
||||||
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":
|
||||||
@@ -623,6 +631,34 @@ def _cmd_addresses(config, args) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_state_contract(config) -> int:
|
||||||
|
contracts = state_contract(config)
|
||||||
|
for contract in contracts:
|
||||||
|
_print_state_contract(contract)
|
||||||
|
print(
|
||||||
|
"AMPG_STATE_SUMMARY "
|
||||||
|
f"sites={len(config.sites)} "
|
||||||
|
f"entries={len(contracts)} "
|
||||||
|
f"required={sum(1 for contract in contracts if contract.required)} "
|
||||||
|
f"sensitive={sum(1 for contract in contracts if contract.sensitive)}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _print_state_contract(contract: StatePathContract) -> None:
|
||||||
|
print(
|
||||||
|
"AMPG_STATE "
|
||||||
|
f"site={contract.site_id} "
|
||||||
|
f"protocol={contract.protocol} "
|
||||||
|
f"role={contract.role} "
|
||||||
|
f"owner={contract.owner} "
|
||||||
|
f"required={_bool(contract.required)} "
|
||||||
|
f"sensitive={_bool(contract.sensitive)} "
|
||||||
|
f"path=\"{_quote(str(contract.path))}\" "
|
||||||
|
f"description=\"{_quote(contract.description)}\""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _print_address(record: AddressRecord) -> None:
|
def _print_address(record: AddressRecord) -> None:
|
||||||
print(
|
print(
|
||||||
"AMPG_ADDRESS "
|
"AMPG_ADDRESS "
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||||
from .platforms import PlatformProvider, detect_platform
|
from .platforms import PlatformProvider, detect_platform
|
||||||
|
from .state_contract import daemon_config_path, protocol_state_dir
|
||||||
from .status import DaemonProbeFunc, TransportStatus, gateway_status
|
from .status import DaemonProbeFunc, TransportStatus, gateway_status
|
||||||
from .transports import fallback_transport_adapter, transport_adapter
|
from .transports import fallback_transport_adapter, transport_adapter
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ def _protocol_install_steps(
|
|||||||
provider,
|
provider,
|
||||||
stage="config",
|
stage="config",
|
||||||
action="write-managed-config",
|
action="write-managed-config",
|
||||||
target=str(_state_dir(config, provider, site, protocol) / "config"),
|
target=str(daemon_config_path(config, site, protocol)),
|
||||||
status="planned",
|
status="planned",
|
||||||
command="-",
|
command="-",
|
||||||
message=f"write managed {adapter.backend} config after plan review",
|
message=f"write managed {adapter.backend} config after plan review",
|
||||||
@@ -356,8 +357,7 @@ def _state_dir(
|
|||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
protocol: ProtocolConfig,
|
protocol: ProtocolConfig,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
root = config.paths.state_dir if config.paths else provider.state_root
|
return protocol_state_dir(config, site, protocol)
|
||||||
return root / site.id / protocol.name
|
|
||||||
|
|
||||||
|
|
||||||
def _artifact_dir(
|
def _artifact_dir(
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StatePathContract:
|
||||||
|
site_id: str
|
||||||
|
protocol: str
|
||||||
|
role: str
|
||||||
|
path: Path
|
||||||
|
owner: str
|
||||||
|
required: bool
|
||||||
|
sensitive: bool
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_state_root(config: GatewayConfig) -> Path:
|
||||||
|
if config.paths:
|
||||||
|
return config.paths.state_dir
|
||||||
|
return config.config_path.parent / ".ampg/state"
|
||||||
|
|
||||||
|
|
||||||
|
def protocol_state_dir(
|
||||||
|
config: GatewayConfig,
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
) -> Path:
|
||||||
|
return gateway_state_root(config) / site.id / protocol.name
|
||||||
|
|
||||||
|
|
||||||
|
def state_contract(config: GatewayConfig) -> list[StatePathContract]:
|
||||||
|
contracts: list[StatePathContract] = []
|
||||||
|
for site in config.sites:
|
||||||
|
for protocol in site.protocols.values():
|
||||||
|
if not protocol.enabled:
|
||||||
|
continue
|
||||||
|
contracts.extend(protocol_state_contract(config, site, protocol))
|
||||||
|
return contracts
|
||||||
|
|
||||||
|
|
||||||
|
def protocol_state_contract(
|
||||||
|
config: GatewayConfig,
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
) -> list[StatePathContract]:
|
||||||
|
state_dir = protocol_state_dir(config, site, protocol)
|
||||||
|
contracts = [
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="state-dir",
|
||||||
|
path=state_dir,
|
||||||
|
owner="ampg",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="AMPG-owned protocol state directory.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if protocol.name == "tor":
|
||||||
|
contracts.extend(_tor_contract(site, protocol, state_dir))
|
||||||
|
elif protocol.name == "i2p":
|
||||||
|
contracts.extend(_i2p_contract(site, protocol, state_dir))
|
||||||
|
elif protocol.name == "gemini":
|
||||||
|
contracts.extend(_gemini_contract(site, protocol, state_dir))
|
||||||
|
elif protocol.name == "clearnet":
|
||||||
|
contracts.extend(_clearnet_contract(site, protocol, state_dir))
|
||||||
|
else:
|
||||||
|
contracts.extend(_generic_contract(site, protocol, state_dir))
|
||||||
|
return contracts
|
||||||
|
|
||||||
|
|
||||||
|
def address_capture_candidates(
|
||||||
|
config: GatewayConfig,
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
) -> tuple[Path, ...]:
|
||||||
|
candidates: list[Path] = []
|
||||||
|
configured_file = protocol.options.get("address_file")
|
||||||
|
if configured_file:
|
||||||
|
path = Path(str(configured_file)).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = config.config_path.parent / path
|
||||||
|
candidates.append(path.resolve())
|
||||||
|
candidates.extend(
|
||||||
|
contract.path
|
||||||
|
for contract in protocol_state_contract(config, site, protocol)
|
||||||
|
if contract.role == "address-file"
|
||||||
|
)
|
||||||
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def daemon_config_path(
|
||||||
|
config: GatewayConfig,
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
) -> Path:
|
||||||
|
state_dir = protocol_state_dir(config, site, protocol)
|
||||||
|
if protocol.name == "tor":
|
||||||
|
return state_dir / "torrc"
|
||||||
|
if protocol.name == "i2p":
|
||||||
|
return state_dir / "i2pd-tunnels.conf"
|
||||||
|
if protocol.name == "clearnet":
|
||||||
|
return state_dir / "nginx-server.conf"
|
||||||
|
if protocol.name == "gemini":
|
||||||
|
return state_dir / "agate-plan.txt"
|
||||||
|
return state_dir / f"{protocol.daemon}-plan.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def _tor_contract(
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
state_dir: Path,
|
||||||
|
) -> list[StatePathContract]:
|
||||||
|
return [
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="daemon-config",
|
||||||
|
path=state_dir / "torrc",
|
||||||
|
owner="ampg",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Reviewed Tor config copied into managed state before start.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="daemon-data",
|
||||||
|
path=state_dir / "data",
|
||||||
|
owner="daemon",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Tor DataDirectory.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="identity-dir",
|
||||||
|
path=state_dir / "hidden-service",
|
||||||
|
owner="daemon",
|
||||||
|
required=True,
|
||||||
|
sensitive=True,
|
||||||
|
description="Tor onion-service key material and hostname.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="address-file",
|
||||||
|
path=state_dir / "hidden-service/hostname",
|
||||||
|
owner="daemon",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Onion hostname captured after Tor creates the hidden service.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="address-file",
|
||||||
|
path=state_dir / "hostname",
|
||||||
|
owner="operator",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Optional adopted-daemon hostname export.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="log-file",
|
||||||
|
path=state_dir / "tor.log",
|
||||||
|
owner="daemon",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Managed Tor log file.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _i2p_contract(
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
state_dir: Path,
|
||||||
|
) -> list[StatePathContract]:
|
||||||
|
key_file = str(protocol.options.get("keys_file", f"{site.id}-web.dat"))
|
||||||
|
return [
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="daemon-config",
|
||||||
|
path=state_dir / "i2pd-tunnels.conf",
|
||||||
|
owner="ampg",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Reviewed i2pd tunnel config copied into managed state before start.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="daemon-data",
|
||||||
|
path=state_dir / "data",
|
||||||
|
owner="daemon",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Managed i2pd data directory.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="identity-key",
|
||||||
|
path=state_dir / key_file,
|
||||||
|
owner="daemon",
|
||||||
|
required=True,
|
||||||
|
sensitive=True,
|
||||||
|
description="I2P destination key file for the web tunnel.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="address-file",
|
||||||
|
path=state_dir / "hostname.txt",
|
||||||
|
owner="daemon",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Preferred I2P hostname or b32 export captured after tunnel creation.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="address-file",
|
||||||
|
path=state_dir / "b32.txt",
|
||||||
|
owner="daemon",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Alternate I2P b32 export captured after tunnel creation.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="address-file",
|
||||||
|
path=state_dir / "address.txt",
|
||||||
|
owner="operator",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Optional adopted-daemon address export.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _gemini_contract(
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
state_dir: Path,
|
||||||
|
) -> list[StatePathContract]:
|
||||||
|
return [
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="daemon-config",
|
||||||
|
path=state_dir / "agate-plan.txt",
|
||||||
|
owner="ampg",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Reviewed Agate launch values copied into managed state before start.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="certificate",
|
||||||
|
path=state_dir / f"{site.id}.crt",
|
||||||
|
owner="ampg",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Gemini certificate.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="identity-key",
|
||||||
|
path=state_dir / f"{site.id}.key",
|
||||||
|
owner="ampg",
|
||||||
|
required=True,
|
||||||
|
sensitive=True,
|
||||||
|
description="Gemini private key.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _clearnet_contract(
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
state_dir: Path,
|
||||||
|
) -> list[StatePathContract]:
|
||||||
|
return [
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="daemon-config",
|
||||||
|
path=state_dir / "nginx-server.conf",
|
||||||
|
owner="ampg",
|
||||||
|
required=True,
|
||||||
|
sensitive=False,
|
||||||
|
description="Reviewed nginx config copied into managed state before start.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="pid-file",
|
||||||
|
path=state_dir / "nginx.pid",
|
||||||
|
owner="daemon",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Managed nginx pid file.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="log-file",
|
||||||
|
path=state_dir / "nginx-error.log",
|
||||||
|
owner="daemon",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Managed nginx error log.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _generic_contract(
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
state_dir: Path,
|
||||||
|
) -> list[StatePathContract]:
|
||||||
|
return [
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="daemon-config",
|
||||||
|
path=state_dir / f"{protocol.daemon}-plan.txt",
|
||||||
|
owner="ampg",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Adapter-specific reviewed daemon config.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="address-file",
|
||||||
|
path=state_dir / "address.txt",
|
||||||
|
owner="daemon",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Adapter-specific public address export.",
|
||||||
|
),
|
||||||
|
_contract(
|
||||||
|
site,
|
||||||
|
protocol,
|
||||||
|
role="address-file",
|
||||||
|
path=state_dir / "hostname",
|
||||||
|
owner="daemon",
|
||||||
|
required=False,
|
||||||
|
sensitive=False,
|
||||||
|
description="Adapter-specific public hostname export.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _contract(
|
||||||
|
site: SiteConfig,
|
||||||
|
protocol: ProtocolConfig,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
path: Path,
|
||||||
|
owner: str,
|
||||||
|
required: bool,
|
||||||
|
sensitive: bool,
|
||||||
|
description: str,
|
||||||
|
) -> StatePathContract:
|
||||||
|
return StatePathContract(
|
||||||
|
site_id=site.id,
|
||||||
|
protocol=protocol.name,
|
||||||
|
role=role,
|
||||||
|
path=path,
|
||||||
|
owner=owner,
|
||||||
|
required=required,
|
||||||
|
sensitive=sensitive,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
@@ -41,6 +41,7 @@ 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 state-contract --profile mobile-i2p
|
||||||
python3 -m ampg --config gateway.toml addresses list --profile mobile-i2p
|
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 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
|
||||||
@@ -69,6 +70,9 @@ Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopbac
|
|||||||
artifacts. The generated daemon configs store runtime state, identity keys, logs, and
|
artifacts. The generated daemon configs store runtime state, identity keys, logs, and
|
||||||
daemon-written addresses under `gateway.state_dir`.
|
daemon-written addresses under `gateway.state_dir`.
|
||||||
|
|
||||||
|
`state-contract` prints the resolved managed-state paths for enabled protocols, including
|
||||||
|
which paths are AMPG-owned, daemon-written, required, or sensitive.
|
||||||
|
|
||||||
`addresses list` prints the effective public address for each enabled protocol:
|
`addresses list` prints the effective public address for each enabled protocol:
|
||||||
configured, captured, derived, or placeholder. `addresses capture` reads daemon-written
|
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 files from AMPG state or a protocol `address_file` override, then updates the
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# State Contract
|
||||||
|
|
||||||
|
> Status: draft | Updated 2026-07-06 | Applies to: managed daemon state
|
||||||
|
|
||||||
|
AMPG keeps managed daemon state under `gateway.state_dir`. The state contract describes
|
||||||
|
which files AMPG owns, which files daemons or adapters write, which paths may contain
|
||||||
|
private keys, and which public address files `addresses capture` reads.
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m ampg --config gateway.toml state-contract
|
||||||
|
python3 -m ampg --config gateway.toml state-contract --profile mobile-i2p
|
||||||
|
python3 -m ampg --config gateway.toml state-contract --protocol tor
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `AMPG_STATE` row includes:
|
||||||
|
|
||||||
|
- `role`: state directory, daemon config, identity key, address file, log file, or similar.
|
||||||
|
- `owner`: `ampg`, `daemon`, or `operator`.
|
||||||
|
- `required`: whether a managed deployment expects the path.
|
||||||
|
- `sensitive`: whether the path may contain private identity material.
|
||||||
|
- `path`: resolved path under `gateway.state_dir` unless an adapter documents otherwise.
|
||||||
|
|
||||||
|
## Address Capture
|
||||||
|
|
||||||
|
`addresses capture` uses the same contract. For each enabled protocol it checks explicit
|
||||||
|
`address_file` config first, then contract `address-file` entries in order.
|
||||||
|
|
||||||
|
Current managed defaults:
|
||||||
|
|
||||||
|
| Protocol | Preferred captured address file | Sensitive identity path |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Tor | `<state_dir>/<site>/tor/hidden-service/hostname` | `<state_dir>/<site>/tor/hidden-service` |
|
||||||
|
| I2P | `<state_dir>/<site>/i2p/hostname.txt` | `<state_dir>/<site>/i2p/<keys_file>` |
|
||||||
|
| Reticulum/IPFS/custom | `<state_dir>/<site>/<protocol>/address.txt` | adapter-specific |
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
AMPG must never delete sensitive contract paths without an explicit destructive command.
|
||||||
|
Live apply should copy reviewed config artifacts into their contract `daemon-config`
|
||||||
|
paths, start daemons, capture public addresses, and then run published health checks.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ampg.addresses import capture_addresses
|
||||||
|
from ampg.cli import main
|
||||||
|
from ampg.config import load_config
|
||||||
|
from ampg.state_contract import address_capture_candidates, state_contract
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
[profiles.mobile-i2p]
|
||||||
|
protocols = ["i2p"]
|
||||||
|
|
||||||
|
[[site]]
|
||||||
|
id = "example"
|
||||||
|
domain = "example.test"
|
||||||
|
|
||||||
|
[site.source]
|
||||||
|
kind = "static-html"
|
||||||
|
path = "./site"
|
||||||
|
|
||||||
|
[site.outputs]
|
||||||
|
root = "./out"
|
||||||
|
plan_root = "./plan"
|
||||||
|
|
||||||
|
{protocol_block}
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
class StateContractTest(unittest.TestCase):
|
||||||
|
def test_i2p_contract_declares_config_key_and_address_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config = load_config(
|
||||||
|
_write_config(
|
||||||
|
root,
|
||||||
|
"""
|
||||||
|
[site.protocols.i2p]
|
||||||
|
enabled = true
|
||||||
|
renderer = "privacy-html"
|
||||||
|
keys_file = "example-web.dat"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
contracts = state_contract(config)
|
||||||
|
|
||||||
|
by_role_path = {(contract.role, contract.path) for contract in contracts}
|
||||||
|
self.assertIn(("daemon-config", root / "state/example/i2p/i2pd-tunnels.conf"), by_role_path)
|
||||||
|
self.assertIn(("identity-key", root / "state/example/i2p/example-web.dat"), by_role_path)
|
||||||
|
self.assertIn(("address-file", root / "state/example/i2p/hostname.txt"), by_role_path)
|
||||||
|
sensitive = {contract.role for contract in contracts if contract.sensitive}
|
||||||
|
self.assertEqual({"identity-key"}, sensitive)
|
||||||
|
|
||||||
|
def test_address_capture_uses_contract_candidates(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config = load_config(
|
||||||
|
_write_config(
|
||||||
|
root,
|
||||||
|
"""
|
||||||
|
[site.protocols.i2p]
|
||||||
|
enabled = true
|
||||||
|
renderer = "privacy-html"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
site = config.sites[0]
|
||||||
|
protocol = site.protocols["i2p"]
|
||||||
|
candidates = address_capture_candidates(config, site, protocol)
|
||||||
|
address_file = root / "state/example/i2p/hostname.txt"
|
||||||
|
address_file.parent.mkdir(parents=True)
|
||||||
|
address_file.write_text("contractcapture.b32.i2p\n", encoding="utf-8")
|
||||||
|
|
||||||
|
results = capture_addresses(config)
|
||||||
|
|
||||||
|
self.assertEqual(address_file, candidates[0])
|
||||||
|
self.assertEqual("captured", results[0].status)
|
||||||
|
self.assertEqual(address_file, results[0].path)
|
||||||
|
self.assertEqual("http://contractcapture.b32.i2p/", results[0].url)
|
||||||
|
|
||||||
|
def test_state_contract_cli_respects_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"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(stdout):
|
||||||
|
status = main(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"state-contract",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
output = stdout.getvalue()
|
||||||
|
self.assertEqual(0, status)
|
||||||
|
self.assertIn("AMPG_STATE site=example protocol=i2p role=address-file", output)
|
||||||
|
self.assertIn("sensitive=true", output)
|
||||||
|
self.assertIn("AMPG_STATE_SUMMARY", output)
|
||||||
|
self.assertNotIn("protocol=clearnet", output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user