import contextlib import io import tempfile import unittest from pathlib import Path from unittest.mock import patch from ampg.approvals import ApprovalInput, approve_artifacts from ampg.cli import main from ampg.config import load_config from ampg.install_plan import ( blocked_install_steps, install_artifacts, install_plan, install_state_copies, install_supervisor_actions, write_install_artifacts, ) from ampg.platforms import platform_by_name from ampg.status import DaemonProbe def _probe(*, installed: bool = False, running: bool = False): def probe(adapter): executable_path = f"/usr/bin/{adapter.executable}" if installed else None return DaemonProbe( installed=installed, running=running, executable_path=executable_path, ) return probe 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""" [profiles.mobile-i2p] protocols = ["i2p"] platform = "android-termux" [[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 InstallPlanTest(unittest.TestCase): def test_android_i2p_plan_includes_transport_and_loopback_packages(self): with tempfile.TemporaryDirectory() as tmp: config = load_config( _write_config( Path(tmp), """ [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) ) steps = install_plan( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) self.assertEqual([], blocked_install_steps(steps)) package_commands = { (step.target, step.command) for step in steps if step.stage == "package" } self.assertIn(("i2pd", "pkg install i2pd"), package_commands) self.assertIn(("nginx", "pkg install nginx"), package_commands) self.assertTrue(any(step.stage == "state" for step in steps)) self.assertTrue( any( step.stage == "supervisor" and step.command == "sv-enable ampg-example-i2p" for step in steps ) ) def test_adopted_daemon_has_no_managed_install_steps(self): with tempfile.TemporaryDirectory() as tmp: config = load_config( _write_config( Path(tmp), """ [site.protocols.tor] enabled = true renderer = "privacy-html" daemon = "tor" daemon_policy = "auto" """, ) ) steps = install_plan( config, platform_provider=platform_by_name("linux-systemd"), daemon_probe=_probe(installed=True, running=True), ) self.assertEqual(1, len(steps)) self.assertEqual("adopt-existing", steps[0].action) self.assertEqual("skipped", steps[0].status) def test_unknown_platform_blocks_managed_install(self): with tempfile.TemporaryDirectory() as tmp: config = load_config( _write_config( Path(tmp), """ [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) ) steps = install_plan( config, platform_provider=platform_by_name("unknown"), daemon_probe=_probe(installed=False, running=False), ) self.assertEqual(1, len(steps)) self.assertEqual("blocked", steps[0].status) self.assertIn("cannot manage", steps[0].message) def test_install_plan_cli_uses_profile_defaults(self): with tempfile.TemporaryDirectory() as tmp: config_path = _write_config( Path(tmp), """ [site.protocols.clearnet] enabled = true renderer = "clearnet" daemon = "nginx" daemon_policy = "adopt" [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) stdout = io.StringIO() with contextlib.redirect_stdout(stdout), patch("ampg.status.shutil.which", return_value=None): status = main( [ "--config", str(config_path), "install-plan", "--profile", "mobile-i2p", ] ) output = stdout.getvalue() self.assertEqual(0, status) self.assertIn("protocol=i2p", output) self.assertIn("platform=android-termux", output) self.assertIn('command="pkg install i2pd"', output) self.assertIn("blocked=0", output) self.assertNotIn("protocol=clearnet", output) def test_managed_i2p_artifacts_include_daemon_loopback_and_supervisors(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp).resolve() config = load_config( _write_config( root, """ [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" """, ) ) artifacts = install_artifacts( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) by_name = {artifact.path.name: artifact for artifact in artifacts} self.assertEqual( { "i2pd-tunnels.conf", "i2pd.run", "nginx-loopback.conf", "nginx-loopback.run", }, set(by_name), ) self.assertIn("[example-web]", by_name["i2pd-tunnels.conf"].content) self.assertIn("type = http", by_name["i2pd-tunnels.conf"].content) self.assertIn("inport = 80", by_name["i2pd-tunnels.conf"].content) self.assertIn("keys = example-web.dat", by_name["i2pd-tunnels.conf"].content) self.assertIn("listen 127.0.0.1:19081", by_name["nginx-loopback.conf"].content) self.assertIn(f"mkdir -p {root / '.ampg/state/example/i2p'}", by_name["i2pd.run"].content) self.assertIn(f"mkdir -p {root / '.ampg/state/example/i2p/data'}", by_name["i2pd.run"].content) self.assertIn("exec i2pd", by_name["i2pd.run"].content) self.assertIn("exec nginx", by_name["nginx-loopback.run"].content) def test_write_install_artifacts_creates_review_files(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) config = load_config( _write_config( root, """ [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) ) artifacts = write_install_artifacts( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) for artifact in artifacts: self.assertTrue(artifact.path.exists()) self.assertTrue( ( root / "plan/example/i2p/managed/android-termux/i2pd-tunnels.conf" ).exists() ) def test_state_copy_plan_maps_review_artifacts_to_managed_state(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp).resolve() config = load_config( _write_config( root, """ [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) ) write_install_artifacts( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) copies = install_state_copies( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) by_kind = {copy.kind: copy for copy in copies} self.assertEqual({"daemon-config", "loopback-config"}, set(by_kind)) self.assertEqual("review", by_kind["daemon-config"].status) self.assertIn("not approved", by_kind["daemon-config"].message) self.assertEqual( root / ".ampg/state/example/i2p/i2pd-tunnels.conf", by_kind["daemon-config"].target, ) self.assertEqual( root / ".ampg/state/example/i2p/nginx-loopback.conf", by_kind["loopback-config"].target, ) self.assertIn("cp", by_kind["daemon-config"].command) def test_supervisor_action_plan_uses_reviewed_supervisor_artifacts(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp).resolve() config = load_config( _write_config( root, """ [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) ) write_install_artifacts( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) actions = install_supervisor_actions( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) by_kind = {action.kind: action for action in actions} self.assertEqual({"daemon-supervisor", "loopback-supervisor"}, set(by_kind)) self.assertEqual("review", by_kind["daemon-supervisor"].status) self.assertIn("not approved", by_kind["daemon-supervisor"].message) self.assertEqual("ampg-example-i2p-i2pd", by_kind["daemon-supervisor"].service) self.assertEqual("sv-enable ampg-example-i2p-i2pd", by_kind["daemon-supervisor"].command) self.assertEqual( root / "plan/example/i2p/managed/android-termux/i2pd.run", by_kind["daemon-supervisor"].source, ) self.assertEqual( "sv-enable ampg-example-i2p-nginx-loopback", by_kind["loopback-supervisor"].command, ) def test_approved_managed_artifacts_enable_copy_and_supervisor_plans(self): with tempfile.TemporaryDirectory() as tmp: config = load_config( _write_config( Path(tmp).resolve(), """ [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) ) artifacts = write_install_artifacts( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) approve_artifacts( config, [ ApprovalInput( site_id=artifact.site_id, protocol=artifact.protocol, platform=artifact.platform, kind=artifact.kind, path=artifact.path, content=artifact.content, ) for artifact in artifacts ], ) copies = install_state_copies( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) actions = install_supervisor_actions( config, platform_provider=platform_by_name("android-termux"), daemon_probe=_probe(installed=False, running=False), ) self.assertTrue(all(copy.status == "planned" for copy in copies)) self.assertTrue(all(action.status == "planned" for action in actions)) def test_install_plan_cli_can_write_managed_artifacts(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) config_path = _write_config( root, """ [site.protocols.i2p] enabled = true renderer = "privacy-html" daemon = "i2pd" daemon_policy = "auto" """, ) stdout = io.StringIO() with contextlib.redirect_stdout(stdout): status = main( [ "--config", str(config_path), "install-plan", "--profile", "mobile-i2p", "--write-artifacts", ] ) output = stdout.getvalue() self.assertEqual(0, status) self.assertIn("AMPG_INSTALL_ARTIFACT", output) self.assertIn("kind=daemon-config", output) self.assertTrue( ( root / "plan/example/i2p/managed/android-termux/i2pd-tunnels.conf" ).exists() ) def test_systemd_supervisor_wraps_start_command_in_shell(self): with tempfile.TemporaryDirectory() as tmp: config = load_config( _write_config( Path(tmp), """ [site.protocols.tor] enabled = true renderer = "privacy-html" daemon = "tor" daemon_policy = "auto" """, ) ) artifacts = install_artifacts( config, platform_provider=platform_by_name("linux-systemd"), daemon_probe=_probe(installed=False, running=False), ) service = next(artifact for artifact in artifacts if artifact.path.name == "tor.service") self.assertIn("ExecStart=/bin/sh -lc 'exec tor", service.content) class DaemonConfigInjectionTests(unittest.TestCase): """torrc and tunnels.conf are line-oriented with no escape mechanism, so an interpolated newline injects an arbitrary directive.""" def test_rejects_newline_in_tunnel_name(self): from ampg.install_plan import _validate_config_token with self.assertRaises(ValueError): _validate_config_token("web\nhost = attacker.example", "tunnel_name") def test_rejects_separator_characters(self): from ampg.install_plan import _validate_config_token for value in ["a b", "../../etc/tor", "name=value", "a\rb", ""]: with self.subTest(value=value): with self.assertRaises(ValueError): _validate_config_token(value, "keys_file") def test_accepts_ordinary_identifiers(self): from ampg.install_plan import _validate_config_token self.assertEqual(_validate_config_token("smirk-web.dat", "keys_file"), "smirk-web.dat") def test_rejects_line_break_in_state_path(self): from ampg.install_plan import _validate_config_path with self.assertRaises(ValueError): _validate_config_path(Path("/var/lib/x\nDataDirectory /tmp/evil"), "state directory") class SystemdHardeningTests(unittest.TestCase): """A unit with no User= runs as whoever installs it, normally root.""" def _unit(self, **options) -> str: import dataclasses from ampg.install_plan import _supervisor_content config = load_config( _write_config( Path(tempfile.mkdtemp()), """ [site.protocols.tor] enabled = true renderer = "gemtext" daemon = "tor" daemon_policy = "auto" """, ) ) site = config.sites[0] base = site.protocols["tor"] protocol = dataclasses.replace(base, options={**base.options, **options}) return _supervisor_content( config, site, protocol, platform_by_name("linux-systemd"), service_suffix="daemon", command="exec tor -f /etc/tor/torrc", ) def test_unit_warns_loudly_when_no_service_user_configured(self): unit = self._unit() self.assertIn("WARNING: no service_user configured", unit) self.assertNotIn("\nUser=", unit) def test_unit_sets_account_when_service_user_configured(self): unit = self._unit(service_user="ampg-tor") self.assertIn("User=ampg-tor", unit) self.assertIn("Group=ampg-tor", unit) self.assertNotIn("WARNING: no service_user", unit) def test_unit_carries_baseline_hardening(self): unit = self._unit(service_user="ampg-tor") for directive in ( "NoNewPrivileges=true", "PrivateTmp=true", "RestrictSUIDSGID=true", "CapabilityBoundingSet=", ): with self.subTest(directive=directive): self.assertIn(directive, unit) def test_unit_rejects_injected_service_user(self): with self.assertRaises(ValueError): self._unit(service_user="root\nExecStartPre=/bin/evil") if __name__ == "__main__": unittest.main()