mirror of
https://github.com/Such-Software/adaptive-multi-protocol-browser.git
synced 2026-10-17 14:31:46 +02:00
Add managed Tor start path
This commit is contained in:
@@ -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}\""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user