Add managed I2P provider start path

This commit is contained in:
jwinterm
2026-07-07 22:29:23 -04:00
parent 86ed9aeb5c
commit 29f3a95625
7 changed files with 280 additions and 20 deletions
+4
View File
@@ -44,9 +44,13 @@ python3 -m ampbrowser open http://example.onion/ --yes --launch
python3 -m ampbrowser shell http://example.onion/
python3 -m ampbrowser shell http://example.onion/ --yes
python3 -m ampbrowser open http://example.b32.i2p/ --yes
python3 -m ampbrowser open http://example.b32.i2p/ --yes --launch
python3 -m ampbrowser transport start tor
python3 -m ampbrowser transport start i2p
python3 -m ampbrowser transport status tor
python3 -m ampbrowser transport status i2p
python3 -m ampbrowser transport stop tor
python3 -m ampbrowser transport stop i2p
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 fixture check ../adaptive-multi-protocol-gateway/dist/wownero/ampg-fixture-manifest.json
+174 -13
View File
@@ -25,6 +25,12 @@ class TorProvider:
binary: str
@dataclass(frozen=True)
class I2PProvider:
kind: str
binary: str
@dataclass(frozen=True)
class ManagedTransportResult:
transport: str
@@ -72,20 +78,22 @@ def ensure_transport_ready(
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",
)
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 _start_tor(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(
@@ -368,6 +376,159 @@ def _tor_command(provider: TorProvider, *, endpoint: str, state_path: Path) -> t
)
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:
return ManagedTransportResult(
"i2p",
"-",
"missing-provider",
endpoint,
False,
0,
state_dir,
(),
"I2P provider not found; install i2pd or set AMPB_I2PD_BIN or transports.i2p.binary_path",
)
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}",
)
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",
)
_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}",
)
def _i2p_provider(config: AppConfig) -> I2PProvider | None:
env_path = os.environ.get("AMPB_I2PD_BIN")
if env_path:
return I2PProvider("i2pd", env_path)
config_path = config.transport_binary("i2p")
if config_path:
return I2PProvider("i2pd", config_path)
system_i2pd = shutil.which("i2pd")
if system_i2pd:
return I2PProvider("i2pd", system_i2pd)
return None
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),
"--daemon=false",
"--service=false",
)
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)
+8
View File
@@ -94,6 +94,14 @@ workspace:
`AMPB_ARTI_BIN`, `AMPB_TOR_BIN`, configured `transports.tor.binary_path`, and finally a
system `tor` binary if one is present.
## I2P Provider Probe
Managed I2P startup currently targets `i2pd`. AMPB adopts an existing HTTP proxy on
`127.0.0.1:4444` when available; otherwise `ampbrowser open <i2p-url> --yes --launch`
uses `AMPB_I2PD_BIN`, configured `transports.i2p.binary_path`, or a system `i2pd` binary.
The AMPB-owned process writes config, router data, logs, and ownership metadata under
`.ampb/transports/i2p`.
## Source Plan
Print the canonical source targets and paths:
+2
View File
@@ -42,6 +42,8 @@ socks_endpoint = "127.0.0.1:9050"
[transports.i2p]
enabled = true
mode = "adopt-or-prompt-manage"
# Optional. Used when AMPB should manage a local i2pd binary.
binary_path = ""
http_proxy = "127.0.0.1:4444"
[transports.ipfs]
+6 -5
View File
@@ -9,8 +9,8 @@ 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.
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
With `--launch --yes`, AMPB can start configured Tor and I2P providers with AMPB-owned
state before launching. Transport policy comes from `.ampb/config.toml` or an explicit
`--config` path.
`ampbrowser transport start|status|stop <name>` exposes the same ownership model without
@@ -39,9 +39,10 @@ AMPB records owned process metadata in `.ampb/transports/<name>/ampb-owned.json`
## Current Adapters
- 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.
- Tor: detect SOCKS on `127.0.0.1:9050`; managed start supports Arti, configured `tor`,
and system `tor` providers with AMPB-owned state.
- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start supports configured or system
`i2pd` providers with AMPB-owned state.
- 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.
- Reticulum: resilient/private route contract exists; adapter and readiness model are planned.
+2
View File
@@ -13,6 +13,8 @@ binary_path = ""
[transports.i2p]
enabled = true
mode = "adopt-or-prompt-manage"
# Optional. Used when AMPB should manage a local i2pd binary.
binary_path = ""
[transports.ipfs]
enabled = true
+84 -2
View File
@@ -252,7 +252,26 @@ class TransportManagerTest(unittest.TestCase):
self.assertFalse(result.ready)
popen.return_value.terminate.assert_called_once()
def test_non_tor_managed_start_is_reported_as_unsupported(self) -> None:
def test_reports_missing_i2p_provider(self) -> None:
status = TransportStatus(
transport="i2p",
installed=False,
running=False,
endpoint="http://127.0.0.1:4444",
adoptable=False,
manage_supported=True,
note="I2P HTTP proxy",
)
with tempfile.TemporaryDirectory() as tmp:
with patch("ampbrowser.transport_manager.shutil.which", return_value=None):
result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
self.assertEqual("missing-provider", result.status)
self.assertFalse(result.ready)
self.assertIn("I2P provider not found", result.message)
def test_starts_configured_i2pd_provider_with_ampb_state(self) -> None:
status = TransportStatus(
transport="i2p",
installed=True,
@@ -262,9 +281,72 @@ class TransportManagerTest(unittest.TestCase):
manage_supported=True,
note="I2P HTTP proxy",
)
config = AppConfig(transport_modes={}, transport_binaries={"i2p": "/opt/ampb/i2pd"})
with tempfile.TemporaryDirectory() as tmp:
result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
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("i2p", config=config, root=root, status=status)
self.assertEqual("started", result.status)
self.assertEqual("i2pd", result.provider)
self.assertTrue(result.owned)
self.assertEqual(1234, result.pid)
self.assertEqual(str(root / ".ampb/transports/i2p"), result.state_dir)
self.assertEqual("/opt/ampb/i2pd", result.command[0])
self.assertIn("--conf", result.command)
self.assertIn(str(root / ".ampb/transports/i2p/i2pd.conf"), result.command)
self.assertIn("--datadir", result.command)
self.assertIn(str(root / ".ampb/transports/i2p/i2pd-data"), result.command)
self.assertIn("--daemon=false", result.command)
self.assertIn("--service=false", result.command)
config_text = (root / ".ampb/transports/i2p/i2pd.conf").read_text(encoding="utf-8")
self.assertIn("[httpproxy]", config_text)
self.assertIn("address = 127.0.0.1", config_text)
self.assertIn("port = 4444", config_text)
self.assertIn("notransit = true", config_text)
state_path = root / ".ampb/transports/i2p/ampb-owned.json"
self.assertTrue(state_path.exists())
self.assertIn('"provider": "i2pd"', state_path.read_text(encoding="utf-8"))
popen.assert_called_once()
def test_terminates_owned_i2pd_process_on_timeout(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",
)
config = AppConfig(transport_modes={}, transport_binaries={"i2p": "/opt/ampb/i2pd"})
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("i2p", 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_other_managed_start_is_reported_as_unsupported(self) -> None:
status = TransportStatus(
transport="ipfs",
installed=True,
running=False,
endpoint="http://127.0.0.1:8080",
adoptable=False,
manage_supported=True,
note="IPFS local gateway",
)
with tempfile.TemporaryDirectory() as tmp:
result = ensure_transport_ready("ipfs", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
self.assertEqual("unsupported", result.status)
self.assertFalse(result.ready)