mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
972 lines
30 KiB
TypeScript
972 lines
30 KiB
TypeScript
import express from "express";
|
|
import cookieParser from "cookie-parser";
|
|
import { Client, type ConnectConfig } from "ssh2";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { createCorsMiddleware } from "../utils/cors-config.js";
|
|
import { AuthManager } from "../utils/auth-manager.js";
|
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
|
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
|
import { tmuxSessionTags, users } from "../database/db/schema.js";
|
|
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
|
import { sshLogger } from "../utils/logger.js";
|
|
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
|
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
|
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
|
import { resolveHostById, checkHostAccess } from "./host-resolver.js";
|
|
import { createJumpHostChain } from "./jump-host-chain.js";
|
|
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
|
import {
|
|
createSocks5Connection,
|
|
type SOCKS5Config,
|
|
} from "../utils/socks5-helper.js";
|
|
import { withConnection } from "./ssh-connection-pool.js";
|
|
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
|
import { execCommand, tmuxCommand } from "./tmux-helper.js";
|
|
import {
|
|
SEP,
|
|
parseSessions,
|
|
parseWindows,
|
|
parsePanes,
|
|
parsePsOutput,
|
|
parseGpuOutput,
|
|
buildPaneMetrics,
|
|
attachPanesToWindows,
|
|
shellEscape,
|
|
type RawPane,
|
|
type TmuxSessionSummary,
|
|
type TmuxWindow,
|
|
type PaneMetrics,
|
|
} from "./tmux-monitor-helpers.js";
|
|
import type { SSHHost, AuthenticatedRequest } from "../../types/index.js";
|
|
|
|
const PANE_ID_RE = /^%\d+$/;
|
|
// tmux session names cannot contain ":" or "."; keep to a conservative
|
|
// printable subset so the name is safe as a tmux target everywhere.
|
|
const SESSION_NAME_RE = /^[A-Za-z0-9_@%+=-]{1,64}$/;
|
|
const MAX_SEARCH_PANES = 100;
|
|
const MAX_MATCHES_PER_PANE = 50;
|
|
const SEARCH_HISTORY_LINES = 2000;
|
|
const SEARCH_CONCURRENCY = 4;
|
|
|
|
interface TmuxSessionOverview extends TmuxSessionSummary {
|
|
windows: TmuxWindow[];
|
|
tags: string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSH connection (lean variant of the per-module pattern used by server-stats
|
|
// and docker; jump hosts and SOCKS5 reuse the shared helpers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
|
|
const base: ConnectConfig = {
|
|
host: (host.ip || "").replace(/^\[|\]$/g, ""),
|
|
port: host.port,
|
|
username: host.username,
|
|
tryKeyboard: true,
|
|
keepaliveInterval: 30000,
|
|
keepaliveCountMax: 3,
|
|
readyTimeout: 60000,
|
|
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
|
host.id,
|
|
host.ip,
|
|
host.port,
|
|
null,
|
|
host.userId || "",
|
|
false,
|
|
),
|
|
algorithms: SSH_ALGORITHMS,
|
|
} as ConnectConfig;
|
|
|
|
if (host.authType === "password") {
|
|
if (!host.password) {
|
|
throw new Error(`No password available for host ${host.ip}`);
|
|
}
|
|
base.password = host.password;
|
|
} else if (host.authType === "key") {
|
|
if (!host.key) {
|
|
throw new Error(`No valid SSH key available for host ${host.ip}`);
|
|
}
|
|
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
|
|
host.key,
|
|
host.keyPassword,
|
|
);
|
|
if (host.keyPassword) {
|
|
(base as Record<string, unknown>).passphrase = host.keyPassword;
|
|
}
|
|
} else if (host.authType === "none") {
|
|
// no credentials needed
|
|
} else if (host.authType === "vault") {
|
|
// cert auth setup happens in connectToHost (needs client instance)
|
|
} else if (host.authType === "agent") {
|
|
const result = await applyAgentAuth(
|
|
base as Record<string, unknown>,
|
|
host.terminalConfig as unknown as Record<string, unknown> | undefined,
|
|
);
|
|
if ("error" in result) {
|
|
throw new Error(result.error);
|
|
}
|
|
} else {
|
|
// opkssh and other interactive flows are not supported by this module
|
|
throw new Error(
|
|
`Authentication type '${host.authType}' is not supported by the tmux monitor. Open a terminal connection instead.`,
|
|
);
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
export function connectToHost(host: SSHHost): () => Promise<Client> {
|
|
return async () => {
|
|
const config = await buildSshConfig(host);
|
|
const client = new Client();
|
|
|
|
if (host.authType === "vault") {
|
|
const { setupVaultSshSignerAuth } =
|
|
await import("./vault-ssh-connect.js");
|
|
await setupVaultSshSignerAuth(config, client, host);
|
|
}
|
|
|
|
const proxyConfig: SOCKS5Config | null =
|
|
host.useSocks5 &&
|
|
(host.socks5Host ||
|
|
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
|
|
? {
|
|
useSocks5: host.useSocks5,
|
|
socks5Host: host.socks5Host,
|
|
socks5Port: host.socks5Port,
|
|
socks5Username: host.socks5Username,
|
|
socks5Password: host.socks5Password,
|
|
socks5ProxyChain: host.socks5ProxyChain,
|
|
}
|
|
: null;
|
|
|
|
let jumpClient: Client | null = null;
|
|
if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
|
|
jumpClient = await createJumpHostChain(
|
|
host.jumpHosts,
|
|
host.userId,
|
|
proxyConfig,
|
|
);
|
|
if (!jumpClient) {
|
|
throw new Error("Failed to establish jump host chain");
|
|
}
|
|
} else if (proxyConfig) {
|
|
const proxySocket = await createSocks5Connection(
|
|
host.ip,
|
|
host.port,
|
|
proxyConfig,
|
|
);
|
|
if (proxySocket) {
|
|
config.sock = proxySocket;
|
|
}
|
|
}
|
|
|
|
return new Promise<Client>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
client.end();
|
|
jumpClient?.end();
|
|
reject(new Error("SSH connection timeout"));
|
|
}, 30000);
|
|
|
|
client.on("ready", () => {
|
|
clearTimeout(timeout);
|
|
resolve(client);
|
|
});
|
|
client.on("error", (err) => {
|
|
clearTimeout(timeout);
|
|
jumpClient?.end();
|
|
reject(err);
|
|
});
|
|
client.on(
|
|
"keyboard-interactive",
|
|
(_name, _instructions, _lang, prompts, finish) => {
|
|
finish(
|
|
prompts.map((p) =>
|
|
/password/i.test(p.prompt) ? host.password || "" : "",
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (jumpClient) {
|
|
jumpClient.forwardOut(
|
|
"127.0.0.1",
|
|
0,
|
|
host.ip,
|
|
host.port,
|
|
(err, stream) => {
|
|
if (err) {
|
|
clearTimeout(timeout);
|
|
jumpClient!.end();
|
|
reject(
|
|
new Error(
|
|
"Failed to forward through jump host: " + err.message,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
config.sock = stream;
|
|
client.connect(config);
|
|
},
|
|
);
|
|
} else if (config.sock) {
|
|
client.connect(config);
|
|
} else {
|
|
resolveSshConnectConfigHost(config)
|
|
.then(() => {
|
|
client.connect(config);
|
|
})
|
|
.catch((error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
function getPoolKey(host: SSHHost): string {
|
|
const socks5Key = host.useSocks5
|
|
? `:socks5:${host.socks5Host}:${host.socks5Port}`
|
|
: "";
|
|
return `tmux-monitor:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
|
|
}
|
|
|
|
async function withHostConnection<T>(
|
|
host: SSHHost,
|
|
fn: (client: Client) => Promise<T>,
|
|
): Promise<T> {
|
|
return withConnection(getPoolKey(host), connectToHost(host), fn);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tmux queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
|
try {
|
|
await execCommand(conn, tmuxCommand("-V"));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runTmuxList(conn: Client, command: string): Promise<string> {
|
|
try {
|
|
return await execCommand(conn, command);
|
|
} catch {
|
|
return ""; // tmux server not running -- no sessions
|
|
}
|
|
}
|
|
|
|
function listSessionsCmd(): string {
|
|
return tmuxCommand(
|
|
`list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`,
|
|
);
|
|
}
|
|
|
|
function listWindowsCmd(): string {
|
|
return tmuxCommand(
|
|
`list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`,
|
|
);
|
|
}
|
|
|
|
function listPanesCmd(): string {
|
|
return tmuxCommand(
|
|
`list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`,
|
|
);
|
|
}
|
|
|
|
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
|
|
return parsePanes(await runTmuxList(conn, listPanesCmd()));
|
|
}
|
|
|
|
async function fetchSessionTags(
|
|
userId: string,
|
|
hostId: number,
|
|
): Promise<Map<string, string[]>> {
|
|
const rows = await getDb()
|
|
.select()
|
|
.from(tmuxSessionTags)
|
|
.where(
|
|
and(
|
|
eq(tmuxSessionTags.userId, userId),
|
|
eq(tmuxSessionTags.hostId, hostId),
|
|
),
|
|
);
|
|
const bySession = new Map<string, string[]>();
|
|
for (const row of rows) {
|
|
if (!bySession.has(row.sessionName)) bySession.set(row.sessionName, []);
|
|
bySession.get(row.sessionName)!.push(row.tag);
|
|
}
|
|
return bySession;
|
|
}
|
|
|
|
async function collectPaneMetrics(
|
|
conn: Client,
|
|
panes: RawPane[],
|
|
): Promise<PaneMetrics[]> {
|
|
let psOutput = "";
|
|
try {
|
|
psOutput = await execCommand(
|
|
conn,
|
|
"ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null",
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
// GPU memory per pid (best effort; nvidia-smi may not exist)
|
|
let gpuOutput = "";
|
|
try {
|
|
gpuOutput = await execCommand(
|
|
conn,
|
|
"command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true",
|
|
);
|
|
} catch {
|
|
// no GPU on host
|
|
}
|
|
|
|
return buildPaneMetrics(
|
|
panes,
|
|
parsePsOutput(psOutput),
|
|
parseGpuOutput(gpuOutput),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Express app
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const app = express();
|
|
const authManager = AuthManager.getInstance();
|
|
|
|
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
|
app.use(cookieParser());
|
|
app.use(express.json({ limit: "1mb" }));
|
|
app.use((_req, res, next) => {
|
|
res.setHeader("Cache-Control", "no-store");
|
|
next();
|
|
});
|
|
app.use(authManager.createAuthMiddleware());
|
|
|
|
/**
|
|
* Resolve the host for a request and verify the user can access it.
|
|
* Sends the error response and returns null when access is denied.
|
|
*/
|
|
async function requireHost(
|
|
req: express.Request,
|
|
res: express.Response,
|
|
permission: "read" | "execute" = "read",
|
|
): Promise<SSHHost | null> {
|
|
const userId = (req as unknown as AuthenticatedRequest).userId;
|
|
const hostId = parseInt(String(req.params.hostId), 10);
|
|
if (isNaN(hostId)) {
|
|
res.status(400).json({ error: "Invalid host ID" });
|
|
return null;
|
|
}
|
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
|
res.status(401).json({ error: "User data is locked" });
|
|
return null;
|
|
}
|
|
|
|
let host: SSHHost | null = null;
|
|
try {
|
|
host = await resolveHostById(hostId, userId);
|
|
} catch (err) {
|
|
sshLogger.error(`Failed to resolve host ${hostId} for tmux monitor`, err);
|
|
}
|
|
if (!host) {
|
|
res.status(404).json({ error: "Host not found" });
|
|
return null;
|
|
}
|
|
|
|
const hasAccess = await checkHostAccess(
|
|
hostId,
|
|
userId,
|
|
host.userId || userId,
|
|
permission,
|
|
);
|
|
if (!hasAccess) {
|
|
res.status(403).json({ error: "Access denied" });
|
|
return null;
|
|
}
|
|
|
|
// The monitor is opt-in per host (same pattern as enableDocker in
|
|
// docker.ts): hiding the UI is not enough, the API must refuse too.
|
|
if (!host.enableTmuxMonitor) {
|
|
res
|
|
.status(403)
|
|
.json({ error: "Tmux Monitor is not enabled for this host" });
|
|
return null;
|
|
}
|
|
return host;
|
|
}
|
|
|
|
function toErrorMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : "Unknown error";
|
|
}
|
|
|
|
// Destructive tmux actions terminate processes on the remote host, so they
|
|
// land in the audit log like other host-level mutations (see host.ts).
|
|
async function auditTmuxAction(
|
|
req: express.Request,
|
|
host: SSHHost,
|
|
action: string,
|
|
resourceName: string,
|
|
details?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const userId = (req as unknown as AuthenticatedRequest).userId;
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
let username = userId;
|
|
try {
|
|
const actor = await getDb()
|
|
.select({ username: users.username })
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
username = actor[0]?.username ?? userId;
|
|
} catch {
|
|
// fall back to the raw user id
|
|
}
|
|
await logAudit({
|
|
userId,
|
|
username,
|
|
action,
|
|
resourceType: "host",
|
|
resourceId: String(host.id),
|
|
resourceName,
|
|
details: details ? JSON.stringify(details) : undefined,
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
}
|
|
|
|
// Typed error codes so the frontend can render a helpful state instead of a
|
|
// raw 500 (same pattern as SESSION_EXPIRED handling in main-axios).
|
|
type TmuxErrorCode =
|
|
| "TMUX_NOT_INSTALLED"
|
|
| "TMUX_NO_SERVER"
|
|
| "HOST_UNREACHABLE"
|
|
| "TMUX_ERROR";
|
|
|
|
function classifyTmuxError(err: unknown): TmuxErrorCode {
|
|
const msg = err instanceof Error ? err.message : "";
|
|
if (/command not found|exited with code 127/i.test(msg))
|
|
return "TMUX_NOT_INSTALLED";
|
|
if (/no server running|lost server/i.test(msg)) return "TMUX_NO_SERVER";
|
|
if (
|
|
/timeout|timed out|econnrefused|ehostunreach|enotfound|enetunreach|econnreset|authentication|handshake|keepalive/i.test(
|
|
msg,
|
|
)
|
|
)
|
|
return "HOST_UNREACHABLE";
|
|
return "TMUX_ERROR";
|
|
}
|
|
|
|
function sendTmuxError(
|
|
res: express.Response,
|
|
err: unknown,
|
|
context: string,
|
|
hostId: number,
|
|
): void {
|
|
const code = classifyTmuxError(err);
|
|
const status = code === "TMUX_ERROR" ? 500 : 503;
|
|
const error =
|
|
code === "TMUX_NOT_INSTALLED"
|
|
? "tmux is not installed on this host"
|
|
: code === "TMUX_NO_SERVER"
|
|
? "No tmux server is running on this host"
|
|
: code === "HOST_UNREACHABLE"
|
|
? "Could not connect to the host"
|
|
: toErrorMessage(err);
|
|
sshLogger.error(`tmux ${context} failed for host ${hostId}`, err);
|
|
res.status(status).json({ error, code });
|
|
}
|
|
|
|
app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
|
|
const userId = (req as unknown as AuthenticatedRequest).userId;
|
|
const host = await requireHost(req, res);
|
|
if (!host) return;
|
|
|
|
try {
|
|
const result = await withHostConnection(host, async (conn) => {
|
|
if (!(await tmuxAvailable(conn))) {
|
|
return { available: false, sessions: [] as TmuxSessionOverview[] };
|
|
}
|
|
const [sessionsOut, windowsOut, panesOut] = await Promise.all([
|
|
runTmuxList(conn, listSessionsCmd()),
|
|
runTmuxList(conn, listWindowsCmd()),
|
|
runTmuxList(conn, listPanesCmd()),
|
|
]);
|
|
const sessions = parseSessions(sessionsOut);
|
|
const windows = parseWindows(windowsOut);
|
|
attachPanesToWindows(windows, parsePanes(panesOut));
|
|
|
|
const tags = await fetchSessionTags(userId, host.id);
|
|
const full: TmuxSessionOverview[] = sessions.map((s) => ({
|
|
...s,
|
|
windows: windows.get(s.name) || [],
|
|
tags: tags.get(s.name) || [],
|
|
}));
|
|
return { available: true, sessions: full };
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
sendTmuxError(res, err, "overview", host.id);
|
|
}
|
|
});
|
|
|
|
// Focus a pane: select its window and pane on the server so every attached
|
|
// client (including the monitor's embedded terminal) switches to it.
|
|
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
|
if (!PANE_ID_RE.test(paneId)) {
|
|
return res.status(400).json({ error: "Invalid pane ID" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
// A pane id is a valid window target: tmux resolves it to the window
|
|
// containing the pane.
|
|
execCommand(
|
|
conn,
|
|
tmuxCommand(
|
|
`select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
|
|
),
|
|
),
|
|
);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
sendTmuxError(res, err, "focus", host.id);
|
|
}
|
|
});
|
|
|
|
// Create a detached session. Starts the tmux server if none is running.
|
|
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const name = String((req.body as { name?: string })?.name || "").trim();
|
|
if (!SESSION_NAME_RE.test(name)) {
|
|
return res.status(400).json({ error: "Invalid session name" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
execCommand(conn, tmuxCommand(`new-session -d -s ${shellEscape(name)}`)),
|
|
);
|
|
sshLogger.info("tmux session created", {
|
|
operation: "tmux_session_create",
|
|
hostId: host.id,
|
|
sessionName: name,
|
|
});
|
|
res.json({ ok: true, name });
|
|
} catch (err) {
|
|
if (/duplicate session/i.test(toErrorMessage(err))) {
|
|
return res
|
|
.status(409)
|
|
.json({ error: "A session with this name already exists" });
|
|
}
|
|
sendTmuxError(res, err, "create session", host.id);
|
|
}
|
|
});
|
|
|
|
// Create a window in an existing session. The session name comes from tmux's
|
|
// own listing, so it is only checked for characters that would change the
|
|
// target's meaning (":" and "." are window/pane separators in tmux targets);
|
|
// "=" prefixes the target for an exact-name match.
|
|
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const sessionName = String(
|
|
(req.body as { sessionName?: string })?.sessionName || "",
|
|
).trim();
|
|
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
|
return res.status(400).json({ error: "Invalid session name" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
execCommand(
|
|
conn,
|
|
tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`),
|
|
),
|
|
);
|
|
sshLogger.info("tmux window created", {
|
|
operation: "tmux_window_create",
|
|
hostId: host.id,
|
|
sessionName,
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
|
return res.status(404).json({ error: "Session not found" });
|
|
}
|
|
sendTmuxError(res, err, "create window", host.id);
|
|
}
|
|
});
|
|
|
|
// Rename a session. Saved tags follow the session to its new name (for every
|
|
// user — the session itself is shared on the host).
|
|
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const body = req.body as { sessionName?: string; newName?: string };
|
|
const sessionName = String(body?.sessionName || "").trim();
|
|
const newName = String(body?.newName || "").trim();
|
|
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
|
return res.status(400).json({ error: "Invalid session name" });
|
|
}
|
|
if (!SESSION_NAME_RE.test(newName)) {
|
|
return res.status(400).json({ error: "Invalid new session name" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
execCommand(
|
|
conn,
|
|
tmuxCommand(
|
|
`rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
|
|
),
|
|
),
|
|
);
|
|
await getDb()
|
|
.update(tmuxSessionTags)
|
|
.set({ sessionName: newName })
|
|
.where(
|
|
and(
|
|
eq(tmuxSessionTags.hostId, host.id),
|
|
eq(tmuxSessionTags.sessionName, sessionName),
|
|
),
|
|
);
|
|
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
|
|
sshLogger.info("tmux session renamed", {
|
|
operation: "tmux_session_rename",
|
|
hostId: host.id,
|
|
sessionName,
|
|
newName,
|
|
});
|
|
await auditTmuxAction(req, host, "tmux_session_rename", sessionName, {
|
|
newName,
|
|
});
|
|
res.json({ ok: true, name: newName });
|
|
} catch (err) {
|
|
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
|
return res.status(404).json({ error: "Session not found" });
|
|
}
|
|
if (/duplicate session/i.test(toErrorMessage(err))) {
|
|
return res
|
|
.status(409)
|
|
.json({ error: "A session with this name already exists" });
|
|
}
|
|
sendTmuxError(res, err, "rename session", host.id);
|
|
}
|
|
});
|
|
|
|
// Kill a session and drop its saved tags.
|
|
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const sessionName = String(
|
|
(req.body as { sessionName?: string })?.sessionName || "",
|
|
).trim();
|
|
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
|
return res.status(400).json({ error: "Invalid session name" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
execCommand(
|
|
conn,
|
|
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
|
|
),
|
|
);
|
|
await getDb()
|
|
.delete(tmuxSessionTags)
|
|
.where(
|
|
and(
|
|
eq(tmuxSessionTags.hostId, host.id),
|
|
eq(tmuxSessionTags.sessionName, sessionName),
|
|
),
|
|
);
|
|
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
|
|
sshLogger.info("tmux session killed", {
|
|
operation: "tmux_session_kill",
|
|
hostId: host.id,
|
|
sessionName,
|
|
});
|
|
await auditTmuxAction(req, host, "tmux_session_kill", sessionName);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
|
|
return res.status(404).json({ error: "Session not found" });
|
|
}
|
|
sendTmuxError(res, err, "kill session", host.id);
|
|
}
|
|
});
|
|
|
|
// Kill a window (and every pane in it). Killing the last window of a session
|
|
// ends the session — tmux semantics.
|
|
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const body = req.body as { sessionName?: string; windowIndex?: number };
|
|
const sessionName = String(body?.sessionName || "").trim();
|
|
const windowIndex = Number(body?.windowIndex);
|
|
if (!sessionName || /[:.\n]/.test(sessionName)) {
|
|
return res.status(400).json({ error: "Invalid session name" });
|
|
}
|
|
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
|
|
return res.status(400).json({ error: "Invalid window index" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
execCommand(
|
|
conn,
|
|
tmuxCommand(
|
|
`kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
|
|
),
|
|
),
|
|
);
|
|
sshLogger.info("tmux window killed", {
|
|
operation: "tmux_window_kill",
|
|
hostId: host.id,
|
|
sessionName,
|
|
windowIndex,
|
|
});
|
|
await auditTmuxAction(req, host, "tmux_window_kill", sessionName, {
|
|
windowIndex,
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
if (
|
|
/can't find window|no such window|can't find session/i.test(
|
|
toErrorMessage(err),
|
|
)
|
|
) {
|
|
return res.status(404).json({ error: "Window not found" });
|
|
}
|
|
sendTmuxError(res, err, "kill window", host.id);
|
|
}
|
|
});
|
|
|
|
// Kill a single pane. Killing the last pane of a window closes the window,
|
|
// and the last window of a session ends the session — tmux semantics.
|
|
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const paneId = String((req.body as { paneId?: string })?.paneId || "");
|
|
if (!PANE_ID_RE.test(paneId)) {
|
|
return res.status(400).json({ error: "Invalid pane ID" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)),
|
|
);
|
|
sshLogger.info("tmux pane killed", {
|
|
operation: "tmux_pane_kill",
|
|
hostId: host.id,
|
|
paneId,
|
|
});
|
|
await auditTmuxAction(req, host, "tmux_pane_kill", paneId);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
if (/can't find pane|no such pane/i.test(toErrorMessage(err))) {
|
|
return res.status(404).json({ error: "Pane not found" });
|
|
}
|
|
sendTmuxError(res, err, "kill pane", host.id);
|
|
}
|
|
});
|
|
|
|
// Split the window containing a pane. "h" places the new pane to the right,
|
|
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
|
|
// source pane's working directory.
|
|
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
|
|
const host = await requireHost(req, res, "execute");
|
|
if (!host) return;
|
|
|
|
const body = req.body as { paneId?: string; direction?: string };
|
|
const paneId = String(body?.paneId || "");
|
|
const direction = body?.direction === "v" ? "-v" : "-h";
|
|
if (!PANE_ID_RE.test(paneId)) {
|
|
return res.status(400).json({ error: "Invalid pane ID" });
|
|
}
|
|
if (body?.direction !== "h" && body?.direction !== "v") {
|
|
return res.status(400).json({ error: "Invalid split direction" });
|
|
}
|
|
|
|
try {
|
|
await withHostConnection(host, (conn) =>
|
|
execCommand(
|
|
conn,
|
|
tmuxCommand(
|
|
`split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
|
|
),
|
|
),
|
|
);
|
|
sshLogger.info("tmux pane split", {
|
|
operation: "tmux_pane_split",
|
|
hostId: host.id,
|
|
paneId,
|
|
direction: body.direction,
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
sendTmuxError(res, err, "split", host.id);
|
|
}
|
|
});
|
|
|
|
app.get("/tmux_monitor/:hostId/search", async (req, res) => {
|
|
const host = await requireHost(req, res);
|
|
if (!host) return;
|
|
|
|
const query = String(req.query.q || "").trim();
|
|
if (!query) {
|
|
return res.status(400).json({ error: "Missing search query" });
|
|
}
|
|
|
|
try {
|
|
const results = await withHostConnection(host, async (conn) => {
|
|
const allPanes = await listPanesRaw(conn);
|
|
const panes = allPanes.slice(0, MAX_SEARCH_PANES);
|
|
// Flips to true whenever a limit was hit, so the UI can tell the user
|
|
// the results are partial instead of silently truncating.
|
|
let truncated = allPanes.length > MAX_SEARCH_PANES;
|
|
const matches: Array<{
|
|
paneId: string;
|
|
sessionName: string;
|
|
windowIndex: number;
|
|
line: number;
|
|
text: string;
|
|
}> = [];
|
|
|
|
// Bounded concurrency; each search runs capture+grep remotely so only
|
|
// matching lines travel back over the wire.
|
|
for (let i = 0; i < panes.length; i += SEARCH_CONCURRENCY) {
|
|
const batch = panes.slice(i, i + SEARCH_CONCURRENCY);
|
|
await Promise.all(
|
|
batch.map(async (pane) => {
|
|
try {
|
|
const output = await execCommand(
|
|
conn,
|
|
tmuxCommand(
|
|
`capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
|
|
),
|
|
);
|
|
const lines = output.split("\n").filter(Boolean);
|
|
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
|
|
for (const line of lines) {
|
|
const sep = line.indexOf(":");
|
|
if (sep === -1) continue;
|
|
matches.push({
|
|
paneId: pane.id,
|
|
sessionName: pane.sessionName,
|
|
windowIndex: pane.windowIndex,
|
|
line: parseInt(line.slice(0, sep), 10) || 0,
|
|
text: line.slice(sep + 1).slice(0, 500),
|
|
});
|
|
}
|
|
} catch {
|
|
// grep exits non-zero when there are no matches -- not an error
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
return { matches, truncated };
|
|
});
|
|
res.json({
|
|
query,
|
|
matches: results.matches,
|
|
truncated: results.truncated,
|
|
searchedLines: SEARCH_HISTORY_LINES,
|
|
maxPanes: MAX_SEARCH_PANES,
|
|
});
|
|
} catch (err) {
|
|
sendTmuxError(res, err, "search", host.id);
|
|
}
|
|
});
|
|
|
|
app.get("/tmux_monitor/:hostId/metrics", async (req, res) => {
|
|
const host = await requireHost(req, res);
|
|
if (!host) return;
|
|
|
|
try {
|
|
const metrics = await withHostConnection(host, async (conn) => {
|
|
const panes = await listPanesRaw(conn);
|
|
if (panes.length === 0) return [];
|
|
return collectPaneMetrics(conn, panes);
|
|
});
|
|
res.json({ panes: metrics });
|
|
} catch (err) {
|
|
sendTmuxError(res, err, "metrics", host.id);
|
|
}
|
|
});
|
|
|
|
app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
|
|
const userId = (req as unknown as AuthenticatedRequest).userId;
|
|
const host = await requireHost(req, res);
|
|
if (!host) return;
|
|
|
|
const { sessionName, tags } = req.body as {
|
|
sessionName?: string;
|
|
tags?: string[];
|
|
};
|
|
if (!sessionName || typeof sessionName !== "string") {
|
|
return res.status(400).json({ error: "Missing session name" });
|
|
}
|
|
if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
|
|
return res.status(400).json({ error: "Tags must be an array of strings" });
|
|
}
|
|
const cleanTags = [
|
|
...new Set(tags.map((t) => t.trim().slice(0, 64)).filter(Boolean)),
|
|
].slice(0, 20);
|
|
|
|
try {
|
|
const db = getDb();
|
|
await db
|
|
.delete(tmuxSessionTags)
|
|
.where(
|
|
and(
|
|
eq(tmuxSessionTags.userId, userId),
|
|
eq(tmuxSessionTags.hostId, host.id),
|
|
eq(tmuxSessionTags.sessionName, sessionName),
|
|
),
|
|
);
|
|
if (cleanTags.length > 0) {
|
|
await db.insert(tmuxSessionTags).values(
|
|
cleanTags.map((tag) => ({
|
|
userId,
|
|
hostId: host.id,
|
|
sessionName,
|
|
tag,
|
|
})),
|
|
);
|
|
}
|
|
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
|
|
res.json({ sessionName, tags: cleanTags });
|
|
} catch (err) {
|
|
sshLogger.error(
|
|
`Failed to save tmux session tags for host ${host.id}`,
|
|
err,
|
|
);
|
|
res.status(500).json({ error: toErrorMessage(err) });
|
|
}
|
|
});
|
|
|
|
const PORT = 30010;
|
|
app.listen(PORT, () => {});
|