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