diff --git a/README.md b/README.md index 99c9195..7e01cc2 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,9 @@ The current implementation is dependency-free Python: ```sh python3 -m ampg --config examples/wownero.gateway.toml plan +python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml build +python3 -m ampg --config examples/i2p-only.gateway.toml build python3 -m ampg docs generate python3 -m unittest discover -s tests ``` @@ -104,6 +106,9 @@ The Wownero build writes generated files under `dist/wownero/`. Each protocol ou root contains `.ampg-output`; AMPG refuses to clean a non-empty output directory unless that marker is present. +Plan artifacts are written under `dist/ampg-plan/` only when `--write-artifacts` is +passed. They are reviewable snippets, not installed daemon config. + ## Reusable checklist - [ ] Pick source kind: `static-html`, `markdown`, or `ssg-output`. diff --git a/ampg/build.py b/ampg/build.py index 60f40c7..b85f3ba 100644 --- a/ampg/build.py +++ b/ampg/build.py @@ -67,6 +67,11 @@ def _render_privacy_html_tree( files_skipped = 0 stats = PrivacyRenderStats() max_asset_bytes = int(protocol.options.get("max_asset_bytes", 1024 * 1024)) + script_policy = str(protocol.options.get("script_policy", "strip")) + if script_policy != "strip": + raise ValueError( + f"{site.id}/{protocol.name}: privacy-html only supports script_policy='strip'" + ) for source_path in _iter_source_files(site.source.path): target_path = output_root / source_path.relative_to(site.source.path) target_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/ampg/cli.py b/ampg/cli.py index 00cdbbd..3117610 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -7,7 +7,7 @@ import sys from .build import build_gateway from .config import load_config from .docsgen import generate_docs -from .plan import plan_gateway +from .plan import plan_gateway, write_plan_artifacts def main(argv: list[str] | None = None) -> int: @@ -19,7 +19,12 @@ def main(argv: list[str] | None = None) -> int: help="Path to gateway TOML config.", ) subcommands = parser.add_subparsers(dest="command", required=True) - subcommands.add_parser("plan", help="Print a dry-run plan.") + plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.") + plan_parser.add_argument( + "--write-artifacts", + action="store_true", + help="Write generated config snippets to the configured plan root.", + ) subcommands.add_parser("build", help="Build enabled protocol outputs.") docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.") docs_subcommands = docs_parser.add_subparsers(dest="docs_command", required=True) @@ -37,7 +42,7 @@ def main(argv: list[str] | None = None) -> int: return _cmd_docs(args) config = load_config(args.config) if args.command == "plan": - return _cmd_plan(config) + return _cmd_plan(config, write_artifacts=args.write_artifacts) if args.command == "build": return _cmd_build(config) except Exception as exc: # noqa: BLE001 - CLI should print concise failures. @@ -46,8 +51,9 @@ def main(argv: list[str] | None = None) -> int: return 1 -def _cmd_plan(config) -> int: +def _cmd_plan(config, *, write_artifacts: bool) -> int: for line in plan_gateway(config): + artifact_text = ",".join(str(path) for path in line.artifacts) if line.artifacts else "-" print( "AMPG_PLAN " f"site={line.site_id} " @@ -56,8 +62,13 @@ def _cmd_plan(config) -> int: f"output={line.output_root} " f"daemon={line.daemon} " f"policy={line.daemon_policy} " + f"artifacts={artifact_text} " f"action=\"{line.action}\"" ) + if write_artifacts: + artifacts = write_plan_artifacts(config) + for artifact in artifacts: + print(f"AMPG_ARTIFACT path={artifact.path}") return 0 diff --git a/ampg/config.py b/ampg/config.py index 0c1c5e7..0779683 100644 --- a/ampg/config.py +++ b/ampg/config.py @@ -33,6 +33,7 @@ class SourceConfig: @dataclass(frozen=True) class OutputConfig: root: Path + plan_root: Path @dataclass(frozen=True) @@ -82,6 +83,7 @@ def _parse_site(raw_site: dict[str, Any], base_dir: Path) -> SiteConfig: source_path = _resolve_path(base_dir, _required_str(source, "path")) output_root = _resolve_path(base_dir, _required_str(outputs, "root")) + plan_root = _resolve_path(base_dir, str(outputs.get("plan_root", "../dist/ampg-plan"))) parsed_protocols = { name: _parse_protocol(name, raw_protocol) @@ -97,7 +99,7 @@ def _parse_site(raw_site: dict[str, Any], base_dir: Path) -> SiteConfig: path=source_path, canonical_url=source.get("canonical_url"), ), - outputs=OutputConfig(root=output_root), + outputs=OutputConfig(root=output_root, plan_root=plan_root), protocols=parsed_protocols, ) diff --git a/ampg/metadata.py b/ampg/metadata.py index 11dfd8b..6ba8741 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -40,6 +40,7 @@ CONFIG_FIELDS = ( ConfigField("site.source", "path", "path", True, "", "Source tree path."), ConfigField("site.source", "canonical_url", "url", False, "", "Canonical clearnet URL."), ConfigField("site.outputs", "root", "path", True, "", "Generated output root."), + ConfigField("site.outputs", "plan_root", "path", False, "../dist/ampg-plan", "Generated plan artifact root."), ConfigField( "site.interactions", "default_tier", @@ -88,6 +89,22 @@ CONFIG_FIELDS = ( "protocol default", "Whether AMPG adopts, manages, or only renders config.", ), + ConfigField( + "site.protocols.", + "max_asset_bytes", + "integer", + False, + "1048576", + "Maximum asset size copied by privacy-html render targets.", + ), + ConfigField( + "site.protocols.", + "script_policy", + "enum", + False, + "strip", + "Script handling policy for privacy-html render targets.", + ), ) diff --git a/ampg/plan.py b/ampg/plan.py index fef60e5..a4215cb 100644 --- a/ampg/plan.py +++ b/ampg/plan.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from .config import GatewayConfig +from .config import GatewayConfig, ProtocolConfig, SiteConfig @dataclass(frozen=True) @@ -15,6 +15,13 @@ class PlanLine: daemon: str daemon_policy: str action: str + artifacts: tuple[Path, ...] + + +@dataclass(frozen=True) +class PlanArtifact: + path: Path + content: str def plan_gateway(config: GatewayConfig) -> list[PlanLine]: @@ -23,6 +30,7 @@ def plan_gateway(config: GatewayConfig) -> list[PlanLine]: for protocol in site.protocols.values(): if not protocol.enabled: continue + artifacts = tuple(artifact.path for artifact in plan_artifacts(site, protocol)) lines.append( PlanLine( site_id=site.id, @@ -32,11 +40,43 @@ def plan_gateway(config: GatewayConfig) -> list[PlanLine]: daemon=protocol.daemon, daemon_policy=protocol.daemon_policy, action=_action_for_policy(protocol.daemon_policy), + artifacts=artifacts, ) ) return lines +def write_plan_artifacts(config: GatewayConfig) -> list[PlanArtifact]: + artifacts: list[PlanArtifact] = [] + for site in config.sites: + for protocol in site.protocols.values(): + if not protocol.enabled: + continue + artifacts.extend(plan_artifacts(site, protocol)) + for artifact in artifacts: + artifact.path.parent.mkdir(parents=True, exist_ok=True) + artifact.path.write_text(artifact.content, encoding="utf-8") + return artifacts + + +def plan_artifacts(site: SiteConfig, protocol: ProtocolConfig) -> list[PlanArtifact]: + if protocol.name == "clearnet" and protocol.daemon == "nginx": + return [_nginx_clearnet_artifact(site, protocol)] + if protocol.name == "tor": + artifacts = [] + if protocol.daemon == "tor": + artifacts.append(_tor_artifact(site, protocol)) + artifacts.append(_nginx_loopback_artifact(site, protocol)) + return artifacts + if protocol.name == "i2p": + artifacts = [] + if protocol.daemon == "i2pd": + artifacts.append(_i2pd_artifact(site, protocol)) + artifacts.append(_nginx_loopback_artifact(site, protocol)) + return artifacts + return [] + + def _action_for_policy(policy: str) -> str: if policy == "external": return "render outputs and config snippets only" @@ -47,3 +87,71 @@ def _action_for_policy(policy: str) -> str: if policy == "auto": return "adopt healthy daemon, otherwise manage AMPG-owned instance" return "unknown policy; no apply support" + + +def _protocol_plan_dir(site: SiteConfig, protocol: ProtocolConfig) -> Path: + return site.outputs.plan_root / site.id / protocol.name + + +def _nginx_clearnet_artifact(site: SiteConfig, protocol: ProtocolConfig) -> PlanArtifact: + path = _protocol_plan_dir(site, protocol) / "nginx-server.conf" + content = f"""# Generated by AMPG. Review before installing. +server {{ + listen 80; + listen [::]:80; + server_name {site.domain} www.{site.domain}; + root {site.outputs.root / protocol.name}; + index index.html; + + location / {{ + try_files $uri $uri/ /index.html; + }} +}} +""" + return PlanArtifact(path=path, content=content) + + +def _nginx_loopback_artifact(site: SiteConfig, protocol: ProtocolConfig) -> PlanArtifact: + port = int(protocol.options.get("local_port", 18080 if protocol.name == "tor" else 18081)) + path = _protocol_plan_dir(site, protocol) / "nginx-loopback.conf" + content = f"""# Generated by AMPG. Review before installing. +server {{ + listen 127.0.0.1:{port}; + server_name _; + root {site.outputs.root / protocol.name}; + index index.html; + + location / {{ + try_files $uri $uri/ /index.html; + }} +}} +""" + return PlanArtifact(path=path, content=content) + + +def _tor_artifact(site: SiteConfig, protocol: ProtocolConfig) -> PlanArtifact: + port = int(protocol.options.get("local_port", 18080)) + hidden_service_dir = protocol.options.get( + "hidden_service_dir", f"/var/lib/ampg/tor/{site.id}" + ) + path = _protocol_plan_dir(site, protocol) / "torrc-snippet.conf" + content = f"""# Generated by AMPG. Review before installing. +HiddenServiceDir {hidden_service_dir}/ +HiddenServicePort 80 127.0.0.1:{port} +""" + return PlanArtifact(path=path, content=content) + + +def _i2pd_artifact(site: SiteConfig, protocol: ProtocolConfig) -> PlanArtifact: + port = int(protocol.options.get("local_port", 18081)) + key_file = protocol.options.get("keys_file", f"{site.id}-web.dat") + tunnel_name = protocol.options.get("tunnel_name", f"{site.id}-web") + path = _protocol_plan_dir(site, protocol) / "i2pd-tunnel.conf" + content = f"""# Generated by AMPG. Review before installing. +[{tunnel_name}] +type = server +host = 127.0.0.1 +port = {port} +keys = {key_file} +""" + return PlanArtifact(path=path, content=content) diff --git a/docs/configuration.md b/docs/configuration.md index 265fc88..80d9f0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,6 +25,7 @@ canonical_url = "https://wownero.org" [site.outputs] root = "/var/www/ampg/wownero" +plan_root = "/var/lib/ampg/plans" [site.interactions] default_tier = "static" @@ -43,6 +44,8 @@ daemon = "tor" daemon_policy = "auto" local_port = 18080 onion_location = "auto" +max_asset_bytes = 1048576 +script_policy = "strip" [site.protocols.i2p] enabled = false @@ -50,6 +53,10 @@ renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" local_port = 18081 +keys_file = "wownero-web.dat" +tunnel_name = "wownero-web" +max_asset_bytes = 1048576 +script_policy = "strip" [site.protocols.gemini] enabled = false @@ -88,6 +95,8 @@ workflow. - remote fonts, analytics, and third-party embeds. - localStorage/sessionStorage assumptions. - non-local asset references unless explicitly allowed. +- JavaScript files when `script_policy = "strip"`. +- assets larger than `max_asset_bytes`. `gemtext` defaults: @@ -163,6 +172,54 @@ max_tier = "forms" `max_tier` is a hard cap. If a route needs a higher tier than the protocol allows, AMPG omits it or renders a safe alternate page. +## Protocol-only deployments + +Enabled protocols are independent. A config can omit clearnet entirely and publish only +to I2P, Tor, Gemini, Reticulum, or any combination of supported targets. + +For an existing HTML site that should publish only to I2P: + +```toml +[[site]] +id = "example_i2p_only" +domain = "example.org" + +[site.source] +kind = "static-html" +path = "../example.org" + +[site.outputs] +root = "/var/www/ampg/example" +plan_root = "/var/lib/ampg/plans" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +local_port = 18081 +keys_file = "example-web.dat" +tunnel_name = "example-web" +max_asset_bytes = 524288 +script_policy = "strip" +``` + +This builds only the I2P output root and only the I2P/nginx plan snippets. + +## Plan artifacts + +`ampg plan` prints expected artifact paths. `ampg plan --write-artifacts` writes +reviewable snippets under `site.outputs.plan_root`. + +Generated snippets are not installed by `plan`: + +- nginx server blocks for static HTTP roots. +- Tor hidden-service snippets for loopback HTTP targets. +- i2pd server tunnel snippets for I2P web publishing. + +I2P web tunnels should use a web-specific key file such as `wownero-web.dat`; AMPG does +not reuse daemon RPC/P2P tunnel keys for web publishing. + ## Reusable checklist - [ ] Keep paths explicit in production configs. diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 3e6a878..18384b0 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -79,6 +79,7 @@ from transport-interface readiness. ## Safety requirements - Show every config write before applying it. +- Generate snippets into the plan root before any install or reload step. - Back up any adopted-daemon snippet AMPG touches. - Never delete hidden-service keys, I2P keys, or Reticulum identities without an explicit destructive command. diff --git a/docs/generated/config-schema.md b/docs/generated/config-schema.md index d619a2f..5087047 100644 --- a/docs/generated/config-schema.md +++ b/docs/generated/config-schema.md @@ -12,9 +12,12 @@ This file is generated from code. Do not edit it by hand. | site.source | `path` | path | yes | - | Source tree path. | | site.source | `canonical_url` | url | no | - | Canonical clearnet URL. | | site.outputs | `root` | path | yes | - | Generated output root. | +| site.outputs | `plan_root` | path | no | ../dist/ampg-plan | Generated plan artifact root. | | site.interactions | `default_tier` | enum | no | static | Default interaction tier for routes. | | site.interactions | `deny_routes` | array | no | [] | Route patterns that must not be published. | | site.protocols. | `enabled` | boolean | no | false | Whether this protocol target is built and planned. | | site.protocols. | `renderer` | enum | no | | Renderer profile to use for this protocol. | | site.protocols. | `daemon` | enum | no | protocol default | Ingress daemon adapter. | | site.protocols. | `daemon_policy` | enum | no | protocol default | Whether AMPG adopts, manages, or only renders config. | +| site.protocols. | `max_asset_bytes` | integer | no | 1048576 | Maximum asset size copied by privacy-html render targets. | +| site.protocols. | `script_policy` | enum | no | strip | Script handling policy for privacy-html render targets. | diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index d34d460..f222093 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -107,7 +107,7 @@ enabled = true daemon_policy = "auto" [site.protocols.i2p] -enabled = false +enabled = true daemon_policy = "auto" [site.protocols.gemini] @@ -133,6 +133,8 @@ daemon_policy = "auto" ```sh python3 -m ampg --config examples/wownero.gateway.toml plan python3 -m ampg --config examples/wownero.gateway.toml build +python3 -m ampg --config examples/i2p-only.gateway.toml plan +python3 -m ampg --config examples/i2p-only.gateway.toml build ``` ## Reusable checklist diff --git a/examples/i2p-only.gateway.toml b/examples/i2p-only.gateway.toml new file mode 100644 index 0000000..6b93bb1 --- /dev/null +++ b/examples/i2p-only.gateway.toml @@ -0,0 +1,33 @@ +[gateway] +state_dir = "./.ampg/state" +cache_dir = "./.ampg/cache" +run_dir = "./.ampg/run" +user = "ampg" + +[[site]] +id = "wownero_i2p_only" +domain = "wownero.org" + +[site.source] +kind = "static-html" +path = "../../wownero.org-website" +canonical_url = "https://wownero.org" + +[site.outputs] +root = "../dist/wownero-i2p-only" +plan_root = "../dist/ampg-plan" + +[site.interactions] +default_tier = "static" +deny_routes = ["/admin/*", "/internal/*", "/webhooks/*"] + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +local_port = 18081 +keys_file = "wownero-web.dat" +tunnel_name = "wownero-web" +max_asset_bytes = 524288 +script_policy = "strip" diff --git a/examples/wownero.gateway.toml b/examples/wownero.gateway.toml index dee5fd4..37a414c 100644 --- a/examples/wownero.gateway.toml +++ b/examples/wownero.gateway.toml @@ -15,6 +15,7 @@ canonical_url = "https://wownero.org" [site.outputs] root = "../dist/wownero" +plan_root = "../dist/ampg-plan" [site.interactions] default_tier = "static" @@ -32,15 +33,21 @@ renderer = "privacy-html" daemon = "tor" daemon_policy = "auto" local_port = 18080 +hidden_service_dir = "/var/lib/ampg/tor/wownero" onion_location = "auto" max_asset_bytes = 1048576 +script_policy = "strip" [site.protocols.i2p] -enabled = false +enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" local_port = 18081 +keys_file = "wownero-web.dat" +tunnel_name = "wownero-web" +max_asset_bytes = 1048576 +script_policy = "strip" [site.protocols.gemini] enabled = false diff --git a/tests/test_i2p_only.py b/tests/test_i2p_only.py new file mode 100644 index 0000000..bd24e36 --- /dev/null +++ b/tests/test_i2p_only.py @@ -0,0 +1,65 @@ +import tempfile +import unittest +from pathlib import Path + +from ampg.build import build_gateway +from ampg.config import load_config +from ampg.plan import plan_gateway + + +class I2POnlyTest(unittest.TestCase): + def test_existing_html_source_can_build_only_i2p(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.mkdir() + (source / "index.html").write_text( + '

