diff --git a/backend/druks/browser/login.py b/backend/druks/browser/login.py index cf61691d..5419297b 100644 --- a/backend/druks/browser/login.py +++ b/backend/druks/browser/login.py @@ -1,5 +1,6 @@ import asyncio import json +import shlex import tempfile from pathlib import Path from urllib.parse import urlsplit @@ -51,7 +52,7 @@ async def open(cls, session: StoredBrowserSession) -> "LoginWindow": raise exceptions.BrowserLaunchError(session.name, str(error)) from error try: await _seed(browser, session) - await _launch(browser, session.name) + await _launch(browser, session.name, settings.sandbox.browser_login_proxy) except BaseException: await sandbox_client.release(host_id=browser.id) raise @@ -167,12 +168,16 @@ async def _seed(browser: Sandbox, session: StoredBrowserSession) -> None: await browser.upload_file(local=meta, remote=f"{SESSION_ROOT}/state.meta.json") -async def _launch(browser: Sandbox, name: str) -> None: +async def _launch(browser: Sandbox, name: str, login_proxy: str) -> None: + # A login-only egress proxy: the launcher reads it from its environment. + # Empty leaves the browser on the box's own IP. + proxy_env = f"env DRUKS_BROWSER_LOGIN_PROXY={shlex.quote(login_proxy)} " if login_proxy else "" ready = await browser.exec( [ "sh", "-c", - f"nohup setsid session-launch --headed >{SESSION_ROOT}/launch.log 2>&1 {SESSION_ROOT}/launch.log 2>&1 /dev/null; then ' diff --git a/backend/druks/settings.py b/backend/druks/settings.py index ec8de208..ef56fb9a 100644 --- a/backend/druks/settings.py +++ b/backend/druks/settings.py @@ -157,6 +157,11 @@ class Sandbox(BaseModel): # The browser home: browser containers boot on this provider with this image. browser_sandbox_provider: str = "docker" browser_sandbox_image: str = "ghcr.io/czpython/druks-browser:latest" + # An HTTP proxy the login window routes through, so the login egresses from a + # different IP than the box — for sign-in flows that reject the box's own + # address. Authless address; credentials, if any, are terminated deploy-side. + # Empty → the box's own IP. Only the login window uses it; borrows keep it. + browser_login_proxy: str = "" # Sized for the slowest provisioner. timeout: float = 180.0 diff --git a/backend/druks/setup_env.py b/backend/druks/setup_env.py index 7b8702db..31bb5087 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -65,7 +65,14 @@ "secrets_key", ), "paths": ("data_dir", "claude_home", "codex_home", "claude_json"), - "sandbox": ("provider", "service_url", "service_token", "image", "timeout"), + "sandbox": ( + "provider", + "service_url", + "service_token", + "image", + "browser_login_proxy", + "timeout", + ), "env": (), } @@ -174,6 +181,11 @@ def run_setup( service_url = "" service_token = "" image = "" +# An HTTP proxy the login window routes through, so the login egresses from a +# different IP than the box — for sign-in flows that reject the box's own +# address. Authless address, e.g. http://172.17.0.1:8888. Empty => the box's own +# IP. Only the login window uses it; borrows keep it. See configuration.md. +browser_login_proxy = "" timeout = 180 # Put drukbox environment in [sandbox.]. The table is passed through diff --git a/backend/tests/test_browser_session_login_window.py b/backend/tests/test_browser_session_login_window.py index 69676314..ad6ee680 100644 --- a/backend/tests/test_browser_session_login_window.py +++ b/backend/tests/test_browser_session_login_window.py @@ -1,4 +1,5 @@ import json +import shlex from contextlib import asynccontextmanager from types import SimpleNamespace @@ -25,12 +26,14 @@ class FakeSandbox: def __init__(self, sandbox_id: str) -> None: self.id = sandbox_id self.files: dict[str, bytes] = {} + self.launch_command: str | None = None self.export_exit_code = 0 self.export_payload = b"fresh-profile" async def exec(self, command: list[str], *, timeout: float = 30.0) -> ExecResult: del timeout if command[:2] == ["sh", "-c"] and "session-launch --headed" in command[2]: + self.launch_command = command[2] self.files["/work/session/.runtime/ready.json"] = b"{}\n" if command == ["session-export"]: if self.export_exit_code: @@ -87,6 +90,45 @@ def create_session(name: str = "x-main") -> StoredBrowserSession: ) +async def test_login_launch_leaves_the_box_ip_when_no_proxy_is_set(window_runtime): + client = window_runtime + + await LoginWindow.open(create_session()) + + assert "DRUKS_BROWSER_LOGIN_PROXY" not in (client.browsers[0].launch_command or "") + + +def _runtime_with_proxy(tmp_path, monkeypatch, proxy: str) -> FakeSandboxClient: + settings = make_settings(tmp_path, sandbox={"browser_login_proxy": proxy}) + client = FakeSandboxClient() + monkeypatch.setattr(login, "sandbox_client", client) + monkeypatch.setattr(login, "load_settings", lambda: settings) + monkeypatch.setattr(secret_utils, "load_settings", lambda: settings) + return client + + +async def test_login_launch_routes_through_the_configured_proxy(tmp_path, monkeypatch): + proxy = "http://172.17.0.1:8888" + client = _runtime_with_proxy(tmp_path, monkeypatch, proxy) + + await LoginWindow.open(create_session()) + + command = client.browsers[0].launch_command or "" + assert f"env DRUKS_BROWSER_LOGIN_PROXY={shlex.quote(proxy)} session-launch --headed" in command + + +async def test_login_launch_quotes_the_proxy_so_a_bad_value_cannot_inject(tmp_path, monkeypatch): + proxy = "http://h:8888; rm -rf /" + client = _runtime_with_proxy(tmp_path, monkeypatch, proxy) + + await LoginWindow.open(create_session()) + + command = client.browsers[0].launch_command or "" + assert shlex.quote(proxy) in command + # The metacharacters survive only inside the quoted word, never as live shell. + assert "; rm -rf /" not in command.replace(shlex.quote(proxy), "") + + async def test_open_seeds_a_blank_profile_and_records_the_container(window_runtime): client = window_runtime session = create_session() diff --git a/deploy/browser/session-launch b/deploy/browser/session-launch index 56e20d18..c8e57176 100755 --- a/deploy/browser/session-launch +++ b/deploy/browser/session-launch @@ -18,6 +18,9 @@ const PROFILE_PATH = path.join(SESSION_ROOT, "profile"); // the login browser must be real Chrome wherever it can be. const CHROME_PATH = "/opt/google/chrome/chrome"; const channel = fs.existsSync(CHROME_PATH) ? "chrome" : undefined; +// A login-only egress proxy, set by druks only when the operator configured +// one. Empty leaves the browser on the box's own IP. +const loginProxy = process.env.DRUKS_BROWSER_LOGIN_PROXY; const RUNTIME_PATH = path.join(SESSION_ROOT, ".runtime"); const PID_PATH = path.join(RUNTIME_PATH, "launcher.pid"); const READY_PATH = path.join(RUNTIME_PATH, "ready.json"); @@ -93,6 +96,7 @@ async function main() { try { context = await chromium.launchPersistentContext(PROFILE_PATH, { channel, + proxy: loginProxy ? { server: loginProxy } : undefined, args: [ "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9222", @@ -101,6 +105,10 @@ async function main() { // reads to challenge the session. The operator drives a real login // here, so the browser must not announce itself as driven. "--disable-blink-features=AutomationControlled", + // WebRTC gathers ICE candidates over raw UDP, which bypasses an HTTP + // proxy and would reveal the box's real address. Only matters with a + // login proxy set. + ...(loginProxy ? ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"] : []), ], handleSIGHUP: false, handleSIGINT: false, diff --git a/docs/configuration.md b/docs/configuration.md index 29161f53..b23d26ae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -260,10 +260,24 @@ connected. | `sandbox.service_token` | Drukbox API token | | `sandbox.timeout` | Control-plane request timeout; default 180 seconds | | `sandbox.image` | Optional provider image override | +| `sandbox.browser_login_proxy` | Login-window egress proxy; empty keeps the box IP | `DRUKS_SANDBOX_KEYS_DIR` remains a process environment override for the per-host SSH private-key directory. +`[sandbox].browser_login_proxy` routes the browser **login window** through an +HTTP proxy, so the login egresses from a different IP than the box — for sign-in +flows that reject a login from the box's own address. It applies to the login +window only — borrows keep the box IP, which is enough once the session is +minted. The value is an authless proxy address, e.g. `http://172.17.0.1:8888`; +credentials, if any, are terminated deploy-side, so no proxy secret enters +Druks. Two ways to stand an exit up: run a CONNECT proxy on an always-on device +reachable over the tailnet, or run a local `gost` relay +(`gost -L=http://172.17.0.1:8888 -F=http://USER:PASS@host:PORT`) in front of a +proxy you provide. Leaving it empty keeps today's behavior. A fixed proxy fails +closed — if the exit is unreachable the login browser errors rather than falling +back to the box IP. + `[sandbox].provider` accepts any Drukbox provider name. `docker` selects the local install shape, `exe` selects the exe.dev + tailnet shape, and every other name selects the generic remote shape. Provider-specific credentials and host