From b268e9363441401a0a8528c507004d423aa6714d Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 11:06:11 -0400 Subject: [PATCH] Add managed state contract --- README.md | 2 + ampg/addresses.py | 43 +--- ampg/cli.py | 36 ++++ ampg/install_plan.py | 6 +- ampg/state_contract.py | 389 +++++++++++++++++++++++++++++++++++ docs/daemon-management.md | 4 + docs/state-contract.md | 42 ++++ tests/test_state_contract.py | 136 ++++++++++++ 8 files changed, 614 insertions(+), 44 deletions(-) create mode 100644 ampg/state_contract.py create mode 100644 docs/state-contract.md create mode 100644 tests/test_state_contract.py diff --git a/README.md b/README.md index dee9d62..80599f1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ API remains internal even when the public site is available on Tor or I2P. - [Configuration](docs/configuration.md) - [Daemon management](docs/daemon-management.md) - [Fixture manifests](docs/fixture-manifests.md) +- [State contract](docs/state-contract.md) - [Interaction capabilities](docs/interaction-capabilities.md) - [Interactive applications](docs/interactive-apps.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 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 state-contract --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml preview endpoints python3 -m ampg --config examples/wownero.gateway.toml preview manifest python3 -m ampg --config examples/wownero.gateway.toml routes explain diff --git a/ampg/addresses.py b/ampg/addresses.py index 262503b..6ba929b 100644 --- a/ampg/addresses.py +++ b/ampg/addresses.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any from .config import GatewayConfig, ProtocolConfig, SiteConfig +from .state_contract import address_capture_candidates SCHEMA = "ampg.address-registry.v1" @@ -244,7 +245,7 @@ def _capture_protocol_address( 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"): return AddressCaptureResult( 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: if protocol.name == "clearnet": 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}") -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() diff --git a/ampg/cli.py b/ampg/cli.py index 3c5f8f4..e4003bf 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -40,6 +40,7 @@ from .route_manifest import ( ) from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues 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 @@ -175,6 +176,11 @@ def main(argv: list[str] | None = None) -> int: default="manual", 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_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True) for preview_command in ("endpoints", "manifest", "serve"): @@ -269,6 +275,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_manifest(config) if args.command == "addresses": return _cmd_addresses(config, args) + if args.command == "state-contract": + return _cmd_state_contract(config) if args.command == "preview": return _cmd_preview(config, args) if args.command == "routes": @@ -623,6 +631,34 @@ def _cmd_addresses(config, args) -> int: 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: print( "AMPG_ADDRESS " diff --git a/ampg/install_plan.py b/ampg/install_plan.py index 4ca3140..8c57b81 100644 --- a/ampg/install_plan.py +++ b/ampg/install_plan.py @@ -5,6 +5,7 @@ from pathlib import Path from .config import GatewayConfig, ProtocolConfig, SiteConfig from .platforms import PlatformProvider, detect_platform +from .state_contract import daemon_config_path, protocol_state_dir from .status import DaemonProbeFunc, TransportStatus, gateway_status from .transports import fallback_transport_adapter, transport_adapter @@ -182,7 +183,7 @@ def _protocol_install_steps( provider, stage="config", action="write-managed-config", - target=str(_state_dir(config, provider, site, protocol) / "config"), + target=str(daemon_config_path(config, site, protocol)), status="planned", command="-", message=f"write managed {adapter.backend} config after plan review", @@ -356,8 +357,7 @@ def _state_dir( site: SiteConfig, protocol: ProtocolConfig, ) -> Path: - root = config.paths.state_dir if config.paths else provider.state_root - return root / site.id / protocol.name + return protocol_state_dir(config, site, protocol) def _artifact_dir( diff --git a/ampg/state_contract.py b/ampg/state_contract.py new file mode 100644 index 0000000..305bc0a --- /dev/null +++ b/ampg/state_contract.py @@ -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, + ) diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 5a9c5cf..abe6c2b 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -41,6 +41,7 @@ python3 -m ampg --config gateway.toml status python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p --write-artifacts +python3 -m ampg --config gateway.toml state-contract --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 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 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: configured, captured, derived, or placeholder. `addresses capture` reads daemon-written address files from AMPG state or a protocol `address_file` override, then updates the diff --git a/docs/state-contract.md b/docs/state-contract.md new file mode 100644 index 0000000..6fab221 --- /dev/null +++ b/docs/state-contract.md @@ -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 | `//tor/hidden-service/hostname` | `//tor/hidden-service` | +| I2P | `//i2p/hostname.txt` | `//i2p/` | +| Reticulum/IPFS/custom | `///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. diff --git a/tests/test_state_contract.py b/tests/test_state_contract.py new file mode 100644 index 0000000..f4d3e20 --- /dev/null +++ b/tests/test_state_contract.py @@ -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("

Hello

", 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()