Hi

', + encoding="utf-8", + ) + (source / "app.js").write_text("boot()", encoding="utf-8") + config_path = root / "gateway.toml" + config_path.write_text( + """ +[[site]] +id = "i2p_only" +domain = "example.test" + +[site.source] +kind = "static-html" +path = "./source" + +[site.outputs] +root = "./out" +plan_root = "./plan" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +local_port = 19081 +keys_file = "example-web.dat" +tunnel_name = "example-web" +max_asset_bytes = 1024 +script_policy = "strip" +""", + encoding="utf-8", + ) + + config = load_config(config_path) + plan = plan_gateway(config) + results = build_gateway(config) + + self.assertEqual([line.protocol for line in plan], ["i2p"]) + self.assertEqual([result.protocol for result in results], ["i2p"]) + self.assertTrue((root / "out/i2p/index.html").exists()) + self.assertFalse((root / "out/clearnet").exists()) + self.assertFalse((root / "out/i2p/app.js").exists()) + rendered = (root / "out/i2p/index.html").read_text(encoding="utf-8") + self.assertNotIn("Hi", encoding="utf-8") + config_path = root / "gateway.toml" + config_path.write_text( + """ +[[site]] +id = "example" +domain = "example.test" + +[site.source] +kind = "static-html" +path = "./site" + +[site.outputs] +root = "./out" +plan_root = "./plan" + +[site.protocols.tor] +enabled = true +renderer = "privacy-html" +daemon = "tor" +daemon_policy = "auto" +local_port = 19080 +hidden_service_dir = "/var/lib/ampg/tor/example" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +local_port = 19081 +keys_file = "example-web.dat" +tunnel_name = "example-web" +""", + encoding="utf-8", + ) + + config = load_config(config_path) + lines = plan_gateway(config) + artifacts = write_plan_artifacts(config) + + self.assertEqual(len(lines), 2) + self.assertEqual(len(artifacts), 4) + + torrc = root / "plan/example/tor/torrc-snippet.conf" + tor_nginx = root / "plan/example/tor/nginx-loopback.conf" + i2p_tunnel = root / "plan/example/i2p/i2pd-tunnel.conf" + i2p_nginx = root / "plan/example/i2p/nginx-loopback.conf" + + self.assertIn("HiddenServicePort 80 127.0.0.1:19080", torrc.read_text()) + self.assertIn("listen 127.0.0.1:19080;", tor_nginx.read_text()) + self.assertIn("[example-web]", i2p_tunnel.read_text()) + self.assertIn("keys = example-web.dat", i2p_tunnel.read_text()) + self.assertIn("listen 127.0.0.1:19081;", i2p_nginx.read_text()) + + +if __name__ == "__main__": + unittest.main()