Route remote localhost browser traffic through SSH proxy
This commit is contained in:
parent
e97378846c
commit
803912a9e3
3 changed files with 309 additions and 3 deletions
|
|
@ -1124,6 +1124,13 @@ private enum BrowserInsecureHTTPNavigationIntent {
|
|||
final class BrowserPanel: Panel, ObservableObject {
|
||||
/// Shared process pool for cookie sharing across all browser panels
|
||||
private static let sharedProcessPool = WKProcessPool()
|
||||
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
|
||||
private static let remoteLoopbackHosts: Set<String> = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"0.0.0.0",
|
||||
]
|
||||
|
||||
static let telemetryHookBootstrapScriptSource = """
|
||||
(() => {
|
||||
|
|
@ -1621,10 +1628,31 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
applyRemoteProxyConfigurationIfAvailable()
|
||||
|
||||
if shouldRestoreNavigation, let restoreURL {
|
||||
replacement.load(browserPreparedNavigationRequest(URLRequest(url: restoreURL)))
|
||||
replacement.load(preparedNavigationRequest(URLRequest(url: restoreURL)))
|
||||
}
|
||||
}
|
||||
|
||||
private func rewriteLoopbackURLForRemoteProxyIfNeeded(_ url: URL) -> URL {
|
||||
guard remoteProxyEndpoint != nil else { return url }
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return url }
|
||||
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url }
|
||||
guard Self.remoteLoopbackHosts.contains(host) else { return url }
|
||||
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
components?.host = Self.remoteLoopbackProxyAliasHost
|
||||
return components?.url ?? url
|
||||
}
|
||||
|
||||
private func preparedNavigationRequest(_ request: URLRequest) -> URLRequest {
|
||||
var prepared = browserPreparedNavigationRequest(request)
|
||||
guard let url = prepared.url else { return prepared }
|
||||
let rewrittenURL = rewriteLoopbackURLForRemoteProxyIfNeeded(url)
|
||||
if rewrittenURL != url {
|
||||
prepared.url = rewrittenURL
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
focusFlashToken &+= 1
|
||||
}
|
||||
|
|
@ -2006,7 +2034,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
|
||||
}
|
||||
navigationDelegate?.lastAttemptedURL = url
|
||||
webView.load(browserPreparedNavigationRequest(request))
|
||||
webView.load(preparedNavigationRequest(request))
|
||||
}
|
||||
|
||||
/// Navigate with smart URL/search detection
|
||||
|
|
|
|||
|
|
@ -1080,6 +1080,7 @@ private final class WorkspaceRemoteDaemonRPCClient {
|
|||
private final class WorkspaceRemoteDaemonProxyTunnel {
|
||||
private final class ProxySession {
|
||||
private static let maxHandshakeBytes = 64 * 1024
|
||||
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
|
||||
|
||||
private enum HandshakeProtocol {
|
||||
case undecided
|
||||
|
|
@ -1361,7 +1362,8 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
|
|||
) {
|
||||
guard !isClosed else { return }
|
||||
do {
|
||||
let streamID = try rpcClient.openStream(host: host, port: port)
|
||||
let targetHost = Self.normalizedProxyTargetHost(host)
|
||||
let streamID = try rpcClient.openStream(host: targetHost, port: port)
|
||||
self.streamID = streamID
|
||||
connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in
|
||||
guard let self else { return }
|
||||
|
|
@ -1503,6 +1505,17 @@ private final class WorkspaceRemoteDaemonProxyTunnel {
|
|||
return (host, port)
|
||||
}
|
||||
|
||||
private static func normalizedProxyTargetHost(_ host: String) -> String {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
.lowercased()
|
||||
if normalized == remoteLoopbackProxyAliasHost {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data {
|
||||
var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n"
|
||||
if closeAfterResponse {
|
||||
|
|
@ -5023,6 +5036,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
terminalPanel.updateWorkspaceId(id)
|
||||
} else if let browserPanel = detached.panel as? BrowserPanel {
|
||||
browserPanel.updateWorkspaceId(id)
|
||||
browserPanel.setRemoteProxyEndpoint(remoteProxyEndpoint)
|
||||
installBrowserPanelSubscription(browserPanel)
|
||||
}
|
||||
|
||||
|
|
|
|||
264
tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py
Normal file
264
tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: moving a browser surface into an SSH workspace must rebind remote proxy state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock")
|
||||
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
|
||||
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
|
||||
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
|
||||
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
resolved = str(row.get("id") or "")
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
new_ids = sorted(current - before_workspace_ids)
|
||||
if len(new_ids) == 1:
|
||||
return new_ids[0]
|
||||
|
||||
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
|
||||
|
||||
|
||||
def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 60.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last = {}
|
||||
while time.time() < deadline:
|
||||
last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
proxy = remote.get("proxy") or {}
|
||||
if (
|
||||
str(remote.get("state") or "") == "connected"
|
||||
and str(daemon.get("state") or "") == "ready"
|
||||
and str(proxy.get("state") or "") == "ready"
|
||||
):
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}")
|
||||
|
||||
|
||||
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
|
||||
) or {}
|
||||
return str(payload.get("text") or "")
|
||||
|
||||
|
||||
def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if token in _surface_scrollback_text(client, workspace_id, surface_id):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise cmuxError(f"Timed out waiting for remote terminal token: {token}")
|
||||
|
||||
|
||||
def _browser_body_text(client: cmux, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"browser.eval",
|
||||
{
|
||||
"surface_id": surface_id,
|
||||
"script": "document.body ? (document.body.innerText || '') : ''",
|
||||
},
|
||||
) or {}
|
||||
return str(payload.get("value") or "")
|
||||
|
||||
|
||||
def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_text = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
last_text = _browser_body_text(client, surface_id)
|
||||
except cmuxError:
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
if token in last_text:
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SSH_HOST:
|
||||
print("SKIP: set CMUX_SSH_TEST_HOST to run remote browser move/proxy regression")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
remote_workspace_id = ""
|
||||
remote_surface_id = ""
|
||||
|
||||
stamp = secrets.token_hex(4)
|
||||
marker_file = f"CMUX_REMOTE_PROXY_MOVE_{stamp}.txt"
|
||||
marker_body = f"CMUX_REMOTE_PROXY_BODY_{stamp}"
|
||||
ready_token = f"CMUX_HTTP_READY_{stamp}"
|
||||
default_web_port = 20000 + (os.getpid() % 5000)
|
||||
ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port)))
|
||||
url = f"http://localhost:{ssh_web_port}/{marker_file}"
|
||||
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
|
||||
browser_surface_id = client.open_browser("about:blank")
|
||||
_must(bool(browser_surface_id), "browser.open_split returned no surface")
|
||||
|
||||
ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-move-proxy-{stamp}"]
|
||||
if SSH_PORT:
|
||||
ssh_args.extend(["--port", SSH_PORT])
|
||||
if SSH_IDENTITY:
|
||||
ssh_args.extend(["--identity", SSH_IDENTITY])
|
||||
if SSH_OPTIONS_RAW:
|
||||
for option in SSH_OPTIONS_RAW.split(","):
|
||||
trimmed = option.strip()
|
||||
if trimmed:
|
||||
ssh_args.extend(["--ssh-option", trimmed])
|
||||
|
||||
payload = _run_cli_json(cli, ssh_args)
|
||||
remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
|
||||
_wait_remote_ready(client, remote_workspace_id, timeout_s=65.0)
|
||||
|
||||
surfaces = client.list_surfaces(remote_workspace_id)
|
||||
_must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}")
|
||||
remote_surface_id = str(surfaces[0][1])
|
||||
|
||||
server_script = (
|
||||
f"printf '%s\\n' {marker_body} > /tmp/{marker_file}; "
|
||||
f"python3 -m http.server {ssh_web_port} --directory /tmp >/tmp/cmux-remote-browser-proxy-{stamp}.log 2>&1 & "
|
||||
"for _ in $(seq 1 30); do "
|
||||
f" if curl -fsS http://localhost:{ssh_web_port}/{marker_file} | grep -q {marker_body}; then "
|
||||
f" echo {ready_token}; "
|
||||
" break; "
|
||||
" fi; "
|
||||
" sleep 0.2; "
|
||||
"done"
|
||||
)
|
||||
client._call(
|
||||
"surface.send_text",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script},
|
||||
)
|
||||
client._call(
|
||||
"surface.send_key",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"},
|
||||
)
|
||||
_wait_surface_contains(client, remote_workspace_id, remote_surface_id, ready_token, timeout_s=12.0)
|
||||
|
||||
browser_surface_id = str(client._resolve_surface_id(browser_surface_id))
|
||||
client.move_surface(browser_surface_id, workspace=remote_workspace_id, focus=True)
|
||||
|
||||
def _browser_in_remote_workspace() -> bool:
|
||||
for _idx, sid, _focused in client.list_surfaces(remote_workspace_id):
|
||||
if str(sid) == browser_surface_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
_wait_for(_browser_in_remote_workspace, timeout_s=10.0, step_s=0.15)
|
||||
|
||||
client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url})
|
||||
_wait_browser_contains(client, browser_surface_id, marker_body, timeout_s=20.0)
|
||||
|
||||
body = _browser_body_text(client, browser_surface_id)
|
||||
_must(marker_body in body, f"browser did not load remote localhost content over SSH proxy: {body[:240]!r}")
|
||||
_must("Can't reach this page" not in body, f"browser rendered local error page instead of remote content: {body[:240]!r}")
|
||||
|
||||
print("PASS: browser moved into ssh workspace rebinds proxy endpoint and reaches remote localhost")
|
||||
return 0
|
||||
finally:
|
||||
if remote_surface_id and remote_workspace_id:
|
||||
try:
|
||||
cleanup = f"pkill -f 'python3 -m http.server {ssh_web_port}' >/dev/null 2>&1 || true"
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client._call(
|
||||
"surface.send_text",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup},
|
||||
)
|
||||
cleanup_client._call(
|
||||
"surface.send_key",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue