Add managed Tor start path

This commit is contained in:
jwinterm
2026-07-07 19:44:24 -04:00
parent 637b2e6e7c
commit 7bf833125d
10 changed files with 385 additions and 3 deletions
+19
View File
@@ -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}\""
+21
View File
@@ -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):
+2
View File
@@ -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(
+186
View File
@@ -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
+2
View File
@@ -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]
+3 -1
View File
@@ -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
```
+5 -2
View File
@@ -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.
+2
View File
@@ -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
+3
View File
@@ -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"))
+142
View File
@@ -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()