From 29f3a95625a5ff012fdc7c85dc83261e6de69643 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 7 Jul 2026 22:29:23 -0400 Subject: [PATCH] Add managed I2P provider start path --- README.md | 4 + ampbrowser/transport_manager.py | 187 +++++++++++++++++++++++++++++--- docs/browser-builds.md | 8 ++ docs/configuration.md | 2 + docs/transport-management.md | 11 +- examples/config.toml | 2 + tests/test_transport_manager.py | 86 ++++++++++++++- 7 files changed, 280 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 01b8b5b..3a07808 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,13 @@ python3 -m ampbrowser open http://example.onion/ --yes --launch python3 -m ampbrowser shell http://example.onion/ python3 -m ampbrowser shell http://example.onion/ --yes python3 -m ampbrowser open http://example.b32.i2p/ --yes +python3 -m ampbrowser open http://example.b32.i2p/ --yes --launch python3 -m ampbrowser transport start tor +python3 -m ampbrowser transport start i2p python3 -m ampbrowser transport status tor +python3 -m ampbrowser transport status i2p python3 -m ampbrowser transport stop tor +python3 -m ampbrowser transport stop i2p python3 -m ampbrowser open http://example.b32.i2p/ --config examples/config.toml python3 -m ampbrowser open http://example.b32.i2p/ --platform android python3 -m ampbrowser fixture check ../adaptive-multi-protocol-gateway/dist/wownero/ampg-fixture-manifest.json diff --git a/ampbrowser/transport_manager.py b/ampbrowser/transport_manager.py index 119176f..196c9fc 100644 --- a/ampbrowser/transport_manager.py +++ b/ampbrowser/transport_manager.py @@ -25,6 +25,12 @@ class TorProvider: binary: str +@dataclass(frozen=True) +class I2PProvider: + kind: str + binary: str + + @dataclass(frozen=True) class ManagedTransportResult: transport: str @@ -72,20 +78,22 @@ def ensure_transport_ready( f"adopted existing {transport} transport", ) - if transport != "tor": - return ManagedTransportResult( - transport, - "-", - "unsupported", - status.endpoint, - False, - 0, - _managed_state_dir(root, config, transport), - (), - f"managed start for {transport} is not implemented yet", - ) + if transport == "tor": + return _start_tor(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds) + if transport == "i2p": + return _start_i2p(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds) - return _start_tor(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds) + return ManagedTransportResult( + transport, + "-", + "unsupported", + status.endpoint, + False, + 0, + _managed_state_dir(root, config, transport), + (), + f"managed start for {transport} is not implemented yet", + ) def transport_status( @@ -368,6 +376,159 @@ def _tor_command(provider: TorProvider, *, endpoint: str, state_path: Path) -> t ) +def _start_i2p( + *, + config: AppConfig, + root: Path, + endpoint: str, + wait_seconds: float, +) -> ManagedTransportResult: + provider = _i2p_provider(config) + state_dir = _managed_state_dir(root, config, "i2p") + if not provider: + return ManagedTransportResult( + "i2p", + "-", + "missing-provider", + endpoint, + False, + 0, + state_dir, + (), + "I2P provider not found; install i2pd or set AMPB_I2PD_BIN or transports.i2p.binary_path", + ) + + state_path = Path(state_dir) + state_path.mkdir(parents=True, exist_ok=True) + _chmod_private(state_path) + + command = _i2p_command(provider, endpoint=endpoint, state_path=state_path) + try: + with (state_path / f"{provider.kind}.log").open("ab") as log_file: + process = subprocess.Popen(command, stdout=log_file, stderr=log_file, start_new_session=True) # noqa: S603 + except OSError as exc: + return ManagedTransportResult( + "i2p", + provider.kind, + "start-failed", + endpoint, + False, + 0, + state_dir, + command, + f"managed {provider.kind} could not start: {exc}", + ) + + if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds): + result = ManagedTransportResult( + "i2p", + provider.kind, + "started", + endpoint, + True, + process.pid, + state_dir, + command, + f"started managed {provider.kind} transport", + ) + _write_owned_state(root, config, result) + return result + + process.terminate() + return ManagedTransportResult( + "i2p", + provider.kind, + "start-timeout", + endpoint, + True, + process.pid, + state_dir, + command, + f"managed {provider.kind} did not become ready at {endpoint}", + ) + + +def _i2p_provider(config: AppConfig) -> I2PProvider | None: + env_path = os.environ.get("AMPB_I2PD_BIN") + if env_path: + return I2PProvider("i2pd", env_path) + + config_path = config.transport_binary("i2p") + if config_path: + return I2PProvider("i2pd", config_path) + + system_i2pd = shutil.which("i2pd") + if system_i2pd: + return I2PProvider("i2pd", system_i2pd) + return None + + +def _i2p_command(provider: I2PProvider, *, endpoint: str, state_path: Path) -> tuple[str, ...]: + host, port = _endpoint_host_port(endpoint) + data_dir = state_path / "i2pd-data" + data_dir.mkdir(parents=True, exist_ok=True) + _chmod_private(data_dir) + config_path = state_path / "i2pd.conf" + tunnels_path = state_path / "tunnels.conf" + tunnels_path.touch(exist_ok=True) + config_path.write_text(_i2pd_config(host=host, port=port, state_path=state_path), encoding="utf-8") + _chmod_private(config_path) + _chmod_private(tunnels_path) + return ( + provider.binary, + "--conf", + str(config_path), + "--tunconf", + str(tunnels_path), + "--datadir", + str(data_dir), + "--daemon=false", + "--service=false", + ) + + +def _i2pd_config(*, host: str, port: int, state_path: Path) -> str: + return "\n".join( + ( + "daemon = false", + "service = false", + "notransit = true", + "log = file", + f"logfile = {state_path / 'i2pd-router.log'}", + "loglevel = warn", + "", + "[http]", + "enabled = false", + "", + "[httpproxy]", + "enabled = true", + f"address = {host}", + f"port = {port}", + "addresshelper = true", + "senduseragent = false", + "", + "[socksproxy]", + "enabled = false", + "", + "[sam]", + "enabled = false", + "", + "[bob]", + "enabled = false", + "", + "[i2cp]", + "enabled = false", + "", + "[i2pcontrol]", + "enabled = false", + "", + "[upnp]", + "enabled = false", + "", + ) + ) + + def _managed_state_dir(root: Path, config: AppConfig, transport: str) -> str: return str(root / config.state_dir / "transports" / transport) diff --git a/docs/browser-builds.md b/docs/browser-builds.md index f22fa5c..a30768c 100644 --- a/docs/browser-builds.md +++ b/docs/browser-builds.md @@ -94,6 +94,14 @@ workspace: `AMPB_ARTI_BIN`, `AMPB_TOR_BIN`, configured `transports.tor.binary_path`, and finally a system `tor` binary if one is present. +## I2P Provider Probe + +Managed I2P startup currently targets `i2pd`. AMPB adopts an existing HTTP proxy on +`127.0.0.1:4444` when available; otherwise `ampbrowser open --yes --launch` +uses `AMPB_I2PD_BIN`, configured `transports.i2p.binary_path`, or a system `i2pd` binary. +The AMPB-owned process writes config, router data, logs, and ownership metadata under +`.ampb/transports/i2p`. + ## Source Plan Print the canonical source targets and paths: diff --git a/docs/configuration.md b/docs/configuration.md index 8d75452..60e8903 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,6 +42,8 @@ socks_endpoint = "127.0.0.1:9050" [transports.i2p] enabled = true mode = "adopt-or-prompt-manage" +# Optional. Used when AMPB should manage a local i2pd binary. +binary_path = "" http_proxy = "127.0.0.1:4444" [transports.ipfs] diff --git a/docs/transport-management.md b/docs/transport-management.md index feac63d..780449a 100644 --- a/docs/transport-management.md +++ b/docs/transport-management.md @@ -9,8 +9,8 @@ transports only after the user opens a URL that needs them and approves setup. profile, proxy endpoint, browser runtime path, generated profile prefs path, required setup steps, and whether user consent is still needed. With `--launch`, AMPB creates the profile and starts the bundled browser only when the selected route is already ready. -With `--launch --yes`, AMPB can start a configured Tor provider with AMPB-owned state -before launching. Transport policy comes from `.ampb/config.toml` or an explicit +With `--launch --yes`, AMPB can start configured Tor and I2P providers with AMPB-owned +state before launching. Transport policy comes from `.ampb/config.toml` or an explicit `--config` path. `ampbrowser transport start|status|stop ` exposes the same ownership model without @@ -39,9 +39,10 @@ AMPB records owned process metadata in `.ampb/transports//ampb-owned.json` ## Current Adapters -- Tor: detect SOCKS on `127.0.0.1:9050`; managed start supports a configured `tor` binary - with AMPB-owned state; packaged Arti/Tor bundling remains a packaging target. -- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start planned. +- Tor: detect SOCKS on `127.0.0.1:9050`; managed start supports Arti, configured `tor`, + and system `tor` providers with AMPB-owned state. +- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start supports configured or system + `i2pd` providers with AMPB-owned state. - IPFS: detect HTTP gateway on `127.0.0.1:8080`; managed gateway start planned. - Gemini: built-in route and render path; no daemon required for static browsing. - Reticulum: resilient/private route contract exists; adapter and readiness model are planned. diff --git a/examples/config.toml b/examples/config.toml index 2271cd8..e96b73f 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -13,6 +13,8 @@ binary_path = "" [transports.i2p] enabled = true mode = "adopt-or-prompt-manage" +# Optional. Used when AMPB should manage a local i2pd binary. +binary_path = "" [transports.ipfs] enabled = true diff --git a/tests/test_transport_manager.py b/tests/test_transport_manager.py index 4f8e12d..119f116 100644 --- a/tests/test_transport_manager.py +++ b/tests/test_transport_manager.py @@ -252,7 +252,26 @@ class TransportManagerTest(unittest.TestCase): self.assertFalse(result.ready) popen.return_value.terminate.assert_called_once() - def test_non_tor_managed_start_is_reported_as_unsupported(self) -> None: + def test_reports_missing_i2p_provider(self) -> None: + status = TransportStatus( + transport="i2p", + installed=False, + running=False, + endpoint="http://127.0.0.1:4444", + adoptable=False, + manage_supported=True, + note="I2P HTTP proxy", + ) + + with tempfile.TemporaryDirectory() as tmp: + with patch("ampbrowser.transport_manager.shutil.which", return_value=None): + result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) + + self.assertEqual("missing-provider", result.status) + self.assertFalse(result.ready) + self.assertIn("I2P provider not found", result.message) + + def test_starts_configured_i2pd_provider_with_ampb_state(self) -> None: status = TransportStatus( transport="i2p", installed=True, @@ -262,9 +281,72 @@ class TransportManagerTest(unittest.TestCase): manage_supported=True, note="I2P HTTP proxy", ) + config = AppConfig(transport_modes={}, transport_binaries={"i2p": "/opt/ampb/i2pd"}) with tempfile.TemporaryDirectory() as tmp: - result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) + root = Path(tmp) + with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=True): + with patch("ampbrowser.transport_manager.subprocess.Popen") as popen: + popen.return_value.pid = 1234 + result = ensure_transport_ready("i2p", config=config, root=root, status=status) + + self.assertEqual("started", result.status) + self.assertEqual("i2pd", result.provider) + self.assertTrue(result.owned) + self.assertEqual(1234, result.pid) + self.assertEqual(str(root / ".ampb/transports/i2p"), result.state_dir) + self.assertEqual("/opt/ampb/i2pd", result.command[0]) + self.assertIn("--conf", result.command) + self.assertIn(str(root / ".ampb/transports/i2p/i2pd.conf"), result.command) + self.assertIn("--datadir", result.command) + self.assertIn(str(root / ".ampb/transports/i2p/i2pd-data"), result.command) + self.assertIn("--daemon=false", result.command) + self.assertIn("--service=false", result.command) + config_text = (root / ".ampb/transports/i2p/i2pd.conf").read_text(encoding="utf-8") + self.assertIn("[httpproxy]", config_text) + self.assertIn("address = 127.0.0.1", config_text) + self.assertIn("port = 4444", config_text) + self.assertIn("notransit = true", config_text) + state_path = root / ".ampb/transports/i2p/ampb-owned.json" + self.assertTrue(state_path.exists()) + self.assertIn('"provider": "i2pd"', state_path.read_text(encoding="utf-8")) + popen.assert_called_once() + + def test_terminates_owned_i2pd_process_on_timeout(self) -> None: + status = TransportStatus( + transport="i2p", + installed=True, + running=False, + endpoint="http://127.0.0.1:4444", + adoptable=False, + manage_supported=True, + note="I2P HTTP proxy", + ) + config = AppConfig(transport_modes={}, transport_binaries={"i2p": "/opt/ampb/i2pd"}) + + with tempfile.TemporaryDirectory() as tmp: + with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=False): + with patch("ampbrowser.transport_manager.subprocess.Popen") as popen: + popen.return_value.pid = 1234 + result = ensure_transport_ready("i2p", config=config, root=Path(tmp), status=status) + + self.assertEqual("start-timeout", result.status) + self.assertFalse(result.ready) + popen.return_value.terminate.assert_called_once() + + def test_other_managed_start_is_reported_as_unsupported(self) -> None: + status = TransportStatus( + transport="ipfs", + installed=True, + running=False, + endpoint="http://127.0.0.1:8080", + adoptable=False, + manage_supported=True, + note="IPFS local gateway", + ) + + with tempfile.TemporaryDirectory() as tmp: + result = ensure_transport_ready("ipfs", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) self.assertEqual("unsupported", result.status) self.assertFalse(result.ready)