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/
python3 -m ampbrowser shell http://example.onion/ --yes 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
python3 -m ampbrowser open http://example.b32.i2p/ --yes --launch
python3 -m ampbrowser transport start tor python3 -m ampbrowser transport start tor
python3 -m ampbrowser transport start i2p
python3 -m ampbrowser transport status tor python3 -m ampbrowser transport status tor
python3 -m ampbrowser transport status i2p
python3 -m ampbrowser transport stop tor 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/ --config examples/config.toml
python3 -m ampbrowser open http://example.b32.i2p/ --platform android 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 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 binary: str
@dataclass(frozen=True)
class I2PProvider:
kind: str
binary: str
@dataclass(frozen=True) @dataclass(frozen=True)
class ManagedTransportResult: class ManagedTransportResult:
transport: str transport: str
@@ -72,20 +78,22 @@ def ensure_transport_ready(
f"adopted existing {transport} transport", f"adopted existing {transport} transport",
) )
if transport != "tor": if transport == "tor":
return ManagedTransportResult( return _start_tor(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds)
transport, if transport == "i2p":
"-", return _start_i2p(config=config, root=root, endpoint=status.endpoint, wait_seconds=wait_seconds)
"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) 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( 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: def _managed_state_dir(root: Path, config: AppConfig, transport: str) -> str:
return str(root / config.state_dir / "transports" / transport) 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 `AMPB_ARTI_BIN`, `AMPB_TOR_BIN`, configured `transports.tor.binary_path`, and finally a
system `tor` binary if one is present. 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 ## Source Plan
Print the canonical source targets and paths: Print the canonical source targets and paths:
+2
View File
@@ -42,6 +42,8 @@ socks_endpoint = "127.0.0.1:9050"
[transports.i2p] [transports.i2p]
enabled = true enabled = true
mode = "adopt-or-prompt-manage" mode = "adopt-or-prompt-manage"
# Optional. Used when AMPB should manage a local i2pd binary.
binary_path = ""
http_proxy = "127.0.0.1:4444" http_proxy = "127.0.0.1:4444"
[transports.ipfs] [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 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 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. 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 With `--launch --yes`, AMPB can start configured Tor and I2P providers with AMPB-owned
before launching. Transport policy comes from `.ampb/config.toml` or an explicit state before launching. Transport policy comes from `.ampb/config.toml` or an explicit
`--config` path. `--config` path.
`ampbrowser transport start|status|stop <name>` exposes the same ownership model without `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 ## Current Adapters
- Tor: detect SOCKS on `127.0.0.1:9050`; managed start supports a configured `tor` binary - Tor: detect SOCKS on `127.0.0.1:9050`; managed start supports Arti, configured `tor`,
with AMPB-owned state; packaged Arti/Tor bundling remains a packaging target. and system `tor` providers with AMPB-owned state.
- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start planned. - 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. - 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. - 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. - Reticulum: resilient/private route contract exists; adapter and readiness model are planned.
+2
View File
@@ -13,6 +13,8 @@ binary_path = ""
[transports.i2p] [transports.i2p]
enabled = true enabled = true
mode = "adopt-or-prompt-manage" mode = "adopt-or-prompt-manage"
# Optional. Used when AMPB should manage a local i2pd binary.
binary_path = ""
[transports.ipfs] [transports.ipfs]
enabled = true enabled = true
+84 -2
View File
@@ -252,7 +252,26 @@ class TransportManagerTest(unittest.TestCase):
self.assertFalse(result.ready) self.assertFalse(result.ready)
popen.return_value.terminate.assert_called_once() 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( status = TransportStatus(
transport="i2p", transport="i2p",
installed=True, installed=True,
@@ -262,9 +281,72 @@ class TransportManagerTest(unittest.TestCase):
manage_supported=True, manage_supported=True,
note="I2P HTTP proxy", note="I2P HTTP proxy",
) )
config = AppConfig(transport_modes={}, transport_binaries={"i2p": "/opt/ampb/i2pd"})
with tempfile.TemporaryDirectory() as tmp: 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.assertEqual("unsupported", result.status)
self.assertFalse(result.ready) self.assertFalse(result.ready)