diff --git a/ampbrowser/adapters.py b/ampbrowser/adapters.py index b4c9de4..ef63b61 100644 --- a/ampbrowser/adapters.py +++ b/ampbrowser/adapters.py @@ -1,6 +1,8 @@ from __future__ import annotations from dataclasses import dataclass +import os +from pathlib import Path import shutil import socket @@ -34,7 +36,7 @@ class TransportAdapter: manage_supported: bool = True def inspect(self) -> TransportStatus: - installed = self.builtin_running or any(shutil.which(command) for command in self.commands) + installed = self.builtin_running or any(_command_available(command) for command in self.commands) running = self.builtin_running or ( bool(self.connect_host) and bool(self.connect_port) and _can_connect(self.connect_host, self.connect_port) ) @@ -84,7 +86,7 @@ ADAPTERS: dict[str, TransportAdapter] = { start_strategy="start managed Arti SOCKS proxy", stop_policy="stop only AMPB-owned Tor daemon", note="Tor via existing SOCKS proxy or managed Arti/Tor runtime", - commands=("arti", "tor"), + commands=("ampb-bundled-arti", "arti", "tor"), connect_host="127.0.0.1", connect_port=9050, ), @@ -148,6 +150,16 @@ def adapter_for(name: str) -> TransportAdapter | None: return ADAPTERS.get(name) +def _command_available(command: str) -> bool: + if command == "ampb-bundled-arti": + path = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build")) / "providers/arti/bin/arti" + return path.exists() and path.is_file() + if "/" in command: + path = Path(command) + return path.exists() and path.is_file() + return bool(shutil.which(command)) + + def _can_connect(host: str, port: int) -> bool: try: with socket.create_connection((host, port), timeout=0.2): diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index 6e7e3c1..0285c55 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -153,6 +153,7 @@ def _cmd_open( status="ready" if transport_result.ready else transport_result.status, message=transport_result.message, transport_setup_status=transport_result.status, + transport_setup_provider=transport_result.provider, transport_setup_message=transport_result.message, ) open_plan = execute_open(open_plan, root=Path.cwd()) @@ -181,6 +182,7 @@ def _cmd_open( f"runtime_path={runtime_path} " f"user_js_path={user_js_path} " f"transport_setup_status={open_plan.transport_setup_status} " + f"transport_setup_provider={open_plan.transport_setup_provider} " f"transport_setup_message=\"{_safe(open_plan.transport_setup_message)}\" " f"launch_command=\"{_safe(launch_command)}\" " f"setup_steps=\"{_safe(setup_steps)}\" " diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py index ad255ea..e1f3063 100644 --- a/ampbrowser/launch.py +++ b/ampbrowser/launch.py @@ -34,6 +34,7 @@ class OpenPlan: message: str launch_spec: BrowserLaunchSpec | None = None transport_setup_status: str = "-" + transport_setup_provider: str = "-" transport_setup_message: str = "-" diff --git a/ampbrowser/transport_manager.py b/ampbrowser/transport_manager.py index 2e3bb19..9972715 100644 --- a/ampbrowser/transport_manager.py +++ b/ampbrowser/transport_manager.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +import json from pathlib import Path import os import shutil @@ -13,9 +14,19 @@ from .adapters import TransportStatus, adapter_for from .config import AppConfig +ARTI_RELATIVE_PATH = Path("providers/arti/bin/arti") + + +@dataclass(frozen=True) +class TorProvider: + kind: str + binary: str + + @dataclass(frozen=True) class ManagedTransportResult: transport: str + provider: str status: str endpoint: str owned: bool @@ -39,12 +50,13 @@ def ensure_transport_ready( ) -> ManagedTransportResult: adapter = adapter_for(transport) if adapter is None: - return ManagedTransportResult(transport, "unsupported", "-", False, 0, "-", (), "transport adapter not found") + 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, @@ -57,6 +69,7 @@ def ensure_transport_ready( if transport != "tor": return ManagedTransportResult( transport, + "-", "unsupported", status.endpoint, False, @@ -76,28 +89,130 @@ def _start_tor( endpoint: str, wait_seconds: float, ) -> ManagedTransportResult: - binary = _transport_binary(config, "tor") + provider = _tor_provider(config) state_dir = _managed_state_dir(root, config, "tor") - if not binary: + if not provider: return ManagedTransportResult( "tor", + "-", "missing-provider", endpoint, False, 0, state_dir, (), - "Tor provider not found; set AMPB_TOR_BIN or transports.tor.binary_path", + "Tor provider not found; run tools/browser-tor-provider-build.sh or set AMPB_ARTI_BIN, 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) + state_path.mkdir(parents=True, exist_ok=True) + _chmod_private(state_path) + command = _tor_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( + "tor", + 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): + return ManagedTransportResult( + "tor", + provider.kind, + "started", + endpoint, + True, + process.pid, + state_dir, + command, + f"started managed {provider.kind} transport", + ) + + process.terminate() + return ManagedTransportResult( + "tor", + provider.kind, + "start-timeout", + endpoint, + True, + process.pid, + state_dir, + command, + f"managed {provider.kind} did not become ready at {endpoint}", + ) + + +def _tor_provider(config: AppConfig) -> TorProvider | None: + arti_path = os.environ.get("AMPB_ARTI_BIN") or _bundled_arti_path() + if arti_path: + return TorProvider("arti", arti_path) + + tor_path = os.environ.get("AMPB_TOR_BIN") + if tor_path: + return TorProvider("tor", tor_path) + + config_path = config.transport_binary("tor") + if config_path: + return TorProvider(_infer_provider_kind(config_path), config_path) + + system_tor = shutil.which("tor") + if system_tor: + return TorProvider("tor", system_tor) + return None + + +def _bundled_arti_path() -> str: + build_root = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build")) + candidate = build_root / ARTI_RELATIVE_PATH + if candidate.exists() and candidate.is_file(): + return str(candidate) + return "" + + +def _infer_provider_kind(binary_path: str) -> str: + name = Path(binary_path).name.lower() + if name.startswith("arti"): + return "arti" + return "tor" + + +def _tor_command(provider: TorProvider, *, endpoint: str, state_path: Path) -> tuple[str, ...]: host, port = _endpoint_host_port(endpoint) - command = ( - binary, + if provider.kind == "arti": + state_dir = state_path / "arti-state" + cache_dir = state_path / "arti-cache" + state_dir.mkdir(parents=True, exist_ok=True) + cache_dir.mkdir(parents=True, exist_ok=True) + _chmod_private(state_dir) + _chmod_private(cache_dir) + return ( + provider.binary, + "proxy", + "-p", + str(port), + "-o", + f"storage.state_dir={json.dumps(str(state_dir))}", + "-o", + f"storage.cache_dir={json.dumps(str(cache_dir))}", + "-l", + "warn", + ) + + data_dir = state_path / "tor-data" + data_dir.mkdir(parents=True, exist_ok=True) + _chmod_private(data_dir) + return ( + provider.binary, "--SocksPort", f"{host}:{port}", "--DataDirectory", @@ -105,64 +220,21 @@ def _start_tor( "--ClientOnly", "1", "--Log", - f"notice file {log_path}", + f"notice file {state_path / 'tor.log'}", ) - 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 _chmod_private(path: Path) -> None: + try: + path.chmod(0o700) + except OSError: + pass + + def _wait_for_endpoint(endpoint: str, *, timeout_seconds: float) -> bool: deadline = time.monotonic() + timeout_seconds while time.monotonic() <= deadline: diff --git a/docs/browser-builds.md b/docs/browser-builds.md index d1cd6fb..f22fa5c 100644 --- a/docs/browser-builds.md +++ b/docs/browser-builds.md @@ -76,6 +76,24 @@ A successful source build produces a bundled app at Configure and build modes may install Mozilla build Python helpers such as `zstandard` into mach virtualenvs under `/tmp/ampb-browser-build/cache/mozbuild`. +## Tor Provider Probe + +Build a local Arti provider for managed Tor startup: + +```sh +sh tools/browser-tor-provider-build.sh +``` + +The provider script keeps Cargo output and the installed Arti binary under the external +workspace: + +- `/tmp/ampb-browser-build/providers/arti/bin/arti` +- `/tmp/ampb-browser-build/logs/arti-cargo-install.log` + +`ampbrowser open --yes --launch` will prefer this local Arti provider, then +`AMPB_ARTI_BIN`, `AMPB_TOR_BIN`, configured `transports.tor.binary_path`, and finally a +system `tor` binary if one is present. + ## Source Plan Print the canonical source targets and paths: diff --git a/tests/test_transport_manager.py b/tests/test_transport_manager.py index af54682..c27328d 100644 --- a/tests/test_transport_manager.py +++ b/tests/test_transport_manager.py @@ -26,6 +26,7 @@ class TransportManagerTest(unittest.TestCase): result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) self.assertEqual("ready", result.status) + self.assertEqual("-", result.provider) self.assertFalse(result.owned) self.assertTrue(result.ready) self.assertEqual(0, result.pid) @@ -43,13 +44,46 @@ class TransportManagerTest(unittest.TestCase): 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) + with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""): + 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: + def test_starts_bundled_arti_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", + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + arti = root / "providers/arti/bin/arti" + with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=True): + with patch("ampbrowser.transport_manager.subprocess.Popen") as popen: + with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=str(arti)): + popen.return_value.pid = 1234 + result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=root, status=status) + + self.assertEqual("started", result.status) + self.assertEqual("arti", result.provider) + self.assertTrue(result.owned) + self.assertEqual(1234, result.pid) + self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir) + self.assertEqual(str(arti), result.command[0]) + self.assertIn("proxy", result.command) + self.assertIn("-p", result.command) + self.assertIn('storage.state_dir="' + str(root / ".ampb/transports/tor/arti-state") + '"', result.command) + self.assertIn('storage.cache_dir="' + str(root / ".ampb/transports/tor/arti-cache") + '"', result.command) + popen.assert_called_once() + + def test_starts_configured_classic_tor_provider_with_ampb_state(self) -> None: status = TransportStatus( transport="tor", installed=True, @@ -65,16 +99,18 @@ class TransportManagerTest(unittest.TestCase): 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) + with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""): + popen.return_value.pid = 1234 + result = ensure_transport_ready("tor", config=config, root=root, status=status) self.assertEqual("started", result.status) + self.assertEqual("tor", result.provider) 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) + self.assertIn(str(root / ".ampb/transports/tor/tor-data"), result.command) popen.assert_called_once() def test_reports_tor_spawn_failure(self) -> None: @@ -90,10 +126,12 @@ class TransportManagerTest(unittest.TestCase): 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) + with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""): + 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.assertEqual("tor", result.provider) self.assertFalse(result.ready) self.assertIn("could not start", result.message) @@ -112,8 +150,9 @@ class TransportManagerTest(unittest.TestCase): 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) + with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""): + 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) diff --git a/tools/browser-tor-provider-build.sh b/tools/browser-tor-provider-build.sh new file mode 100644 index 0000000..83f9940 --- /dev/null +++ b/tools/browser-tor-provider-build.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +root="${AMPB_BROWSER_BUILD_ROOT:-/tmp/ampb-browser-build}" +provider_root="$root/providers/arti" +log="$root/logs/arti-cargo-install.log" + +mkdir -p "$root/logs" +mkdir -p "$root/providers" + +cargo install arti --locked --root "$provider_root" >"$log" 2>&1 + +binary="$provider_root/bin/arti" +if [ ! -x "$binary" ]; then + printf 'missing arti binary after install: %s\n' "$binary" >&2 + exit 1 +fi + +version="$("$binary" --version | head -n 1)" +printf 'AMPB_TOR_PROVIDER status=ok kind=arti binary=%s log=%s version="%s"\n' "$binary" "$log" "$version"