From 7bf833125d91feb46b477780b3dd7797c131a135 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 7 Jul 2026 19:44:24 -0400 Subject: [PATCH] Add managed Tor start path --- ampbrowser/cli.py | 19 ++++ ampbrowser/config.py | 21 ++++ ampbrowser/launch.py | 2 + ampbrowser/transport_manager.py | 186 ++++++++++++++++++++++++++++++++ docs/configuration.md | 2 + docs/open-flow.md | 4 +- docs/transport-management.md | 7 +- examples/config.toml | 2 + tests/test_config.py | 3 + tests/test_transport_manager.py | 142 ++++++++++++++++++++++++ 10 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 ampbrowser/transport_manager.py create mode 100644 tests/test_transport_manager.py diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index 560eb5f..6e7e3c1 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from dataclasses import replace from pathlib import Path import shlex import sys @@ -12,6 +13,7 @@ from .launch import execute_open, prepare_open from .plan import plan_url from .platforms import PLATFORM_CHOICES from .routing import route_url +from .transport_manager import ensure_transport_ready from .transports import inspect_transports @@ -138,6 +140,21 @@ def _cmd_open( config = load_config(Path.cwd(), config_path) open_plan = prepare_open(url, consent=consent, dry_run=dry_run, config=config, platform=platform) if launch: + if open_plan.status == "setup-approved": + transport_result = ensure_transport_ready( + open_plan.browse_plan.route.transport, + config=config, + root=Path.cwd(), + status=open_plan.browse_plan.status, + ) + open_plan = replace( + open_plan, + dry_run=False, + status="ready" if transport_result.ready else transport_result.status, + message=transport_result.message, + transport_setup_status=transport_result.status, + transport_setup_message=transport_result.message, + ) open_plan = execute_open(open_plan, root=Path.cwd()) browse_plan = open_plan.browse_plan setup_steps = "|".join(open_plan.setup_steps) if open_plan.setup_steps else "-" @@ -163,6 +180,8 @@ def _cmd_open( f"proxy={open_plan.proxy} " f"runtime_path={runtime_path} " f"user_js_path={user_js_path} " + f"transport_setup_status={open_plan.transport_setup_status} " + f"transport_setup_message=\"{_safe(open_plan.transport_setup_message)}\" " f"launch_command=\"{_safe(launch_command)}\" " f"setup_steps=\"{_safe(setup_steps)}\" " f"message=\"{message}\"" diff --git a/ampbrowser/config.py b/ampbrowser/config.py index f366903..d9fdf46 100644 --- a/ampbrowser/config.py +++ b/ampbrowser/config.py @@ -27,6 +27,7 @@ class AppConfig: runtime_path: str = "" isolate_by_transport: bool = True transport_modes: dict[str, str] | None = None + transport_binaries: dict[str, str] | None = None def transport_mode(self, transport: str) -> str: if not self.transport_modes: @@ -38,6 +39,11 @@ class AppConfig: return str(Path(self.state_dir) / "profiles" / profile) return str(Path(self.state_dir) / "profile") + def transport_binary(self, transport: str) -> str: + if not self.transport_binaries: + return "" + return self.transport_binaries.get(transport, "") + def default_config() -> AppConfig: return AppConfig(transport_modes={}) @@ -73,6 +79,7 @@ def _parse_config(data: dict[str, Any], path: Path) -> AppConfig: runtime_path = _string(browser, "runtime_path", "", path) isolate_by_transport = _bool(profiles, "isolate_by_transport", True, path) transport_modes = _transport_modes(transports, path) + transport_binaries = _transport_binaries(transports, path) return AppConfig( state_dir=state_dir, @@ -80,6 +87,7 @@ def _parse_config(data: dict[str, Any], path: Path) -> AppConfig: runtime_path=runtime_path, isolate_by_transport=isolate_by_transport, transport_modes=transport_modes, + transport_binaries=transport_binaries, ) @@ -107,6 +115,19 @@ def _transport_modes(transports: dict[str, Any], path: Path) -> dict[str, str]: return modes +def _transport_binaries(transports: dict[str, Any], path: Path) -> dict[str, str]: + binaries: dict[str, str] = {} + for name, raw_config in transports.items(): + if not isinstance(raw_config, dict): + raise ConfigError(f"{path}: transports.{name} must be a table") + binary_path = raw_config.get("binary_path", "") + if not isinstance(binary_path, str): + raise ConfigError(f"{path}: transports.{name}.binary_path must be a string") + if binary_path: + binaries[name] = binary_path + return binaries + + def _table(data: dict[str, Any], key: str, path: Path) -> dict[str, Any]: value = data.get(key, {}) if not isinstance(value, dict): diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py index 595d4f7..ad255ea 100644 --- a/ampbrowser/launch.py +++ b/ampbrowser/launch.py @@ -33,6 +33,8 @@ class OpenPlan: setup_steps: tuple[str, ...] message: str launch_spec: BrowserLaunchSpec | None = None + transport_setup_status: str = "-" + transport_setup_message: str = "-" def prepare_open( diff --git a/ampbrowser/transport_manager.py b/ampbrowser/transport_manager.py new file mode 100644 index 0000000..2e3bb19 --- /dev/null +++ b/ampbrowser/transport_manager.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import os +import shutil +import socket +import subprocess +import time +from urllib.parse import urlparse + +from .adapters import TransportStatus, adapter_for +from .config import AppConfig + + +@dataclass(frozen=True) +class ManagedTransportResult: + transport: str + status: str + endpoint: str + owned: bool + pid: int + state_dir: str + command: tuple[str, ...] + message: str + + @property + def ready(self) -> bool: + return self.status in {"ready", "started"} + + +def ensure_transport_ready( + transport: str, + *, + config: AppConfig, + root: Path, + status: TransportStatus | None = None, + wait_seconds: float = 20.0, +) -> ManagedTransportResult: + adapter = adapter_for(transport) + if adapter is None: + return ManagedTransportResult(transport, "unsupported", "-", False, 0, "-", (), "transport adapter not found") + + status = status or adapter.inspect() + if status.adoptable: + return ManagedTransportResult( + transport, + "ready", + status.endpoint, + False, + 0, + _managed_state_dir(root, config, transport), + (), + 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", + ) + + return _start_tor(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds) + + +def _start_tor( + *, + config: AppConfig, + root: Path, + endpoint: str, + wait_seconds: float, +) -> ManagedTransportResult: + binary = _transport_binary(config, "tor") + state_dir = _managed_state_dir(root, config, "tor") + if not binary: + return ManagedTransportResult( + "tor", + "missing-provider", + endpoint, + False, + 0, + state_dir, + (), + "Tor provider not found; set AMPB_TOR_BIN or transports.tor.binary_path", + ) + + state_path = Path(state_dir) + data_dir = state_path / "data" + log_path = state_path / "tor.log" + data_dir.mkdir(parents=True, exist_ok=True) + + host, port = _endpoint_host_port(endpoint) + command = ( + binary, + "--SocksPort", + f"{host}:{port}", + "--DataDirectory", + str(data_dir), + "--ClientOnly", + "1", + "--Log", + f"notice file {log_path}", + ) + try: + process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # noqa: S603 + except OSError as exc: + return ManagedTransportResult( + "tor", + "start-failed", + endpoint, + False, + 0, + state_dir, + command, + f"managed Tor could not start: {exc}", + ) + + if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds): + return ManagedTransportResult( + "tor", + "started", + endpoint, + True, + process.pid, + state_dir, + command, + "started managed Tor transport", + ) + + process.terminate() + return ManagedTransportResult( + "tor", + "start-timeout", + endpoint, + True, + process.pid, + state_dir, + command, + f"managed Tor did not become ready at {endpoint}", + ) + + +def _transport_binary(config: AppConfig, transport: str) -> str: + env_name = f"AMPB_{transport.upper()}_BIN" + env_path = os.environ.get(env_name) + if env_path: + return env_path + config_path = config.transport_binary(transport) + if config_path: + return config_path + if transport == "tor": + return shutil.which("tor") or "" + return "" + + +def _managed_state_dir(root: Path, config: AppConfig, transport: str) -> str: + return str(root / config.state_dir / "transports" / transport) + + +def _wait_for_endpoint(endpoint: str, *, timeout_seconds: float) -> bool: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() <= deadline: + host, port = _endpoint_host_port(endpoint) + if _can_connect(host, port): + return True + time.sleep(0.2) + return False + + +def _can_connect(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=0.2): + return True + except OSError: + return False + + +def _endpoint_host_port(endpoint: str) -> tuple[str, int]: + parsed = urlparse(endpoint) + return parsed.hostname or "127.0.0.1", parsed.port or 9050 diff --git a/docs/configuration.md b/docs/configuration.md index ebd0eed..8d75452 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,8 @@ runtime_path = "" [transports.tor] enabled = true mode = "adopt-or-prompt-manage" +# Optional. Used when AMPB should manage a local Tor binary. +binary_path = "" socks_endpoint = "127.0.0.1:9050" [transports.i2p] diff --git a/docs/open-flow.md b/docs/open-flow.md index a1c1736..0897215 100644 --- a/docs/open-flow.md +++ b/docs/open-flow.md @@ -19,7 +19,8 @@ application starts. Setup is lazy: By default, `open` output is a dry-run launch spec. Use `--launch` to create the isolated profile and launch the configured browser runtime when the selected route is already -ready. Transport install/start remains gated by first-use approval and readiness checks. +ready. With `--launch --yes`, AMPB may start an approved managed transport before +launching. Transport setup remains gated by first-use approval and readiness checks. ## Examples @@ -30,4 +31,5 @@ python3 -m ampbrowser open http://example.b32.i2p/ --yes 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 open https://wownero.org/ --launch +python3 -m ampbrowser open http://example.onion/ --yes --launch ``` diff --git a/docs/transport-management.md b/docs/transport-management.md index ff57a72..0f47d33 100644 --- a/docs/transport-management.md +++ b/docs/transport-management.md @@ -9,7 +9,9 @@ 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. -Transport policy comes from `.ampb/config.toml` or an explicit `--config` path. +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 +`--config` path. ## Lifecycle @@ -32,7 +34,8 @@ leases, tunnels, and daemon logs in that tree are local state and must not be co ## Current Adapters -- Tor: detect SOCKS on `127.0.0.1:9050`; managed setup prefers Arti/Tor runtime ownership. +- 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. - 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. diff --git a/examples/config.toml b/examples/config.toml index b9ff758..2271cd8 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -7,6 +7,8 @@ runtime_path = "" [transports.tor] enabled = true mode = "adopt-or-prompt-manage" +# Optional. Used when AMPB should manage a local Tor binary. +binary_path = "" [transports.i2p] enabled = true diff --git a/tests/test_config.py b/tests/test_config.py index be66d49..ab56573 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,6 +15,7 @@ class ConfigTest(unittest.TestCase): self.assertEqual(".ampb", config.state_dir) self.assertEqual("ampb-gecko", config.default_engine) self.assertEqual("", config.runtime_path) + self.assertEqual("", config.transport_binary("tor")) self.assertEqual("adopt-or-prompt-manage", config.transport_mode("tor")) self.assertEqual(".ampb/profiles/tor", config.profile_path("tor")) @@ -29,6 +30,7 @@ runtime_path = "/opt/ampb/firefox" [transports.tor] mode = "adopt" +binary_path = "/opt/ampb/tor" [transports.i2p] enabled = false @@ -41,6 +43,7 @@ enabled = false self.assertEqual(".local/ampb", config.state_dir) self.assertEqual("/opt/ampb/firefox", config.runtime_path) self.assertEqual("adopt", config.transport_mode("tor")) + self.assertEqual("/opt/ampb/tor", config.transport_binary("tor")) self.assertEqual("disabled", config.transport_mode("i2p")) self.assertEqual(".local/ampb/profiles/i2p", config.profile_path("i2p")) diff --git a/tests/test_transport_manager.py b/tests/test_transport_manager.py new file mode 100644 index 0000000..af54682 --- /dev/null +++ b/tests/test_transport_manager.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from ampbrowser.config import AppConfig +from ampbrowser.transport_manager import ensure_transport_ready +from ampbrowser.transports import TransportStatus + + +class TransportManagerTest(unittest.TestCase): + def test_adopts_running_transport(self) -> None: + status = TransportStatus( + transport="tor", + installed=True, + running=True, + endpoint="socks5://127.0.0.1:9050", + adoptable=True, + manage_supported=True, + note="Tor SOCKS proxy", + ) + + with tempfile.TemporaryDirectory() as tmp: + result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) + + self.assertEqual("ready", result.status) + self.assertFalse(result.owned) + self.assertTrue(result.ready) + self.assertEqual(0, result.pid) + + def test_reports_missing_tor_provider(self) -> None: + status = TransportStatus( + transport="tor", + installed=False, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + + with tempfile.TemporaryDirectory() as tmp: + with patch("ampbrowser.transport_manager.shutil.which", return_value=None): + result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) + + self.assertEqual("missing-provider", result.status) + self.assertFalse(result.ready) + self.assertIn("Tor provider not found", result.message) + + def test_starts_configured_tor_provider_with_ampb_state(self) -> None: + status = TransportStatus( + transport="tor", + installed=True, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + config = AppConfig(transport_modes={}, transport_binaries={"tor": "/opt/ampb/tor"}) + + with tempfile.TemporaryDirectory() as tmp: + 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("tor", config=config, root=root, status=status) + + self.assertEqual("started", result.status) + self.assertTrue(result.owned) + self.assertEqual(1234, result.pid) + self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir) + self.assertEqual("/opt/ampb/tor", result.command[0]) + self.assertIn("--DataDirectory", result.command) + self.assertIn(str(root / ".ampb/transports/tor/data"), result.command) + popen.assert_called_once() + + def test_reports_tor_spawn_failure(self) -> None: + status = TransportStatus( + transport="tor", + installed=True, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + config = AppConfig(transport_modes={}, transport_binaries={"tor": "/opt/ampb/tor"}) + + with tempfile.TemporaryDirectory() as tmp: + with patch("ampbrowser.transport_manager.subprocess.Popen", side_effect=OSError("nope")): + result = ensure_transport_ready("tor", config=config, root=Path(tmp), status=status) + + self.assertEqual("start-failed", result.status) + self.assertFalse(result.ready) + self.assertIn("could not start", result.message) + + def test_terminates_owned_tor_process_on_timeout(self) -> None: + status = TransportStatus( + transport="tor", + installed=True, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + config = AppConfig(transport_modes={}, transport_binaries={"tor": "/opt/ampb/tor"}) + + 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("tor", 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_non_tor_managed_start_is_reported_as_unsupported(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", + ) + + with tempfile.TemporaryDirectory() as tmp: + result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) + + self.assertEqual("unsupported", result.status) + self.assertFalse(result.ready) + self.assertIn("not implemented yet", result.message) + + +if __name__ == "__main__": + unittest.main()