fix: a token-gated or LAN-bound dashboard works end to end (WSL mirrored)
- /ws-pty carries the dashboard token like /ws (the terminal never attached with DASHBOARD_TOKEN set), and so does everything that cannot send the header: images, downloads, exports, drops (withToken/tokenHeader). A refused upgrade answers 401/403 and is logged and counted by reason. - The browser asks for a missing or wrong token (401 EUNAUTHORIZED) and takes ?token= from the link agenthub start prints, dropping it from the URL. - The CLI sends the token (DASHBOARD_TOKEN, else the one the running server publishes 0600 in <data dir>/runtime/dashboard-token); MCP and statusline read the same file. - DASHBOARD_HOST takes a list, and 127.0.0.1 is always listened on too, so a LAN-only bind keeps hooks and the CLI working. - agenthub start --host/--allowed-hosts/--token are saved in <data dir>/network.json for every later start; a wide bind gets a token; every reachable URL is printed. - tmux < 3.7 is an amber, once-per-version notice that says only the pane scrollbar is missing, with a ~/.local build instead of sudo make install. - INSTALL: PATH belongs in ~/.profile for sudo -iu/ssh; WSL mirrored notes.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file dashboard-token-publish.test.js
|
||||
* @description A token-gated dashboard publishes its token (0600) for local
|
||||
* tools, and `dashboard-url.dashboardToken()` — what the `agenthub` CLI, the
|
||||
* MCP server and the statusline send — takes DASHBOARD_TOKEN first, else that
|
||||
* file, else nothing. Without it `agenthub remote login/status` answered 401
|
||||
* from any shell that had not exported the token.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, before, after, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "dash-token-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
const security = require("../lib/security");
|
||||
const { dashboardToken, tokenHeaders } = require("../lib/dashboard-url");
|
||||
|
||||
describe("the published dashboard token", () => {
|
||||
let saved;
|
||||
before(() => {
|
||||
saved = process.env.DASHBOARD_TOKEN;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.DASHBOARD_TOKEN;
|
||||
else process.env.DASHBOARD_TOKEN = saved;
|
||||
});
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
it("is written 0600 when the server has one, and read back without the env", () => {
|
||||
process.env.DASHBOARD_TOKEN = "server-tok";
|
||||
security.publishDashboardToken();
|
||||
const file = security.tokenFilePath();
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
delete process.env.DASHBOARD_TOKEN; // a shell that never exported it
|
||||
assert.equal(dashboardToken(), "server-tok");
|
||||
assert.deepEqual(tokenHeaders(), { "x-dashboard-token": "server-tok" });
|
||||
});
|
||||
|
||||
it("prefers the caller's own DASHBOARD_TOKEN", () => {
|
||||
process.env.DASHBOARD_TOKEN = "mine";
|
||||
assert.equal(dashboardToken(), "mine");
|
||||
});
|
||||
|
||||
it("is removed when the server runs without a token", () => {
|
||||
delete process.env.DASHBOARD_TOKEN;
|
||||
security.publishDashboardToken();
|
||||
assert.equal(fs.existsSync(security.tokenFilePath()), false);
|
||||
assert.equal(dashboardToken(), null);
|
||||
assert.deepEqual(tokenHeaders(), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file listen-hosts.test.js
|
||||
* @description DASHBOARD_HOST may name several addresses, and a dashboard
|
||||
* bound only to a non-loopback address still answers on 127.0.0.1 — the hook
|
||||
* handler, the CLI, the MCP server and the statusline all call there (WSL in
|
||||
* mirrored networking binds a LAN address because Windows cannot reach the
|
||||
* VM's loopback, and every hook was lost). 127.0.0.2 stands in for the LAN
|
||||
* address: Linux routes all of 127/8 to lo, yet it is not "this machine's
|
||||
* name" to the guards.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "listen-hosts-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.DASHBOARD_POOL_REFRESH_MS = "0";
|
||||
|
||||
const security = require("../lib/security");
|
||||
|
||||
function withHost(value, fn) {
|
||||
const saved = process.env.DASHBOARD_HOST;
|
||||
if (value === undefined) delete process.env.DASHBOARD_HOST;
|
||||
else process.env.DASHBOARD_HOST = value;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.DASHBOARD_HOST;
|
||||
else process.env.DASHBOARD_HOST = saved;
|
||||
}
|
||||
}
|
||||
|
||||
describe("which addresses the dashboard listens on", () => {
|
||||
it("is loopback alone by default, with nothing extra", () =>
|
||||
withHost(undefined, () => {
|
||||
assert.deepEqual(security.resolveHosts(), ["127.0.0.1"]);
|
||||
assert.deepEqual(security.extraListenHosts(), []);
|
||||
assert.equal(security.isExposedBind(), false);
|
||||
}));
|
||||
|
||||
it("adds 127.0.0.1 beside a LAN-only bind, and keeps every listed address", () =>
|
||||
withHost("192.168.1.20, 100.64.0.7", () => {
|
||||
assert.equal(security.resolveHost(), "192.168.1.20");
|
||||
assert.deepEqual(security.extraListenHosts(), ["100.64.0.7", "127.0.0.1"]);
|
||||
assert.equal(security.isExposedBind(), true);
|
||||
}));
|
||||
|
||||
it("adds nothing for a wildcard bind, which answers on loopback already — but calls it exposed", () =>
|
||||
withHost("0.0.0.0", () => {
|
||||
assert.deepEqual(security.extraListenHosts(), []);
|
||||
assert.equal(security.isExposedBind(), true);
|
||||
assert.ok(security.accessUrls(4821).includes("http://localhost:4821"));
|
||||
}));
|
||||
|
||||
it("lists each bound address as a URL to open", () =>
|
||||
withHost("192.168.1.20", () => {
|
||||
assert.deepEqual(security.accessUrls(4821), [
|
||||
"http://localhost:4821",
|
||||
"http://192.168.1.20:4821",
|
||||
]);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("a dashboard bound to another address", () => {
|
||||
let server;
|
||||
after(async () => {
|
||||
delete process.env.DASHBOARD_HOST;
|
||||
if (server) await new Promise((r) => server.close(r));
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("still answers on 127.0.0.1, WebSocket included", async () => {
|
||||
process.env.DASHBOARD_HOST = "127.0.0.2";
|
||||
const { createApp, startServer } = require("../index");
|
||||
server = await startServer(createApp(), 0);
|
||||
const { address, port } = server.address();
|
||||
assert.equal(address, "127.0.0.2");
|
||||
await new Promise((r) => setTimeout(r, 50)); // the extra listener binds async
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
assert.equal(res.status, 200);
|
||||
// The upgrade travels the forwarded connection to the same WS server.
|
||||
const reply = await new Promise((resolve) => {
|
||||
const sock = require("node:net").connect(port, "127.0.0.1", () =>
|
||||
sock.write(
|
||||
`GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\n` +
|
||||
`Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n` +
|
||||
`Sec-WebSocket-Version: 13\r\n\r\n`
|
||||
)
|
||||
);
|
||||
sock.once("data", (d) => {
|
||||
resolve(d.toString());
|
||||
sock.destroy();
|
||||
});
|
||||
sock.on("error", (e) => resolve(String(e)));
|
||||
});
|
||||
assert.match(reply, /^HTTP\/1\.1 101/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saved network settings (agenthub start --host/--token)", () => {
|
||||
const net = require("../lib/network-config");
|
||||
const keys = ["DASHBOARD_HOST", "DASHBOARD_ALLOWED_HOSTS", "DASHBOARD_TOKEN"];
|
||||
|
||||
it("are written 0600 and applied only where the environment says nothing", () => {
|
||||
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
||||
try {
|
||||
net.writeNetworkConfig({ host: "0.0.0.0", allowedHosts: "box.lan", token: "t1" });
|
||||
assert.equal(fs.statSync(net.configPath()).mode & 0o777, 0o600);
|
||||
const env = { DASHBOARD_TOKEN: "from-env" };
|
||||
net.applyNetworkConfig(env);
|
||||
assert.deepEqual(env, {
|
||||
DASHBOARD_TOKEN: "from-env",
|
||||
DASHBOARD_HOST: "0.0.0.0",
|
||||
DASHBOARD_ALLOWED_HOSTS: "box.lan",
|
||||
});
|
||||
// null removes a key: `--host local`, `--token off`.
|
||||
net.writeNetworkConfig({ host: null, token: null });
|
||||
assert.deepEqual(net.readNetworkConfig(), { allowedHosts: "box.lan" });
|
||||
} finally {
|
||||
fs.rmSync(net.configPath(), { force: true });
|
||||
for (const k of keys) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -379,6 +379,13 @@ describe("tmux wrapper", () => {
|
||||
assert.match(tmux.upgradeAdvice("3.4", "darwin"), /brew upgrade tmux/);
|
||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
|
||||
assert.equal(tmux.upgradeAdvice("3.7c", "linux"), null);
|
||||
// An old tmux only lacks the pane scrollbar: a warning, and it says so.
|
||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /only the pane scrollbar is missing/);
|
||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /--prefix=\$HOME\/\.local/);
|
||||
assert.doesNotMatch(tmux.upgradeAdvice("3.4", "linux"), /sudo make install/);
|
||||
assert.equal(tmux.upgradeSeverity("3.4", "linux"), "warning");
|
||||
assert.equal(tmux.upgradeSeverity("3.3.2", "win32"), "error");
|
||||
assert.equal(tmux.upgradeSeverity("3.7", "linux"), null);
|
||||
assert.equal(tmux.upgradeAdvice("next-3.8", "darwin"), null);
|
||||
// Nothing to compare: no advice rather than a wrong one.
|
||||
assert.equal(tmux.upgradeAdvice(null, "linux"), null);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file websocket-pty-token.test.js
|
||||
* @description With DASHBOARD_TOKEN set, a `/ws-pty/:runId` upgrade is let
|
||||
* through only with the token (`?token=`, appended after the size query the
|
||||
* terminal sends), and a refused one gets a real HTTP status and is counted
|
||||
* by reason — it used to be a silent `socket.destroy()`, so a terminal stuck
|
||||
* "reconnecting" behind a token gave no clue why. Real http.Server, raw
|
||||
* upgrade requests; the run itself does not exist, so a let-through upgrade
|
||||
* ends in the attach failing, never in a 401.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
|
||||
process.env.DASHBOARD_TOKEN = "s3cret-token";
|
||||
const ws = require("../websocket");
|
||||
|
||||
function rawUpgrade(port, path) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.write(
|
||||
`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n` +
|
||||
`Upgrade: websocket\r\nConnection: Upgrade\r\n` +
|
||||
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n`
|
||||
);
|
||||
});
|
||||
let body = "";
|
||||
socket.on("data", (d) => {
|
||||
body += d.toString("utf8");
|
||||
// A 101 means the upgrade was accepted: that is all this test needs.
|
||||
if (body.startsWith("HTTP/1.1 101")) socket.destroy();
|
||||
});
|
||||
socket.on("close", () => resolve(body));
|
||||
socket.on("error", () => resolve(body));
|
||||
});
|
||||
}
|
||||
|
||||
describe("/ws-pty behind DASHBOARD_TOKEN", () => {
|
||||
let server;
|
||||
let port;
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => res.end("ok"));
|
||||
ws.initWebSocket(server);
|
||||
ws.initPtyWebSocket(server);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
port = server.address().port;
|
||||
});
|
||||
after(async () => {
|
||||
ws.closeWebSocket();
|
||||
await new Promise((r) => server.close(r));
|
||||
delete process.env.DASHBOARD_TOKEN;
|
||||
});
|
||||
|
||||
it("refuses an attach without the token with a 401, and counts why", async () => {
|
||||
const before = ws.getUpgradeRefusals().token;
|
||||
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?cols=120&rows=30");
|
||||
assert.match(reply, /^HTTP\/1\.1 401/);
|
||||
assert.equal(ws.getUpgradeRefusals().token, before + 1);
|
||||
});
|
||||
|
||||
it("refuses a wrong token", async () => {
|
||||
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?cols=120&rows=30&token=nope");
|
||||
assert.match(reply, /^HTTP\/1\.1 401/);
|
||||
});
|
||||
|
||||
it("lets the attach through with the token after the size query", async () => {
|
||||
const before = ws.getUpgradeRefusals().token;
|
||||
const reply = await rawUpgrade(
|
||||
port,
|
||||
"/ws-pty/agenthub-lane-1?cols=120&rows=30&token=s3cret-token"
|
||||
);
|
||||
assert.match(reply, /^HTTP\/1\.1 101/);
|
||||
assert.equal(ws.getUpgradeRefusals().token, before);
|
||||
});
|
||||
|
||||
it("lets a sizeless attach through with ?token=", async () => {
|
||||
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?token=s3cret-token");
|
||||
assert.match(reply, /^HTTP\/1\.1 101/);
|
||||
});
|
||||
});
|
||||
+28
-5
@@ -99,7 +99,9 @@ const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./li
|
||||
const { getDataDir } = require("./lib/claude-home");
|
||||
const {
|
||||
resolveHost,
|
||||
isLoopbackHostname,
|
||||
extraListenHosts,
|
||||
isExposedBind,
|
||||
accessUrls,
|
||||
corsOptions,
|
||||
hostGuard,
|
||||
tokenGuard,
|
||||
@@ -158,6 +160,8 @@ const APP_VERSION = (() => {
|
||||
})();
|
||||
|
||||
function createApp() {
|
||||
// Saved network settings first: the CORS options below are read once here.
|
||||
require("./lib/network-config").applyNetworkConfig();
|
||||
const app = express();
|
||||
// Express 5 parses a query string with node's `querystring` by default;
|
||||
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
|
||||
@@ -293,6 +297,9 @@ function createApp() {
|
||||
}
|
||||
|
||||
function startServer(app, port) {
|
||||
// `agenthub start --host/--allowed-hosts/--token`, saved for every start;
|
||||
// a variable set in the environment wins.
|
||||
require("./lib/network-config").applyNetworkConfig();
|
||||
const server = http.createServer(app);
|
||||
initWebSocket(server);
|
||||
initPtyWebSocket(server);
|
||||
@@ -368,8 +375,12 @@ function startServer(app, port) {
|
||||
// Bind to loopback by default so the dashboard is not network-reachable out
|
||||
// of the box (GHSA-gr74-4xfh-6jw9). Operators opt into a wider bind with
|
||||
// DASHBOARD_HOST=0.0.0.0 — and are warned to set DASHBOARD_TOKEN when they do.
|
||||
const host = resolveHost();
|
||||
const boundLoopback = isLoopbackHostname(host);
|
||||
// DASHBOARD_HOST may list several addresses; the first is `server`'s own
|
||||
// bind, every other one (127.0.0.1 included whenever no address covers it,
|
||||
// so hooks and the CLI keep working) a listener handing its connections to
|
||||
// `server` — the same app, WebSockets and peer checks, the real socket kept.
|
||||
const host = resolveHost().replace(/^\[(.*)\]$/, "$1");
|
||||
const boundLoopback = !isExposedBind();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// A port another program holds fails here, not as an uncaught 'error'
|
||||
@@ -377,10 +388,22 @@ function startServer(app, port) {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
const boundPort = server.address().port;
|
||||
const extras = extraListenHosts().map((h) => {
|
||||
const l = require("node:net").createServer((sock) => server.emit("connection", sock));
|
||||
l.on("error", (e) =>
|
||||
console.warn(`⚠️ Could not also listen on ${h}:${boundPort} — ${e.code || e.message}`)
|
||||
);
|
||||
l.listen(boundPort, h);
|
||||
return l;
|
||||
});
|
||||
if (extras.length) server.on("close", () => extras.forEach((l) => l.close()));
|
||||
// Publish the live port so the Claude Code hook handler can find this
|
||||
// server even when it bound a non-default port (the desktop app falls
|
||||
// back off 4820 when that port is already taken).
|
||||
writeServerInfo(port);
|
||||
// The token (if any) for a local CLI/MCP/statusline that never exported it.
|
||||
require("./lib/security").publishDashboardToken();
|
||||
// The AI Proxy gateway URL a server-mode pane is given names this port.
|
||||
require("./lib/proxy-gateway").setListenPort(server.address().port);
|
||||
// S3: what members' panes run — this install's scripts and every
|
||||
@@ -413,8 +436,8 @@ function startServer(app, port) {
|
||||
);
|
||||
}
|
||||
const mode = isProduction ? "production" : "development";
|
||||
const shown = boundLoopback ? "localhost" : host;
|
||||
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
|
||||
const urls = accessUrls(boundPort);
|
||||
console.log(`Agent Dashboard server running on ${urls.join(" ")} (${mode})`);
|
||||
if (isServerMode()) {
|
||||
console.log("Server mode: every page and API call needs a login.");
|
||||
if (!hasAdmin()) {
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* statusline (`statusline/statusline.js`). An explicit CLAUDE_DASHBOARD_PORT /
|
||||
* DASHBOARD_PORT wins (the hook handler's contract), then the live-server
|
||||
* discovery file `~/.claude/.agenthub.json` (`server-info.js`, PID-checked),
|
||||
* then the default port. Never throws.
|
||||
* then the default port. `dashboardToken()` is the token such a process sends
|
||||
* when the dashboard requires one: `DASHBOARD_TOKEN`, else the one the running
|
||||
* server published (`<data dir>/runtime/dashboard-token`, 0600 — see
|
||||
* `security.publishDashboardToken`), so a shell that never exported it still
|
||||
* reaches a token-gated dashboard. Never throws.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,4 +28,23 @@ function dashboardUrl() {
|
||||
return "http://127.0.0.1:4820";
|
||||
}
|
||||
|
||||
module.exports = { dashboardUrl };
|
||||
/** The dashboard token to send, or null when none is configured or readable. */
|
||||
function dashboardToken() {
|
||||
const env = process.env.DASHBOARD_TOKEN;
|
||||
if (typeof env === "string" && env) return env;
|
||||
try {
|
||||
const { tokenFilePath } = require(path.join(__dirname, "security.js"));
|
||||
const t = require("node:fs").readFileSync(tokenFilePath(), "utf8").trim();
|
||||
return t || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The headers carrying `dashboardToken()` (both spellings the server takes); {} with none. */
|
||||
function tokenHeaders() {
|
||||
const t = dashboardToken();
|
||||
return t ? { "x-dashboard-token": t } : {};
|
||||
}
|
||||
|
||||
module.exports = { dashboardUrl, dashboardToken, tokenHeaders };
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file network-config.js
|
||||
* @description The dashboard's saved network settings — bind address(es),
|
||||
* allowed Host names and the dashboard token — in `<data dir>/network.json`
|
||||
* (0600). `agenthub start --host/--allowed-hosts/--token` writes it; every
|
||||
* server start (a restart after an update, the autostart unit, the plugin
|
||||
* bootstrap) applies it with `applyNetworkConfig()`, so a wider bind no longer
|
||||
* falls back to loopback silently the next time something else starts the
|
||||
* server. An environment variable that is set always wins over the file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
/** File key → the environment variable it stands in for. */
|
||||
const KEYS = {
|
||||
host: "DASHBOARD_HOST",
|
||||
allowedHosts: "DASHBOARD_ALLOWED_HOSTS",
|
||||
token: "DASHBOARD_TOKEN",
|
||||
};
|
||||
|
||||
function configPath() {
|
||||
const { getDataDir } = require("./claude-home");
|
||||
return path.join(getDataDir(), "network.json");
|
||||
}
|
||||
|
||||
/** The saved settings, `{}` when there are none (or the file is unreadable). */
|
||||
function readNetworkConfig() {
|
||||
try {
|
||||
const c = JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
||||
if (!c || typeof c !== "object" || Array.isArray(c)) return {};
|
||||
const out = {};
|
||||
for (const k of Object.keys(KEYS)) {
|
||||
if (typeof c[k] === "string" && c[k].trim()) out[k] = c[k].trim();
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `patch` into the saved settings: a string sets a key, `null` removes
|
||||
* it. Written 0600 through a temp file. Returns the new settings.
|
||||
*/
|
||||
function writeNetworkConfig(patch) {
|
||||
const next = { ...readNetworkConfig() };
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!(k in KEYS)) continue;
|
||||
if (v === null || v === "") delete next[k];
|
||||
else next[k] = String(v).trim();
|
||||
}
|
||||
const file = configPath();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
fs.renameSync(tmp, file);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Put every saved setting whose environment variable is unset into `env`. */
|
||||
function applyNetworkConfig(env = process.env) {
|
||||
const saved = readNetworkConfig();
|
||||
for (const [k, name] of Object.entries(KEYS)) {
|
||||
if (saved[k] && !(typeof env[name] === "string" && env[name].trim())) env[name] = saved[k];
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** A fresh random token for `--token auto`. */
|
||||
const newToken = () => crypto.randomBytes(24).toString("base64url");
|
||||
|
||||
module.exports = {
|
||||
configPath,
|
||||
readNetworkConfig,
|
||||
writeNetworkConfig,
|
||||
applyNetworkConfig,
|
||||
newToken,
|
||||
KEYS,
|
||||
};
|
||||
+101
-3
@@ -18,16 +18,77 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Hostnames that count as "this machine". "0.0.0.0" is included because a
|
||||
// browser may resolve a 0.0.0.0 bind via localhost; an empty Host is treated as
|
||||
// loopback (HTTP/1.0 / local tooling).
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", ""]);
|
||||
|
||||
/** The interface to bind. Loopback unless the operator opts into a wider bind. */
|
||||
/**
|
||||
* Every address to bind, in order: `DASHBOARD_HOST` may list several,
|
||||
* comma-separated (`192.168.1.20,100.64.0.7`). Loopback unless the operator
|
||||
* opts into a wider bind.
|
||||
*/
|
||||
function resolveHosts() {
|
||||
const list = (process.env.DASHBOARD_HOST || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return list.length ? [...new Set(list)] : ["127.0.0.1"];
|
||||
}
|
||||
|
||||
/** The first (main) address to bind. */
|
||||
function resolveHost() {
|
||||
const h = (process.env.DASHBOARD_HOST || "").trim();
|
||||
return h || "127.0.0.1";
|
||||
return resolveHosts()[0];
|
||||
}
|
||||
|
||||
/** An all-interfaces bind (it answers on loopback too). */
|
||||
const isWildcardHost = (h) => ["0.0.0.0", "::", "[::]"].includes(String(h || "").trim());
|
||||
|
||||
/**
|
||||
* The addresses to listen on besides the main one: the rest of the list, plus
|
||||
* 127.0.0.1 when no address in it already answers there. The hook handler,
|
||||
* the CLI, the MCP server and the statusline all call 127.0.0.1 — a dashboard
|
||||
* bound only to a LAN address (WSL in mirrored networking, where Windows
|
||||
* cannot reach the VM's loopback) would otherwise lose every hook.
|
||||
*/
|
||||
function extraListenHosts() {
|
||||
const hosts = resolveHosts();
|
||||
const extra = hosts.slice(1).map((h) => h.replace(/^\[(.*)\]$/, "$1"));
|
||||
const coversLoopback = hosts.some(
|
||||
(h) => isWildcardHost(h) || ["127.0.0.1", "localhost"].includes(h.toLowerCase())
|
||||
);
|
||||
if (!coversLoopback) extra.push("127.0.0.1");
|
||||
return [...new Set(extra)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The URLs a browser can open this dashboard at, for the start-up line and the
|
||||
* CLI: `localhost` first, then each bound address — every non-internal IPv4
|
||||
* address of this machine for a wildcard bind.
|
||||
*/
|
||||
function accessUrls(port) {
|
||||
const os = require("node:os");
|
||||
const urls = new Set([`http://localhost:${port}`]);
|
||||
for (const h of resolveHosts()) {
|
||||
if (isWildcardHost(h)) {
|
||||
for (const addrs of Object.values(os.networkInterfaces())) {
|
||||
for (const a of addrs || []) {
|
||||
if (a.family === "IPv4" && !a.internal) urls.add(`http://${a.address}:${port}`);
|
||||
}
|
||||
}
|
||||
} else if (!isLoopbackHostname(h)) {
|
||||
urls.add(`http://${h.includes(":") && !h.startsWith("[") ? `[${h}]` : h}:${port}`);
|
||||
}
|
||||
}
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
/** Whether any bind is reachable from beyond this machine. */
|
||||
function isExposedBind() {
|
||||
return resolveHosts().some((h) => isWildcardHost(h) || !isLoopbackHostname(h));
|
||||
}
|
||||
|
||||
function isLoopbackHostname(name) {
|
||||
@@ -101,6 +162,36 @@ function getDashboardToken() {
|
||||
return typeof t === "string" && t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
/** Where the running server publishes its token for the CLI, MCP server and statusline. */
|
||||
function tokenFilePath() {
|
||||
const { getDataDir } = require("./claude-home");
|
||||
return path.join(getDataDir(), "runtime", "dashboard-token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the active token (0600, this account only) so a local CLI/MCP/
|
||||
* statusline started from a shell that never exported DASHBOARD_TOKEN can
|
||||
* still call a token-gated dashboard; remove a stale one when no token is
|
||||
* set. Best effort: never throws.
|
||||
*/
|
||||
function publishDashboardToken() {
|
||||
const file = tokenFilePath();
|
||||
const token = getDashboardToken();
|
||||
try {
|
||||
if (!token) {
|
||||
fs.rmSync(file, { force: true });
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, token, { mode: 0o600 });
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (e) {
|
||||
console.warn(`[security] could not publish the dashboard token: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function tokensMatch(provided, expected) {
|
||||
if (typeof provided !== "string" || provided.length === 0) return false;
|
||||
const a = Buffer.from(provided);
|
||||
@@ -162,6 +253,11 @@ function isWebSocketAuthorized(req) {
|
||||
module.exports = {
|
||||
LOOPBACK_HOSTS,
|
||||
resolveHost,
|
||||
resolveHosts,
|
||||
extraListenHosts,
|
||||
isWildcardHost,
|
||||
isExposedBind,
|
||||
accessUrls,
|
||||
isLoopbackHostname,
|
||||
allowedHostnames,
|
||||
hostnameOf,
|
||||
@@ -169,6 +265,8 @@ module.exports = {
|
||||
hostGuard,
|
||||
corsOptions,
|
||||
getDashboardToken,
|
||||
tokenFilePath,
|
||||
publishDashboardToken,
|
||||
tokenGuard,
|
||||
isWebSocketAuthorized,
|
||||
// exported for tests
|
||||
|
||||
+25
-4
@@ -1003,16 +1003,30 @@ const UPGRADES = {
|
||||
win32: {
|
||||
min: [3, 4, 0],
|
||||
name: "psmux",
|
||||
// An older psmux stalls a dashboard terminal: a real fault.
|
||||
severity: "error",
|
||||
impact: "dashboard terminals can stall until a page refresh",
|
||||
how: "close every AgentHub terminal pane, then run: winget upgrade marlocarlo.psmux",
|
||||
},
|
||||
darwin: { min: [3, 7, 0], name: "tmux", how: "brew upgrade tmux" },
|
||||
darwin: {
|
||||
min: [3, 7, 0],
|
||||
name: "tmux",
|
||||
severity: "warning",
|
||||
impact: "only the pane scrollbar is missing; terminals work",
|
||||
how: "brew upgrade tmux",
|
||||
},
|
||||
linux: {
|
||||
min: [3, 7, 0],
|
||||
name: "tmux",
|
||||
// Only the pane scrollbar is missing: never a reason a terminal fails.
|
||||
severity: "warning",
|
||||
impact: "only the pane scrollbar is missing; terminals work",
|
||||
how:
|
||||
"distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " +
|
||||
"https://github.com/tmux/tmux/releases (needs libevent-dev libncurses-dev bison: " +
|
||||
"./configure && make && sudo make install)",
|
||||
"https://github.com/tmux/tmux/releases into your own ~/.local, leaving the " +
|
||||
"system's tmux alone (needs libevent-dev libncurses-dev bison: " +
|
||||
"./configure --prefix=$HOME/.local && make && make install, with ~/.local/bin " +
|
||||
"first in PATH; then restart AgentHub once every terminal pane is closed)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1026,7 +1040,13 @@ function upgradeAdvice(version = clientVersion(), platform = process.platform) {
|
||||
const have = [m[1], m[2], m[3] || 0].map(Number);
|
||||
const i = have.findIndex((n, k) => n !== want.min[k]);
|
||||
if (i === -1 || have[i] > want.min[i]) return null;
|
||||
return `${want.name} ${version} is older than ${want.min.join(".")} — ${want.how}`;
|
||||
return `${want.name} ${version} is older than ${want.min.join(".")} (${want.impact}) — ${want.how}`;
|
||||
}
|
||||
|
||||
/** How much an old version matters here: `error` (psmux: terminals stall) or
|
||||
* `warning` (tmux: no pane scrollbar); null when there is nothing to say. */
|
||||
function upgradeSeverity(version = clientVersion(), platform = process.platform) {
|
||||
return upgradeAdvice(version, platform) ? UPGRADES[platform].severity : null;
|
||||
}
|
||||
|
||||
/** The RUNNING server's version, null when none runs. A server started before
|
||||
@@ -1063,6 +1083,7 @@ module.exports = {
|
||||
clientVersion,
|
||||
serverVersion,
|
||||
upgradeAdvice,
|
||||
upgradeSeverity,
|
||||
serverPid,
|
||||
memberAttach,
|
||||
memberServerInfo,
|
||||
|
||||
@@ -618,6 +618,9 @@ router.get("/tmux", (_req, res) => {
|
||||
available: version !== null,
|
||||
version,
|
||||
upgrade: version ? tmux.upgradeAdvice(version) : null,
|
||||
// `warning` (tmux < 3.7: no pane scrollbar) or `error` (an old psmux
|
||||
// stalls terminals) — the Workspace shows each at its own weight.
|
||||
upgradeSeverity: version ? tmux.upgradeSeverity(version) : null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+54
-13
@@ -17,6 +17,48 @@ const MINE_TYPES = new Set([
|
||||
"agent_updated",
|
||||
"new_event",
|
||||
]);
|
||||
/**
|
||||
* Upgrades refused, by reason (`host`, `token`, `login`, `scope`), since start
|
||||
* — and a refusal's one warning line per reason a minute. A browser only sees
|
||||
* a socket that closed and retries; this is where the server says why.
|
||||
*/
|
||||
const refusals = { host: 0, token: 0, login: 0, scope: 0 };
|
||||
const lastRefusalLog = new Map();
|
||||
const REFUSAL_LOG_MS = 60_000;
|
||||
const REFUSAL_STATUS = {
|
||||
host: [403, "Forbidden"],
|
||||
token: [401, "Unauthorized"],
|
||||
login: [401, "Unauthorized"],
|
||||
scope: [403, "Forbidden"],
|
||||
};
|
||||
const REFUSAL_WHY = {
|
||||
host: "Host header not allowed (DASHBOARD_ALLOWED_HOSTS)",
|
||||
token: "missing or invalid dashboard token (DASHBOARD_TOKEN)",
|
||||
login: "not logged in",
|
||||
scope: "not this user's pane",
|
||||
};
|
||||
function noteRefusal(reason, pathname) {
|
||||
refusals[reason] = (refusals[reason] || 0) + 1;
|
||||
const now = Date.now();
|
||||
if (now - (lastRefusalLog.get(reason) || 0) >= REFUSAL_LOG_MS) {
|
||||
lastRefusalLog.set(reason, now);
|
||||
console.warn(`[WS] refused ${pathname}: ${REFUSAL_WHY[reason]} (${refusals[reason]} so far)`);
|
||||
}
|
||||
}
|
||||
function refuseUpgrade(socket, reason, pathname) {
|
||||
noteRefusal(reason, pathname);
|
||||
const [code, text] = REFUSAL_STATUS[reason] || [400, "Bad Request"];
|
||||
// Like ws's own abortHandshake: answer, then drop once it is written.
|
||||
try {
|
||||
socket.once("finish", () => socket.destroy());
|
||||
socket.end(`HTTP/1.1 ${code} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
||||
} catch {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
/** A copy of the refusal counters (for the tests and diagnostics). */
|
||||
const getUpgradeRefusals = () => ({ ...refusals });
|
||||
|
||||
// How often an open terminal's login is re-checked in server mode.
|
||||
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
|
||||
|
||||
@@ -32,9 +74,13 @@ function initWebSocket(server) {
|
||||
noServer: true,
|
||||
maxPayload: 64 * 1024,
|
||||
verifyClient(info, done) {
|
||||
if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed");
|
||||
if (!isWebSocketAuthorized(info.req)) return done(false, 401, "unauthorized");
|
||||
if (!isWebSocketLoggedIn(info.req)) return done(false, 401, "log in first");
|
||||
const refuse = (reason, code, msg) => {
|
||||
noteRefusal(reason, "/ws");
|
||||
return done(false, code, msg);
|
||||
};
|
||||
if (!isHostAllowed(info.req.headers.host)) return refuse("host", 403, "host not allowed");
|
||||
if (!isWebSocketAuthorized(info.req)) return refuse("token", 401, "unauthorized");
|
||||
if (!isWebSocketLoggedIn(info.req)) return refuse("login", 401, "log in first");
|
||||
return done(true);
|
||||
},
|
||||
});
|
||||
@@ -207,19 +253,13 @@ function initPtyWebSocket(server) {
|
||||
if (!match) return; // not ours — the `/ws` WebSocketServer (registered
|
||||
// by initWebSocket, also attached to this same http.Server) handles its
|
||||
// own path independently and ignores upgrades it doesn't match too.
|
||||
if (!isHostAllowed(req.headers.host)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!isWebSocketAuthorized(req) || !isWebSocketLoggedIn(req)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!isHostAllowed(req.headers.host)) return refuseUpgrade(socket, "host", url.pathname);
|
||||
if (!isWebSocketAuthorized(req)) return refuseUpgrade(socket, "token", url.pathname);
|
||||
if (!isWebSocketLoggedIn(req)) return refuseUpgrade(socket, "login", url.pathname);
|
||||
// Server mode: a terminal is a write, so a developer or an analyst
|
||||
// attaches only to a pane of their own workspace.
|
||||
if (isServerMode() && !canSeeRun({ user: userFromRequest(req), method: "POST" }, match[1])) {
|
||||
socket.destroy();
|
||||
return;
|
||||
return refuseUpgrade(socket, "scope", url.pathname);
|
||||
}
|
||||
// Same shutdown race as `/ws` above — and far easier to hit here, since
|
||||
// TerminalView re-attaches every 1.5s while a run console is open.
|
||||
@@ -410,4 +450,5 @@ module.exports = {
|
||||
onConnection,
|
||||
closeWebSocket,
|
||||
sizeFromQuery,
|
||||
getUpgradeRefusals,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user