feat(server-s3): a member's tmux follows tmux upgrades — tmux-member + Users → Members' tmux
A member's tmux server keeps the version it started with until their last pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a 3.8 client on a 3.6 server spun it at 100% CPU (three members needed a manual kill on 2026-10-15). - /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a member's tmux call with the binary of THEIR running server (/proc/<pid>/exe, found from the socket in /proc/net/unix), else TMUX_BIN. It runs as the member and execs only a process they own. sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN until then. - Users → Members' tmux (GET /api/users/tmux): each member server's version beside the installed one, sessions, agents working. An admin restarts one (POST /api/users/:id/tmux-restart) or every idle outdated one (POST /api/users/tmux-restart-idle), with their password again, audited per member; a server that will not stop answers 409 ETMUXSTUCK naming its pid for root. - server-s3-system.test.js: `delete` is a provision action since Delete user, so the "unknown action" case now uses `purge`. Operators: run `sudo agenthub server install-system` once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @file Tests for each member's own tmux server on the Users page (S3,
|
||||
* server/lib/member-tmux.js + `/api/users/tmux*`): its version beside the
|
||||
* installed one, an admin's restart of one member (password again, audited,
|
||||
* ETMUXSTUCK when it will not stop) and of every idle outdated member — a
|
||||
* member whose agent is working is skipped. Member tmux calls are faked.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "member-tmux-"));
|
||||
const USERS = path.join(TMP, "users");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const tmux = require("../lib/tmux");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
|
||||
// Fake member tmux servers, by Linux login.
|
||||
const servers = {};
|
||||
function fakeMember(acct, args) {
|
||||
const s = servers[acct.login];
|
||||
if (!s || !s.alive) throw Object.assign(new Error("no server running"), { status: 1 });
|
||||
if (args[0] === "display-message") return `${s.version}\t${s.pid}\t1700000000\t${s.sessions}\n`;
|
||||
if (args[0] === "kill-server") {
|
||||
if (s.stuck) throw Object.assign(new Error("server exited unexpectedly"), { status: 1 });
|
||||
s.alive = false;
|
||||
return "";
|
||||
}
|
||||
if (args[0] === "list-sessions") return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
fs.mkdirSync(USERS, { recursive: true });
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
ids.zed = (await auth.createUser("zed", "member-pass-1", "developer")).id;
|
||||
ids.ann = (await auth.createUser("ann", "member-pass-1", "developer")).id;
|
||||
ids.stu = (await auth.createUser("stu", "member-pass-1", "developer")).id;
|
||||
// zed's agent is working right now.
|
||||
db.prepare(
|
||||
"INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'running')"
|
||||
).run(path.join(USERS, "zed", "p"), ids.zed);
|
||||
// S3 on: a server.conf whose TMUX_BIN answers 3.8, and tmux-member installed.
|
||||
const bin = path.join(TMP, "tmux");
|
||||
fs.writeFileSync(bin, "#!/bin/sh\necho 'tmux 3.8'\n", { mode: 0o755 });
|
||||
const wrapper = path.join(TMP, "tmux-member");
|
||||
fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 });
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nTMUX_BIN=${bin}\nGIT_BIN=/usr/bin/git\n`
|
||||
);
|
||||
linux.__setTmuxMember(wrapper);
|
||||
Object.assign(servers, {
|
||||
"ah-mia": { version: "3.6", pid: 11, sessions: 2, alive: true },
|
||||
"ah-zed": { version: "3.6", pid: 12, sessions: 1, alive: true },
|
||||
"ah-ann": { version: "3.8", pid: 13, sessions: 3, alive: true },
|
||||
"ah-stu": { version: "3.6", pid: 14, sessions: 1, alive: true, stuck: true },
|
||||
});
|
||||
tmux.__setAccountImpl(() => null, fakeMember);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
delete process.env.AGENTHUB_SERVER_CONF;
|
||||
linux.__setTmuxMember("/opt/agenthub/libexec/tmux-member");
|
||||
linux.__resetConfCache();
|
||||
tmux.__reset();
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("member tmux servers on the Users page", () => {
|
||||
it("lists each member's version beside the installed one, and who is working", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("GET", "/api/users/tmux", { cookie: boss });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.available, true);
|
||||
assert.equal(r.body.installed, "3.8");
|
||||
assert.equal(r.body.matched, true);
|
||||
const by = Object.fromEntries(r.body.users.map((u) => [u.username, u]));
|
||||
assert.equal(by.mia.version, "3.6");
|
||||
assert.equal(by.mia.outdated, true);
|
||||
assert.equal(by.mia.sessions, 2);
|
||||
assert.equal(by.zed.busy, 1);
|
||||
assert.equal(by.ann.outdated, false);
|
||||
// boss (no server of theirs) is listed as not running.
|
||||
assert.equal(by.boss.running, false);
|
||||
});
|
||||
|
||||
it("is staff-only to read and an admin's to restart", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
assert.equal((await call("GET", "/api/users/tmux", { cookie: mia })).status, 403);
|
||||
const r = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||
cookie: mia,
|
||||
body: { currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(servers["ah-ann"].alive, true);
|
||||
});
|
||||
|
||||
it("restarts one member's server with the admin's password, audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const wrong = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: "nope" },
|
||||
});
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
assert.equal(servers["ah-ann"].alive, true);
|
||||
const ok = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(servers["ah-ann"].alive, false);
|
||||
const audit = db
|
||||
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(ids.ann);
|
||||
assert.match(audit.path, /ann: tmux restarted \(was 3\.8, 3 session/);
|
||||
});
|
||||
|
||||
it("names the pid to kill as root when a server will not stop", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", `/api/users/${ids.stu}/tmux-restart`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ETMUXSTUCK");
|
||||
assert.match(r.body.error.message, /sudo kill -9 14/);
|
||||
});
|
||||
|
||||
it("restarts every idle outdated member, skipping one whose agent works", async () => {
|
||||
servers["ah-ann"] = { version: "3.8", pid: 13, sessions: 1, alive: true };
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", "/api/users/tmux-restart-idle", {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.restarted, ["mia"]);
|
||||
assert.deepEqual(r.body.skipped, ["zed"]);
|
||||
assert.equal(r.body.failed[0].username, "stu");
|
||||
assert.equal(servers["ah-mia"].alive, false);
|
||||
assert.equal(servers["ah-zed"].alive, true);
|
||||
assert.equal(servers["ah-ann"].alive, true); // already on 3.8
|
||||
});
|
||||
});
|
||||
@@ -240,9 +240,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
linux.__setTmuxMember(path.join(TMP, "no-tmux-member"));
|
||||
assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git");
|
||||
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux");
|
||||
// Once install-system put tmux-member there, a member's tmux goes through
|
||||
// it (their running server's own binary); the installed one is still known.
|
||||
const wrapper = path.join(TMP, "tmux-member");
|
||||
fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 });
|
||||
linux.__setTmuxMember(wrapper);
|
||||
try {
|
||||
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], wrapper);
|
||||
assert.equal(linux.serverConf().tmuxInstalled, "/opt/x/tmux");
|
||||
} finally {
|
||||
linux.__setTmuxMember(path.join(TMP, "no-tmux-member"));
|
||||
}
|
||||
assert.equal(
|
||||
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
|
||||
"/opt/agenthub/libexec/write-as"
|
||||
|
||||
@@ -187,6 +187,7 @@ describe("Linux accounts", () => {
|
||||
hostUid: 4242,
|
||||
usersRoot: "/srv/agenthub/users",
|
||||
port: 4820,
|
||||
tmuxInstalled: null,
|
||||
bins: {},
|
||||
});
|
||||
assert.deepEqual(linux.accountFor("mia"), {
|
||||
@@ -199,7 +200,8 @@ describe("Linux accounts", () => {
|
||||
for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) {
|
||||
assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad);
|
||||
}
|
||||
assert.throws(() => linux.provision("delete", "mia"), /unknown provision action/);
|
||||
// `delete` is an action now (Delete user); anything else is still refused.
|
||||
assert.throws(() => linux.provision("purge", "mia"), /unknown provision action/);
|
||||
assert.deepEqual(argvs, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@ function serverConf() {
|
||||
return confCache.value;
|
||||
}
|
||||
|
||||
let TMUX_MEMBER = "/opt/agenthub/libexec/tmux-member";
|
||||
/** Test seam: where tmux-member is looked for. */
|
||||
function __setTmuxMember(p) {
|
||||
TMUX_MEMBER = p;
|
||||
confCache = { at: 0, path: null, value: null };
|
||||
}
|
||||
|
||||
function readConf(file) {
|
||||
let text;
|
||||
try {
|
||||
@@ -56,16 +63,28 @@ function readConf(file) {
|
||||
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
|
||||
const usersRoot = get("USERS_ROOT");
|
||||
if (!path.isAbsolute(usersRoot)) return null;
|
||||
// A member's tmux runs through tmux-member once install-system put it there
|
||||
// (and in sudoers): it picks their RUNNING server's own binary, so a tmux
|
||||
// upgrade never mixes a new client with an old server. Before that, TMUX_BIN.
|
||||
let tmuxBin = get("TMUX_BIN");
|
||||
try {
|
||||
fs.accessSync(TMUX_MEMBER, fs.constants.X_OK);
|
||||
if (path.isAbsolute(tmuxBin)) tmuxBin = TMUX_MEMBER;
|
||||
} catch {
|
||||
/* not installed yet */
|
||||
}
|
||||
return {
|
||||
hostUser: get("HOST_USER"),
|
||||
hostUid: Number(get("HOST_UID")),
|
||||
usersRoot,
|
||||
port: Number(get("PORT")) || null,
|
||||
// The installed tmux (a new member server starts on it).
|
||||
tmuxInstalled: path.isAbsolute(get("TMUX_BIN")) ? get("TMUX_BIN") : null,
|
||||
// The absolute paths sudoers lists (sudo would resolve a bare name on its
|
||||
// own secure_path, which may not be them).
|
||||
bins: Object.fromEntries(
|
||||
[
|
||||
["tmux", get("TMUX_BIN")],
|
||||
["tmux", tmuxBin],
|
||||
["git", get("GIT_BIN")],
|
||||
["gh", get("GH_BIN")],
|
||||
].filter(([, v]) => path.isAbsolute(v))
|
||||
@@ -154,6 +173,7 @@ function __resetConfCache() {
|
||||
|
||||
module.exports = {
|
||||
__resetConfCache,
|
||||
__setTmuxMember,
|
||||
confPath,
|
||||
LINUX_NAME_RE,
|
||||
serverConf,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file member-tmux.js
|
||||
* @description Server mode, one Linux user per account (S3): each member's
|
||||
* own tmux server as the Users page shows it — the version it runs next to
|
||||
* the installed one (`TMUX_BIN`), how many sessions it holds and whether an
|
||||
* agent of theirs is working — and an admin's restart of it. A tmux server
|
||||
* keeps the version it started with until its last session closes, so after
|
||||
* a tmux upgrade an old server lingers; `tmux-member` (install-system) keeps
|
||||
* every call on it matched to its own binary, and this is how an admin moves
|
||||
* a member onto the new version on purpose. A restart ends every pane of
|
||||
* theirs (their Claude sessions resume from the workspace).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { db } = require("../db");
|
||||
const linux = require("./linux-accounts");
|
||||
const tmux = require("./tmux");
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
// The installed tmux's version (`3.8`), re-read at most once a minute.
|
||||
let installed = { at: 0, bin: null, version: null };
|
||||
function installedVersion() {
|
||||
const bin = linux.serverConf()?.tmuxInstalled || null;
|
||||
if (installed.bin === bin && Date.now() - installed.at < 60_000) return installed.version;
|
||||
let version = null;
|
||||
if (bin) {
|
||||
try {
|
||||
version =
|
||||
String(execFileSync(bin, ["-V"], { encoding: "utf8", timeout: 3000, stdio: "pipe" }))
|
||||
.trim()
|
||||
.replace(/^tmux\s+/, "") || null;
|
||||
} catch {
|
||||
/* missing or broken: unknown */
|
||||
}
|
||||
}
|
||||
installed = { at: Date.now(), bin, version };
|
||||
return version;
|
||||
}
|
||||
|
||||
/** How many of `userId`'s workspaces have an agent working right now. */
|
||||
function busyCount(userId) {
|
||||
return db
|
||||
.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ? AND status = 'running'")
|
||||
.get(userId).n;
|
||||
}
|
||||
|
||||
/** One user's row; null for an account with no Linux side. */
|
||||
function rowFor(user, installedV) {
|
||||
const acct = linux.accountFor(user.username);
|
||||
if (!acct) return null;
|
||||
const info = tmux.memberServerInfo(acct);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
...info,
|
||||
busy: busyCount(user.id),
|
||||
outdated: !!(info.running && info.version && installedV && info.version !== installedV),
|
||||
};
|
||||
}
|
||||
|
||||
/** `{available, installed, matched, users: [...]}` for the Users page. */
|
||||
function overview(users) {
|
||||
if (!linux.s3Enabled()) return { available: false, users: [] };
|
||||
const conf = linux.serverConf();
|
||||
const v = installedVersion();
|
||||
return {
|
||||
available: true,
|
||||
installed: v,
|
||||
// Whether calls on an older server already use its own binary.
|
||||
matched: conf?.bins?.tmux !== conf?.tmuxInstalled,
|
||||
users: users.map((u) => rowFor(u, v)).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart `user`'s tmux server. `{restarted: false}` when none ran; throws
|
||||
* ETMUXSTUCK (with the pid) when it would not stop — root has to kill it.
|
||||
*/
|
||||
function restart(user) {
|
||||
const acct = linux.accountFor(user.username);
|
||||
if (!acct) throw err("ENOS3", "this account has no Linux user (S3 is not installed)");
|
||||
const before = tmux.memberServerInfo(acct);
|
||||
if (!before.running) return { restarted: false, before };
|
||||
try {
|
||||
tmux.killMemberServer(acct);
|
||||
} catch {
|
||||
/* judged by what is left below */
|
||||
}
|
||||
const after = tmux.memberServerInfo(acct);
|
||||
if (after.running) {
|
||||
const pid = before.pid || after.pid;
|
||||
throw err(
|
||||
"ETMUXSTUCK",
|
||||
`${user.username}'s tmux server did not stop — run \`sudo kill -9 ${pid || "<pid>"}\` on the server`
|
||||
);
|
||||
}
|
||||
return { restarted: true, before };
|
||||
}
|
||||
|
||||
module.exports = { overview, restart, installedVersion };
|
||||
@@ -720,6 +720,53 @@ function killSession(name) {
|
||||
noteSession(name, accountOfQuiet(name), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's tmux server as the Users page shows it: `{running: false}`, or
|
||||
* `{running: true, version, pid, startedAt, sessions}`, or `{stuck: true}`
|
||||
* when it did not answer (hung, or backed off). Asked with `tmux-member`, i.e.
|
||||
* with that server's own binary.
|
||||
*/
|
||||
function memberServerInfo(acct) {
|
||||
try {
|
||||
const out = String(
|
||||
memberCall(acct, [
|
||||
"display-message",
|
||||
"-p",
|
||||
"#{version}\t#{pid}\t#{start_time}\t#{server_sessions}",
|
||||
])
|
||||
).trim();
|
||||
const [version, pid, start, sessions] = out.split("\t");
|
||||
return {
|
||||
running: true,
|
||||
version: version || null,
|
||||
pid: /^\d+$/.test(pid) ? Number(pid) : null,
|
||||
startedAt: /^\d+$/.test(start) ? Number(start) * 1000 : null,
|
||||
sessions: /^\d+$/.test(sessions) ? Number(sessions) : null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code === "ETMUXHUNG" || err.code === "ETIMEDOUT" || err.code === "ENOBUFS") {
|
||||
return { running: true, stuck: true };
|
||||
}
|
||||
// "no server running" / "error connecting": nothing to report.
|
||||
return { running: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a member's tmux server — every pane of theirs ends (an admin's Users
|
||||
* page action, so the next start runs the installed tmux). Throws when the
|
||||
* server did not go: a stuck one may need root (`sudo kill <pid>`).
|
||||
*/
|
||||
function killMemberServer(acct) {
|
||||
unreachable.delete(acct.login); // asked deliberately, past any back-off
|
||||
try {
|
||||
memberCall(acct, ["kill-server"]);
|
||||
} finally {
|
||||
listGen++;
|
||||
listed.delete(acct.login);
|
||||
}
|
||||
}
|
||||
|
||||
/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */
|
||||
function listSessions(prefix) {
|
||||
const names = [];
|
||||
@@ -867,6 +914,8 @@ module.exports = {
|
||||
upgradeAdvice,
|
||||
serverPid,
|
||||
memberAttach,
|
||||
memberServerInfo,
|
||||
killMemberServer,
|
||||
__setExecImpl,
|
||||
__setAccountImpl,
|
||||
__settle,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -409,6 +411,77 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/tmux": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "listMemberTmux",
|
||||
summary:
|
||||
"Each member's own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3",
|
||||
},
|
||||
403: err("EADMIN — a developer"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/tmux-restart": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "restartMemberTmux",
|
||||
summary:
|
||||
"End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: { currentPassword: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "{user}" },
|
||||
403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"),
|
||||
404: err("ENOUSER"),
|
||||
409: err(
|
||||
"ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/tmux-restart-idle": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "restartIdleMemberTmux",
|
||||
summary:
|
||||
"Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: { currentPassword: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}",
|
||||
},
|
||||
403: err("EREAUTH / EADMIN / ELOCALMODE"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/avatar": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
||||
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
||||
*
|
||||
* `GET /tmux` (S3) is each member's own tmux server — its version beside the
|
||||
* installed one, its sessions, whether an agent of theirs works — and an
|
||||
* admin restarts one (`POST /:id/tmux-restart`) or every idle outdated one
|
||||
* (`POST /tmux-restart-idle`), with their password again and audited
|
||||
* (server/lib/member-tmux.js); a restart ends that member's panes.
|
||||
*
|
||||
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
|
||||
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
||||
* owner groups — staff only like the rest of `/users`.
|
||||
@@ -96,6 +102,11 @@ router.get("/audit", (req, res) => {
|
||||
res.json({ entries: listAudit(req.query.limit) });
|
||||
});
|
||||
|
||||
/** S3: each member's tmux server — its version beside the installed one (member-tmux.js). */
|
||||
router.get("/tmux", (_req, res) => {
|
||||
res.json(require("../lib/member-tmux").overview(listUsers()));
|
||||
});
|
||||
|
||||
const STATUS = {
|
||||
EBADUSER: 400,
|
||||
EBADEMAIL: 400,
|
||||
@@ -114,6 +125,9 @@ const STATUS = {
|
||||
ETOOMANY: 429,
|
||||
// S3: the Linux account could not be created/locked (the helper said why).
|
||||
EPROVISION: 502,
|
||||
ENOS3: 409,
|
||||
// A member's tmux server that would not stop: root has to kill it.
|
||||
ETMUXSTUCK: 409,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -290,6 +304,73 @@ router.delete(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Restart one member's tmux server (every pane of theirs ends, so the next
|
||||
* start runs the installed tmux) — the admin's password again, audited.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/tmux-restart",
|
||||
accountChange(async (req) => {
|
||||
const u = target(req);
|
||||
const r = require("../lib/member-tmux").restart(u);
|
||||
return {
|
||||
user: auth.publicUser(u),
|
||||
action: r.restarted
|
||||
? `${u.username}: tmux restarted (was ${r.before.version || "?"}, ${r.before.sessions ?? "?"} session(s))`
|
||||
: `${u.username}: tmux restart — no server was running`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Restart the tmux server of every member running an older tmux than the
|
||||
* installed one while no agent of theirs works. One password, one audit
|
||||
* entry per member restarted.
|
||||
*/
|
||||
router.post("/tmux-restart-idle", async (req, res) => {
|
||||
try {
|
||||
if (!auth.isServerMode() || !req.user) {
|
||||
throw Object.assign(new Error("accounts are managed in server mode only"), {
|
||||
code: "ELOCALMODE",
|
||||
});
|
||||
}
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const mt = require("../lib/member-tmux");
|
||||
const all = db.prepare("SELECT * FROM users ORDER BY id").all();
|
||||
const rows = mt.overview(all.map(auth.publicUser)).users;
|
||||
const restarted = [];
|
||||
const skipped = [];
|
||||
const failed = [];
|
||||
for (const row of rows) {
|
||||
if (!row.outdated) continue;
|
||||
if (row.busy > 0) {
|
||||
skipped.push(row.username);
|
||||
continue;
|
||||
}
|
||||
const u = all.find((x) => x.id === row.id);
|
||||
try {
|
||||
const r = mt.restart(u);
|
||||
if (!r.restarted) continue;
|
||||
restarted.push(row.username);
|
||||
recordAccountChange(
|
||||
req.user.id,
|
||||
u.id,
|
||||
"POST",
|
||||
`${u.username}: tmux restarted (idle, was ${row.version})`
|
||||
);
|
||||
} catch (e) {
|
||||
failed.push({ username: row.username, message: e.message });
|
||||
}
|
||||
}
|
||||
res.json({ restarted, skipped, failed });
|
||||
} catch (err) {
|
||||
const status = STATUS[err.code];
|
||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/:id/password",
|
||||
accountChange(async (req, body) => {
|
||||
|
||||
Reference in New Issue
Block a user