aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/pages/proxy.js
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-12 15:03:03 +0100
committerGitHub Enterprise <[email protected]>2026-03-12 15:03:03 +0100
commit81bc43aa96f0059cecb28d1bd88338b7d84667f9 (patch)
treea3428cb7fddceae0b284d33562af5bf3e64a367e /src/zenserver/frontend/html/pages/proxy.js
parentupdate fmt 12.0.0 -> 12.1.0 (#828) (diff)
downloadzen-81bc43aa96f0059cecb28d1bd88338b7d84667f9.tar.xz
zen-81bc43aa96f0059cecb28d1bd88338b7d84667f9.zip
Transparent proxy mode (#823)
Adds a **transparent TCP proxy mode** to zenserver (activated via `zenserver proxy`), allowing it to sit between clients and upstream Zen servers to inspect and monitor HTTP/1.x traffic in real time. Primarily useful during development, to be able to observe multi-server/client interactions in one place. - **Dedicated proxy port** -- Proxy mode defaults to port 8118 with its own data directory to avoid collisions with a normal zenserver instance. - **TCP proxy core** (`src/zenserver/proxy/`) -- A new transparent TCP proxy that forwards connections to upstream targets, with support for both TCP/IP and Unix socket listeners. Multi-threaded I/O for connection handling. Supports Unix domain sockets for both upstream/downstream. - **HTTP traffic inspection** -- Parses HTTP/1.x request/response streams inline to extract method, path, status, content length, and WebSocket upgrades without breaking the proxied data. - **Proxy dashboard** -- A web UI showing live connection stats, per-target request counts, active connections, bytes transferred, and client IP/session ID rollups. - **Server mode display** -- Dashboard banner now shows the running server mode (Zen Proxy, Zen Compute, etc.). Supporting changes included in this branch: - **Wildcard log level matching** -- Log levels can now be set per-category using wildcard patterns (e.g. `proxy.*=debug`). - **`zen down --all`** -- New flag to shut down all running zenserver instances; also used by the new `xmake kill` task. - Minor test stability fixes (flaky hash collisions, per-thread RNG seeds). - Support ZEN_MALLOC environment variable for default allocator selection and switch default to rpmalloc - Fixed sentry-native build to allow LTO on Windows
Diffstat (limited to 'src/zenserver/frontend/html/pages/proxy.js')
-rw-r--r--src/zenserver/frontend/html/pages/proxy.js452
1 files changed, 452 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/proxy.js b/src/zenserver/frontend/html/pages/proxy.js
new file mode 100644
index 000000000..50e1f255a
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/proxy.js
@@ -0,0 +1,452 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+import { ZenPage } from "./page.js"
+import { Fetcher } from "../util/fetcher.js"
+import { Friendly } from "../util/friendly.js"
+import { Table } from "../util/widgets.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ generate_crumbs() {}
+
+ async main()
+ {
+ this.set_title("proxy");
+
+ // Recording
+ const record_section = this.add_section("Recording");
+ this._record_host = record_section;
+ this._init_record_controls(record_section);
+
+ // Summary
+ const summary_section = this.add_section("Summary");
+ this._summary_grid = summary_section.tag().classify("grid").classify("stats-tiles");
+
+ // Mappings
+ const mappings_section = this.add_section("Proxy Mappings");
+ this._mappings_host = mappings_section;
+ this._mappings_table = null;
+
+ // Active Connections
+ const connections_section = this.add_section("Active Connections");
+ this._connections_host = connections_section;
+ this._connections_table = null;
+
+ try { this._view_mode = localStorage.getItem("zen-proxy-view-mode") || "per-conn"; } catch (e) { this._view_mode = "per-conn"; }
+ this._init_view_tabs(connections_section);
+
+ await this._update();
+ this._connect_stats_ws();
+ }
+
+ async _update()
+ {
+ try
+ {
+ const data = await new Fetcher().resource("/proxy/stats").json();
+ this._render_summary(data);
+ this._render_mappings(data);
+ this._render_connections(data);
+ }
+ catch (e) { /* service unavailable */ }
+ }
+
+ _connect_stats_ws()
+ {
+ try
+ {
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(`${proto}//${location.host}/stats`);
+
+ try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; }
+ document.addEventListener("zen-ws-toggle", (e) => {
+ this._ws_paused = e.detail.paused;
+ });
+
+ ws.onmessage = (ev) => {
+ if (this._ws_paused)
+ {
+ return;
+ }
+ try
+ {
+ const all_stats = JSON.parse(ev.data);
+ const data = all_stats["proxy"];
+ if (data)
+ {
+ this._render_summary(data);
+ this._render_mappings(data);
+ this._render_connections(data);
+ }
+ }
+ catch (e) { /* ignore parse errors */ }
+ };
+
+ ws.onclose = () => { this._stats_ws = null; };
+ ws.onerror = () => { ws.close(); };
+
+ this._stats_ws = ws;
+ }
+ catch (e) { /* WebSocket not available */ }
+ }
+
+ _init_record_controls(host)
+ {
+ const container = host.tag().classify("card");
+ container.inner().style.display = "flex";
+ container.inner().style.alignItems = "center";
+ container.inner().style.gap = "12px";
+ container.inner().style.padding = "12px 16px";
+
+ this._record_btn = document.createElement("button");
+ this._record_btn.className = "history-tab";
+ this._record_btn.textContent = "Start Recording";
+ this._record_btn.addEventListener("click", () => this._toggle_recording());
+ container.inner().appendChild(this._record_btn);
+
+ this._record_status = document.createElement("span");
+ this._record_status.style.fontSize = "0.85em";
+ this._record_status.style.opacity = "0.7";
+ this._record_status.textContent = "Off";
+ container.inner().appendChild(this._record_status);
+
+ this._recording = false;
+ }
+
+ _update_record_ui(data)
+ {
+ const recording = !!data.recording;
+ this._recording = recording;
+
+ this._record_btn.textContent = recording ? "Stop Recording" : "Start Recording";
+ this._record_btn.classList.toggle("active", recording);
+
+ const dir = data.recordDir || "";
+ this._record_status.textContent = recording ? "Recording to: " + dir : "Off";
+ }
+
+ async _toggle_recording()
+ {
+ try
+ {
+ const endpoint = this._recording ? "/proxy/record/stop" : "/proxy/record/start";
+ await fetch(endpoint, { method: "POST" });
+ }
+ catch (e) { /* ignore */ }
+ }
+
+ _render_summary(data)
+ {
+ this._update_record_ui(data);
+
+ const grid = this._summary_grid;
+ grid.inner().innerHTML = "";
+
+ const mappings = data.mappings || [];
+ let totalActive = 0;
+ let totalPeak = 0;
+ let totalConn = 0;
+ let totalBytes = 0;
+ let totalRequestRate1 = 0;
+ let totalByteRate1 = 0;
+ let totalByteRate5 = 0;
+
+ for (const m of mappings)
+ {
+ totalActive += (m.activeConnections || 0);
+ totalPeak += (m.peakActiveConnections || 0);
+ totalConn += (m.totalConnections || 0);
+ totalBytes += (m.bytesFromClient || 0) + (m.bytesToClient || 0);
+ totalRequestRate1 += (m.requestRate1 || 0);
+ totalByteRate1 += (m.byteRate1 || 0);
+ totalByteRate5 += (m.byteRate5 || 0);
+ }
+
+ {
+ const tile = grid.tag().classify("card").classify("stats-tile");
+ tile.tag().classify("card-title").text("Connections");
+ const body = tile.tag().classify("tile-metrics");
+ this._metric(body, Friendly.sep(totalActive), "currently open", true);
+ this._metric(body, Friendly.sep(totalPeak), "peak");
+ this._metric(body, Friendly.sep(totalConn), "total since startup");
+ }
+
+ {
+ const tile = grid.tag().classify("card").classify("stats-tile");
+ tile.tag().classify("card-title").text("Throughput");
+ const body = tile.tag().classify("tile-metrics");
+ this._metric(body, Friendly.sep(totalRequestRate1, 1) + "/s", "req/sec (1m)", true);
+ this._metric(body, Friendly.bytes(totalByteRate1) + "/s", "bandwidth (1m)", true);
+ this._metric(body, Friendly.bytes(totalByteRate5) + "/s", "bandwidth (5m)");
+ this._metric(body, Friendly.bytes(totalBytes), "total transferred");
+ }
+ }
+
+ _render_mappings(data)
+ {
+ const mappings = data.mappings || [];
+
+ if (this._mappings_table)
+ {
+ this._mappings_table.clear();
+ }
+ else
+ {
+ this._mappings_table = this._mappings_host.add_widget(
+ Table,
+ ["listen", "target", "active", "peak", "total", "from client", "to client"],
+ Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1
+ );
+ }
+
+ for (const m of mappings)
+ {
+ this._mappings_table.add_row(
+ m.listen || "",
+ m.target || "",
+ Friendly.sep(m.activeConnections || 0),
+ Friendly.sep(m.peakActiveConnections || 0),
+ Friendly.sep(m.totalConnections || 0),
+ Friendly.bytes(m.bytesFromClient || 0),
+ Friendly.bytes(m.bytesToClient || 0),
+ );
+ }
+ }
+
+ _init_view_tabs(host)
+ {
+ const tabs_el = document.createElement("div");
+ tabs_el.className = "history-tabs";
+ tabs_el.style.marginBottom = "8px";
+ tabs_el.style.width = "fit-content";
+ host.tag().inner().appendChild(tabs_el);
+
+ this._view_tabs = {};
+ const make_tab = (label, mode) => {
+ const btn = document.createElement("button");
+ btn.className = "history-tab";
+ btn.textContent = label;
+ btn.addEventListener("click", () => {
+ if (this._view_mode === mode) { return; }
+ this._view_mode = mode;
+ try { localStorage.setItem("zen-proxy-view-mode", mode); } catch (e) {}
+ this._update_active_tab();
+ if (this._connections_table)
+ {
+ this._connections_table.destroy();
+ this._connections_table = null;
+ }
+ if (this._last_data)
+ {
+ this._render_connections(this._last_data);
+ }
+ });
+ tabs_el.appendChild(btn);
+ this._view_tabs[mode] = btn;
+ };
+
+ make_tab("Per Connection", "per-conn");
+ make_tab("Group by IP", "by-ip");
+ make_tab("Group by Session", "by-session");
+ this._update_active_tab();
+ }
+
+ _update_active_tab()
+ {
+ for (const [mode, btn] of Object.entries(this._view_tabs))
+ {
+ btn.classList.toggle("active", this._view_mode === mode);
+ }
+ }
+
+ _render_connections(data)
+ {
+ this._last_data = data;
+
+ const mappings = data.mappings || [];
+ let connections = [];
+ for (const m of mappings)
+ {
+ for (const c of (m.connections || []))
+ {
+ connections.push(c);
+ }
+ }
+
+ if (this._view_mode === "by-ip")
+ {
+ this._render_connections_grouped_ip(connections);
+ }
+ else if (this._view_mode === "by-session")
+ {
+ this._render_connections_grouped_session(connections);
+ }
+ else
+ {
+ this._render_connections_flat(connections);
+ }
+ }
+
+ _render_connections_flat(connections)
+ {
+ if (this._connections_table)
+ {
+ this._connections_table.clear();
+ }
+ else
+ {
+ this._connections_table = this._connections_host.add_widget(
+ Table,
+ ["client", "session", "target", "requests", "from client", "to client", "duration"],
+ Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1
+ );
+ }
+
+ for (const c of connections)
+ {
+ const row = this._connections_table.add_row(
+ c.client || "",
+ c.sessionId || "",
+ c.target || "",
+ Friendly.sep(c.requests || 0),
+ Friendly.bytes(c.bytesFromClient || 0),
+ Friendly.bytes(c.bytesToClient || 0),
+ Friendly.duration((c.durationMs || 0) / 1000),
+ );
+ row.get_cell(0).style("textAlign", "left");
+ row.get_cell(1).style("textAlign", "left");
+ row.get_cell(2).style("textAlign", "left");
+ if (c.websocket)
+ {
+ this._append_badge(row.get_cell(0), "WS");
+ }
+ }
+ }
+
+ _append_badge(cell, text)
+ {
+ const badge = document.createElement("span");
+ badge.className = "detail-tag";
+ badge.style.marginLeft = "6px";
+ badge.style.background = "color-mix(in srgb, var(--theme_p0) 15%, transparent)";
+ badge.style.color = "var(--theme_p0)";
+ badge.textContent = text;
+ cell.inner().appendChild(badge);
+ }
+
+ _render_connections_grouped_ip(connections)
+ {
+ if (this._connections_table)
+ {
+ this._connections_table.clear();
+ }
+ else
+ {
+ this._connections_table = this._connections_host.add_widget(
+ Table,
+ ["client ip", "conns", "requests", "from client", "to client", "max duration"],
+ Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1
+ );
+ }
+
+ const groups = new Map();
+ for (const c of connections)
+ {
+ const ip = (c.client || "").replace(/:\d+$/, "");
+ let group = groups.get(ip);
+ if (!group)
+ {
+ group = { ip: ip, conns: 0, wsConns: 0, requests: 0, bytesFromClient: 0, bytesToClient: 0, maxDurationMs: 0 };
+ groups.set(ip, group);
+ }
+ group.conns++;
+ if (c.websocket) { group.wsConns++; }
+ group.requests += (c.requests || 0);
+ group.bytesFromClient += (c.bytesFromClient || 0);
+ group.bytesToClient += (c.bytesToClient || 0);
+ group.maxDurationMs = Math.max(group.maxDurationMs, c.durationMs || 0);
+ }
+
+ for (const g of groups.values())
+ {
+ const row = this._connections_table.add_row(
+ g.ip,
+ Friendly.sep(g.conns),
+ Friendly.sep(g.requests),
+ Friendly.bytes(g.bytesFromClient),
+ Friendly.bytes(g.bytesToClient),
+ Friendly.duration(g.maxDurationMs / 1000),
+ );
+ row.get_cell(0).style("textAlign", "left");
+ if (g.wsConns > 0)
+ {
+ this._append_badge(row.get_cell(0), g.wsConns === 1 ? "WS" : `${g.wsConns} WS`);
+ }
+ }
+ }
+
+ _render_connections_grouped_session(connections)
+ {
+ if (this._connections_table)
+ {
+ this._connections_table.clear();
+ }
+ else
+ {
+ this._connections_table = this._connections_host.add_widget(
+ Table,
+ ["session", "conns", "requests", "from client", "to client", "max duration"],
+ Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1
+ );
+ }
+
+ const groups = new Map();
+ for (const c of connections)
+ {
+ const sid = c.sessionId || "(none)";
+ let group = groups.get(sid);
+ if (!group)
+ {
+ group = { sessionId: sid, conns: 0, wsConns: 0, requests: 0, bytesFromClient: 0, bytesToClient: 0, maxDurationMs: 0 };
+ groups.set(sid, group);
+ }
+ group.conns++;
+ if (c.websocket) { group.wsConns++; }
+ group.requests += (c.requests || 0);
+ group.bytesFromClient += (c.bytesFromClient || 0);
+ group.bytesToClient += (c.bytesToClient || 0);
+ group.maxDurationMs = Math.max(group.maxDurationMs, c.durationMs || 0);
+ }
+
+ for (const g of groups.values())
+ {
+ const row = this._connections_table.add_row(
+ g.sessionId,
+ Friendly.sep(g.conns),
+ Friendly.sep(g.requests),
+ Friendly.bytes(g.bytesFromClient),
+ Friendly.bytes(g.bytesToClient),
+ Friendly.duration(g.maxDurationMs / 1000),
+ );
+ row.get_cell(0).style("textAlign", "left");
+ if (g.wsConns > 0)
+ {
+ this._append_badge(row.get_cell(0), g.wsConns === 1 ? "WS" : `${g.wsConns} WS`);
+ }
+ }
+ }
+
+ _metric(parent, value, label, hero = false)
+ {
+ const m = parent.tag().classify("tile-metric");
+ if (hero)
+ {
+ m.classify("tile-metric-hero");
+ }
+ m.tag().classify("metric-value").text(value);
+ m.tag().classify("metric-label").text(label);
+ }
+}