Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions backend/druks/browser/login.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import json
import shlex
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 </dev/null & "
f"nohup setsid {proxy_env}session-launch --headed "
f">{SESSION_ROOT}/launch.log 2>&1 </dev/null & "
'launcher=$!; attempt=0; while [ "$attempt" -lt 300 ]; do '
f"if [ -f {SESSION_ROOT}/.runtime/ready.json ]; then exit 0; fi; "
'if ! kill -0 "$launcher" 2>/dev/null; then '
Expand Down
5 changes: 5 additions & 0 deletions backend/druks/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 13 additions & 1 deletion backend/druks/setup_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (),
}

Expand Down Expand Up @@ -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.<provider>]. The table is passed through
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/test_browser_session_login_window.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import shlex
from contextlib import asynccontextmanager
from types import SimpleNamespace

Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions deploy/browser/session-launch
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading