Files

864 lines
24 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
import os
import signal
import shutil
import socket
import subprocess
import sys
import time
from urllib.parse import urlparse
from .adapters import TransportStatus, adapter_for
from .config import AppConfig
ARTI_RELATIVE_PATH = Path("providers/arti/bin/arti")
I2PD_RELATIVE_PATH = Path("providers/i2pd/bin/i2pd")
OWNED_STATE_FILE = "ampb-owned.json"
@dataclass(frozen=True)
class TorProvider:
kind: str
binary: str
source: str
@dataclass(frozen=True)
class I2PProvider:
kind: str
binary: str
source: str
@dataclass(frozen=True)
class SetupHint:
message: str
command: tuple[str, ...] = ()
@dataclass(frozen=True)
class ManagedTransportResult:
transport: str
provider: str
status: str
endpoint: str
owned: bool
pid: int
state_dir: str
command: tuple[str, ...]
message: str
provider_source: str = "-"
setup_hint: str = "-"
install_command: tuple[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")
owned = _owned_transport_result(transport, config=config, root=root, wait_seconds=wait_seconds)
if owned:
return owned
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",
provider_source="system-adopted",
)
if transport == "tor":
return _start_tor(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds)
if transport == "i2p":
return _start_i2p(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds)
return ManagedTransportResult(
transport,
"-",
"unsupported",
status.endpoint,
False,
0,
_managed_state_dir(root, config, transport),
(),
f"managed start for {transport} is not implemented yet",
)
def transport_status(
transport: str,
*,
config: AppConfig,
root: Path,
) -> ManagedTransportResult:
adapter = adapter_for(transport)
if adapter is None:
return ManagedTransportResult(transport, "-", "unsupported", "-", False, 0, "-", (), "transport adapter not found")
owned = _owned_transport_result(transport, config=config, root=root, wait_seconds=0.1)
if owned:
return owned
status = adapter.inspect()
if status.adoptable:
return ManagedTransportResult(
transport,
"-",
"ready",
status.endpoint,
False,
0,
_managed_state_dir(root, config, transport),
(),
f"{transport} transport is running but not AMPB-owned",
provider_source="system-adopted",
)
return ManagedTransportResult(
transport,
"-",
"stopped",
status.endpoint,
False,
0,
_managed_state_dir(root, config, transport),
(),
f"{transport} transport is not running",
setup_hint=_stopped_setup_hint(transport, config),
install_command=_stopped_install_command(transport, config),
)
def stop_managed_transport(
transport: str,
*,
config: AppConfig,
root: Path,
wait_seconds: float = 5.0,
) -> ManagedTransportResult:
state_path = _owned_state_path(root, config, transport)
state = _read_owned_state(state_path)
endpoint = str(state.get("endpoint") or _endpoint_for(transport))
state_dir = _managed_state_dir(root, config, transport)
provider = str(state.get("provider") or "-")
provider_source = str(state.get("provider_source") or "-")
command = tuple(str(part) for part in state.get("command", ()))
pid = _state_pid(state)
if not state or not pid:
return ManagedTransportResult(
transport,
"-",
"not-owned",
endpoint,
False,
0,
state_dir,
(),
f"no AMPB-owned {transport} process is recorded",
provider_source=provider_source,
)
if not _pid_alive(pid):
cleaned = _unlink_state(state_path)
return ManagedTransportResult(
transport,
provider,
"stale" if cleaned else "stale-cleanup-failed",
endpoint,
True,
pid,
state_dir,
command,
(
f"removed stale AMPB-owned {transport} state"
if cleaned
else f"could not remove stale AMPB-owned {transport} state"
),
provider_source=provider_source,
)
try:
os.kill(pid, signal.SIGTERM)
except OSError as exc:
return ManagedTransportResult(
transport,
provider,
"stop-failed",
endpoint,
True,
pid,
state_dir,
command,
f"could not stop AMPB-owned {transport}: {exc}",
provider_source=provider_source,
)
deadline = time.monotonic() + wait_seconds
while time.monotonic() <= deadline:
if not _pid_alive(pid):
cleaned = _unlink_state(state_path)
return ManagedTransportResult(
transport,
provider,
"stopped" if cleaned else "stopped-cleanup-failed",
endpoint,
True,
pid,
state_dir,
command,
(
f"stopped AMPB-owned {transport}"
if cleaned
else f"stopped AMPB-owned {transport} but could not remove state"
),
provider_source=provider_source,
)
time.sleep(0.1)
return ManagedTransportResult(
transport,
provider,
"stop-timeout",
endpoint,
True,
pid,
state_dir,
command,
f"AMPB-owned {transport} did not stop before timeout",
provider_source=provider_source,
)
def repair_managed_transport(
transport: str,
*,
config: AppConfig,
root: Path,
wait_seconds: float = 5.0,
) -> ManagedTransportResult:
adapter = adapter_for(transport)
if adapter is None:
return ManagedTransportResult(transport, "-", "unsupported", "-", False, 0, "-", (), "transport adapter not found")
state_dir = _managed_state_dir(root, config, transport)
state_path = Path(state_dir)
stop_result = stop_managed_transport(transport, config=config, root=root, wait_seconds=wait_seconds)
if stop_result.status in {"stop-failed", "stop-timeout"}:
return ManagedTransportResult(
transport,
stop_result.provider,
"repair-blocked",
stop_result.endpoint,
stop_result.owned,
stop_result.pid,
state_dir,
stop_result.command,
f"could not repair AMPB-owned {transport}: {stop_result.message}",
provider_source=stop_result.provider_source,
)
try:
shutil.rmtree(state_path)
except FileNotFoundError:
pass
except OSError as exc:
return ManagedTransportResult(
transport,
stop_result.provider,
"repair-failed",
stop_result.endpoint,
stop_result.owned,
stop_result.pid,
state_dir,
stop_result.command,
f"could not remove AMPB-owned {transport} runtime state: {exc}",
provider_source=stop_result.provider_source,
)
return ManagedTransportResult(
transport,
stop_result.provider,
"repaired",
stop_result.endpoint,
stop_result.owned,
stop_result.pid,
state_dir,
stop_result.command,
f"removed AMPB-owned {transport} runtime state; run `ampbrowser transport start {transport}` to rebuild it",
provider_source=stop_result.provider_source,
)
def _start_tor(
*,
config: AppConfig,
root: Path,
endpoint: str,
wait_seconds: float,
) -> ManagedTransportResult:
provider = _tor_provider(config)
state_dir = _managed_state_dir(root, config, "tor")
if not provider:
return ManagedTransportResult(
"tor",
"-",
"missing-provider",
endpoint,
False,
0,
state_dir,
(),
"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)
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}",
provider_source=provider.source,
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
result = ManagedTransportResult(
"tor",
provider.kind,
"started",
endpoint,
True,
process.pid,
state_dir,
command,
f"started managed {provider.kind} transport",
provider_source=provider.source,
)
_write_owned_state(root, config, result)
return result
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}",
provider_source=provider.source,
)
def _tor_provider(config: AppConfig) -> TorProvider | None:
arti_path = os.environ.get("AMPB_ARTI_BIN")
if arti_path:
return TorProvider("arti", arti_path, "configured")
tor_path = os.environ.get("AMPB_TOR_BIN")
if tor_path:
return TorProvider("tor", tor_path, "configured")
config_path = config.transport_binary("tor")
if config_path:
return TorProvider(_infer_provider_kind(config_path), config_path, "configured")
bundled_arti = _bundled_arti_path()
if bundled_arti:
return TorProvider("arti", bundled_arti, "bundled-sidecar")
system_tor = shutil.which("tor")
if system_tor:
return TorProvider("tor", system_tor, "system-package")
return None
def _bundled_arti_path() -> str:
return _bundled_provider_path(ARTI_RELATIVE_PATH)
def _bundled_i2pd_path() -> str:
return _bundled_provider_path(I2PD_RELATIVE_PATH)
def _bundled_provider_path(relative_path: Path) -> str:
provider_root = os.environ.get("AMPB_PROVIDER_ROOT")
if provider_root:
candidate = Path(provider_root) / relative_path
if candidate.exists() and candidate.is_file():
return str(candidate)
build_root = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build"))
candidate = build_root / 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)
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",
str(data_dir),
"--ClientOnly",
"1",
"--Log",
f"notice file {state_path / 'tor.log'}",
)
def _start_i2p(
*,
config: AppConfig,
root: Path,
endpoint: str,
wait_seconds: float,
) -> ManagedTransportResult:
provider = _i2p_provider(config)
state_dir = _managed_state_dir(root, config, "i2p")
if not provider:
setup = _i2p_setup_hint()
return ManagedTransportResult(
"i2p",
"-",
"missing-provider",
endpoint,
False,
0,
state_dir,
(),
"I2P provider not found. " + setup.message,
setup_hint=setup.message,
install_command=setup.command,
)
state_path = Path(state_dir)
state_path.mkdir(parents=True, exist_ok=True)
_chmod_private(state_path)
command = _i2p_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(
"i2p",
provider.kind,
"start-failed",
endpoint,
False,
0,
state_dir,
command,
f"managed {provider.kind} could not start: {exc}",
provider_source=provider.source,
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
result = ManagedTransportResult(
"i2p",
provider.kind,
"started",
endpoint,
True,
process.pid,
state_dir,
command,
f"started managed {provider.kind} transport",
provider_source=provider.source,
)
_write_owned_state(root, config, result)
return result
process.terminate()
return ManagedTransportResult(
"i2p",
provider.kind,
"start-timeout",
endpoint,
True,
process.pid,
state_dir,
command,
f"managed {provider.kind} did not become ready at {endpoint}",
provider_source=provider.source,
)
def _i2p_provider(config: AppConfig) -> I2PProvider | None:
env_path = os.environ.get("AMPB_I2PD_BIN")
if env_path:
return I2PProvider("i2pd", env_path, "configured")
config_path = config.transport_binary("i2p")
if config_path:
return I2PProvider("i2pd", config_path, "configured")
bundled_i2pd = _bundled_i2pd_path()
if bundled_i2pd:
return I2PProvider("i2pd", bundled_i2pd, "bundled-sidecar")
system_i2pd = shutil.which("i2pd")
if system_i2pd:
return I2PProvider("i2pd", system_i2pd, "system-package")
homebrew_i2pd = _homebrew_i2pd_path()
if homebrew_i2pd:
return I2PProvider("i2pd", homebrew_i2pd, "system-package")
return None
def _stopped_setup_hint(transport: str, config: AppConfig) -> str:
if transport == "i2p" and _i2p_provider(config) is None:
return _i2p_setup_hint().message
return "-"
def _stopped_install_command(transport: str, config: AppConfig) -> tuple[str, ...]:
if transport == "i2p" and _i2p_provider(config) is None:
return _i2p_setup_hint().command
return ()
def _i2p_setup_hint() -> SetupHint:
override = "Or set AMPB_I2PD_BIN or transports.i2p.binary_path to an i2pd binary."
if sys.platform == "darwin":
return SetupHint("Install i2pd with Homebrew: brew install i2pd. " + override, ("brew", "install", "i2pd"))
if shutil.which("pkg"):
return SetupHint("Install i2pd with pkg: pkg install i2pd. " + override, ("pkg", "install", "i2pd"))
if shutil.which("apt"):
return SetupHint("Install i2pd with apt: sudo apt install i2pd. " + override, ("sudo", "apt", "install", "i2pd"))
if shutil.which("dnf"):
return SetupHint("Install i2pd with dnf: sudo dnf install i2pd. " + override, ("sudo", "dnf", "install", "i2pd"))
if shutil.which("pacman"):
return SetupHint("Install i2pd with pacman: sudo pacman -S i2pd. " + override, ("sudo", "pacman", "-S", "i2pd"))
return SetupHint("Install i2pd with your package manager. " + override)
def _homebrew_i2pd_path() -> str:
brew = shutil.which("brew")
if not brew:
return ""
try:
result = subprocess.run(
[brew, "--prefix", "i2pd"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=2,
check=False,
)
except (OSError, subprocess.SubprocessError):
return ""
if result.returncode != 0:
return ""
prefix = Path(result.stdout.strip())
for candidate in (prefix / "bin/i2pd", prefix / "sbin/i2pd"):
if candidate.exists() and candidate.is_file():
return str(candidate)
return ""
def _i2p_command(provider: I2PProvider, *, endpoint: str, state_path: Path) -> tuple[str, ...]:
host, port = _endpoint_host_port(endpoint)
data_dir = state_path / "i2pd-data"
data_dir.mkdir(parents=True, exist_ok=True)
_chmod_private(data_dir)
config_path = state_path / "i2pd.conf"
tunnels_path = state_path / "tunnels.conf"
tunnels_path.touch(exist_ok=True)
config_path.write_text(_i2pd_config(host=host, port=port, state_path=state_path), encoding="utf-8")
_chmod_private(config_path)
_chmod_private(tunnels_path)
return (
provider.binary,
"--conf",
str(config_path),
"--tunconf",
str(tunnels_path),
"--datadir",
str(data_dir),
)
def _i2pd_config(*, host: str, port: int, state_path: Path) -> str:
return "\n".join(
(
"daemon = false",
"service = false",
"notransit = true",
"log = file",
f"logfile = {state_path / 'i2pd-router.log'}",
"loglevel = warn",
"",
"[http]",
"enabled = false",
"",
"[httpproxy]",
"enabled = true",
f"address = {host}",
f"port = {port}",
"addresshelper = true",
"senduseragent = false",
"",
"[socksproxy]",
"enabled = false",
"",
"[sam]",
"enabled = false",
"",
"[bob]",
"enabled = false",
"",
"[i2cp]",
"enabled = false",
"",
"[i2pcontrol]",
"enabled = false",
"",
"[upnp]",
"enabled = false",
"",
)
)
def _managed_state_dir(root: Path, config: AppConfig, transport: str) -> str:
return str(root / config.state_dir / "transports" / transport)
def _owned_state_path(root: Path, config: AppConfig, transport: str) -> Path:
return Path(_managed_state_dir(root, config, transport)) / OWNED_STATE_FILE
def _write_owned_state(root: Path, config: AppConfig, result: ManagedTransportResult) -> None:
state_path = _owned_state_path(root, config, result.transport)
state_path.parent.mkdir(parents=True, exist_ok=True)
_chmod_private(state_path.parent)
data = {
"transport": result.transport,
"provider": result.provider,
"endpoint": result.endpoint,
"pid": result.pid,
"state_dir": result.state_dir,
"command": list(result.command),
"provider_source": result.provider_source,
"started_at": int(time.time()),
}
state_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
_chmod_private(state_path)
def _owned_transport_result(
transport: str,
*,
config: AppConfig,
root: Path,
wait_seconds: float,
) -> ManagedTransportResult | None:
state_path = _owned_state_path(root, config, transport)
state = _read_owned_state(state_path)
if not state:
return None
pid = _state_pid(state)
provider = str(state.get("provider") or "-")
provider_source = str(state.get("provider_source") or "-")
endpoint = str(state.get("endpoint") or _endpoint_for(transport))
state_dir = str(state.get("state_dir") or _managed_state_dir(root, config, transport))
command = tuple(str(part) for part in state.get("command", ()))
if not pid or not _pid_alive(pid):
cleaned = _unlink_state(state_path)
return ManagedTransportResult(
transport,
provider,
"stale" if cleaned else "stale-cleanup-failed",
endpoint,
True,
pid,
state_dir,
command,
(
f"removed stale AMPB-owned {transport} state"
if cleaned
else f"could not remove stale AMPB-owned {transport} state"
),
provider_source=provider_source,
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
return ManagedTransportResult(
transport,
provider,
"ready",
endpoint,
True,
pid,
state_dir,
command,
f"using running AMPB-owned {transport}",
provider_source=provider_source,
)
return ManagedTransportResult(
transport,
provider,
"running",
endpoint,
True,
pid,
state_dir,
command,
f"AMPB-owned {transport} process is running but endpoint is not ready",
provider_source=provider_source,
)
def _read_owned_state(path: Path) -> dict[str, object]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict):
return {}
return data
def _state_pid(state: dict[str, object]) -> int:
value = state.get("pid")
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return 0
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except OSError:
return False
def _unlink_state(path: Path) -> bool:
try:
path.unlink()
return True
except FileNotFoundError:
return True
except OSError:
return False
def _endpoint_for(transport: str) -> str:
adapter = adapter_for(transport)
if not adapter:
return "-"
return adapter.endpoint
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:
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