refactor(backend): reorganize top-level layout

- ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics)
- serial/serial.ts and guacamole/ moved inside hosts/
- dashboard.ts and homepage.ts moved to services/
- swagger.ts moved to utils/ with adjusted scan globs

Import paths and the generate:openapi script updated; ports and endpoints
unchanged.
This commit is contained in:
LukeGus
2026-07-16 04:29:26 -05:00
parent 5e7090542d
commit 8b6037b7ff
121 changed files with 66 additions and 58 deletions
+321
View File
@@ -0,0 +1,321 @@
import { createCurrentAlertRepository } from "../database/repositories/factory.js";
import { statsLogger } from "../utils/logger.js";
import {
sendNotification,
type AlertPayload,
type NotificationChannel,
} from "../utils/notification-sender.js";
type AlertTriggerType =
| "host_offline"
| "host_online"
| "cpu_threshold"
| "memory_threshold"
| "disk_threshold"
| "health_check_failure"
| "health_check_recovery"
| "user_login";
interface AlertRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: AlertTriggerType;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
}
export class AlertEngine {
private static instance: AlertEngine;
// "${ruleId}:${hostId}" → lastFiredAt ms
private cooldownMap = new Map<string, number>();
// "${ruleId}:${hostId}" → breachStartTime ms
private breachStartMap = new Map<string, number>();
// hostId → last known status
private lastStatusMap = new Map<number, "online" | "offline">();
// "${hostId}:${checkId}" → last known ok state
private healthCheckStateMap = new Map<string, boolean>();
static getInstance(): AlertEngine {
if (!AlertEngine.instance) {
AlertEngine.instance = new AlertEngine();
}
return AlertEngine.instance;
}
async evaluateMetrics(
hostId: number,
metrics: {
cpu?: { percent: number | null } | null;
memory?: { percent: number | null } | null;
disk?: { percent: number | null } | null;
},
): Promise<void> {
const rules = (await this.loadRulesForHost(hostId)).filter((r) =>
["cpu_threshold", "memory_threshold", "disk_threshold"].includes(
r.triggerType,
),
);
if (rules.length === 0) return;
const now = Date.now();
for (const rule of rules) {
let currentValue: number | null | undefined;
if (rule.triggerType === "cpu_threshold")
currentValue = metrics.cpu?.percent;
else if (rule.triggerType === "memory_threshold")
currentValue = metrics.memory?.percent;
else if (rule.triggerType === "disk_threshold")
currentValue = metrics.disk?.percent;
if (currentValue == null || rule.thresholdValue == null) continue;
const key = `${rule.id}:${hostId}`;
const isBreaching = currentValue >= rule.thresholdValue;
if (isBreaching) {
if (!this.breachStartMap.has(key)) {
this.breachStartMap.set(key, now);
}
const breachStart = this.breachStartMap.get(key)!;
const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000;
if (
now - breachStart >= durationMs &&
!(await this.isCoolingDown(rule.id, hostId))
) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
value: currentValue,
message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`,
severity: currentValue >= 95 ? "critical" : "warning",
});
}
} else {
this.breachStartMap.delete(key);
}
}
}
async evaluateStatus(hostId: number, isOnline: boolean): Promise<void> {
const currentStatus = isOnline ? "online" : "offline";
const lastStatus = this.lastStatusMap.get(hostId);
if (lastStatus === currentStatus) return;
this.lastStatusMap.set(hostId, currentStatus);
if (lastStatus === undefined) return;
const triggerType: AlertTriggerType = isOnline
? "host_online"
: "host_offline";
const rules = (await this.loadRulesForHost(hostId)).filter(
(r) => r.triggerType === triggerType,
);
for (const rule of rules) {
if (!(await this.isCoolingDown(rule.id, hostId))) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
message: isOnline
? `Host "${hostName}" is back online`
: `Host "${hostName}" is offline`,
severity: isOnline ? "info" : "critical",
});
}
}
}
async evaluateHealthCheck(
hostId: number,
userId: string,
checkId: string,
ok: boolean,
detail?: string,
): Promise<void> {
const stateKey = `${hostId}:${checkId}`;
const lastOk = this.healthCheckStateMap.get(stateKey);
this.healthCheckStateMap.set(stateKey, ok);
if (lastOk === undefined) return;
let triggerType: AlertTriggerType | null = null;
if (!ok && lastOk) triggerType = "health_check_failure";
else if (ok && !lastOk) triggerType = "health_check_recovery";
if (!triggerType) return;
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
(r) => r.triggerType === triggerType,
);
for (const rule of rules) {
if (!(await this.isCoolingDown(rule.id, hostId))) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
message: ok
? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}`
: `Health check failed on "${hostName}"${detail ? `: ${detail}` : ""}`,
severity: ok ? "info" : "warning",
});
}
}
}
async evaluateUserLogin(
hostId: number,
userId: string,
sshUser: string,
fromIp: string,
): Promise<void> {
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
(r) => r.triggerType === "user_login",
);
for (const rule of rules) {
if (!(await this.isCoolingDown(rule.id, hostId))) {
const hostName = await this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`,
severity: "info",
});
}
}
}
private async fireAlert(
rule: AlertRule,
hostId: number,
hostName: string,
context: {
value?: number;
message: string;
severity: "info" | "warning" | "critical";
},
): Promise<void> {
this.markCooldown(rule.id, hostId);
const payload: AlertPayload = {
hostName,
hostId,
triggerType: rule.triggerType,
value: context.value,
threshold: rule.thresholdValue ?? undefined,
message: context.message,
severity: context.severity,
timestamp: new Date().toISOString(),
ruleId: rule.id,
ruleName: rule.name,
};
try {
const repository = createCurrentAlertRepository();
await repository.createFiring({
userId: rule.userId,
ruleId: rule.id,
hostId,
hostName,
value: context.value ?? null,
message: context.message,
severity: context.severity,
});
repository.pruneFiringsOlderThan(rule.userId, 30);
} catch (err) {
statsLogger.warn("Failed to write alert firing", {
operation: "alert_firing_insert_error",
ruleId: rule.id,
error: err instanceof Error ? err.message : String(err),
});
}
const channels = await this.loadChannelsForRule(rule.id);
for (const channel of channels) {
sendNotification(channel, payload).catch((err) => {
statsLogger.warn("Failed to send notification", {
operation: "notification_delivery_error",
channelId: channel.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
}
private async isCoolingDown(
ruleId: number,
hostId: number,
): Promise<boolean> {
const key = `${ruleId}:${hostId}`;
const lastFired = this.cooldownMap.get(key);
if (!lastFired) return false;
const rule = await this.loadRuleById(ruleId);
const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000;
return Date.now() - lastFired < cooldownMs;
}
private markCooldown(ruleId: number, hostId: number): void {
this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now());
}
private async loadRulesForHost(hostId: number): Promise<AlertRule[]> {
try {
return (await createCurrentAlertRepository().listEnabledRulesForHost(
hostId,
)) as AlertRule[];
} catch {
return [];
}
}
private async loadRulesForHostUser(
hostId: number,
userId: string,
): Promise<AlertRule[]> {
try {
return (await createCurrentAlertRepository().listEnabledRulesForHostUser(
hostId,
userId,
)) as AlertRule[];
} catch {
return [];
}
}
private async loadRuleById(ruleId: number): Promise<AlertRule | null> {
try {
return (await createCurrentAlertRepository().findRuleById(
ruleId,
)) as AlertRule | null;
} catch {
return null;
}
}
private async loadChannelsForRule(
ruleId: number,
): Promise<NotificationChannel[]> {
try {
return await createCurrentAlertRepository().listEnabledChannelsForRule(
ruleId,
);
} catch {
return [];
}
}
private async getHostName(hostId: number): Promise<string> {
try {
return (
(await createCurrentAlertRepository().getHostDisplayName(hostId)) ??
`Host #${hostId}`
);
} catch {
return `Host #${hostId}`;
}
}
}
+409
View File
@@ -0,0 +1,409 @@
import type { WebSocket } from "ws";
import { sshLogger, authLogger } from "../utils/logger.js";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
interface ResolvedCredentials {
username: string;
password?: string;
key?: Buffer;
keyPassword?: string;
authType?: string;
certPublicKey?: string;
}
interface HostConfig {
id: number;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
useWarpgate?: boolean;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
}
interface AuthContext {
userId: string;
ws: WebSocket;
hostId: number;
isKeyboardInteractive: boolean;
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
export class SSHAuthManager {
public context: AuthContext;
constructor(context: AuthContext) {
this.context = context;
}
async resolveCredentials(
hostConfig: HostConfig,
): Promise<ResolvedCredentials> {
let resolvedCredentials: ResolvedCredentials = {
username: hostConfig.username,
authType: hostConfig.authType || "none",
};
if (hostConfig.credentialId) {
const cred =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
hostConfig.credentialId,
this.context.userId,
);
if (cred) {
resolvedCredentials = {
username: (cred.username as string) || hostConfig.username,
password: (cred.password as string) || undefined,
key:
cred.key || cred.privateKey
? Buffer.from((cred.key || cred.privateKey) as string)
: undefined,
keyPassword: (cred.keyPassword as string) || undefined,
authType: (cred.authType as string) || "none",
certPublicKey: (cred.certPublicKey as string) || undefined,
};
}
} else {
if (hostConfig.password) {
resolvedCredentials.password = hostConfig.password;
resolvedCredentials.authType = "password";
}
if (hostConfig.key) {
resolvedCredentials.key = Buffer.from(hostConfig.key, "utf8");
resolvedCredentials.authType = "key";
if (hostConfig.keyPassword) {
resolvedCredentials.keyPassword = hostConfig.keyPassword;
}
}
}
return resolvedCredentials;
}
handleKeyboardInteractive(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostConfig,
): void {
this.context.isKeyboardInteractive = true;
const promptTexts = prompts.map((p) => p.prompt);
const warpgatePattern = /warpgate\s+authentication/i;
const isWarpgate =
warpgatePattern.test(name) ||
warpgatePattern.test(instructions) ||
promptTexts.some((p) => warpgatePattern.test(p));
if (isWarpgate) {
this.handleWarpgateAuth(name, instructions, promptTexts, finish);
return;
}
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
this.handleTotpAuth(
prompts,
totpPromptIndex,
finish,
resolvedCredentials,
);
return;
}
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
}
private handleWarpgateAuth(
name: string,
instructions: string,
promptTexts: string[],
finish: (responses: string[]) => void,
): void {
const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`;
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
const keyMatch = fullText.match(
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
);
if (urlMatch) {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: urlMatch[0],
securityKey: keyMatch ? keyMatch[1] : "N/A",
instructions: instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
sshLogger.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
}),
);
}
}, 300000);
}
}
private handleTotpAuth(
prompts: Array<{ prompt: string; echo: boolean }>,
totpPromptIndex: number,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
): void {
if (this.context.totpPromptSent) {
sshLogger.warn("TOTP prompt asked again - invalid code", {
operation: "ssh_keyboard_interactive_totp_retry",
hostId: this.context.hostId,
});
authLogger.warn("TOTP verification failed for SSH session", {
operation: "terminal_totp_failed",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.sendLog("auth", "warning", "Invalid TOTP code");
this.context.ws.send(
JSON.stringify({
type: "totp_retry",
}),
);
return;
}
this.context.totpPromptSent = true;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (totpResponses: string[]) => {
const totpCode = (totpResponses[0] || "").trim();
const responses = prompts.map((p, index) => {
if (index === totpPromptIndex) {
return totpCode;
}
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
};
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
}
this.context.totpTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
sshLogger.warn("TOTP prompt timeout", {
operation: "totp_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "TOTP verification timeout. Please reconnect.",
}),
);
}
}, 180000);
this.sendLog("auth", "info", "TOTP verification required");
authLogger.info("TOTP verification prompt sent to client", {
operation: "terminal_totp_prompt",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "totp_required",
prompt: prompts[totpPromptIndex].prompt,
}),
);
}
private handlePasswordAuth(
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostConfig,
): void {
// For Warpgate hosts: auto-answer password prompts silently using stored credentials.
// Warpgate sends a password prompt before its browser-verification round; we must
// not show a UI prompt here -- the WarpgateDialog handles user interaction later.
if (hostConfig?.useWarpgate) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
return;
}
const hasStoredPassword =
resolvedCredentials.password && resolvedCredentials.authType !== "none";
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
// Find the first prompt we can't auto-answer. This handles DUO/PAM challenges
// that don't say "password" (e.g. "Passcode or option (1-N):").
const firstUnansweredIndex = prompts.findIndex((p) => {
if (/password/i.test(p.prompt) && hasStoredPassword) return false;
return true;
});
if (firstUnansweredIndex !== -1) {
if (this.context.keyboardInteractiveResponded) {
return;
}
this.context.keyboardInteractiveResponded = true;
const promptIndex =
passwordPromptIndex !== -1 && !hasStoredPassword
? passwordPromptIndex
: firstUnansweredIndex;
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
const userInput = (userResponses[0] || "").trim();
const responses = prompts.map((p, index) => {
if (index === promptIndex) {
return userInput;
}
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
};
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
}
this.context.totpTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.keyboardInteractiveResponded = false;
sshLogger.warn("Password prompt timeout", {
operation: "password_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Password verification timeout. Please reconnect.",
}),
);
}
}, 180000);
this.sendLog("auth", "info", "Password authentication required");
this.context.ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[promptIndex].prompt,
}),
);
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
}
sendLog(
stage: string,
level: string,
message: string,
details?: Record<string, unknown>,
): void {
this.context.ws.send(
JSON.stringify({
type: "connection_log",
data: {
stage,
level,
message,
details,
},
}),
);
}
cleanup(): void {
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.keyboardInteractiveResponded = false;
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { ConnectionStage, LogEntry } from "../../types/connection-log.js";
export function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return {
type,
stage,
message,
details,
};
}
+43
View File
@@ -0,0 +1,43 @@
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getContainerRuntimeConfig(raw: unknown): {
runtime: ContainerRuntime;
} {
if (!raw) {
return { runtime: "docker" };
}
let config: unknown = raw;
if (typeof raw === "string") {
try {
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
return { runtime: "docker" };
}
}
if (!config || typeof config !== "object") {
return { runtime: "docker" };
}
return {
runtime: normalizeContainerRuntime(
(config as Record<string, unknown>).runtime,
),
};
}
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
): string {
return `${normalizeContainerRuntime(runtime)} ${args}`;
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
pickResolvedUsername,
pickResolvedPassword,
expandOidcUsername,
} from "./credential-username.js";
describe("pickResolvedUsername", () => {
it("keeps the host username when one is set, even with a credential username", () => {
expect(pickResolvedUsername("admin", "root", false)).toBe("admin");
});
it("falls back to the credential username when the host has none", () => {
expect(pickResolvedUsername("", "root", false)).toBe("root");
expect(pickResolvedUsername(undefined, "root", false)).toBe("root");
expect(pickResolvedUsername(" ", "root", false)).toBe("root");
});
it("treats whitespace-only host usernames as empty", () => {
expect(pickResolvedUsername(" ", "deploy", false)).toBe("deploy");
});
it("forces the host username when overrideCredentialUsername is set", () => {
expect(pickResolvedUsername("admin", "root", true)).toBe("admin");
expect(pickResolvedUsername("", "root", true)).toBeUndefined();
});
it("returns undefined when neither username is usable", () => {
expect(pickResolvedUsername("", "", false)).toBeUndefined();
expect(pickResolvedUsername(undefined, undefined, false)).toBeUndefined();
});
});
describe("pickResolvedPassword", () => {
it("keeps the host-specific password ahead of the credential password", () => {
expect(pickResolvedPassword("host-pass", "credential-pass")).toBe(
"host-pass",
);
});
it("falls back to the credential password when the host has none", () => {
expect(pickResolvedPassword("", "credential-pass")).toBe("credential-pass");
expect(pickResolvedPassword(undefined, "credential-pass")).toBe(
"credential-pass",
);
});
it("treats whitespace-only passwords as empty", () => {
expect(pickResolvedPassword(" ", "credential-pass")).toBe(
"credential-pass",
);
});
});
describe("expandOidcUsername", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns the username unchanged when it has no placeholder", async () => {
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
});
it("expands the placeholder with the user's OIDC identifier", async () => {
vi.doMock("../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({ oidcIdentifier: "jdoe" }),
}),
}));
const { expandOidcUsername: expand } =
await import("./credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
});
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
vi.doMock("../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({ oidcIdentifier: null }),
}),
}));
const { expandOidcUsername: expand } =
await import("./credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
);
});
it("returns the username unchanged when the DB lookup throws", async () => {
vi.doMock("../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => {
throw new Error("DB unavailable");
},
}));
const { expandOidcUsername: expand } =
await import("./credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Decides which username to use when a host is backed by a saved credential.
*
* An explicitly-set host username always wins - if the user typed a username on
* the host it should be honoured even when a credential is attached. The
* credential's username is only used as a fallback when the host has none. The
* `overrideCredentialUsername` flag forces the host username regardless.
*/
export function pickResolvedUsername(
hostUsername: unknown,
credentialUsername: unknown,
overrideCredentialUsername?: unknown,
): string | undefined {
const host = isNonEmptyString(hostUsername) ? hostUsername : undefined;
const cred = isNonEmptyString(credentialUsername)
? credentialUsername
: undefined;
if (overrideCredentialUsername) return host;
if (host) return host;
return cred;
}
/**
* A host can keep a per-host password while using a shared key credential.
* Prefer that host-specific value and fall back to the credential password.
*/
export function pickResolvedPassword(
hostPassword: unknown,
credentialPassword: unknown,
): string | undefined {
if (isNonEmptyString(hostPassword)) return hostPassword;
if (isNonEmptyString(credentialPassword)) return credentialPassword;
return undefined;
}
/**
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
* connecting user's OIDC identifier. Returns the username unchanged if it does
* not contain the placeholder or the user has no OIDC identifier.
*/
export async function expandOidcUsername(
username: string | undefined,
userId: string,
): Promise<string | undefined> {
if (!username || !username.includes("$oidc.preferred_username")) {
return username;
}
try {
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const user = await createCurrentUserRepository().findById(userId);
const oidcIdentifier = user?.oidcIdentifier;
if (!oidcIdentifier) return username;
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
} catch {
return username;
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}
+787
View File
@@ -0,0 +1,787 @@
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
import { systemLogger } from "../../utils/logger.js";
import type { SSHHost } from "../../../types/index.js";
import { applyAgentAuth } from "../terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "../container-runtime.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
const sshLogger = systemLogger;
interface SSHSession {
client: SSHClient;
stream: import("ssh2").ClientChannel | null;
isConnected: boolean;
containerId?: string;
shell?: string;
hostId?: number;
containerRuntime?: ContainerRuntime;
}
const activeSessions = new Map<string, SSHSession>();
const wss = new WebSocketServer({
host: "0.0.0.0",
port: 30009,
});
async function detectShell(
session: SSHSession,
containerId: string,
): Promise<string> {
const shells = ["bash", "sh", "ash"];
for (const shell of shells) {
try {
await new Promise<void>((resolve, reject) => {
session.client.exec(
containerCommand(
session.containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not found`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
);
});
return shell;
} catch {
continue;
}
}
return "sh";
}
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise<SSHClient | null> {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
let currentClient: SSHClient | null = null;
const repository = createCurrentHostResolutionRepository();
for (let i = 0; i < jumpHosts.length; i++) {
const jumpHostId = jumpHosts[i].hostId;
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
throw new Error(`Jump host ${jumpHostId} not found`);
}
const jumpHost = resolvedJumpHost as unknown as SSHHost;
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
try {
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
} catch (e) {
sshLogger.error("Failed to parse jump hosts", e, {
hostId: jumpHost.id,
});
jumpHost.jumpHosts = [];
}
}
let resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: jumpHost.password,
sshKey: jumpHost.key,
keyPassword: jumpHost.keyPassword,
authType: jumpHost.authType,
};
if (jumpHost.credentialId) {
const credential = await repository.findCredentialByIdForUser(
jumpHost.credentialId as number,
userId,
);
if (credential) {
resolvedCredentials = {
password: credential.password as string | undefined,
sshKey: (credential.key || credential.privateKey) as
| string
| undefined,
keyPassword: credential.keyPassword as string | undefined,
authType: credential.authType as string | undefined,
};
}
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
tryKeyboard: resolvedCredentials.authType !== "none",
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
config.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
jumpHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
client.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
await new Promise<void>((resolve, reject) => {
currentClient!.forwardOut(
"127.0.0.1",
0,
jumpHost.ip,
jumpHost.port || 22,
(err, stream) => {
if (err) return reject(err);
config.sock = stream;
resolve();
},
);
});
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
currentClient = client;
}
return currentClient;
}
wss.on("connection", async (ws: WebSocket, req) => {
let token: string | undefined;
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
}
if (!token) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
}
if (!token) {
const urlObj = new URL(req.url || "", "http://localhost");
const qp = urlObj.searchParams.get("token");
if (qp) token = qp;
}
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const authManagerInstance = AuthManager.getInstance();
const payload = await authManagerInstance.verifyJWTToken(token);
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
}
const userId = payload.userId;
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
sshLogger.info("Docker console WebSocket connected", {
operation: "docker_console_connect",
sessionId,
userId,
});
let sshSession: SSHSession | null = null;
const wsPingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);
ws.on("message", async (data) => {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case "connect": {
const { hostConfig, containerId, shell, cols, rows } =
message.data as {
hostConfig: SSHHost;
containerId: string;
shell?: string;
cols?: number;
rows?: number;
};
const hostId = hostConfig?.id;
if (!hostId || !containerId) {
ws.send(
JSON.stringify({
type: "error",
message: "Host configuration and container ID are required",
}),
);
return;
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid container ID",
}),
);
return;
}
const allowedShells = ["bash", "sh", "ash", "zsh"];
if (shell && !allowedShells.includes(shell)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid shell",
}),
);
return;
}
if (!hostConfig.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message: "Docker is not enabled on this host",
}),
);
return;
}
try {
// Resolve host with credentials server-side
const { resolveHostById } = await import("../host-resolver.js");
const resolvedHost = await resolveHostById(hostId, userId);
if (!resolvedHost) {
ws.send(
JSON.stringify({
type: "error",
message: "Host not found",
}),
);
return;
}
if (!resolvedHost.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message:
"Docker is not enabled for this host. Enable it in Host Settings.",
}),
);
return;
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
port: resolvedHost.port || 22,
username: resolvedHost.username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
};
if (resolvedHost.authType === "password" && resolvedHost.password) {
config.password = resolvedHost.password;
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
const cleanKey = resolvedHost.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedHost.keyPassword) {
config.passphrase = resolvedHost.keyPassword;
}
} else if (resolvedHost.authType === "agent") {
const result = await applyAgentAuth(
config,
resolvedHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
ws.send(
JSON.stringify({
type: "error",
message: result.error,
}),
);
return;
}
}
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain(
resolvedHost.jumpHosts,
userId,
);
if (jumpClient) {
const stream = await new Promise<import("ssh2").ClientChannel>(
(resolve, reject) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
resolvedHost.ip,
resolvedHost.port || 22,
(err, stream) => {
if (err) return reject(err);
resolve(stream);
},
);
},
);
config.sock = stream;
}
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
const { runtime: containerRuntime } = getContainerRuntimeConfig(
resolvedHost.dockerConfig,
);
sshSession = {
client,
stream: null,
isConnected: true,
containerId,
hostId: resolvedHost.id,
containerRuntime,
};
activeSessions.set(sessionId, sshSession);
let shellToUse = shell || "bash";
if (shell) {
try {
await new Promise<void>((resolve, reject) => {
client.exec(
containerCommand(
containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not available`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
);
});
} catch {
sshLogger.warn(
`Requested shell ${shell} not found, detecting available shell`,
{
operation: "shell_validation",
sessionId,
containerId,
requestedShell: shell,
},
);
shellToUse = await detectShell(sshSession, containerId);
}
} else {
shellToUse = await detectShell(sshSession, containerId);
}
sshSession.shell = shellToUse;
const execCommand = containerCommand(
containerRuntime,
`exec -it ${containerId} /bin/${shellToUse}`,
);
sshLogger.info("Attaching to Docker container", {
operation: "docker_attach",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
client.exec(
execCommand,
{
pty: {
term: "xterm-256color",
cols: cols || 80,
rows: rows || 24,
},
},
(err, stream) => {
if (err) {
sshLogger.error("Failed to create docker exec", err, {
operation: "docker_exec",
sessionId,
containerId,
});
ws.send(
JSON.stringify({
type: "error",
message: `Failed to start console: ${err.message}`,
}),
);
return;
}
sshSession!.stream = stream;
sshLogger.success("Docker container attached", {
operation: "docker_attach_success",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
stream.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "output",
data: data.toString("utf8"),
}),
);
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
sshLogger.error("Docker console stream error", streamErr, {
operation: "docker_console_stream_error",
sessionId,
containerId,
});
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: `Console error: ${streamErr.message}`,
}),
);
}
});
stream.on("close", () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Console session ended",
}),
);
}
if (sshSession) {
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
ws.send(
JSON.stringify({
type: "connected",
data: {
shell: shellToUse,
requestedShell: shell,
shellChanged: shell && shell !== shellToUse,
},
}),
);
},
);
} catch (error) {
sshLogger.error("Failed to connect to container", error, {
operation: "console_connect",
sessionId,
containerId: message.data.containerId,
});
ws.send(
JSON.stringify({
type: "error",
message:
error instanceof Error
? error.message
: "Failed to connect to container",
}),
);
}
break;
}
case "input": {
if (sshSession && sshSession.stream) {
sshSession.stream.write(message.data);
}
break;
}
case "resize": {
if (sshSession && sshSession.stream) {
const { cols, rows } = message.data;
sshSession.stream.setWindow(rows, cols, rows, cols);
}
break;
}
case "disconnect": {
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
ws.send(
JSON.stringify({
type: "disconnected",
message: "Disconnected from container",
}),
);
}
break;
}
case "ping": {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
break;
}
default:
sshLogger.warn("Unknown message type", {
operation: "ws_message",
type: message.type,
});
}
} catch (error) {
sshLogger.error("WebSocket message error", error, {
operation: "ws_message",
sessionId,
});
ws.send(
JSON.stringify({
type: "error",
message: error instanceof Error ? error.message : "An error occurred",
}),
);
}
});
ws.on("close", () => {
clearInterval(wsPingInterval);
sshLogger.info("Docker console disconnected", {
operation: "docker_console_disconnect",
sessionId,
userId,
hostId: sshSession?.hostId,
containerId: sshSession?.containerId,
});
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
ws.on("error", (error) => {
sshLogger.error("WebSocket error", error, {
operation: "ws_error",
sessionId,
});
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
});
process.on("SIGTERM", () => {
activeSessions.forEach((session) => {
if (session.stream) {
session.stream.end();
}
session.client.end();
});
activeSessions.clear();
wss.close(() => {
process.exit(0);
});
});
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
import express from "express";
import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import { logger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
import {
sshSessions,
pendingTOTPSessions,
cleanupSession,
executeDockerCommand,
} from "./session-manager.js";
import {
DOCKER_TIMESTAMP_RE,
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
const sshLogger = logger;
const app = express();
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions,
pendingTOTPSessions,
getRequestUserId,
executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const PORT = 30007;
app.listen(PORT, async () => {
try {
await authManager.initialize();
} catch (err) {
sshLogger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId);
});
process.exit(0);
});
process.on("SIGTERM", () => {
Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId);
});
process.exit(0);
});
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
import { Client as SSHClient } from "ssh2";
import { logger } from "../../utils/logger.js";
import type { ContainerRuntime } from "../container-runtime.js";
const sshLogger = logger;
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId?: number;
userId?: string;
isWindows?: boolean;
containerRuntime?: ContainerRuntime;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: Record<string, unknown>;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
containerRuntime?: ContainerRuntime;
}
export const sshSessions: Record<string, SSHSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
setInterval(() => {
const now = Date.now();
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
const session = pendingTOTPSessions[sessionId];
if (now - session.createdAt > 180000) {
try {
session.client.end();
} catch {
// expected
}
delete pendingTOTPSessions[sessionId];
}
});
}, 60000);
export function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
sshLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
export function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => {
cleanupSession(sessionId);
}, SESSION_IDLE_TIMEOUT);
}
}
export async function executeDockerCommand(
session: SSHSession,
command: string,
sessionId?: string,
userId?: string,
hostId?: number,
): Promise<string> {
const startTime = Date.now();
sshLogger.info("Executing Docker command", {
operation: "docker_command_exec",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return new Promise((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) {
sshLogger.error("Docker command execution error", err, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return reject(err);
}
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (code !== 0) {
sshLogger.error("Docker command failed", undefined, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
exitCode: code,
stderr,
});
reject(new Error(stderr || `Command exited with code ${code}`));
} else {
sshLogger.success("Docker command completed", {
operation: "docker_command_success",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
duration: Date.now() - startTime,
});
resolve(stdout);
}
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
stream.on("error", (streamErr: Error) => {
sshLogger.error("Docker command stream error", streamErr, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
reject(streamErr);
});
});
});
}
@@ -0,0 +1,541 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { fileLogger } from "../utils/logger.js";
import { execChannel, type SSHSession } from "./file-manager-session.js";
type FileActionRoutesDeps = {
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileActionRoutes(
app: Express,
{
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileActionRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/copyItem:
* post:
* summary: Copy a file or directory
* description: Copies a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* sourcePath:
* type: string
* targetDir:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: Item copied successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 500:
* description: Failed to copy item.
*/
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
const { sessionId, sourcePath, targetDir, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !sourcePath || !targetDir) {
return res.status(400).json({ error: "Missing required parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const sourceName = sourcePath.split("/").pop() || "copied_item";
const timestamp = Date.now().toString().slice(-8);
const uniqueName = `${sourceName}_copy_${timestamp}`;
const targetPath = `${targetDir}/${uniqueName}`;
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
const commandTimeout = setTimeout(() => {
fileLogger.error("Copy command timed out after 60 seconds", {
sourcePath,
targetPath,
command: copyCommand,
});
if (!res.headersSent) {
res.status(500).json({
error: "Copy operation timed out",
toast: {
type: "error",
message:
"Copy operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, copyCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let errorData = "";
let stdoutData = "";
stream.on("data", (data: Buffer) => {
const output = data.toString();
stdoutData += output;
stream.stderr.on("data", (data: Buffer) => {
const output = data.toString();
errorData += output;
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (code !== 0) {
const fullErrorInfo =
errorData || stdoutData || "No error message available";
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
operation: "file_copy_failed",
sessionId,
sourcePath,
targetPath,
command: copyCommand,
exitCode: code,
errorData,
stdoutData,
fullErrorInfo,
});
if (!res.headersSent) {
return res.status(500).json({
error: `Copy failed: ${fullErrorInfo}`,
toast: {
type: "error",
message: `Copy failed: ${fullErrorInfo}`,
},
debug: {
sourcePath,
targetPath,
exitCode: code,
command: copyCommand,
},
});
}
return;
}
const copySuccessful =
stdoutData.includes("COPY_SUCCESS") || code === 0;
if (copySuccessful) {
fileLogger.success("Item copied successfully", {
operation: "file_copy",
sessionId,
sourcePath,
targetPath,
uniqueName,
hostId,
userId,
});
if (!res.headersSent) {
res.json({
message: "Item copied successfully",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "success",
message: `Successfully copied to: ${uniqueName}`,
},
});
}
} else {
fileLogger.warn("Copy completed but without success confirmation", {
operation: "file_copy_uncertain",
sessionId,
sourcePath,
targetPath,
code,
stdoutData: stdoutData.substring(0, 200),
});
if (!res.headersSent) {
res.json({
message: "Copy may have completed",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "warning",
message: `Copy completed but verification uncertain for: ${uniqueName}`,
},
});
}
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem stream error:", streamErr);
if (!res.headersSent) {
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/executeFile:
* post:
* summary: Execute a file
* description: Executes a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* filePath:
* type: string
* responses:
* 200:
* description: File execution result.
* 400:
* description: Missing required parameters or SSH connection not available.
* 500:
* description: Failed to execute file.
*/
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for executeFile",
{
operation: "execute_file",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr);
return res
.status(500)
.json({ error: "Failed to check file executability" });
}
let checkResult = "";
checkStream.on("data", (data) => {
checkResult += data.toString();
});
checkStream.on("close", () => {
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" });
}
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
execChannel(sshConn, executeCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
}
let output = "";
let errorOutput = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
const actualExitCode = exitCodeMatch
? parseInt(exitCodeMatch[1])
: code;
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
fileLogger.info("File execution completed", {
operation: "execute_file",
sessionId,
filePath,
exitCode: actualExitCode,
outputLength: cleanOutput.length,
errorLength: errorOutput.length,
});
res.json({
success: true,
exitCode: actualExitCode,
output: cleanOutput,
error: errorOutput,
timestamp: new Date().toISOString(),
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH executeFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: "Execution stream error" });
}
});
});
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/changePermissions:
* post:
* summary: Change file permissions
* description: Changes the permissions of a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* permissions:
* type: string
* responses:
* 200:
* description: Permissions changed successfully.
* 400:
* description: Missing required parameters or SSH connection not available.
* 408:
* description: Permission change timed out.
* 500:
* description: Failed to change permissions.
*/
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for changePermissions",
{
operation: "change_permissions",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)",
});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
fileLogger.info("Changing file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
fileLogger.error("changePermissions command timeout", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
res.status(408).json({
error: "Permission change timed out. SSH connection may be unstable.",
});
}
}, 10000);
execChannel(sshConn, command, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
return res
.status(500)
.json({ error: "Failed to change permissions" });
}
return;
}
let outputData = "";
let errorOutput = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
return;
}
if (code !== 0) {
fileLogger.error("chmod command failed", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
exitCode: code,
error: errorOutput,
});
if (!res.headersSent) {
return res.status(500).json({
error: errorOutput || "Failed to change permissions",
});
}
return;
}
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while changing permissions" });
}
});
});
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,360 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { fileLogger } from "../utils/logger.js";
import { getMimeType } from "./file-manager-utils.js";
import {
execChannel,
getSessionSftp,
type SSHSession,
} from "./file-manager-session.js";
type FileDownloadRoutesDeps = {
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
function escapeSingleQuotes(path: string): string {
return path.replace(/'/g, "'\"'\"'");
}
function downloadViaExec(
session: SSHSession,
filePath: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const escaped = escapeSingleQuotes(filePath);
execChannel(session, `cat '${escaped}'`, (err, stream) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
return reject(
new Error(stderr.trim() || `cat exited with code ${code}`),
);
}
resolve(Buffer.concat(chunks));
});
stream.on("error", reject);
});
});
}
export function registerFileDownloadRoutes(
app: Express,
{
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileDownloadRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/downloadFile:
* post:
* summary: Download a file
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: The file content.
* 400:
* description: Missing required parameters or file too large.
* 500:
* description: Failed to download file.
*/
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const { sessionId, path: filePath, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const downloadStartTime = Date.now();
if (!sessionId || !filePath) {
fileLogger.warn("Missing download parameters", {
operation: "file_download",
sessionId,
hasFilePath: !!filePath,
});
return res.status(400).json({ error: "Missing download parameters" });
}
fileLogger.info("File download started", {
operation: "file_download_start",
sessionId,
userId,
path: filePath,
});
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.warn("SSH session not found or not connected for download", {
operation: "file_download",
sessionId,
isConnected: sshConn?.isConnected,
});
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
if (sshConn.scpLegacy) {
fileLogger.info(
"Downloading file via legacy exec/cat (SCP legacy mode)",
{
operation: "file_download_legacy",
sessionId,
userId,
path: filePath,
},
);
try {
const data = await downloadViaExec(sshConn, filePath);
const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File download completed (legacy mode)", {
operation: "file_download_complete",
sessionId,
userId,
hostId,
path: filePath,
bytes: data.length,
duration: Date.now() - downloadStartTime,
});
return res.json({
content: data.toString("base64"),
fileName,
size: data.length,
mimeType: getMimeType(fileName),
path: filePath,
});
} catch (err) {
fileLogger.error("Legacy exec/cat download failed:", err);
return res.status(500).json({
error: `Failed to download file: ${(err as Error).message}`,
});
}
}
fileLogger.info("Opening SFTP channel", {
operation: "file_sftp_open",
sessionId,
userId,
path: filePath,
});
getSessionSftp(sshConn)
.then((sftp) => {
sftp.stat(filePath, (statErr, stats) => {
if (statErr) {
fileLogger.error("File stat failed for download:", statErr);
return res
.status(500)
.json({ error: `Cannot access file: ${statErr.message}` });
}
if (!stats.isFile()) {
fileLogger.warn("Attempted to download non-file", {
operation: "file_download",
sessionId,
filePath,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
});
return res
.status(400)
.json({ error: "Cannot download directories or special files" });
}
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
if (stats.size > MAX_FILE_SIZE) {
fileLogger.warn("File too large for download", {
operation: "file_download",
sessionId,
filePath,
fileSize: stats.size,
maxSize: MAX_FILE_SIZE,
});
return res.status(400).json({
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
});
}
sftp.readFile(filePath, (readErr, data) => {
if (readErr) {
fileLogger.error("File read failed for download:", readErr);
return res
.status(500)
.json({ error: `Failed to read file: ${readErr.message}` });
}
const base64Content = data.toString("base64");
const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File download completed", {
operation: "file_download_complete",
sessionId,
userId,
hostId,
path: filePath,
bytes: stats.size,
duration: Date.now() - downloadStartTime,
});
res.json({
content: base64Content,
fileName: fileName,
size: stats.size,
mimeType: getMimeType(fileName),
path: filePath,
});
});
});
})
.catch((err) => {
fileLogger.error("SFTP connection failed for download:", err);
return res.status(500).json({ error: "SFTP connection failed" });
});
});
/**
* @openapi
* /ssh/file_manager/ssh/downloadFileStream:
* post:
* summary: Stream-download a file
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* responses:
* 200:
* description: Binary file stream.
* 400:
* description: Missing required parameters.
* 500:
* description: Download failed.
*/
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
const { sessionId, path: filePath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !filePath) {
return res.status(400).json({ error: "Missing download parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn?.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
if (sshConn.scpLegacy) {
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
operation: "file_download_stream_legacy",
sessionId,
userId,
path: filePath,
});
const escaped = escapeSingleQuotes(filePath);
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
if (err) {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
}
return;
}
stream.on("error", (streamErr: Error) => {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${streamErr.message}` });
} else {
res.destroy();
}
});
stream.pipe(res);
});
return;
}
try {
const sftp = await getSessionSftp(sshConn);
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
(resolve, reject) => {
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
},
);
if (!stats.isFile()) {
return res.status(400).json({ error: "Cannot download directories" });
}
res.setHeader("Content-Length", String(stats.size));
const readStream = sftp.createReadStream(filePath);
readStream.on("error", (err) => {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
} else {
res.destroy();
}
});
readStream.pipe(res);
} catch (err) {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${(err as Error).message}` });
}
}
});
}
@@ -0,0 +1,473 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { fileLogger } from "../utils/logger.js";
import {
execChannel,
getSessionSftp,
type SSHSession,
} from "./file-manager-session.js";
import {
formatMtime,
isExecutableFile,
modeToPermissions,
parseLsDateToTimestamp,
} from "./file-manager-utils.js";
type FileListingRoutesDeps = {
sshSessions: Record<string, SSHSession>;
activeListRequests: Record<string, boolean>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileListingRoutes(
app: Express,
{
sshSessions,
activeListRequests,
verifySessionOwnership,
}: FileListingRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/listFiles:
* get:
* summary: List files in a directory
* description: Lists the files and directories in a given path on the remote host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* - in: query
* name: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: A list of files and directories.
* 400:
* description: Session ID is required or SSH connection not established.
* 500:
* description: Failed to list files.
*/
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = decodeURIComponent((req.query.path as string) || "/");
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
// Drop concurrent requests for the same session+path — each would open
// a new SSH channel and can exceed the server's per-connection channel limit.
const listKey = `${sessionId}:${sshPath}`;
if (activeListRequests[listKey]) {
return res
.status(409)
.json({ error: "List request already in progress" });
}
activeListRequests[listKey] = true;
res.on("finish", () => {
delete activeListRequests[listKey];
});
sshConn.lastActive = Date.now();
sshConn.activeOperations++;
const trySFTP = () => {
try {
fileLogger.info("Opening SFTP channel", {
operation: "file_sftp_open",
sessionId,
userId,
path: sshPath,
});
getSessionSftp(sshConn)
.then((sftp) => {
sftp.readdir(sshPath, (readdirErr, list) => {
if (readdirErr) {
fileLogger.warn(
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
);
tryFallbackMethod();
return;
}
const symlinks: Array<{ index: number; path: string }> = [];
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (const entry of list) {
if (entry.filename === "." || entry.filename === "..") continue;
const attrs = entry.attrs;
const permissions = modeToPermissions(attrs.mode);
const isDirectory = attrs.isDirectory();
const isLink = attrs.isSymbolicLink();
const fileEntry = {
name: entry.filename,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : attrs.size,
modified: formatMtime(attrs.mtime),
modifiedTimestamp: attrs.mtime,
permissions,
owner: String(attrs.uid),
group: String(attrs.gid),
linkTarget: undefined as string | undefined,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, entry.filename)
: false,
};
if (isLink) {
symlinks.push({ index: files.length, path: fileEntry.path });
}
files.push(fileEntry);
}
if (symlinks.length === 0) {
sshConn.activeOperations--;
return res.json({ files, path: sshPath });
}
let resolved = 0;
let responded = false;
const sendResponse = () => {
if (responded) return;
responded = true;
sshConn.activeOperations--;
res.json({ files, path: sshPath });
};
const readlinkTimeout = setTimeout(sendResponse, 5000);
for (const link of symlinks) {
sftp.readlink(link.path, (linkErr, target) => {
resolved++;
if (!linkErr && target) {
files[link.index].linkTarget = target;
}
if (resolved === symlinks.length) {
clearTimeout(readlinkTimeout);
sendResponse();
}
});
}
});
})
.catch((err: Error) => {
fileLogger.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`,
);
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
sshConn.isConnected = false;
sshConn.sftp = undefined;
}
tryFallbackMethod();
});
} catch (sftpErr: unknown) {
const errMsg =
sftpErr instanceof Error ? sftpErr.message : "Unknown error";
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
tryFallbackMethod();
}
};
const tryFallbackMethod = () => {
if (!sshConn?.isConnected) {
sshConn.activeOperations--;
return res
.status(503)
.json({ error: "SSH session disconnected", disconnected: true });
}
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
execChannel(
sshConn,
`command ls -la --color=never '${escapedPath}'`,
(err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
const isPermissionDenied =
errorData.toLowerCase().includes("permission denied") ||
errorData.toLowerCase().includes("access denied");
if (isPermissionDenied) {
if (sshConn.sudoPassword) {
fileLogger.info(
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
);
tryWithSudo();
return;
}
sshConn.activeOperations--;
fileLogger.warn(
`Permission denied for listFiles, sudo required: ${sshPath}`,
);
return res.status(403).json({
error: `Permission denied: Cannot access ${sshPath}`,
needsSudo: true,
path: sshPath,
});
}
sshConn.activeOperations--;
fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Command failed: ${errorData}` });
}
sshConn.activeOperations--;
const lines = data.split("\n").filter((line) => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
},
);
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg =
execErr instanceof Error ? execErr.message : "Unknown error";
fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
const tryWithSudo = () => {
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
execChannel(sshConn, sudoCommand, (err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH sudo listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
sshConn.activeOperations--;
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
if (
data.toLowerCase().includes("sorry, try again") ||
data.toLowerCase().includes("incorrect password") ||
errorData.toLowerCase().includes("sorry, try again")
) {
sshConn.sudoPassword = undefined;
return res.status(403).json({
error: "Sudo authentication failed. Please try again.",
needsSudo: true,
sudoFailed: true,
path: sshPath,
});
}
if (code !== 0 && !data.trim()) {
fileLogger.error(
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Sudo command failed: ${errorData || data}` });
}
const lines = data.split("\n").filter((line) => line.trim());
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
});
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg =
execErr instanceof Error ? execErr.message : "Unknown error";
fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
trySFTP();
});
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { buildDeleteCommand } from "./file-manager-operation-commands.js";
describe("buildDeleteCommand", () => {
it("builds a PowerShell 5.1 compatible delete command for Windows files", () => {
const command = buildDeleteCommand(
"/C:/Users/Administrator/test.txt",
false,
);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop",
);
expect(command.commandWithSuccess).toBe(
`${command.command}; if ($?) { Write-Output "SUCCESS" }`,
);
expect(command.commandWithSuccess).not.toContain("&&");
});
it("adds recursive deletion for Windows directories", () => {
const command = buildDeleteCommand("C:/Temp/Folder", true);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop",
);
});
it("escapes single quotes in Windows literal paths", () => {
const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop",
);
});
it("keeps POSIX delete commands using shell success chaining", () => {
const command = buildDeleteCommand("/tmp/O'Brien.txt", false);
expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'");
expect(command.commandWithSuccess).toBe(
`${command.command} && echo "SUCCESS"`,
);
});
});
@@ -0,0 +1,39 @@
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
export interface DeleteCommand {
command: string;
commandWithSuccess: string;
}
function quotePosixPath(path: string): string {
return `'${path.replace(/'/g, "'\"'\"'")}'`;
}
function quotePowerShellLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
export function buildDeleteCommand(
itemPath: string,
isDirectory: boolean,
): DeleteCommand {
if (isWindowsSftpPath(itemPath)) {
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
const command = isDirectory
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
return {
command,
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
};
}
const path = quotePosixPath(itemPath);
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
return {
command,
commandWithSuccess: `${command} && echo "SUCCESS"`,
};
}
@@ -0,0 +1,825 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { fileLogger } from "../utils/logger.js";
import {
execChannel,
execWithSudo,
type SSHSession,
} from "./file-manager-session.js";
import { buildDeleteCommand } from "./file-manager-operation-commands.js";
type FileOperationRoutesDeps = {
sshSessions: Record<string, SSHSession>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileOperationRoutes(
app: Express,
{ sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/createFile:
* post:
* summary: Create a file
* description: Creates an empty file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* fileName:
* type: string
* responses:
* 200:
* description: File created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create file.
*/
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName) {
return res.status(400).json({ error: "File path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = filePath.endsWith("/")
? filePath + fileName
: filePath + "/" + fileName;
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, createCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH createFile error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied creating file: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {
type: "error",
message: `File creation failed: ${errorData}`,
},
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH createFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/createFolder:
* post:
* summary: Create a folder
* description: Creates a new folder on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* folderName:
* type: string
* responses:
* 200:
* description: Folder created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create folder.
*/
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
const { sessionId, path: folderPath, folderName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!folderPath || !folderName) {
return res
.status(400)
.json({ error: "Folder path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = folderPath.endsWith("/")
? folderPath + folderName
: folderPath + "/" + folderName;
fileLogger.info("Creating directory", {
operation: "file_mkdir",
sessionId,
userId,
path: fullPath,
});
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, createCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH createFolder error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied creating folder: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
fileLogger.success("Directory created successfully", {
operation: "file_mkdir_success",
sessionId,
userId,
path: fullPath,
});
if (!res.headersSent) {
res.json({
message: "Folder created successfully",
path: fullPath,
toast: {
type: "success",
message: `Folder created: ${fullPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {
type: "error",
message: `Folder creation failed: ${errorData}`,
},
});
}
return;
}
fileLogger.success("Directory created successfully", {
operation: "file_mkdir_success",
sessionId,
userId,
path: fullPath,
});
if (!res.headersSent) {
res.json({
message: "Folder created successfully",
path: fullPath,
toast: { type: "success", message: `Folder created: ${fullPath}` },
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH createFolder stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/deleteItem:
* delete:
* summary: Delete a file or directory
* description: Deletes a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* isDirectory:
* type: boolean
* responses:
* 200:
* description: Item deleted successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to delete item.
*/
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
const { sessionId, path: itemPath, isDirectory } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!itemPath) {
return res.status(400).json({ error: "Item path is required" });
}
fileLogger.info("Deleting item", {
operation: "file_delete",
sessionId,
userId,
path: itemPath,
type: isDirectory ? "directory" : "file",
});
sshConn.lastActive = Date.now();
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
itemPath,
Boolean(isDirectory),
);
const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => {
if (useSudo && sshConn.sudoPassword) {
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
(result) => {
if (
result.code === 0 ||
(!result.stderr.includes("Permission denied") &&
!result.stdout.includes("Permission denied"))
) {
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
res.status(500).json({
error: `Delete failed: ${result.stderr || result.stdout}`,
});
}
resolve();
},
);
return;
}
execChannel(sshConn, commandWithSuccess, (err, stream) => {
if (err) {
fileLogger.error("SSH deleteItem error:", err);
res.status(500).json({ error: err.message });
resolve();
return;
}
let outputData = "";
let errorData = "";
let permissionDenied = false;
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
permissionDenied = true;
}
});
stream.on("close", (code) => {
if (permissionDenied) {
if (sshConn.sudoPassword) {
executeDelete(true).then(resolve);
return;
}
fileLogger.error(`Permission denied deleting: ${itemPath}`);
res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}.`,
needsSudo: true,
});
resolve();
return;
}
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item deleted successfully", {
operation: "file_delete_success",
sessionId,
userId,
path: itemPath,
});
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
const detail =
errorData.trim() ||
outputData.trim() ||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
operation: "file_delete_failed",
sessionId,
userId,
path: itemPath,
});
res.status(500).json({
error: `Delete failed: ${detail}`,
});
}
resolve();
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH deleteItem stream error:", streamErr);
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
resolve();
});
});
});
};
await executeDelete(false);
});
/**
* @openapi
* /ssh/file_manager/ssh/renameItem:
* put:
* summary: Rename a file or directory
* description: Renames a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newName:
* type: string
* responses:
* 200:
* description: Item renamed successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to rename item.
*/
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
const { sessionId, oldPath, newName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newName) {
return res
.status(400)
.json({ error: "Old path and new name are required" });
}
sshConn.lastActive = Date.now();
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
const newPath = oldDir + newName;
fileLogger.info("Renaming item", {
operation: "file_rename",
sessionId,
userId,
from: oldPath,
to: newPath,
});
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, renameCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH renameItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied renaming: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item renamed successfully", {
operation: "file_rename_success",
sessionId,
userId,
from: oldPath,
to: newPath,
});
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Rename failed: ${errorData}` },
});
}
return;
}
fileLogger.success("Item renamed successfully", {
operation: "file_rename_success",
sessionId,
userId,
from: oldPath,
to: newPath,
});
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH renameItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/moveItem:
* put:
* summary: Move a file or directory
* description: Moves a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newPath:
* type: string
* responses:
* 200:
* description: Item moved successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 408:
* description: Move operation timed out.
* 500:
* description: Failed to move item.
*/
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newPath) {
return res
.status(400)
.json({ error: "Old path and new path are required" });
}
sshConn.lastActive = Date.now();
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
res.status(408).json({
error: "Move operation timed out. SSH connection may be unstable.",
toast: {
type: "error",
message:
"Move operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, moveCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied moving: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
toast: {
type: "error",
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
},
});
}
return;
}
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Move failed: ${errorData}` },
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
}
+193
View File
@@ -0,0 +1,193 @@
import type { Client as SSHClient } from "ssh2";
// Serializes SSH channel open requests so only one channel negotiation is
// in-flight at a time per session. Once the channel is established the slot
// is released immediately so the next open can proceed; the channels
// themselves remain open concurrently.
export class ChannelOpenSerializer {
private tail: Promise<void> = Promise.resolve();
run<T>(action: () => Promise<T>): Promise<T> {
const next = this.tail.then(
() => action(),
() => action(),
);
this.tail = next.then(
() => {},
() => {},
);
return next;
}
}
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
sudoPassword?: string;
sftp?: import("ssh2").SFTPWrapper;
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
channelOpener: ChannelOpenSerializer;
poolKey?: string;
userId?: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
transferDedicated?: boolean;
transferId?: string;
browseSessionId?: string;
scpLegacy?: boolean;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: import("ssh2").ConnectConfig;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
}
export function execWithSudo(
session: SSHSession,
command: string,
sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
stdout: result.stdout.toString("utf8"),
stderr: result.stderr,
code: result.code,
}));
}
export function execWithSudoBuffer(
session: SSHSession,
command: string,
sudoPassword: string,
): Promise<{ stdout: Buffer; stderr: string; code: number }> {
return new Promise((resolve) => {
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
let stdout = Buffer.concat(stdoutChunks);
const sudoPromptMatch = stdout
.toString("utf8", 0, Math.min(stdout.length, 256))
.match(/^\[sudo\] password for .+?:\s*/);
if (sudoPromptMatch) {
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
}
resolve({ stdout, stderr, code: code || 0 });
});
stream.on("error", (streamErr: Error) => {
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr: streamErr.message,
code: 1,
});
});
});
});
}
export function getSessionSftp(
session: SSHSession,
): Promise<import("ssh2").SFTPWrapper> {
if (session.sftp) {
return Promise.resolve(session.sftp);
}
if (session.sftpPending) {
return session.sftpPending;
}
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
session.channelOpener.run(
() =>
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
session.client.sftp((err, sftp) => {
if (err) return reject(err);
session.sftp = sftp;
sftp.on("error", () => {
session.sftp = undefined;
});
sftp.on("close", () => {
session.sftp = undefined;
});
resolve(sftp);
});
}),
);
session.sftpPending = openOnce()
.catch((err: Error) => {
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
setTimeout(() => openOnce().then(resolve, reject), 500),
);
}
return Promise.reject(err);
})
.finally(() => {
session.sftpPending = undefined;
});
return session.sftpPending;
}
export function execChannel(
session: SSHSession,
command: string,
callback: (
err: Error | undefined,
stream: import("ssh2").ClientChannel,
) => void,
): void {
session.channelOpener
.run(
() =>
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) return reject(err);
resolve(stream);
});
}),
)
.then(
(stream) => callback(undefined, stream),
(err: Error) => callback(err, undefined as never),
);
}
@@ -0,0 +1,150 @@
import { describe, it, expect } from "vitest";
import {
isExecutableFile,
modeToPermissions,
formatMtime,
getMimeType,
detectBinary,
parseLsDateToTimestamp,
} from "./file-manager-utils.js";
describe("isExecutableFile", () => {
it("flags scripts with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "deploy.sh")).toBe(true);
expect(isExecutableFile("-rwxr-xr-x", "run.py")).toBe(true);
});
it("flags known executable extensions with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "tool.bin")).toBe(true);
expect(isExecutableFile("-rwxr-xr-x", "app.exe")).toBe(true);
});
it("flags extensionless files with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "myprogram")).toBe(true);
});
it("does not flag files without execute permission", () => {
expect(isExecutableFile("-rw-r--r--", "deploy.sh")).toBe(false);
expect(isExecutableFile("-rw-r--r--", "myprogram")).toBe(false);
});
it("does not flag non-script data files even when executable", () => {
expect(isExecutableFile("-rwxr-xr-x", "notes.txt")).toBe(false);
});
});
describe("modeToPermissions", () => {
it("renders a regular file with rwxr-xr-x", () => {
expect(modeToPermissions(0o100755)).toBe("-rwxr-xr-x");
});
it("renders a directory prefix", () => {
expect(modeToPermissions(0o040755)).toBe("drwxr-xr-x");
});
it("renders a symlink prefix", () => {
expect(modeToPermissions(0o120777)).toBe("lrwxrwxrwx");
});
it("renders a read-only file", () => {
expect(modeToPermissions(0o100444)).toBe("-r--r--r--");
});
it("renders no permissions", () => {
expect(modeToPermissions(0o100000)).toBe("----------");
});
});
describe("formatMtime", () => {
it("uses HH:MM format for recent timestamps", () => {
// Within the last 6 months relative to now.
const recent = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 5;
const result = formatMtime(recent);
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}$/);
});
it("uses the year for old timestamps", () => {
// ~2 years ago is comfortably outside the 6-month window.
const old = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 365 * 2;
const result = formatMtime(old);
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} +\d{4}$/);
});
});
describe("getMimeType", () => {
it("maps known extensions", () => {
expect(getMimeType("readme.txt")).toBe("text/plain");
expect(getMimeType("data.json")).toBe("application/json");
expect(getMimeType("photo.JPEG")).toBe("image/jpeg");
expect(getMimeType("archive.zip")).toBe("application/zip");
});
it("falls back to octet-stream for unknown or missing extensions", () => {
expect(getMimeType("mystery.xyz")).toBe("application/octet-stream");
expect(getMimeType("noextension")).toBe("application/octet-stream");
});
});
describe("parseLsDateToTimestamp", () => {
it("parses a year-format date string", () => {
const ts = parseLsDateToTimestamp("Dec 12 2025");
const d = new Date(ts * 1000);
expect(d.getFullYear()).toBe(2025);
expect(d.getMonth()).toBe(11);
expect(d.getDate()).toBe(12);
});
it("parses a time-format date string for a recent file", () => {
const now = new Date();
const month = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
][now.getMonth()];
const day = now.getDate();
const ts = parseLsDateToTimestamp(`${month} ${day} 10:30`);
const d = new Date(ts * 1000);
expect(d.getHours()).toBe(10);
expect(d.getMinutes()).toBe(30);
});
it("returns 0 for an empty or invalid string", () => {
expect(parseLsDateToTimestamp("")).toBe(0);
expect(parseLsDateToTimestamp("Xyz 5 12:00")).toBe(0);
});
it("produces ascending order for older vs newer dates", () => {
const older = parseLsDateToTimestamp("Jan 1 2020");
const newer = parseLsDateToTimestamp("Dec 31 2024");
expect(older).toBeLessThan(newer);
});
});
describe("detectBinary", () => {
it("returns false for empty buffers", () => {
expect(detectBinary(Buffer.from([]))).toBe(false);
});
it("returns false for plain UTF-8 text", () => {
expect(detectBinary(Buffer.from("hello world\nsecond line\t tab"))).toBe(
false,
);
});
it("returns true when null bytes are present", () => {
expect(detectBinary(Buffer.from([0x48, 0x00, 0x49, 0x00]))).toBe(true);
});
it("allows common whitespace control characters", () => {
expect(detectBinary(Buffer.from("line1\r\nline2\tend"))).toBe(false);
});
});
+164
View File
@@ -0,0 +1,164 @@
export function isExecutableFile(
permissions: string,
fileName: string,
): boolean {
const hasExecutePermission =
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
const scriptExtensions = [
".sh",
".py",
".pl",
".rb",
".js",
".php",
".bash",
".zsh",
".fish",
];
const hasScriptExtension = scriptExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const executableExtensions = [".bin", ".exe", ".out"];
const hasExecutableExtension = executableExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
return (
hasExecutePermission &&
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
);
}
export function modeToPermissions(mode: number): string {
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;
const S_IFMT = 0o170000;
const type = mode & S_IFMT;
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
const perms = [
mode & 0o400 ? "r" : "-",
mode & 0o200 ? "w" : "-",
mode & 0o100 ? "x" : "-",
mode & 0o040 ? "r" : "-",
mode & 0o020 ? "w" : "-",
mode & 0o010 ? "x" : "-",
mode & 0o004 ? "r" : "-",
mode & 0o002 ? "w" : "-",
mode & 0o001 ? "x" : "-",
].join("");
return prefix + perms;
}
export function parseLsDateToTimestamp(dateStr: string): number {
const parts = dateStr.trim().split(/\s+/);
if (parts.length < 3) return 0;
const [month, day, timeOrYear] = parts;
const monthIndex = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
].indexOf(month);
if (monthIndex === -1) return 0;
const dayNum = parseInt(day, 10);
const now = new Date();
if (timeOrYear.includes(":")) {
const [hours, minutes] = timeOrYear.split(":").map(Number);
let year = now.getFullYear();
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
if (candidate > now) year -= 1;
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
}
const year = parseInt(timeOrYear, 10);
if (isNaN(year)) return 0;
return new Date(year, monthIndex, dayNum).getTime() / 1000;
}
export function formatMtime(mtime: number): string {
const date = new Date(mtime * 1000);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const month = months[date.getMonth()];
const day = date.getDate().toString().padStart(2, " ");
const now = new Date();
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
if (date > sixMonthsAgo) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day} ${hours}:${minutes}`;
}
return `${month} ${day} ${date.getFullYear()}`;
}
export function getMimeType(fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: "text/plain",
json: "application/json",
js: "text/javascript",
html: "text/html",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
pdf: "application/pdf",
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
export function detectBinary(buffer: Buffer): boolean {
if (buffer.length === 0) return false;
const sampleSize = Math.min(buffer.length, 8192);
let nullBytes = 0;
for (let i = 0; i < sampleSize; i++) {
const byte = buffer[i];
if (byte === 0) {
nullBytes++;
}
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
if (++nullBytes > 1) return true;
}
}
return nullBytes / sampleSize > 0.01;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
import GuacamoleLite from "guacamole-lite";
import { guacLogger } from "../../utils/logger.js";
import { GuacamoleTokenService } from "./token-service.js";
import { getCurrentSettingValue } from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
import fs from "fs";
import path from "path";
import { createCurrentSessionRecordingRepository } from "../../database/repositories/factory.js";
import type { GuacamoleRecordingMetadata } from "./token-service.js";
const tokenService = GuacamoleTokenService.getInstance();
function readGuacdOptions(): { host: string; port: number } {
let dbUrl: string | undefined;
try {
dbUrl = getCurrentSettingValue("guac_url") ?? undefined;
} catch {
// DB not available yet, use env var defaults
}
return resolveGuacdOptions(dbUrl);
}
const GUAC_WS_PORT = 30008;
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const GUACAMOLE_RECORDINGS_DIR =
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = {
connectionSettings?: {
connection?: { type?: string };
recording?: GuacamoleRecordingMetadata;
};
};
async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection,
): Promise<void> {
const recording = clientConnection.connectionSettings?.recording;
if (!recording) return;
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
if (!resolvedPath.startsWith(allowedBase)) return;
// guacd may flush/rename the recording just after the websocket closes.
for (
let attempt = 0;
attempt < 10 && !fs.existsSync(resolvedPath);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!fs.existsSync(resolvedPath)) {
guacLogger.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
});
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await createCurrentSessionRecordingRepository().create({
hostId: recording.hostId,
userId: recording.userId,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: Math.max(
0,
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
),
recordingPath: resolvedPath,
protocol: recording.protocol,
format: "guacamole",
});
}
const websocketOptions = {
port: GUAC_WS_PORT,
};
const clientOptions = {
crypt: {
cypher: "AES-256-CBC",
key: tokenService.getEncryptionKey(),
},
log: {
level: "ERRORS",
stdLog: (...args: unknown[]) => {
guacLogger.info(args.join(" "));
},
errorLog: (...args: unknown[]) => {
guacLogger.error(args.join(" "));
},
},
allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"],
vnc: ["width", "height"],
telnet: ["width", "height"],
},
connectionDefaultSettings: {
rdp: {
security: "any",
"ignore-cert": true,
"enable-wallpaper": false,
"enable-font-smoothing": true,
"enable-desktop-composition": false,
"disable-audio": false,
"enable-drive": false,
"resize-method": "display-update",
width: 1280,
height: 720,
dpi: 96,
audio: ["audio/L16"],
},
vnc: {
"swap-red-blue": false,
cursor: "remote",
security: "any",
width: 1280,
height: 720,
},
telnet: {
"terminal-type": "xterm-256color",
},
},
};
const _origConsoleLog = console.log;
console.log = (...args: unknown[]) => {
const msg = args[0];
if (typeof msg === "string" && msg.startsWith("New client connection"))
return;
_origConsoleLog(...args);
};
function createGuacServer(): GuacamoleLite {
const guacdOptions = readGuacdOptions();
const server = new GuacamoleLite(
websocketOptions,
guacdOptions,
clientOptions,
);
server.on("open", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
});
server.on("close", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type,
});
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",
});
});
});
server.on(
"error",
(clientConnection: GuacamoleClientConnection, error: Error) => {
guacLogger.error("Guacamole connection error", error, {
operation: "guac_connection_error",
type: clientConnection.connectionSettings?.connection?.type,
});
},
);
return server;
}
let guacServer = createGuacServer();
export async function restartGuacServer(): Promise<void> {
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during restart", err as Error);
}
guacServer = createGuacServer();
}
export { guacServer, tokenService };
+658
View File
@@ -0,0 +1,658 @@
import express from "express";
import { GuacamoleTokenService } from "./token-service.js";
import { guacLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { Client } from "ssh2";
import net from "net";
import crypto from "crypto";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance();
const authManager = AuthManager.getInstance();
const DATA_DIR = process.env.DATA_DIR || "./db/data";
router.use(authManager.createAuthMiddleware());
/**
* @openapi
* /guacamole/token:
* post:
* summary: Generate an encrypted Guacamole connection token
* description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - type
* - hostname
* properties:
* type:
* type: string
* enum: [rdp, vnc, telnet]
* hostname:
* type: string
* port:
* type: integer
* username:
* type: string
* password:
* type: string
* domain:
* type: string
* responses:
* 200:
* description: Encrypted connection token
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* 400:
* description: Invalid request
* 500:
* description: Server error
*/
router.post("/token", async (req, res) => {
try {
const { type, hostname, port, username, password, domain, ...rawOptions } =
req.body;
// Strip "auto" sentinel values before forwarding to guacd
const options: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawOptions)) {
if (value !== "auto") options[key] = value;
}
if (!type || !hostname) {
return res
.status(400)
.json({ error: "Missing required fields: type and hostname" });
}
if (!["rdp", "vnc", "telnet"].includes(type)) {
return res.status(400).json({
error: "Invalid connection type. Must be rdp, vnc, or telnet",
});
}
let token: string;
switch (type) {
case "rdp":
token = tokenService.createRdpToken(
hostname,
username || "",
password || "",
{
port: port || 3389,
domain,
...options,
},
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900,
...options,
},
);
break;
case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, {
port: port || 23,
...options,
});
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
res.json({ token });
} catch (error) {
guacLogger.error("Failed to generate guacamole token", error, {
operation: "guac_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
});
/**
* @openapi
* /guacamole/connect-host/{hostId}:
* post:
* summary: Generate Guacamole connection token from host configuration
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* description: Host ID to connect to
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* protocol:
* type: string
* enum: [rdp, vnc, telnet]
* description: Override the host's default connection type
* responses:
* 200:
* description: Connection token generated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* description: Encrypted connection token
* 400:
* description: Invalid request or unsupported connection type
* 403:
* description: Access denied to host
* 404:
* description: Host not found
* 500:
* description: Server error
*/
router.post(
"/connect-host/:hostId",
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
if (host.userId !== userId) {
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"read",
);
if (!accessInfo.hasAccess) {
guacLogger.warn("User attempted to access host without permission", {
operation: "guac_access_denied",
userId,
hostId,
});
return res.status(403).json({ error: "Access denied to this host" });
}
}
const requestedProtocol = req.body?.protocol as string | undefined;
const connectionType =
requestedProtocol || (host.connectionType as string);
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
return res.status(400).json({
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
});
}
// Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false.
// Apply the same migration fallback used in host.ts GET routes.
const ct = host.connectionType as string;
const rdpRaw = !!host.enableRdp;
const vncRaw = !!host.enableVnc;
const telRaw = !!host.enableTelnet;
const isMigratedNonSsh =
!rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
const protocolEnabledMap: Record<string, boolean> = {
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
telnet: isMigratedNonSsh ? ct === "telnet" : telRaw,
};
if (!protocolEnabledMap[connectionType]) {
return res.status(400).json({
error: `${connectionType.toUpperCase()} is not enabled for this host.`,
});
}
let guacConfig: Record<string, unknown> = {};
if (host.guacamoleConfig) {
try {
guacConfig =
typeof host.guacamoleConfig === "string"
? JSON.parse(host.guacamoleConfig as string)
: (host.guacamoleConfig as Record<string, unknown>);
} catch (error) {
guacLogger.warn("Failed to parse guacamole config", {
operation: "guac_config_parse_error",
hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
// Strip "auto" sentinel values — these mean "use guacd default" in the UI
// but guacd doesn't recognise "auto" as a valid parameter value.
for (const key of Object.keys(guacConfig)) {
if (guacConfig[key] === "auto") {
delete guacConfig[key];
}
}
// Extract per-connection guacd proxy settings before passing the rest as connection settings
const perConnectionGuacdHost = guacConfig["guacd-hostname"] as
| string
| undefined;
const perConnectionGuacdPortRaw = guacConfig["guacd-port"];
const perConnectionGuacdPort = perConnectionGuacdPortRaw
? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined
: undefined;
delete guacConfig["guacd-hostname"];
delete guacConfig["guacd-port"];
if (guacConfig.dpi != null) {
const parsed = parseInt(String(guacConfig.dpi), 10);
guacConfig.dpi =
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
const hostRecord = host as Record<string, unknown>;
const hostRepository = createCurrentHostResolutionRepository();
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
const rdpEffectiveAuthType =
(host.rdpAuthType as string) ||
(host.rdpCredentialId ? "credential" : "direct");
const vncEffectiveAuthType =
(host.vncAuthType as string) ||
(host.vncCredentialId ? "credential" : "direct");
const telnetEffectiveAuthType =
(host.telnetAuthType as string) ||
(hostRecord.telnetCredentialId ? "credential" : "direct");
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
try {
const cred =
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
host.rdpCredentialId as number,
host.userId as string,
userId,
);
if (cred) {
if (cred.username) host.rdpUser = cred.username;
if (cred.password) host.rdpPassword = cred.password;
// domain is never sourced from credential
}
} catch (e) {
guacLogger.warn("Failed to resolve RDP credential", {
operation: "guac_rdp_credential_resolve",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
try {
const cred =
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
host.vncCredentialId as number,
host.userId as string,
userId,
);
if (cred) {
if (cred.password) host.vncPassword = cred.password;
if (cred.username) host.vncUser = cred.username;
}
} catch (e) {
guacLogger.warn("Failed to resolve VNC credential", {
operation: "guac_vnc_credential_resolve",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
if (
telnetEffectiveAuthType === "credential" &&
hostRecord.telnetCredentialId
) {
try {
const cred =
await hostRepository.findCredentialByIdForOwnerDecryptedAs(
hostRecord.telnetCredentialId as number,
host.userId as string,
userId,
);
if (cred) {
if (cred.username) host.telnetUser = cred.username;
if (cred.password) host.telnetPassword = cred.password;
}
} catch (e) {
guacLogger.warn("Failed to resolve Telnet credential", {
operation: "guac_telnet_credential_resolve",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
let token: string;
let hostname = host.ip as string;
let port = host.port as number;
let username: string;
let password: string;
switch (connectionType) {
case "rdp":
username =
(host.rdpUser as string) || (host.username as string) || "";
password =
(host.rdpPassword as string) || (host.password as string) || "";
port = (host.rdpPort as number) || port || 3389;
break;
case "vnc":
username = (host.vncUser as string) || "";
password =
(host.vncPassword as string) || (host.password as string) || "";
port = (host.vncPort as number) || port || 5900;
break;
case "telnet":
username = (host.telnetUser as string) || "";
password =
(host.telnetPassword as string) || (host.password as string) || "";
port = (host.telnetPort as number) || port || 23;
break;
default:
username = "";
password = "";
}
const domain =
(host.rdpDomain as string) || (host.domain as string) || "";
// Establish SSH tunnel if jump hosts are configured
let jumpHosts: Array<{ hostId: number }> = [];
if (host.jumpHosts) {
try {
jumpHosts =
typeof host.jumpHosts === "string"
? JSON.parse(host.jumpHosts as string)
: (host.jumpHosts as Array<{ hostId: number }>);
} catch {
jumpHosts = [];
}
}
if (jumpHosts.length > 0) {
try {
const { resolveHostById } = await import("../host-resolver.js");
const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId);
if (jumpHost) {
const tunnelPort = await new Promise<number>((resolve, reject) => {
const sshClient = new Client();
sshClient.on("ready", () => {
const server = net.createServer((sock) => {
sshClient.forwardOut(
"127.0.0.1",
0,
hostname,
port,
(err, stream) => {
if (err) {
sock.destroy();
return;
}
sock.pipe(stream).pipe(sock);
},
);
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as net.AddressInfo;
// Auto-cleanup after 1 hour
setTimeout(
() => {
server.close();
sshClient.end();
},
60 * 60 * 1000,
);
resolve(addr.port);
});
});
sshClient.on("error", reject);
const connectOpts: Record<string, unknown> = {
host: jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
readyTimeout: 30000,
};
if (jumpHost.key) {
connectOpts.privateKey = jumpHost.key;
if (jumpHost.keyPassword)
connectOpts.passphrase = jumpHost.keyPassword;
} else if (jumpHost.password) {
connectOpts.password = jumpHost.password;
}
sshClient.connect(connectOpts);
});
hostname = "127.0.0.1";
port = tunnelPort;
guacLogger.info("SSH tunnel established for guacamole", {
operation: "guac_ssh_tunnel",
hostId,
tunnelPort,
});
}
} catch (tunnelError) {
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
operation: "guac_ssh_tunnel_error",
hostId,
});
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
}
const guacdOverrides = {
...(perConnectionGuacdHost
? { guacdHost: perConnectionGuacdHost }
: {}),
...(perConnectionGuacdPort
? { guacdPort: perConnectionGuacdPort }
: {}),
};
const recordingEnabled = host.enableSessionLogging !== false;
const recordingName = `${crypto.randomUUID()}.guac`;
const recordingPath =
process.env.GUACD_RECORDING_PATH ||
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.resolve(DATA_DIR, "session_recordings", "guacamole");
const recordingMetadata = recordingEnabled
? {
hostId,
userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
path: recordingName,
startedAt: new Date().toISOString(),
}
: undefined;
if (recordingEnabled) {
guacConfig["recording-path"] = recordingPath;
guacConfig["recording-name"] = recordingName;
guacConfig["create-recording-path"] = true;
guacConfig["recording-exclude-output"] = false;
guacConfig["recording-include-keys"] = true;
}
switch (connectionType) {
case "rdp":
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
guacConfig["drive-path"] = "/drive";
guacConfig["create-drive-path"] = true;
}
token = tokenService.createRdpToken(
hostname,
username,
password,
{
port,
domain,
security:
(host.rdpSecurity as string) ||
(host.security as string) ||
undefined,
"ignore-cert":
host.rdpIgnoreCert !== undefined
? !!host.rdpIgnoreCert
: host.ignoreCert !== undefined
? !!host.ignoreCert
: true,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port,
security: "any",
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
);
break;
case "telnet":
token = tokenService.createTelnetToken(
hostname,
username,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
);
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
res.json({ token });
} catch (error) {
guacLogger.error("Failed to generate guacamole token for host", error, {
operation: "guac_host_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
},
);
/**
* GET /guacamole/status
* Check if guacd is reachable
*/
router.get("/status", async (req, res) => {
try {
let dbUrl: string | undefined;
try {
dbUrl =
(await createCurrentSettingsRepository().get("guac_url")) ?? undefined;
} catch {
// Fall back to env vars
}
const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl);
const net = await import("net");
const checkConnection = (): Promise<boolean> => {
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(3000);
socket.on("connect", () => {
socket.destroy();
resolve(true);
});
socket.on("timeout", () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => {
socket.destroy();
resolve(false);
});
socket.connect(guacdPort, guacdHost);
});
};
const isConnected = await checkConnection();
res.json({
guacd: {
host: guacdHost,
port: guacdPort,
status: isConnected ? "connected" : "disconnected",
},
websocket: {
port: 30008,
status: "running",
},
});
} catch (error) {
guacLogger.error("Failed to check guacamole status", error, {
operation: "guac_status_error",
});
res.status(500).json({ error: "Failed to check status" });
}
});
export default router;
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
guacLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { GuacamoleTokenService } = await import("./token-service.js");
describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance();
it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", "");
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
port: 3389,
"ignore-cert": true,
"disable-auth": true,
});
expect(decrypted?.connection.settings.username).toBeUndefined();
expect(decrypted?.connection.settings.password).toBeUndefined();
});
it("keeps normal RDP credential authentication unchanged", () => {
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
username: "Administrator",
password: "secret",
port: 3389,
});
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
});
it("preserves recording metadata outside guacd connection settings", () => {
const recording = {
hostId: 42,
userId: "user-1",
protocol: "vnc" as const,
path: "recording.guac",
startedAt: "2026-07-14T00:00:00.000Z",
};
const token = tokenService.createVncToken(
"vnc.example.test",
"user",
"secret",
{},
recording,
);
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
});
@@ -0,0 +1,219 @@
import crypto from "crypto";
import { guacLogger } from "../../utils/logger.js";
export interface GuacamoleConnectionSettings {
type: "rdp" | "vnc" | "telnet";
guacdHost?: string;
guacdPort?: number;
settings: {
hostname: string;
port?: number;
username?: string;
password?: string;
domain?: string;
width?: number;
height?: number;
dpi?: number;
security?: string;
"ignore-cert"?: boolean;
"disable-auth"?: boolean;
"enable-wallpaper"?: boolean;
"enable-drive"?: boolean;
"drive-path"?: string;
"create-drive-path"?: boolean;
"swap-red-blue"?: boolean;
cursor?: string;
"terminal-type"?: string;
[key: string]: unknown;
};
}
export interface GuacamoleToken {
connection: GuacamoleConnectionSettings;
recording?: GuacamoleRecordingMetadata;
}
export interface GuacamoleRecordingMetadata {
hostId: number;
userId: string;
protocol: "rdp" | "vnc" | "telnet";
path: string;
startedAt: string;
}
const CIPHER = "aes-256-cbc";
const KEY_LENGTH = 32;
export class GuacamoleTokenService {
private static instance: GuacamoleTokenService;
private encryptionKey: Buffer;
private constructor() {
this.encryptionKey = this.initializeKey();
}
static getInstance(): GuacamoleTokenService {
if (!GuacamoleTokenService.instance) {
GuacamoleTokenService.instance = new GuacamoleTokenService();
}
return GuacamoleTokenService.instance;
}
private initializeKey(): Buffer {
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
if (existingKey) {
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
return Buffer.from(existingKey, "hex");
}
if (existingKey.length === KEY_LENGTH) {
return Buffer.from(existingKey, "utf8");
}
}
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret) {
return crypto
.createHash("sha256")
.update(jwtSecret + "_guacamole")
.digest();
}
guacLogger.warn(
"No persistent encryption key found, generating random key",
{
operation: "guac_key_generation",
},
);
return crypto.randomBytes(KEY_LENGTH);
}
getEncryptionKey(): Buffer {
return this.encryptionKey;
}
encryptToken(tokenObject: GuacamoleToken): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
let encrypted = cipher.update(
JSON.stringify(tokenObject),
"utf8",
"base64",
);
encrypted += cipher.final("base64");
const data = {
iv: iv.toString("base64"),
value: encrypted,
};
return Buffer.from(JSON.stringify(data)).toString("base64");
}
decryptToken(token: string): GuacamoleToken | null {
try {
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
const iv = Buffer.from(data.iv, "base64");
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
let decrypted = decipher.update(data.value, "base64", "utf8");
decrypted += decipher.final("utf8");
return JSON.parse(decrypted) as GuacamoleToken;
} catch (error) {
guacLogger.error("Failed to decrypt guacamole token", error, {
operation: "guac_token_decrypt_error",
});
return null;
}
}
createRdpToken(
hostname: string,
username: string,
password: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "rdp",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
...(username ? { username } : {}),
...(password ? { password } : {}),
port: 3389,
"ignore-cert": true,
...(!username && !password ? { "disable-auth": true } : {}),
...settingsOptions,
},
},
recording,
};
return this.encryptToken(token);
}
createVncToken(
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "vnc",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
...(username ? { username } : {}),
password,
port: 5900,
...settingsOptions,
},
},
recording,
};
return this.encryptToken(token);
}
createTelnetToken(
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "telnet",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
username,
password,
port: 23,
...settingsOptions,
},
},
recording,
};
return this.encryptToken(token);
}
}
+482
View File
@@ -0,0 +1,482 @@
import type { WebSocket } from "ws";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
import { sshLogger } from "../utils/logger.js";
interface HostKeyVerificationData {
scenario: "new" | "changed";
ip: string;
port: number;
hostname?: string;
fingerprint: string;
oldFingerprint?: string;
keyType: string;
oldKeyType?: string;
algorithm: string;
}
interface VerificationResponse {
action: "accept" | "reject";
}
export class SSHHostKeyVerifier {
/**
* Pre-fetches the host record from the database so the verifier callback
* during SSH key exchange doesn't need to do an async DB query. This keeps
* the key exchange critical path fast, avoiding LoginGraceTime expiry on
* the remote server (especially important for jump-host tunneled connections).
*/
static async preloadHostData(hostId: number | null): Promise<{
hostKeyFingerprint: string | null;
hostKeyType: string | null;
hostKeyAlgorithm: string | null;
hostKeyChangedCount: number | null;
name: string | null;
} | null> {
if (!hostId) return null;
try {
return await createCurrentHostResolutionRepository().findHostKeyVerificationData(
hostId,
);
} catch {
return null;
}
}
static async createHostVerifier(
hostId: number | null,
ip: string,
port: number,
ws: WebSocket | null,
userId: string,
isJumpHost: boolean = false,
preloadedHost?: Awaited<
ReturnType<typeof SSHHostKeyVerifier.preloadHostData>
>,
): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> {
return (hostkey: Buffer, verify: (valid: boolean) => void): void => {
(async () => {
try {
const fingerprint = hostkey.toString("hex");
const keyType = this.getKeyType(hostkey);
const algorithm = "sha256";
if (!hostId) {
sshLogger.info(
"Host key verification skipped (no hostId - quick connect)",
{
operation: "host_key_skip",
ip,
port,
fingerprint,
keyType,
userId,
},
);
verify(true);
return;
}
const host =
preloadedHost !== undefined
? preloadedHost
: await createCurrentHostResolutionRepository().findHostKeyVerificationData(
hostId,
);
if (!host) {
sshLogger.warn(
"Host not found in database during key verification",
{
operation: "host_key_no_host",
hostId,
ip,
port,
userId,
},
);
verify(true);
return;
}
if (!host.hostKeyFingerprint) {
if (isJumpHost) {
await this.storeHostKey(hostId, fingerprint, keyType, algorithm);
sshLogger.info("Jump host key auto-accepted and stored", {
operation: "host_key_stored",
hostId,
ip,
port,
fingerprint,
keyType,
userId,
isJumpHost: true,
});
verify(true);
return;
}
if (!ws) {
sshLogger.warn(
"No WebSocket available for host key verification prompt",
{
operation: "host_key_no_ws",
hostId,
ip,
port,
userId,
},
);
verify(true);
return;
}
const accepted = await this.promptUserForNewKey(
ws,
ip,
port,
host.name || undefined,
fingerprint,
keyType,
algorithm,
);
if (accepted) {
await this.storeHostKey(hostId, fingerprint, keyType, algorithm);
sshLogger.info("New host key accepted by user and stored", {
operation: "host_key_stored",
hostId,
ip,
port,
fingerprint,
keyType,
userId,
});
} else {
sshLogger.warn("User rejected new host key", {
operation: "host_key_rejected",
hostId,
ip,
port,
fingerprint,
keyType,
userId,
});
}
verify(accepted);
return;
}
if (host.hostKeyFingerprint === fingerprint) {
sshLogger.info("Host key verified successfully", {
operation: "host_key_verified",
hostId,
ip,
port,
fingerprint,
keyType,
userId,
});
// Verify first, then update the timestamp asynchronously so the
// DB write doesn't delay the SSH key exchange critical path.
verify(true);
createCurrentHostResolutionRepository()
.touchHostKeyLastVerified(hostId)
.catch((err) => {
sshLogger.error("Failed to update hostKeyLastVerified", err, {
operation: "host_key_update_timestamp",
hostId,
});
});
return;
}
sshLogger.error("Host key mismatch detected - SECURITY WARNING", {
operation: "host_key_mismatch",
hostId,
ip,
port,
oldFingerprint: host.hostKeyFingerprint,
newFingerprint: fingerprint,
oldKeyType: host.hostKeyType,
newKeyType: keyType,
userId,
changeCount: host.hostKeyChangedCount || 0,
});
if (isJumpHost) {
await this.updateHostKey(
hostId,
fingerprint,
keyType,
algorithm,
host.hostKeyChangedCount || 0,
);
sshLogger.warn("Jump host key changed - auto-accepted", {
operation: "host_key_updated",
hostId,
ip,
port,
fingerprint,
keyType,
userId,
isJumpHost: true,
});
verify(true);
return;
}
if (!ws) {
sshLogger.error(
"Host key changed - please connect via Terminal to verify the new key",
{
operation: "host_key_no_ws_reject",
hostId,
ip,
port,
userId,
message:
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
},
);
verify(false);
return;
}
const accepted = await this.promptUserForChangedKey(
ws,
ip,
port,
host.name || undefined,
fingerprint,
host.hostKeyFingerprint,
keyType,
host.hostKeyType || "unknown",
algorithm,
);
if (accepted) {
await this.updateHostKey(
hostId,
fingerprint,
keyType,
algorithm,
host.hostKeyChangedCount || 0,
);
sshLogger.warn("Changed host key accepted by user", {
operation: "host_key_updated",
hostId,
ip,
port,
oldFingerprint: host.hostKeyFingerprint,
newFingerprint: fingerprint,
userId,
changeCount: (host.hostKeyChangedCount || 0) + 1,
});
} else {
sshLogger.error("User rejected changed host key", {
operation: "host_key_change_rejected",
hostId,
ip,
port,
userId,
});
}
verify(accepted);
} catch (error) {
sshLogger.error("Error in host key verification", error, {
operation: "host_key_error",
hostId,
ip,
port,
userId,
});
verify(false);
}
})();
};
}
private static async storeHostKey(
hostId: number,
fingerprint: string,
keyType: string,
algorithm: string,
): Promise<void> {
await createCurrentHostResolutionRepository().storeHostKey(
hostId,
fingerprint,
keyType,
algorithm,
);
}
private static async updateHostKey(
hostId: number,
fingerprint: string,
keyType: string,
algorithm: string,
currentChangeCount: number,
): Promise<void> {
await createCurrentHostResolutionRepository().updateHostKey(
hostId,
fingerprint,
keyType,
algorithm,
currentChangeCount,
);
}
private static async promptUserForNewKey(
ws: WebSocket,
ip: string,
port: number,
hostname: string | undefined,
fingerprint: string,
keyType: string,
algorithm: string,
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
ws.removeListener("message", messageHandler);
sshLogger.warn("Host key verification timeout (new key)", {
operation: "host_key_timeout",
ip,
port,
});
resolve(false);
}, 60000);
const messageHandler = (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
if (message.type === "host_key_verification_response") {
clearTimeout(timeout);
ws.removeListener("message", messageHandler);
const response = message.data as VerificationResponse;
resolve(response.action === "accept");
}
} catch (error) {
sshLogger.error(
"Error parsing host key verification response",
error,
);
}
};
ws.on("message", messageHandler);
const verificationData: HostKeyVerificationData = {
scenario: "new",
ip,
port,
hostname,
fingerprint,
keyType,
algorithm,
};
ws.send(
JSON.stringify({
type: "host_key_verification_required",
data: verificationData,
}),
);
});
}
private static async promptUserForChangedKey(
ws: WebSocket,
ip: string,
port: number,
hostname: string | undefined,
fingerprint: string,
oldFingerprint: string,
keyType: string,
oldKeyType: string,
algorithm: string,
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
ws.removeListener("message", messageHandler);
sshLogger.error("Host key verification timeout (changed key)", {
operation: "host_key_timeout",
ip,
port,
});
resolve(false);
}, 120000);
const messageHandler = (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
if (message.type === "host_key_verification_response") {
clearTimeout(timeout);
ws.removeListener("message", messageHandler);
const response = message.data as VerificationResponse;
resolve(response.action === "accept");
}
} catch (error) {
sshLogger.error(
"Error parsing host key verification response",
error,
);
}
};
ws.on("message", messageHandler);
const verificationData: HostKeyVerificationData = {
scenario: "changed",
ip,
port,
hostname,
fingerprint,
oldFingerprint,
keyType,
oldKeyType,
algorithm,
};
ws.send(
JSON.stringify({
type: "host_key_changed",
data: verificationData,
}),
);
});
}
private static getKeyType(key: Buffer): string {
try {
if (key.length < 4) {
return "unknown";
}
const typeLength = key.readUInt32BE(0);
if (typeLength > key.length - 4 || typeLength > 256) {
return "unknown";
}
const keyType = key.toString("utf8", 4, 4 + typeLength);
if (
(keyType && keyType.startsWith("ssh-")) ||
keyType.startsWith("ecdsa-")
) {
return keyType;
}
return "unknown";
} catch (error) {
sshLogger.error("Error parsing SSH key type", error);
return "unknown";
}
}
}
@@ -0,0 +1,124 @@
import type { Client } from "ssh2";
import { describe, it, expect, vi } from "vitest";
import {
supportsMetrics,
isTcpPingEnabled,
tcpPingThroughJumpHost,
} from "./host-metrics-helpers.js";
import { createConnectionLog } from "./connection-log.js";
describe("supportsMetrics", () => {
it("supports plain ssh hosts", () => {
expect(
supportsMetrics({ connectionType: "ssh", authType: "password" }),
).toBe(true);
});
it("defaults missing connectionType to ssh", () => {
expect(supportsMetrics({ authType: "key" })).toBe(true);
});
it("rejects non-ssh connection types", () => {
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
expect(supportsMetrics({ connectionType: "telnet" })).toBe(false);
});
it("rejects ssh hosts that cannot run shell commands", () => {
expect(supportsMetrics({ connectionType: "ssh", authType: "none" })).toBe(
false,
);
expect(supportsMetrics({ connectionType: "ssh", authType: "opkssh" })).toBe(
false,
);
});
});
describe("isTcpPingEnabled", () => {
it("is enabled when status checks are on and tcp ping is not disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: false }),
).toBe(true);
expect(isTcpPingEnabled({ statusCheckEnabled: true })).toBe(true);
});
it("is disabled when status checks are off", () => {
expect(isTcpPingEnabled({ statusCheckEnabled: false })).toBe(false);
});
it("is disabled when tcp ping is explicitly disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: true }),
).toBe(false);
});
});
describe("createConnectionLog", () => {
it("builds a log entry without id/timestamp", () => {
const entry = createConnectionLog("info", "connection", "Connecting", {
hostId: 1,
});
expect(entry).toEqual({
type: "info",
stage: "connection",
message: "Connecting",
details: { hostId: 1 },
});
expect("id" in entry).toBe(false);
expect("timestamp" in entry).toBe(false);
});
});
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Client } from "ssh2";
export type StatsCapableHost = {
connectionType?: string;
authType?: string;
};
export type TcpPingStatsConfig = {
statusCheckEnabled: boolean;
disableTcpPing?: boolean;
};
export function supportsMetrics(host: StatsCapableHost): boolean {
const connectionType = host.connectionType || "ssh";
if (connectionType !== "ssh") return false;
if (host.authType === "none" || host.authType === "opkssh") return false;
return true;
}
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
}
export function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream);
});
});
}
@@ -0,0 +1,130 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../database/repositories/factory.js";
import { statsLogger } from "../utils/logger.js";
type HistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: "read" | "write" | "execute" | "delete" | "share",
) => Promise<boolean>;
};
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
export function registerHostMetricsHistoryRoutes(
app: Express,
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
): void {
/**
* @openapi
* /metrics/history/{id}:
* get:
* summary: Get historical metrics for a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of metric history rows.
* 403:
* description: Access denied.
* 404:
* description: Host not found or no access.
*/
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "read");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromTs: string;
let toTs: string = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
// Normalize both sides to that format for reliable string comparison.
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await createCurrentHostMetricsHistoryRepository().listRange(
hostId,
toSqlite(fromTs),
toSqlite(toTs),
)
).map((row) => ({
ts: row.ts,
cpu_percent: row.cpuPercent,
mem_percent: row.memPercent,
disk_percent: row.diskPercent,
net_rx_bytes: row.netRxBytes,
net_tx_bytes: row.netTxBytes,
}));
res.json({ rows, fromTs, toTs });
} catch (error) {
statsLogger.error("Failed to fetch metrics history", {
operation: "metrics_history_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch metrics history" });
}
});
}
@@ -0,0 +1,229 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { createCurrentHostMetricsPreferenceRepository } from "../database/repositories/factory.js";
import { statsLogger } from "../utils/logger.js";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../types/host-metrics.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
}
interface PrefHost {
id: number;
userId: string;
statsConfig?: unknown;
}
type HostMetricsPreferencesRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
canAccessHost: (
userId: string,
hostId: number,
level: "read" | "execute",
) => Promise<boolean>;
};
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.slots)) return null;
const columns =
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
? Math.round(obj.columns)
: 3;
const slots = obj.slots
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s, i) => ({
id: String(s.id),
order: typeof s.order === "number" ? s.order : i,
colSpan:
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
height:
typeof s.height === "number" && s.height > 0
? Math.round(s.height)
: null,
}))
.filter((s) => s.id && s.id !== "undefined");
return { slots, columns } as HostMetricsLayout;
}
export function registerHostMetricsPreferencesRoutes(
app: Express,
{
validateHostId,
fetchHostById,
parseStatsConfig,
canAccessHost,
}: HostMetricsPreferencesRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/preferences/{id}:
* get:
* summary: Get the Host Metrics layout for a host
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The layout for this host.
* 403:
* description: No access to this host.
* 404:
* description: Host not found.
*/
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "read"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const row =
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
userId,
hostId,
);
if (row?.layout) {
try {
const parsed = sanitizeLayout(JSON.parse(row.layout));
if (parsed) return res.json({ layout: parsed });
} catch {
// fall through to default
}
}
const statsConfig = parseStatsConfig(host.statsConfig);
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
return res.json({ layout });
} catch (error) {
statsLogger.error("Failed to fetch host metrics preferences", {
operation: "host_metrics_prefs_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to fetch host metrics preferences" });
}
});
/**
* @openapi
* /host-metrics/preferences/{id}:
* post:
* summary: Save the Host Metrics layout for a host
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* slots:
* type: array
* columns:
* type: integer
* responses:
* 200:
* description: Layout saved.
* 400:
* description: Invalid layout.
* 403:
* description: No access to this host.
*/
app.post(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "read"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const layout = sanitizeLayout(req.body);
if (!layout) {
return res.status(400).json({ error: "Invalid layout" });
}
const now = new Date().toISOString();
const layoutJson = JSON.stringify(layout);
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
userId,
hostId,
layoutJson,
now,
);
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
// user owns. stats_config is plain JSON text (not an encrypted field).
if (host.userId === userId) {
try {
const current = parseStatsConfig(host.statsConfig);
const merged = {
...current,
enabledWidgets: deriveEnabledWidgets(layout.slots),
};
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
userId,
hostId,
JSON.stringify(merged),
);
} catch (syncErr) {
statsLogger.warn("Failed to sync enabledWidgets from layout", {
operation: "host_metrics_prefs_sync_widgets",
hostId,
error:
syncErr instanceof Error ? syncErr.message : String(syncErr),
});
}
}
return res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save host metrics preferences", {
operation: "host_metrics_prefs_save_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to save host metrics preferences" });
}
},
);
}
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
isMetricCardId,
isManagerCardId,
METRIC_CARD_IDS,
} from "../../types/host-metrics.js";
describe("deriveEnabledWidgets", () => {
it("returns only metric ids, in canonical order, deduped", () => {
const slots = [
{ id: "firewall" },
{ id: "cpu" },
{ id: "cpu" },
{ id: "memory" },
];
expect(deriveEnabledWidgets(slots)).toEqual(["cpu", "memory", "firewall"]);
});
it("excludes manager card ids (mobile contract: never leak managers)", () => {
const slots = [
{ id: "cpu" },
{ id: "service_manager" },
{ id: "log_viewer" },
{ id: "disk" },
];
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(["cpu", "disk"]);
for (const id of out) expect(isMetricCardId(id)).toBe(true);
});
it("output is always a subset of the 10 known WidgetTypes", () => {
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
]);
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(METRIC_CARD_IDS);
expect(out.length).toBe(METRIC_CARD_IDS.length);
});
it("empty slots -> empty widgets", () => {
expect(deriveEnabledWidgets([])).toEqual([]);
});
});
describe("defaultLayoutFromWidgets", () => {
it("builds slots in canonical order with dense ordering", () => {
const layout = defaultLayoutFromWidgets(["disk", "cpu", "memory"]);
expect(layout.slots.map((s) => s.id)).toEqual(["cpu", "memory", "disk"]);
expect(layout.slots.map((s) => s.order)).toEqual([0, 1, 2]);
expect(layout.columns).toBe(3);
});
it("ignores unknown widget ids", () => {
const layout = defaultLayoutFromWidgets(["cpu", "nope", "service_manager"]);
expect(layout.slots.map((s) => s.id)).toEqual(["cpu"]);
});
it("assigns valid colSpans (1..3)", () => {
const layout = defaultLayoutFromWidgets([...METRIC_CARD_IDS]);
for (const s of layout.slots) {
expect([1, 2, 3]).toContain(s.colSpan);
}
});
it("round-trips: deriveEnabledWidgets(defaultLayout) === input order", () => {
const layout = defaultLayoutFromWidgets(["ports", "cpu", "firewall"]);
expect(deriveEnabledWidgets(layout.slots)).toEqual([
"cpu",
"ports",
"firewall",
]);
});
});
describe("card id guards", () => {
it("classifies metric vs manager ids", () => {
expect(isMetricCardId("cpu")).toBe(true);
expect(isMetricCardId("service_manager")).toBe(false);
expect(isManagerCardId("service_manager")).toBe(true);
expect(isManagerCardId("cpu")).toBe(false);
});
});
@@ -0,0 +1,80 @@
import type { Client, ConnectConfig } from "ssh2";
import { statsLogger } from "../utils/logger.js";
export interface MetricsSession {
client: Client;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId: number;
userId: string;
}
export interface PendingTOTPSession {
client: Client;
finish: (responses: string[]) => void;
config: ConnectConfig;
createdAt: number;
sessionId: string;
hostId: number;
userId: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
}
export interface MetricsViewer {
sessionId: string;
userId: string;
hostId: number;
lastHeartbeat: number;
}
export const metricsSessions: Record<string, MetricsSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
export function cleanupMetricsSession(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
statsLogger.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleMetricsSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete metricsSessions[sessionId];
}
}
export function scheduleMetricsSessionCleanup(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(
() => {
cleanupMetricsSession(sessionId);
},
30 * 60 * 1000,
);
}
}
export function getSessionKey(hostId: number, userId: string): string {
return `${userId}:${hostId}`;
}
@@ -0,0 +1,256 @@
import type { Express, RequestHandler } from "express";
import { statsLogger } from "../utils/logger.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
type HostMetricsSettingsConfig = {
statusCheckInterval: number;
metricsInterval: number;
};
type HostMetricsSettingsRoutesDeps = {
requireAdmin: RequestHandler;
defaultStatsConfig: HostMetricsSettingsConfig;
refreshAllPolling: () => Promise<void>;
};
function isMissingSettingsTable(error: unknown): boolean {
return (
error instanceof Error && error.message.includes("no such table: settings")
);
}
export function registerHostMetricsSettingsRoutes(
app: Express,
{
requireAdmin,
defaultStatsConfig: DEFAULT_STATS_CONFIG,
refreshAllPolling,
}: HostMetricsSettingsRoutesDeps,
): void {
/**
* @openapi
* /global-settings:
* get:
* summary: Get global monitoring defaults
* tags:
* - Host Metrics
* responses:
* 200:
* description: Global monitoring settings.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings", requireAdmin, async (_req, res) => {
try {
const settings = createCurrentSettingsRepository();
const statusValue = await settings.get("global_status_check_interval");
const metricsValue = await settings.get("global_metrics_interval");
res.json({
statusCheckInterval: statusValue
? parseInt(statusValue, 10)
: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: metricsValue
? parseInt(metricsValue, 10)
: DEFAULT_STATS_CONFIG.metricsInterval,
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.warn("Settings table does not exist, using defaults", {
operation: "global_settings_table_check",
error: error.message,
});
return res.json({
statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
});
}
statsLogger.error("Failed to fetch global settings", {
operation: "global_settings_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch global settings" });
}
});
/**
* @openapi
* /global-settings:
* post:
* summary: Update global monitoring defaults
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* statusCheckInterval:
* type: integer
* metricsInterval:
* type: integer
* responses:
* 200:
* description: Settings saved.
* 400:
* description: Invalid parameters.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings", requireAdmin, async (req, res) => {
const { statusCheckInterval, metricsInterval } = req.body;
if (
statusCheckInterval !== undefined &&
(typeof statusCheckInterval !== "number" ||
statusCheckInterval < 5 ||
statusCheckInterval > 3600)
) {
return res.status(400).json({
error: "statusCheckInterval must be between 5 and 3600 seconds",
});
}
if (
metricsInterval !== undefined &&
(typeof metricsInterval !== "number" ||
metricsInterval < 5 ||
metricsInterval > 3600)
) {
return res
.status(400)
.json({ error: "metricsInterval must be between 5 and 3600 seconds" });
}
try {
const settings = createCurrentSettingsRepository();
if (statusCheckInterval !== undefined) {
await settings.set(
"global_status_check_interval",
String(statusCheckInterval),
);
}
if (metricsInterval !== undefined) {
await settings.set("global_metrics_interval", String(metricsInterval));
}
await refreshAllPolling();
res.json({
success: true,
message: "Settings updated and polling refreshed",
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.error(
"Settings table does not exist, cannot save settings",
{
operation: "global_settings_table_check",
error: error.message,
},
);
return res.status(500).json({
error:
"Database settings table is missing. Please check database initialization.",
});
}
statsLogger.error("Failed to save global settings", {
operation: "global_settings_save_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: "Failed to save global settings",
details: error instanceof Error ? error.message : String(error),
});
}
});
/**
* @openapi
* /global-settings/history:
* get:
* summary: Get metrics history retention setting
* tags:
* - Host Metrics
* responses:
* 200:
* description: Retention setting in days.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings/history", requireAdmin, async (_req, res) => {
try {
const value = await createCurrentSettingsRepository().get(
"metrics_history_retention_days",
);
const days = value ? parseInt(value, 10) : 7;
res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days });
} catch (error) {
statsLogger.error("Failed to fetch history retention setting", {
operation: "history_retention_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to fetch history retention setting" });
}
});
/**
* @openapi
* /global-settings/history:
* post:
* summary: Update metrics history retention setting
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* metricsHistoryRetentionDays:
* type: integer
* minimum: 1
* maximum: 90
* responses:
* 200:
* description: Setting saved.
* 400:
* description: Invalid value.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings/history", requireAdmin, async (req, res) => {
const { metricsHistoryRetentionDays } = req.body;
if (
typeof metricsHistoryRetentionDays !== "number" ||
metricsHistoryRetentionDays < 1 ||
metricsHistoryRetentionDays > 90
) {
return res.status(400).json({
error: "metricsHistoryRetentionDays must be between 1 and 90",
});
}
try {
await createCurrentSettingsRepository().set(
"metrics_history_retention_days",
String(Math.floor(metricsHistoryRetentionDays)),
);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save history retention setting", {
operation: "history_retention_save_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to save history retention setting" });
}
});
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { ConcurrentLimiter, HostPollCache } from "./host-metrics-state.js";
describe("ConcurrentLimiter", () => {
it("never exceeds max concurrent runners", async () => {
const limiter = new ConcurrentLimiter(2);
let peak = 0;
let current = 0;
const job = async () => {
current += 1;
peak = Math.max(peak, current);
await new Promise((r) => setTimeout(r, 30));
current -= 1;
};
await Promise.all([
limiter.run(job),
limiter.run(job),
limiter.run(job),
limiter.run(job),
]);
expect(peak).toBeLessThanOrEqual(2);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("runs waiters in FIFO order after a slot frees", async () => {
const limiter = new ConcurrentLimiter(1);
const order: number[] = [];
const first = limiter.run(async () => {
order.push(1);
await new Promise((r) => setTimeout(r, 20));
});
const second = limiter.run(async () => {
order.push(2);
});
const third = limiter.run(async () => {
order.push(3);
});
await Promise.all([first, second, third]);
expect(order).toEqual([1, 2, 3]);
});
it("rejects invalid maxConcurrent", () => {
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
});
});
describe("HostPollCache", () => {
it("returns cached host within TTL for the same user", () => {
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
cache.set(1, "user-a", { id: 1, name: "alpha" });
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
expect(cache.get(1, "user-b")).toBeNull();
});
it("expires entries after TTL", () => {
vi.useFakeTimers();
const cache = new HostPollCache<{ id: number }>(1_000);
cache.set(7, "u", { id: 7 });
expect(cache.get(7, "u")).toEqual({ id: 7 });
vi.advanceTimersByTime(1_001);
expect(cache.get(7, "u")).toBeNull();
vi.useRealTimers();
});
it("invalidate drops a host or the whole cache", () => {
const cache = new HostPollCache<{ id: number }>(60_000);
cache.set(1, "u", { id: 1 });
cache.set(2, "u", { id: 2 });
cache.invalidate(1);
expect(cache.get(1, "u")).toBeNull();
expect(cache.get(2, "u")).toEqual({ id: 2 });
cache.invalidate();
expect(cache.get(2, "u")).toBeNull();
});
});
+338
View File
@@ -0,0 +1,338 @@
class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>();
private requestTimeout = 60000;
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const wrappedRequest = async () => {
try {
const result = await Promise.race<T>([
request(),
new Promise<never>((_, rej) =>
setTimeout(
() =>
rej(
new Error(
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
),
),
this.requestTimeout,
),
),
]);
resolve(result);
} catch (error) {
reject(error);
}
};
const queue = this.queues.get(hostId) || [];
queue.push(wrappedRequest);
this.queues.set(hostId, queue);
this.processQueue(hostId);
});
}
private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return;
this.processing.add(hostId);
const queue = this.queues.get(hostId) || [];
while (queue.length > 0) {
const request = queue.shift();
if (request) {
try {
await request();
} catch {
// expected
}
}
}
this.processing.delete(hostId);
const currentQueue = this.queues.get(hostId);
if (currentQueue && currentQueue.length > 0) {
this.processQueue(hostId);
}
}
}
interface CachedMetrics {
data: unknown;
timestamp: number;
hostId: number;
}
class MetricsCache {
private cache = new Map<number, CachedMetrics>();
private ttl = 30000;
get(hostId: number): unknown | null {
const cached = this.cache.get(hostId);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
return null;
}
set(hostId: number, data: unknown): void {
this.cache.set(hostId, {
data,
timestamp: Date.now(),
hostId,
});
}
clear(hostId?: number): void {
if (hostId) {
this.cache.delete(hostId);
} else {
this.cache.clear();
}
}
}
interface AuthFailureRecord {
count: number;
lastFailure: number;
reason: "TOTP" | "AUTH" | "TIMEOUT";
permanent: boolean;
}
class AuthFailureTracker {
private failures = new Map<number, AuthFailureRecord>();
private maxRetries = 3;
private backoffBase = 5000;
recordFailure(
hostId: number,
reason: "TOTP" | "AUTH" | "TIMEOUT",
permanent = false,
): void {
const existing = this.failures.get(hostId);
if (existing) {
existing.count++;
existing.lastFailure = Date.now();
existing.reason = reason;
if (permanent) existing.permanent = true;
} else {
this.failures.set(hostId, {
count: 1,
lastFailure: Date.now(),
reason,
permanent,
});
}
}
shouldSkip(hostId: number): boolean {
const record = this.failures.get(hostId);
if (!record) return false;
if (record.reason === "TOTP" || record.permanent) {
return true;
}
if (record.count >= this.maxRetries) {
return true;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
return timeSinceFailure < backoffTime;
}
getSkipReason(hostId: number): string | null {
const record = this.failures.get(hostId);
if (!record) return null;
if (record.reason === "TOTP") {
return "TOTP authentication required (metrics unavailable)";
}
if (record.permanent) {
return "Authentication permanently failed";
}
if (record.count >= this.maxRetries) {
return `Too many authentication failures (${record.count} attempts)`;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
if (timeSinceFailure < backoffTime) {
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, record] of this.failures.entries()) {
if (!record.permanent && now - record.lastFailure > maxAge) {
this.failures.delete(hostId);
}
}
}
}
class PollingBackoff {
private failures = new Map<number, { count: number; nextRetry: number }>();
private baseDelay = 30000;
private maxDelay = 600000;
private maxRetries = 5;
recordFailure(hostId: number): void {
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
const delay = Math.min(
this.baseDelay * Math.pow(2, existing.count),
this.maxDelay,
);
this.failures.set(hostId, {
count: existing.count + 1,
nextRetry: Date.now() + delay,
});
}
shouldSkip(hostId: number): boolean {
const backoff = this.failures.get(hostId);
if (!backoff) return false;
if (backoff.count >= this.maxRetries) {
return true;
}
return Date.now() < backoff.nextRetry;
}
getBackoffInfo(hostId: number): string | null {
const backoff = this.failures.get(hostId);
if (!backoff) return null;
if (backoff.count >= this.maxRetries) {
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
}
const remainingMs = backoff.nextRetry - Date.now();
if (remainingMs > 0) {
const remainingSec = Math.ceil(remainingMs / 1000);
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, backoff] of this.failures.entries()) {
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
this.failures.delete(hostId);
}
}
}
}
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop host-metrics status/metrics polls from stampeding under load.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private readonly maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
const next = this.waiters.shift();
if (next) next();
}
}
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/** TCP status checks are cheap relative to SSH metrics collection. */
export const statusPollLimiter = new ConcurrentLimiter(20);
/** SSH metrics execs are expensive; keep concurrency tight. */
export const metricsPollLimiter = new ConcurrentLimiter(5);
export const hostPollCache = new HostPollCache(30_000);
export const requestQueue = new RequestQueue();
export const metricsCache = new MetricsCache();
export const authFailureTracker = new AuthFailureTracker();
export const pollingBackoff = new PollingBackoff();
@@ -0,0 +1,290 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { statsLogger } from "../utils/logger.js";
import { DataCrypto } from "../utils/data-crypto.js";
type ViewerStatsConfig = {
metricsEnabled: boolean;
};
type HostMetricsViewerRoutesDeps<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
> = {
fetchHostById: (hostId: number, userId: string) => Promise<THost>;
supportsMetrics: (host: THost) => boolean;
parseStatsConfig: (statsConfig: THost["statsConfig"]) => TStatsConfig;
updateHeartbeat: (viewerSessionId: string) => boolean;
registerViewer: (
hostId: number,
viewerSessionId: string,
userId: string,
) => void;
unregisterViewer: (hostId: number, viewerSessionId: string) => void;
};
export function registerHostMetricsViewerRoutes<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
>(
app: Express,
{
fetchHostById,
supportsMetrics,
parseStatsConfig,
updateHeartbeat,
registerViewer,
unregisterViewer,
}: HostMetricsViewerRoutesDeps<THost, TStatsConfig>,
): void {
/**
* @openapi
* /metrics/heartbeat:
* post:
* summary: Update viewer heartbeat
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Heartbeat updated successfully.
* 400:
* description: Invalid viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Viewer session not found.
* 500:
* description: Failed to update heartbeat.
*/
app.post("/metrics/heartbeat", async (req, res) => {
const { viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
const success = updateHeartbeat(viewerSessionId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Viewer session not found" });
}
} catch (error) {
statsLogger.error("Failed to update heartbeat", {
operation: "heartbeat_error",
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to update heartbeat" });
}
});
/**
* @openapi
* /metrics/register-viewer:
* post:
* summary: Register metrics viewer
* description: Registers a new viewer session for a host to track who is viewing metrics.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Viewer registered successfully.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to register viewer.
*/
app.post("/metrics/register-viewer", async (req, res) => {
const { hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
try {
// Graceful no-op if host is inaccessible, metrics disabled, or host type
// does not support metrics. The client may call this speculatively, so
// avoid returning 5xx for expected "no metrics available" scenarios.
let host: THost | undefined;
try {
host = await fetchHostById(hostId, userId);
} catch (lookupErr) {
statsLogger.warn(
"register-viewer host lookup failed (treating as no-op)",
{
operation: "register_viewer_lookup",
hostId,
userId,
error:
lookupErr instanceof Error
? lookupErr.message
: String(lookupErr),
},
);
}
if (!host) {
return res.json({
success: true,
skipped: true,
reason: "host_not_found",
});
}
if (!supportsMetrics(host)) {
return res.json({
success: true,
skipped: true,
reason: "metrics_unsupported",
});
}
const statsConfig = parseStatsConfig(host.statsConfig);
if (!statsConfig.metricsEnabled) {
return res.json({
success: true,
skipped: true,
reason: "metrics_disabled",
});
}
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
registerViewer(hostId, viewerSessionId, userId);
} catch (regErr) {
statsLogger.warn(
"pollingManager.registerViewer threw (treating as no-op)",
{
operation: "register_viewer_internal",
hostId,
userId,
error: regErr instanceof Error ? regErr.message : String(regErr),
},
);
return res.json({
success: true,
skipped: true,
reason: "register_failed_noop",
});
}
res.json({ success: true, viewerSessionId });
} catch (error) {
statsLogger.error("Failed to register viewer", {
operation: "register_viewer_error",
hostId,
userId,
error: error instanceof Error ? error.message : String(error),
});
// Even on unexpected errors we prefer a graceful client experience: the
// viewer-registration is purely an optimization and should never break
// the UI. Report success:false but HTTP 200 so the client can decide.
res.status(200).json({
success: false,
skipped: true,
reason: "internal_error",
});
}
});
/**
* @openapi
* /metrics/unregister-viewer:
* post:
* summary: Unregister metrics viewer
* description: Unregisters a viewer session when they stop viewing metrics for a host.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Viewer unregistered successfully.
* 400:
* description: Invalid hostId or viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to unregister viewer.
*/
app.post("/metrics/unregister-viewer", async (req, res) => {
const { hostId, viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
unregisterViewer(hostId, viewerSessionId);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to unregister viewer", {
operation: "unregister_viewer_error",
hostId,
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to unregister viewer" });
}
});
}
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
import {
createCurrentHostResolutionRepository,
createCurrentVaultProfileRepository,
} from "../database/repositories/factory.js";
import { logger } from "../utils/logger.js";
import {
pickResolvedPassword,
pickResolvedUsername,
expandOidcUsername,
} from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
const sshLogger = logger;
/**
* Resolve a host with its credentials server-side by hostId.
* This avoids passing credentials through the frontend.
*/
export async function resolveHostById(
hostId: number,
userId: string,
): Promise<SSHHost | null> {
const { PermissionManager } = await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"read",
);
if (!access.hasAccess) return null;
const repository = createCurrentHostResolutionRepository();
const resolvedHost = await repository.findHostById(hostId, userId);
if (!resolvedHost) return null;
const host = resolvedHost as Record<string, unknown>;
// Parse JSON fields
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
try {
host.jumpHosts = JSON.parse(host.jumpHosts as string);
} catch {
host.jumpHosts = [];
}
}
if (typeof host.tunnelConnections === "string") {
try {
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
} catch {
host.tunnelConnections = [];
}
}
if (typeof host.statsConfig === "string" && host.statsConfig) {
try {
host.statsConfig = JSON.parse(host.statsConfig as string);
} catch {
host.statsConfig = undefined;
}
}
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
try {
host.terminalConfig = JSON.parse(host.terminalConfig as string);
} catch {
host.terminalConfig = undefined;
}
}
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
try {
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
} catch {
host.socks5ProxyChain = [];
}
}
if (typeof host.quickActions === "string" && host.quickActions) {
try {
host.quickActions = JSON.parse(host.quickActions as string);
} catch {
host.quickActions = [];
}
}
// Resolve credential if using credential-based auth
if (host.credentialId) {
const ownerId = (host.userId || userId) as string;
try {
// Try user's own override credential first
if (userId !== ownerId) {
try {
const overrideCredId = await repository.findOverrideCredentialId(
hostId,
userId,
);
if (overrideCredId) {
const cred = (await repository.findCredentialByIdForUser(
overrideCredId,
userId,
)) as Record<string, unknown> | null;
if (cred) {
host.password = cred.password;
host.key = cred.key;
host.keyPassword = cred.keyPassword;
host.keyType = cred.keyType;
host.username = pickResolvedUsername(
host.username,
cred.username,
host.overrideCredentialUsername,
);
host.authType = cred.key
? "key"
: cred.password
? "password"
: "none";
host.username = await expandOidcUsername(
host.username as string | undefined,
userId,
);
return host as unknown as SSHHost;
}
}
} catch {
// fall through to shared credential
}
try {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
hostId,
userId,
);
if (sharedCred) {
host.password = sharedCred.password;
host.key = sharedCred.key;
host.keyPassword = sharedCred.keyPassword;
host.keyType = sharedCred.keyType;
host.username = pickResolvedUsername(
host.username,
sharedCred.username,
host.overrideCredentialUsername,
);
host.authType = sharedCred.key
? "key"
: sharedCred.password
? "password"
: "none";
host.username = await expandOidcUsername(
host.username as string | undefined,
userId,
);
return host as unknown as SSHHost;
}
} catch (e) {
sshLogger.warn("Failed to get shared credential", {
operation: "host_resolver_shared_credential",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
return null;
}
const cred = (await repository.findCredentialByIdForUser(
host.credentialId as number,
ownerId,
)) as Record<string, unknown> | null;
if (cred) {
host.password = pickResolvedPassword(host.password, cred.password);
// Prefer the normalised private key; fall back to raw key field
host.key = (cred.privateKey || cred.key) as string | null;
host.keyPassword = cred.keyPassword;
host.keyType = cred.keyType;
// CA-signed certificate for cert-based auth
(host as Record<string, unknown>).certPublicKey =
cred.certPublicKey || null;
host.username = pickResolvedUsername(
host.username,
cred.username,
host.overrideCredentialUsername,
);
host.authType = host.key ? "key" : host.password ? "password" : "none";
}
} catch (e) {
sshLogger.warn("Failed to resolve credential for host", {
operation: "host_resolver_credential",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
host.username = await expandOidcUsername(
host.username as string | undefined,
userId,
);
// Resolve a Vault SSH signer profile (shared settings, no secrets). The
// certificate itself is obtained per-user at connect time via Vault OIDC.
if (host.vaultProfileId) {
try {
const profile = await createCurrentVaultProfileRepository().findById(
host.vaultProfileId as number,
);
if (profile) {
(host as Record<string, unknown>).vaultProfile = profile;
host.authType = "vault";
}
} catch (e) {
sshLogger.warn("Failed to resolve vault profile for host", {
operation: "host_resolver_vault_profile",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
return host as unknown as SSHHost;
}
/**
* Check if a user has access to a host (owner or shared access).
*/
export async function checkHostAccess(
hostId: number,
userId: string,
hostUserId: string,
requiredPermission: "read" | "execute" = "execute",
): Promise<boolean> {
if (userId === hostUserId) return true;
try {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
requiredPermission,
);
return accessInfo.hasAccess;
} catch {
return false;
}
}
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
import { Client as SSHClient } from "ssh2";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
import { fileLogger } from "../utils/logger.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../utils/socks5-helper.js";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
type JumpHostConfig = {
id: number;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null;
[key: string]: unknown;
};
async function resolveJumpHost(
hostId: number,
userId: string,
): Promise<JumpHostConfig | null> {
try {
const repository = createCurrentHostResolutionRepository();
const resolvedHost = await repository.findHostById(hostId, userId);
if (!resolvedHost) {
return null;
}
const host = resolvedHost as Record<string, unknown>;
const ownerId = (host.userId || userId) as string;
if (host.credentialId) {
if (userId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
hostId,
userId,
);
if (sharedCred) {
return {
...host,
password: sharedCred.password,
key: sharedCred.key,
keyPassword: sharedCred.keyPassword,
keyType: sharedCred.keyType,
authType: sharedCred.key
? "key"
: sharedCred.password
? "password"
: "none",
} as JumpHostConfig;
}
} catch {
// fall through to owner credential lookup
}
}
const credential = (await repository.findCredentialByIdForUser(
host.credentialId as number,
ownerId,
)) as Record<string, unknown> | null;
if (credential) {
return {
...host,
password: credential.password as string | undefined,
key: (credential.key || credential.privateKey) as string | undefined,
keyPassword: credential.keyPassword as string | undefined,
keyType: credential.keyType as string | undefined,
authType: credential.authType as string | undefined,
} as JumpHostConfig;
}
}
return host as JumpHostConfig;
} catch (error) {
fileLogger.error("Failed to resolve jump host", error, {
operation: "resolve_jump_host",
hostId,
userId,
});
return null;
}
}
export async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
socks5Config?: SOCKS5Config | null,
): Promise<SSHClient | null> {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
let currentClient: SSHClient | null = null;
const clients: SSHClient[] = [];
try {
const jumpHostConfigs: Array<Awaited<ReturnType<typeof resolveJumpHost>>> =
[];
for (let i = 0; i < jumpHosts.length; i++) {
const config = await resolveJumpHost(jumpHosts[i].hostId, userId);
jumpHostConfigs.push(config);
}
const totalHops = jumpHostConfigs.length;
for (let i = 0; i < jumpHostConfigs.length; i++) {
if (!jumpHostConfigs[i]) {
fileLogger.error(`Jump host ${i + 1} not found`, undefined, {
operation: "jump_host_chain",
hostId: jumpHosts[i].hostId,
hopIndex: i,
totalHops,
});
clients.forEach((c) => c.end());
return null;
}
}
const firstHopSocks5Config = getJumpHostSocks5Config(
jumpHostConfigs[0],
socks5Config,
);
let proxySocket: import("net").Socket | null = null;
if (firstHopSocks5Config?.useSocks5) {
const firstHop = jumpHostConfigs[0]!;
proxySocket = await createSocks5Connection(
firstHop.ip,
firstHop.port || 22,
firstHopSocks5Config,
);
}
for (let i = 0; i < jumpHostConfigs.length; i++) {
const jumpHostConfig = jumpHostConfigs[i]!;
const jumpClient = new SSHClient();
clients.push(jumpClient);
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
jumpHostConfig.id,
jumpHostConfig.ip,
jumpHostConfig.port || 22,
null,
userId,
true,
);
// eslint-disable-next-line no-async-promise-executor
const connected = await new Promise<boolean>(async (resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 30000);
jumpClient.on("ready", () => {
clearTimeout(timeout);
resolve(true);
});
jumpClient.on("error", (err) => {
clearTimeout(timeout);
fileLogger.error(
`Jump host ${i + 1}/${totalHops} connection failed`,
err,
{
operation: "jump_host_connect",
hostId: jumpHostConfig.id,
ip: jumpHostConfig.ip,
hopIndex: i,
totalHops,
previousHop:
i > 0
? jumpHostConfigs[i - 1]?.ip
: proxySocket
? "proxy"
: "direct",
usedProxySocket: i === 0 && !!proxySocket,
},
);
resolve(false);
});
const connectConfig: Record<string, unknown> = {
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: jumpHostConfig.authType !== "none",
readyTimeout: 60000,
hostVerifier: jumpHostVerifier,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
connectConfig.password = jumpHostConfig.password;
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
const cleanKey = jumpHostConfig.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
if (jumpHostConfig.keyPassword) {
connectConfig.passphrase = jumpHostConfig.keyPassword;
}
} else if (jumpHostConfig.authType === "agent") {
const result = await applyAgentAuth(
connectConfig,
jumpHostConfig.terminalConfig as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
jumpClient.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
return jumpHostConfig.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
currentClient.forwardOut(
"127.0.0.1",
0,
jumpHostConfig.ip,
jumpHostConfig.port || 22,
(err, stream) => {
if (err) {
clearTimeout(timeout);
resolve(false);
return;
}
connectConfig.sock = stream;
jumpClient.connect(connectConfig);
},
);
} else if (proxySocket) {
connectConfig.sock = proxySocket;
jumpClient.connect(connectConfig);
} else {
jumpClient.connect(connectConfig);
}
});
if (!connected) {
clients.forEach((c) => c.end());
return null;
}
currentClient = jumpClient;
}
return currentClient;
} catch (error) {
fileLogger.error("Failed to create jump host chain", error, {
operation: "jump_host_chain",
});
clients.forEach((c) => c.end());
return null;
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { ProxyNode } from "../../types/index.js";
import type { SOCKS5Config } from "../utils/socks5-helper.js";
type JumpHostProxyConfig = {
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: ProxyNode[] | string | null;
};
function parseProxyChain(value: JumpHostProxyConfig["socks5ProxyChain"]) {
if (Array.isArray(value)) {
return value;
}
if (typeof value !== "string" || value.length === 0) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? (parsed as ProxyNode[]) : [];
} catch {
return [];
}
}
export function getJumpHostSocks5Config(
firstHop: JumpHostProxyConfig | null | undefined,
fallbackConfig?: SOCKS5Config | null,
): SOCKS5Config | null {
if (!firstHop?.useSocks5) {
return fallbackConfig ?? null;
}
const socks5ProxyChain = parseProxyChain(firstHop.socks5ProxyChain);
if (!firstHop.socks5Host && socks5ProxyChain.length === 0) {
return fallbackConfig ?? null;
}
return {
useSocks5: true,
socks5Host: firstHop.socks5Host ?? undefined,
socks5Port: firstHop.socks5Port ?? undefined,
socks5Username: firstHop.socks5Username ?? undefined,
socks5Password: firstHop.socks5Password ?? undefined,
socks5ProxyChain,
};
}
+127
View File
@@ -0,0 +1,127 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
raw: string;
enabled: boolean;
schedule: string;
command: string;
}
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
export function parseCrontab(output: string): CronEntry[] {
const entries: CronEntry[] = [];
for (const raw of output.split("\n")) {
const line = raw.replace(/\s+$/, "");
if (!line.trim()) continue;
// A commented-out job: "# <schedule> <command>" (our toggle convention).
const disabled = line.match(
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
);
if (disabled) {
const { schedule, command } = splitScheduleCommand(disabled[1]);
if (command) {
entries.push({ raw: line, enabled: false, schedule, command });
continue;
}
}
// Skip pure comments / env assignments.
if (line.trimStart().startsWith("#")) continue;
if (/^\s*[A-Z_]+=/.test(line)) continue;
const { schedule, command } = splitScheduleCommand(line.trim());
if (!command) continue;
entries.push({ raw: line, enabled: true, schedule, command });
}
return entries;
}
function splitScheduleCommand(line: string): {
schedule: string;
command: string;
} {
if (line.startsWith("@")) {
const [sched, ...rest] = line.split(/\s+/);
return { schedule: sched, command: rest.join(" ") };
}
const parts = line.split(/\s+/);
if (parts.length < 6) return { schedule: "", command: "" };
return {
schedule: parts.slice(0, 5).join(" "),
command: parts.slice(5).join(" "),
};
}
const CRON_SCHEDULE_RE =
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
export function isValidCronSchedule(schedule: string): boolean {
return CRON_SCHEDULE_RE.test(schedule.trim());
}
/** Serialize entries into a full crontab body (toggled entries are commented). */
export function serializeCrontab(entries: CronEntry[]): string {
const lines = entries.map((e) => {
const body = `${e.schedule} ${e.command}`.trim();
return e.enabled ? body : `# ${body}`;
});
return lines.join("\n") + (lines.length ? "\n" : "");
}
/** Build a command that atomically replaces the user crontab from the given body. */
export function buildApplyCrontabCommand(body: string): string {
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
}
export function registerCronRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(runOnHost, "read", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
);
app.post(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(
runOnHost,
"execute",
"cron_replace",
async (client, _host, req) => {
const { entries } = req.body as { entries?: CronEntry[] };
if (!Array.isArray(entries)) {
throw new ManagerInputError("entries must be an array");
}
for (const e of entries) {
if (typeof e?.command !== "string" || !e.command.trim()) {
throw new ManagerInputError("Each entry needs a command");
}
if (e.command.includes("\n")) {
throw new ManagerInputError("Commands cannot contain newlines");
}
if (!isValidCronSchedule(String(e.schedule))) {
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
}
}
const body = serializeCrontab(entries);
const { stdout, stderr, code } = await execCommand(
client,
buildApplyCrontabCommand(body),
15000,
);
return { success: code === 0, output: stdout || stderr };
},
),
);
}
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../widgets/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
import { execElevated, ElevationError } from "./exec-elevated.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, stderr = "", code: number | null = 0) {
return { stdout, stderr, code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("execElevated (no force)", () => {
it("returns the direct result when the command succeeds unprivileged", async () => {
execCommand.mockResolvedValueOnce(result("hello", "", 0));
const r = await execElevated(fakeClient, "echo hello", "pw");
expect(r.usedSudo).toBe(false);
expect(r.stdout).toBe("hello");
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("does NOT escalate when stdout merely contains scary words", async () => {
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
// not should be surfaced as-is, never retried under sudo.
execCommand.mockResolvedValueOnce(
result("log line: permission denied for user foo", "", 1),
);
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
expect(r.usedSudo).toBe(false);
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("escalates when stderr indicates a permission problem", async () => {
execCommand
.mockResolvedValueOnce(result("", "Permission denied", 1))
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
expect(r.usedSudo).toBe(true);
expect(r.stdout).toBe("elevated output");
expect(execCommand).toHaveBeenCalledTimes(2);
});
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
await expect(
execElevated(fakeClient, "cat /etc/shadow", undefined),
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
});
});
describe("execElevated (forced)", () => {
it("strips the success marker from stdout", async () => {
execCommand.mockResolvedValueOnce(
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
);
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
expect(r.stdout).toBe("the real output\n");
expect(r.usedSudo).toBe(true);
});
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
execCommand.mockResolvedValueOnce(
result(
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
"",
0,
),
);
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
forceSudo: true,
});
expect(r.usedSudo).toBe(true);
expect(r.stdout).toContain("incorrect password");
});
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
execCommand.mockResolvedValueOnce(
result("", "sudo: 1 incorrect password attempt", 1),
);
await expect(
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
).rejects.toMatchObject({ code: "SUDO_FAILED" });
});
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
execCommand.mockResolvedValueOnce(
result("", "deploy is not in the sudoers file.", 1),
);
await expect(
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
).rejects.toMatchObject({ code: "NOT_SUDOER" });
});
it("throws SUDO_REQUIRED when forced without a password", async () => {
await expect(
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
).rejects.toBeInstanceOf(ElevationError);
expect(execCommand).not.toHaveBeenCalled();
});
});
+169
View File
@@ -0,0 +1,169 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
export class ElevationError extends Error {
code: ElevationErrorCode;
constructor(code: ElevationErrorCode, message: string) {
super(message);
this.name = "ElevationError";
this.code = code;
}
}
export interface ElevatedResult {
stdout: string;
stderr: string;
code: number | null;
usedSudo: boolean;
}
const PERMISSION_DENIED = [
"permission denied",
"operation not permitted",
"must be run as root",
"must be superuser",
"you need to be root",
"are you root",
"access denied",
];
/**
* Phrases sudo itself prints to STDERR when authentication or authorization
* fails. We only match these against sudo's stderr (never command output), so a
* command that happens to print "permission denied" on stdout is not mistaken
* for a sudo failure.
*/
const SUDO_AUTH_FAILED = [
"incorrect password",
"a password is required",
"a terminal is required",
"no tty present",
"sorry, try again",
"no password was provided",
"1 incorrect password attempt",
];
const SUDO_NOT_SUDOER = [
"is not in the sudoers file",
"not allowed to run sudo",
"not allowed to execute",
];
/**
* Marker printed only after sudo has successfully authenticated and started the
* inner shell. Its presence on stdout is the authoritative "elevation worked"
* signal; its absence (together with sudo stderr) means auth failed.
*/
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
/** Escape a value for single-quoted shell context. */
export function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
/**
* Build the elevated command string:
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
*
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
* command's own output stays clean and sudo's auth errors stay on stderr. The
* marker is echoed by the inner shell once sudo authenticates, letting us tell a
* genuine auth failure from command output that merely contains scary words.
*/
export function buildSudoCommand(
command: string,
sudoPassword: string,
): string {
const pw = shellSingleQuote(sudoPassword);
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
}
function includesAny(text: string, needles: string[]): boolean {
const lower = text.toLowerCase();
return needles.some((n) => lower.includes(n));
}
function looksLikePermissionDenied(text: string): boolean {
return includesAny(text, PERMISSION_DENIED);
}
/** Strip the success marker line from the front of stdout. */
function stripMarker(stdout: string): string {
return stdout.replace(SUDO_OK_LINE_RE, "");
}
/**
* Run a command on a pooled client, elevating with the host's stored sudo
* password only when needed (or when forced). Throws a typed `ElevationError`
* when elevation is required but unavailable/incorrect.
*/
export async function execElevated(
client: Client,
command: string,
sudoPassword: string | undefined,
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
): Promise<ElevatedResult> {
const timeoutMs = opts.timeoutMs ?? 30000;
if (!opts.forceSudo) {
const direct = await execCommand(client, command, timeoutMs);
if (direct.code === 0) {
return { ...direct, usedSudo: false };
}
// Only escalate when the failure looks like a privilege problem. A
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
if (!looksLikePermissionDenied(direct.stderr)) {
return { ...direct, usedSudo: false };
}
if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
} else if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
const result = await execCommand(client, sudoCmd, timeoutMs);
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
if (!authenticated) {
// Elevation never started: diagnose from sudo's stderr only.
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
if (notSudoer) {
throw new ElevationError(
"NOT_SUDOER",
"The connected user is not permitted to use sudo on this host.",
);
}
if (authFailed) {
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
// No marker and no recognizable sudo error: treat as a generic failure but
// keep the original output so the caller can surface it.
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
return {
stdout: stripMarker(result.stdout),
stderr: result.stderr,
code: result.code,
usedSudo: true,
};
}
+129
View File
@@ -0,0 +1,129 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "./validation.js";
import { detectPlatform } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
protocol: IpProtocol;
port: number;
target: FirewallTarget;
}
/**
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
* specific dport with an explicit target, and never the chain policy, so an
* existing ESTABLISHED/SSH rule is left intact.
*/
export function buildIptablesRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
const flag = op === "add" ? "-A" : "-D";
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
}
export function buildNftRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
// nftables uses the inet filter table's input chain by convention.
const verb = op === "add" ? "add" : "delete";
const action =
spec.target.toLowerCase() === "reject"
? "reject"
: spec.target.toLowerCase();
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
}
export function registerFirewallRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "read", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
app.post(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
runOnHost,
"execute",
"firewall_rule",
async (client, host, req) => {
const { op, protocol, port, target } = req.body as {
op?: "add" | "delete";
protocol?: string;
port?: number;
target?: string;
};
if (op !== "add" && op !== "delete") {
throw new ManagerInputError("Invalid op");
}
if (!isValidIpProtocol(protocol))
throw new ManagerInputError("Invalid protocol");
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
if (!isValidFirewallTarget(target))
throw new ManagerInputError("Invalid target");
const spec: FirewallRuleSpec = {
protocol,
port: Number(port),
target,
};
const fw = await collectFirewallMetrics(client);
const cmd =
fw.type === "nftables"
? buildNftRuleCommand(op, spec)
: buildIptablesRuleCommand(op, spec);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
backend: fw.type,
};
},
),
);
app.post(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
runOnHost,
"execute",
"firewall_persist",
async (client, host) => {
const platform = await detectPlatform(client);
// Best-effort persistence across common tools.
const cmd =
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
pkg: platform.pkg,
};
},
),
);
}
+253
View File
@@ -0,0 +1,253 @@
import type { Express } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { execCommand } from "../widgets/common-utils.js";
import { createCurrentHostHealthRepository } from "../../database/repositories/factory.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import { isValidPort } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { AlertEngine } from "../alert-engine.js";
export interface HealthCheck {
id: string;
name: string;
type: "tcp" | "http";
target: string;
port?: number;
path?: string;
}
export interface HealthResult {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
export function isValidHealthCheck(c: unknown): c is HealthCheck {
if (!c || typeof c !== "object") return false;
const o = c as Record<string, unknown>;
if (typeof o.id !== "string" || !o.id) return false;
if (typeof o.name !== "string") return false;
if (o.type !== "tcp" && o.type !== "http") return false;
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
if (o.type === "tcp" && !isValidPort(o.port)) return false;
if (
o.path !== undefined &&
(typeof o.path !== "string" || !PATH_RE.test(o.path))
)
return false;
return true;
}
/** Build a command that runs the check from the host and prints "ok latency". */
export function buildHealthCheckCommand(check: HealthCheck): string {
if (check.type === "tcp") {
const host = shellSingleQuote(check.target);
const port = check.port;
// Prefer bash /dev/tcp; time it with date in ms.
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
}
// http
const scheme = check.target.includes("://") ? "" : "http://";
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
}
export function parseHealthResult(
check: HealthCheck,
output: string,
): HealthResult {
const line = output.trim().split("\n").pop() ?? "";
if (check.type === "tcp") {
const [status, ms] = line.split(/\s+/);
return {
checkId: check.id,
ok: status === "ok",
latencyMs: Number(ms) || null,
detail: status === "ok" ? "open" : "closed/timeout",
};
}
const m = line.match(/^(\d{3})\s+([\d.]+)/);
if (!m)
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
const code = Number(m[1]);
return {
checkId: check.id,
ok: code >= 200 && code < 400,
latencyMs: Math.round(Number(m[2]) * 1000),
detail: `HTTP ${code}`,
};
}
async function runChecks(
client: Client,
checks: HealthCheck[],
): Promise<HealthResult[]> {
return Promise.all(
checks.map(async (check) => {
try {
const { stdout } = await execCommand(
client,
buildHealthCheckCommand(check),
8000,
);
return parseHealthResult(check, stdout);
} catch {
return {
checkId: check.id,
ok: false,
latencyMs: null,
detail: "error",
};
}
}),
);
}
const HISTORY_KEEP = 500;
function recordHistory(
userId: string,
hostId: number,
results: HealthResult[],
): Promise<void> {
return createCurrentHostHealthRepository()
.recordHistory(userId, hostId, results, HISTORY_KEEP)
.then(() => undefined);
}
async function loadChecks(
userId: string,
hostId: number,
): Promise<HealthCheck[]> {
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
userId,
hostId,
);
if (!row?.checks) return [];
try {
const parsed = JSON.parse(row.checks);
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
} catch {
return [];
}
}
export function registerHealthRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/health/:id",
validateHostId,
managerHandler(
runOnHost,
"read",
"health_get",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
AlertEngine.getInstance()
.evaluateHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
)
.catch(() => {});
}
}
const history = (
await createCurrentHostHealthRepository().listHistory(
userId,
host.id,
200,
)
).map((row) => ({
checkId: row.checkId,
ts: row.ts,
ok: row.ok,
latencyMs: row.latencyMs,
detail: row.detail,
}));
return { checks, results, history };
},
),
);
app.post(
"/host-metrics/managers/health/:id/config",
validateHostId,
managerHandler(
runOnHost,
"read",
"health_config",
async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const { checks, intervalSeconds } = req.body as {
checks?: unknown;
intervalSeconds?: number;
};
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
throw new ManagerInputError("Invalid checks");
}
const interval =
typeof intervalSeconds === "number" &&
intervalSeconds >= 30 &&
intervalSeconds <= 86400
? Math.round(intervalSeconds)
: 300;
const now = new Date().toISOString();
await createCurrentHostHealthRepository().upsertChecks(
userId,
host.id,
JSON.stringify(checks),
interval,
now,
);
return { success: true };
},
),
);
app.post(
"/host-metrics/managers/health/:id/run",
validateHostId,
managerHandler(
runOnHost,
"read",
"health_run",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = await runChecks(client, checks);
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
AlertEngine.getInstance()
.evaluateHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
)
.catch(() => {});
}
}
return { results };
},
),
);
}
+65
View File
@@ -0,0 +1,65 @@
import type { Express } from "express";
import { detectPlatform } from "./platform.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
import { registerProcessRoutes } from "./processes.js";
import { registerSimpleReadRoutes } from "./simple-reads.js";
import { registerCronRoutes } from "./cron.js";
import { registerPackageRoutes } from "./packages.js";
import { registerSslRoutes } from "./ssl.js";
import { registerFirewallRoutes } from "./firewall.js";
import { registerUserRoutes } from "./users.js";
import { registerHealthRoutes } from "./health.js";
import { registerLogRoutes } from "./logs.js";
import { registerWireGuardRoutes } from "./wireguard.js";
import { registerTailscaleRoutes } from "./tailscale.js";
/**
* Registers every Host Metrics manager route under the `/host-metrics/managers`
* prefix on the stats app. All routes are on-demand (not polled).
*/
export function registerManagerRoutes(
app: Express,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, runOnHost } = deps;
/**
* @openapi
* /host-metrics/platform/{id}:
* get:
* summary: Detect available management tooling on a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Platform capabilities (systemd, package manager, certbot, docker).
*/
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(runOnHost, "read", "platform_detect", (client) =>
detectPlatform(client),
),
);
registerServiceRoutes(app, deps);
registerProcessRoutes(app, deps);
registerSimpleReadRoutes(app, deps);
registerCronRoutes(app, deps);
registerPackageRoutes(app, deps);
registerSslRoutes(app, deps);
registerFirewallRoutes(app, deps);
registerUserRoutes(app, deps);
registerHealthRoutes(app, deps);
registerLogRoutes(app, deps);
registerWireGuardRoutes(app, deps);
registerTailscaleRoutes(app, deps);
}
+92
View File
@@ -0,0 +1,92 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
export const LOG_PATH_ALLOWLIST = ["/var/log"];
const COMMON_LOGS = [
"/var/log/syslog",
"/var/log/messages",
"/var/log/auth.log",
"/var/log/secure",
"/var/log/kern.log",
"/var/log/dpkg.log",
"/var/log/nginx/access.log",
"/var/log/nginx/error.log",
];
const LIST_LOGS_CMD = `ls -1 ${LOG_PATH_ALLOWLIST.map(shellSingleQuote).join(" ")} 2>/dev/null`;
export function clampLines(n: unknown): number {
const v = typeof n === "string" ? Number(n) : n;
if (typeof v !== "number" || !Number.isFinite(v)) return 200;
return Math.min(2000, Math.max(1, Math.round(v)));
}
export function buildTailCommand(path: string, lines: number): string {
// Keep stderr intact so execElevated can detect a permission error and
// escalate; suppressing it (2>/dev/null) would hide the denial and return an
// empty log with no chance to retry under sudo.
return `tail -n ${lines} ${shellSingleQuote(path)}`;
}
export function buildJournalCommand(unit: string, lines: number): string {
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
}
export function registerLogRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(runOnHost, "read", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((name) => `/var/log/${name}`);
return { common: COMMON_LOGS, files: found };
}),
);
app.get(
"/host-metrics/managers/logs/:id",
validateHostId,
managerHandler(
runOnHost,
"read",
"logs_tail",
async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
}
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
},
),
);
}
+416
View File
@@ -0,0 +1,416 @@
import { describe, it, expect } from "vitest";
import { buildSudoCommand, shellSingleQuote } from "./exec-elevated.js";
import { parsePlatformProbe } from "./platform.js";
import {
isValidSystemdUnit,
isValidPid,
isValidPort,
isValidPackageName,
isValidUsername,
isValidDomain,
isValidDnsProvider,
isValidSignal,
isValidServiceAction,
isAllowedPath,
} from "./validation.js";
import { parseServiceList, buildServiceActionCommand } from "./services.js";
import { parseProcessList, buildKillCommand } from "./processes.js";
import { parseDfMounts, parseTopMemory } from "./simple-reads.js";
import {
parseCrontab,
serializeCrontab,
isValidCronSchedule,
buildApplyCrontabCommand,
} from "./cron.js";
import {
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "./packages.js";
import {
buildIssueCommand,
buildRenewCommand,
buildRevokeCommand,
isValidCertName,
parseCertbotCertificates,
} from "./ssl.js";
import { buildIptablesRuleCommand, buildNftRuleCommand } from "./firewall.js";
import { parsePasswd, parseSudoers } from "./users.js";
import { buildHealthCheckCommand, parseHealthResult } from "./health.js";
import { buildTailCommand, clampLines } from "./logs.js";
describe("exec-elevated", () => {
it("single-quotes and escapes for the shell", () => {
expect(shellSingleQuote("abc")).toBe("'abc'");
expect(shellSingleQuote("a'b")).toBe(`'a'"'"'b'`);
});
it("builds the sudo -S pipeline wrapping the command in sh -c with a success marker", () => {
expect(buildSudoCommand("systemctl restart nginx", "pw")).toBe(
`echo 'pw' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; systemctl restart nginx'`,
);
});
it("does not merge stderr into stdout (no 2>&1)", () => {
expect(buildSudoCommand("id", "pw")).not.toContain("2>&1");
});
it("escapes a password containing a quote", () => {
expect(buildSudoCommand("id", "p'w")).toContain(`echo 'p'"'"'w'`);
});
});
describe("platform probe parsing", () => {
it("parses capabilities and prefers dnf over yum", () => {
const out = [
"systemd=1",
"apt=0",
"dnf=1",
"yum=1",
"pacman=0",
"certbot=1",
"acmesh=0",
"docker=1",
"os=Fedora Linux 40",
].join("\n");
const p = parsePlatformProbe(out);
expect(p.hasSystemd).toBe(true);
expect(p.pkg).toBe("dnf");
expect(p.hasCertbot).toBe(true);
expect(p.hasAcmeSh).toBe(false);
expect(p.hasDocker).toBe(true);
expect(p.osPrettyName).toBe("Fedora Linux 40");
});
it("picks apt when present", () => {
expect(parsePlatformProbe("apt=1\ndnf=1").pkg).toBe("apt");
});
});
describe("validation (injection defense)", () => {
it("systemd units", () => {
expect(isValidSystemdUnit("nginx.service")).toBe(true);
expect(isValidSystemdUnit("ssh.socket")).toBe(true);
expect(isValidSystemdUnit("nginx.service; rm -rf /")).toBe(false);
expect(isValidSystemdUnit("nginx")).toBe(false);
});
it("pids", () => {
expect(isValidPid(123)).toBe(true);
expect(isValidPid("123")).toBe(true);
expect(isValidPid(0)).toBe(false);
expect(isValidPid("1; reboot")).toBe(false);
expect(isValidPid(-5)).toBe(false);
});
it("ports", () => {
expect(isValidPort(443)).toBe(true);
expect(isValidPort(0)).toBe(false);
expect(isValidPort(70000)).toBe(false);
});
it("package names", () => {
expect(isValidPackageName("nginx")).toBe(true);
expect(isValidPackageName("lib-foo.bar+1")).toBe(true);
expect(isValidPackageName("nginx && curl evil")).toBe(false);
expect(isValidPackageName("-rf")).toBe(false);
});
it("usernames and domains", () => {
expect(isValidUsername("deploy")).toBe(true);
expect(isValidUsername("root; rm")).toBe(false);
expect(isValidDomain("example.com")).toBe(true);
expect(isValidDomain("*.example.com")).toBe(true);
expect(isValidDomain("ex ample.com")).toBe(false);
expect(isValidDomain("a;b.com")).toBe(false);
});
it("dns providers, signals, service actions", () => {
expect(isValidDnsProvider("cloudflare")).toBe(true);
expect(isValidDnsProvider("cf; rm")).toBe(false);
expect(isValidSignal("KILL")).toBe(true);
expect(isValidSignal("BOOM")).toBe(false);
expect(isValidServiceAction("restart")).toBe(true);
expect(isValidServiceAction("destroy")).toBe(false);
});
it("path allowlist rejects traversal and out-of-allowlist", () => {
expect(isAllowedPath("/var/log/syslog", ["/var/log"])).toBe(true);
expect(isAllowedPath("/var/log/../../etc/passwd", ["/var/log"])).toBe(
false,
);
expect(isAllowedPath("/etc/passwd", ["/var/log"])).toBe(false);
expect(isAllowedPath("relative/path", ["/var/log"])).toBe(false);
});
});
describe("services", () => {
it("parses list-units --plain", () => {
const out =
"nginx.service loaded active running A high performance web server\n" +
"ssh.service loaded active running OpenBSD Secure Shell server\n" +
"cron.service loaded inactive dead Regular background program";
const rows = parseServiceList(out);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ unit: "nginx.service", active: "active" });
expect(rows[2].active).toBe("inactive");
});
it("builds action command", () => {
expect(buildServiceActionCommand("nginx.service", "restart")).toBe(
"systemctl restart nginx.service",
);
});
});
describe("processes", () => {
it("parses ps output", () => {
const out =
"1234 1 root 12.5 3.2 45000 S /usr/bin/node node server.js\n" +
"5678 1234 deploy 0.0 1.1 12000 Sl bash -bash";
const rows = parseProcessList(out);
expect(rows[0]).toMatchObject({
pid: 1234,
user: "root",
cpu: 12.5,
command: "/usr/bin/node",
});
expect(rows[1].pid).toBe(5678);
});
it("builds kill command", () => {
expect(buildKillCommand(42, "TERM")).toBe("kill -TERM 42");
});
});
describe("simple reads", () => {
it("parses df -Pk and skips tmpfs", () => {
const out =
"/dev/sda1 100000 40000 60000 40% /\n" +
"tmpfs 8000 0 8000 0% /dev/shm\n" +
"/dev/sdb1 200000 100000 100000 50% /data";
const mounts = parseDfMounts(out);
expect(mounts).toHaveLength(2);
expect(mounts[0].mount).toBe("/");
expect(mounts[1].usePct).toBe(50);
});
it("parses top by memory", () => {
const rows = parseTopMemory("1234 root 5.5 50000 node");
expect(rows[0]).toMatchObject({ pid: 1234, mem: 5.5, command: "node" });
});
});
describe("cron", () => {
it("parses enabled and toggled entries", () => {
const out = "0 2 * * * /backup.sh\n# 30 4 * * * /old.sh\nPATH=/usr/bin";
const entries = parseCrontab(out);
expect(entries).toHaveLength(2);
expect(entries[0]).toMatchObject({ enabled: true, schedule: "0 2 * * *" });
expect(entries[1].enabled).toBe(false);
});
it("validates schedules", () => {
expect(isValidCronSchedule("0 2 * * *")).toBe(true);
expect(isValidCronSchedule("@daily")).toBe(true);
expect(isValidCronSchedule("not a schedule")).toBe(false);
});
it("serializes (commenting disabled entries) and round-trips", () => {
const body = serializeCrontab([
{ raw: "", enabled: true, schedule: "0 2 * * *", command: "/a.sh" },
{ raw: "", enabled: false, schedule: "@daily", command: "/b.sh" },
]);
expect(body).toBe("0 2 * * * /a.sh\n# @daily /b.sh\n");
const reparsed = parseCrontab(body);
expect(reparsed[0].enabled).toBe(true);
expect(reparsed[1].enabled).toBe(false);
});
it("builds apply command piping into crontab -", () => {
expect(buildApplyCrontabCommand("x\n")).toBe(
`printf '%s' 'x\n' | crontab -`,
);
});
});
describe("packages", () => {
it("builds per-distro commands", () => {
expect(buildPackageActionCommand("apt", "install", "nginx")).toContain(
"apt-get -y install nginx",
);
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
"pacman -Syu --noconfirm",
);
expect(buildPackageActionCommand(null, "install", "x")).toBeNull();
});
it("lists per distro", () => {
expect(buildListUpgradableCommand("apt")).toContain(
"apt list --upgradable",
);
expect(buildListUpgradableCommand(null)).toBeNull();
});
it("parses apt upgradable", () => {
const out =
"nginx/focal-updates 1.18.0-2 amd64 [upgradable from: 1.18.0-1]";
const pkgs = parseUpgradable("apt", out);
expect(pkgs[0]).toMatchObject({
name: "nginx",
newVersion: "1.18.0-2",
currentVersion: "1.18.0-1",
});
});
it("parses pacman upgradable", () => {
const pkgs = parseUpgradable("pacman", "linux 6.1 -> 6.2");
expect(pkgs[0]).toMatchObject({ name: "linux", newVersion: "6.2" });
});
});
describe("ssl (dual client)", () => {
it("builds certbot issue for http + dns", () => {
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "http-standalone",
}),
).toContain(
"certbot certonly --non-interactive --agree-tos --standalone -d 'a.com'",
);
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "dns",
dnsProvider: "cloudflare",
}),
).toContain("--dns-cloudflare");
});
it("builds acme.sh issue", () => {
const cmd = buildIssueCommand({
client: "acme.sh",
domains: ["a.com", "b.com"],
challenge: "dns",
dnsProvider: "cf",
});
expect(cmd).toContain("--issue --dns dns_cf");
expect(cmd).toContain("-d 'a.com'");
expect(cmd).toContain("-d 'b.com'");
});
it("builds renew per client", () => {
expect(buildRenewCommand("certbot", true)).toBe("certbot renew --dry-run");
expect(buildRenewCommand("acme.sh", false)).toContain("--renew-all");
});
it("builds revoke per client", () => {
expect(buildRevokeCommand("certbot", "example.com")).toBe(
"certbot revoke --non-interactive --cert-name 'example.com' --delete-after-revoke",
);
const acme = buildRevokeCommand("acme.sh", "example.com");
expect(acme).toContain("--revoke -d 'example.com'");
expect(acme).toContain("--remove -d 'example.com'");
});
it("validates certificate names (rejects shell metachars)", () => {
expect(isValidCertName("example.com")).toBe(true);
expect(isValidCertName("example.com-0001")).toBe(true);
expect(isValidCertName("*.example.com")).toBe(true);
expect(isValidCertName("a.com; rm -rf /")).toBe(false);
expect(isValidCertName("")).toBe(false);
expect(isValidCertName(undefined)).toBe(false);
});
it("parses certbot certificates", () => {
const out = [
"Found the following certs:",
" Certificate Name: example.com",
" Domains: example.com www.example.com",
" Expiry Date: 2026-09-01 12:00:00+00:00 (VALID: 80 days)",
" Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem",
].join("\n");
const certs = parseCertbotCertificates(out);
expect(certs[0]).toMatchObject({
name: "example.com",
client: "certbot",
});
expect(certs[0].domains).toContain("www.example.com");
});
});
describe("firewall", () => {
it("builds iptables add/delete on INPUT only", () => {
expect(
buildIptablesRuleCommand("add", {
protocol: "tcp",
port: 443,
target: "ACCEPT",
}),
).toBe("iptables -A INPUT -p tcp --dport 443 -j ACCEPT");
expect(
buildIptablesRuleCommand("delete", {
protocol: "udp",
port: 53,
target: "DROP",
}),
).toBe("iptables -D INPUT -p udp --dport 53 -j DROP");
});
it("builds nft rules", () => {
expect(
buildNftRuleCommand("add", {
protocol: "tcp",
port: 22,
target: "ACCEPT",
}),
).toContain("add rule inet filter input tcp dport 22 accept");
});
});
describe("users", () => {
it("parses passwd for human users only", () => {
const out =
"root:x:0:0:root:/root:/bin/bash\n" +
"deploy:x:1000:1000:Deploy:/home/deploy:/bin/bash\n" +
"nobody:x:65534:65534:nobody:/:/usr/sbin/nologin";
const users = parsePasswd(out);
expect(users).toHaveLength(1);
expect(users[0].name).toBe("deploy");
});
it("parses sudoers membership", () => {
const out = "sudo:x:27:deploy,alice\nwheel:x:10:bob";
expect(parseSudoers(out).sort()).toEqual(["alice", "bob", "deploy"]);
});
});
describe("health checks", () => {
it("builds tcp and http commands", () => {
const tcp = buildHealthCheckCommand({
id: "1",
name: "ssh",
type: "tcp",
target: "localhost",
port: 22,
});
expect(tcp).toContain("/dev/tcp/");
const http = buildHealthCheckCommand({
id: "2",
name: "web",
type: "http",
target: "example.com",
path: "/health",
});
expect(http).toContain("curl -s -o /dev/null");
expect(http).toContain("http://example.com/health");
});
it("parses tcp and http results", () => {
const tcp = parseHealthResult(
{ id: "1", name: "ssh", type: "tcp", target: "h", port: 22 },
"ok 12",
);
expect(tcp).toMatchObject({ ok: true, latencyMs: 12 });
const http = parseHealthResult(
{ id: "2", name: "web", type: "http", target: "h" },
"200 0.045",
);
expect(http).toMatchObject({ ok: true, latencyMs: 45 });
const bad = parseHealthResult(
{ id: "3", name: "web", type: "http", target: "h" },
"500 0.01",
);
expect(bad.ok).toBe(false);
});
});
describe("logs", () => {
it("clamps line counts", () => {
expect(clampLines(50)).toBe(50);
expect(clampLines(99999)).toBe(2000);
expect(clampLines("abc")).toBe(200);
expect(clampLines(0)).toBe(1);
});
it("builds a quoted tail command without suppressing stderr", () => {
expect(buildTailCommand("/var/log/syslog", 100)).toBe(
"tail -n 100 '/var/log/syslog'",
);
});
});
+148
View File
@@ -0,0 +1,148 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "./validation.js";
import { detectPlatform, type PackageManager } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
switch (pkg) {
case "apt":
return "apt list --upgradable 2>/dev/null | tail -n +2";
case "dnf":
return "dnf -q check-update 2>/dev/null || true";
case "yum":
return "yum -q check-update 2>/dev/null || true";
case "pacman":
return "pacman -Qu 2>/dev/null || true";
default:
return null;
}
}
export function parseUpgradable(
pkg: PackageManager,
output: string,
): UpgradablePackage[] {
const out: UpgradablePackage[] = [];
const lines = output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (pkg === "apt") {
for (const line of lines) {
// name/suite newver arch [upgradable from: oldver]
const m = line.match(
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
);
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
}
} else if (pkg === "dnf" || pkg === "yum") {
for (const line of lines) {
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
}
} else if (pkg === "pacman") {
for (const line of lines) {
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
}
}
return out;
}
export type PackageAction = "upgrade-all" | "install" | "upgrade";
export function buildPackageActionCommand(
pkg: PackageManager,
action: PackageAction,
name?: string,
): string | null {
const target = name ? ` ${name}` : "";
switch (pkg) {
case "apt":
if (action === "upgrade-all")
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
case "dnf":
return action === "upgrade-all"
? "dnf -y upgrade"
: `dnf -y install${target}`;
case "yum":
return action === "upgrade-all"
? "yum -y update"
: `yum -y install${target}`;
case "pacman":
return action === "upgrade-all"
? "pacman -Syu --noconfirm"
: `pacman -S --noconfirm${target}`;
default:
return null;
}
}
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "read", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
const { stdout } = await execCommand(client, cmd, 60000);
return {
pkg: platform.pkg,
upgradable: parseUpgradable(platform.pkg, stdout),
};
}),
);
app.post(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"packages_action",
async (client, host, req) => {
const { action, pkg: name } = req.body as {
action?: PackageAction;
pkg?: string;
};
if (
action !== "upgrade-all" &&
action !== "install" &&
action !== "upgrade"
) {
throw new ManagerInputError("Invalid action");
}
if (action !== "upgrade-all" && !isValidPackageName(name)) {
throw new ManagerInputError("Invalid package name");
}
const platform = await detectPlatform(client);
const cmd = buildPackageActionCommand(platform.pkg, action, name);
if (!cmd) throw new ManagerInputError("No supported package manager");
// Package operations can be slow; allow up to 10 minutes.
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 600000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
export interface PlatformInfo {
hasSystemd: boolean;
pkg: PackageManager;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
/**
* Single probe that reports which tooling is available. Each line is
* "key=value" so the parser is trivial and order-independent.
*/
export const PLATFORM_PROBE_COMMAND = [
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
"echo docker=$(command -v docker >/dev/null 2>&1 && echo 1 || echo 0)",
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
].join("; ");
export function parsePlatformProbe(output: string): PlatformInfo {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
const idx = line.indexOf("=");
if (idx === -1) continue;
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
}
const on = (k: string) => map.get(k) === "1";
// Prefer dnf over yum when both exist (dnf is the modern front-end).
let pkg: PackageManager = null;
if (on("apt")) pkg = "apt";
else if (on("dnf")) pkg = "dnf";
else if (on("yum")) pkg = "yum";
else if (on("pacman")) pkg = "pacman";
const os = map.get("os");
return {
hasSystemd: on("systemd"),
pkg,
hasCertbot: on("certbot"),
hasAcmeSh: on("acmesh"),
hasDocker: on("docker"),
osPrettyName: os && os.length > 0 ? os : null,
};
}
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
return parsePlatformProbe(stdout);
}
+130
View File
@@ -0,0 +1,130 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
pid: number;
ppid: number;
user: string;
cpu: number;
mem: number;
rss: number;
stat: string;
command: string;
args: string;
}
const LIST_PROCESSES_CMD =
"ps -eo pid,ppid,user:20,pcpu,pmem,rss,stat,comm,args --sort=-pcpu --no-headers 2>/dev/null | head -n 300";
/** Parse `ps -eo pid,ppid,user,pcpu,pmem,rss,stat,comm,args` output. */
export function parseProcessList(output: string): ProcessRow[] {
const rows: ProcessRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/,
);
if (!m) continue;
rows.push({
pid: Number(m[1]),
ppid: Number(m[2]),
user: m[3],
cpu: Number(m[4]),
mem: Number(m[5]),
rss: Number(m[6]),
stat: m[7],
command: m[8],
args: m[9],
});
}
return rows;
}
export function buildKillCommand(pid: number, signal: Signal): string {
return `kill -${signal} ${pid}`;
}
export function registerProcessRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/processes/{id}:
* get:
* summary: List processes (rich, sortable, filterable client-side)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Process list. }
*/
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(runOnHost, "read", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
);
/**
* @openapi
* /host-metrics/managers/processes/{id}/signal:
* post:
* summary: Send a signal to a process (TERM/KILL/HUP/INT)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* pid: { type: integer }
* signal: { type: string }
* responses:
* 200: { description: Signal result. }
* 400: { description: Invalid pid or signal. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/processes/:id/signal",
validateHostId,
managerHandler(
runOnHost,
"execute",
"processes_signal",
async (client, host, req) => {
const { pid, signal } = req.body as {
pid?: number;
signal?: string;
};
if (!isValidPid(pid)) throw new ManagerInputError("Invalid pid");
if (!isValidSignal(signal))
throw new ManagerInputError("Invalid signal");
const cmd = buildKillCommand(Number(pid), signal);
// Try unprivileged first; elevate only if the process isn't owned.
const result = await execElevated(client, cmd, host.sudoPassword);
return {
success: result.code === 0,
output: result.stdout || result.stderr,
usedSudo: result.usedSudo,
};
},
),
);
}
@@ -0,0 +1,60 @@
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { statsLogger } from "../../utils/logger.js";
import { ElevationError } from "./exec-elevated.js";
import type { ManagerHost, RunOnHost } from "./types.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
super(message);
this.name = "AccessDeniedError";
}
}
export class ManagerInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ManagerInputError";
}
}
/**
* Wrap a manager handler: parses hostId, runs `fn` on a pooled connection at the
* given access level, and maps known errors to clean HTTP responses.
*/
export function managerHandler(
runOnHost: RunOnHost,
level: "read" | "execute",
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
return async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
const result = await runOnHost(hostId, userId, level, (client, host) =>
fn(client, host, req),
);
return res.json(result);
} catch (error) {
if (error instanceof ManagerInputError) {
return res.status(400).json({ error: error.message });
}
if (error instanceof AccessDeniedError) {
return res.status(403).json({ error: error.message });
}
if (error instanceof ElevationError) {
return res.status(403).json({ error: error.message, code: error.code });
}
statsLogger.error(`Manager operation failed: ${operation}`, {
operation,
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: error instanceof Error ? error.message : "Operation failed",
});
}
};
}
+135
View File
@@ -0,0 +1,135 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
unit: string;
load: string;
active: string;
sub: string;
description: string;
}
const LIST_SERVICES_CMD =
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null";
/** Parse `systemctl list-units --plain` output into structured rows. */
export function parseServiceList(output: string): SystemdService[] {
const services: SystemdService[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
// Columns: UNIT LOAD ACTIVE SUB DESCRIPTION (description may contain spaces)
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const [unit, load, active, sub, ...rest] = parts;
if (!unit.endsWith(".service")) continue;
services.push({
unit,
load,
active,
sub,
description: rest.join(" "),
});
}
return services;
}
export function buildServiceActionCommand(
unit: string,
action: ServiceAction,
): string {
return `systemctl ${action} ${unit}`;
}
export function registerServiceRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/services/{id}:
* get:
* summary: List systemd services
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: List of services. }
*/
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(runOnHost, "read", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
);
/**
* @openapi
* /host-metrics/managers/services/{id}/action:
* post:
* summary: Start/stop/restart/enable/disable a systemd service
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* unit: { type: string }
* action: { type: string }
* responses:
* 200: { description: Action result. }
* 400: { description: Invalid unit or action. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/services/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"services_action",
async (client, host, req) => {
const { unit, action } = req.body as {
unit?: string;
action?: string;
};
if (!isValidSystemdUnit(unit)) {
throw new ManagerInputError("Invalid unit name");
}
if (!isValidServiceAction(action)) {
throw new ManagerInputError("Invalid action");
}
const result = await execElevated(
client,
buildServiceActionCommand(unit, action),
host.sudoPassword,
{ forceSudo: true, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: result.stdout || result.stderr,
};
},
),
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
// ─── Top by memory ──────────────────────────────────────────────────────────
export interface MemProcessRow {
pid: number;
user: string;
mem: number;
rss: number;
command: string;
}
const TOP_MEM_CMD =
"ps -eo pid,user:20,pmem,rss,comm --sort=-pmem --no-headers 2>/dev/null | head -n 20";
export function parseTopMemory(output: string): MemProcessRow[] {
const rows: MemProcessRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(/^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
if (!m) continue;
rows.push({
pid: Number(m[1]),
user: m[2],
mem: Number(m[3]),
rss: Number(m[4]),
command: m[5],
});
}
return rows;
}
// ─── Systemd timers ─────────────────────────────────────────────────────────
export interface TimerRow {
next: string;
left: string;
last: string;
unit: string;
activates: string;
}
const TIMERS_CMD =
"systemctl list-timers --all --no-legend --no-pager 2>/dev/null";
export function parseTimers(output: string): TimerRow[] {
const rows: TimerRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("NEXT")) continue;
// NEXT(3) LEFT(2) LAST(3) PASSED(2) UNIT ACTIVATES -> columns vary; grab
// the trailing UNIT + ACTIVATES which always end the line.
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const activates = parts[parts.length - 1];
const unit = parts[parts.length - 2];
if (!unit.endsWith(".timer")) continue;
rows.push({
next: parts.slice(0, 3).join(" "),
left: "",
last: "",
unit,
activates,
});
}
return rows;
}
// ─── Disk breakdown (per-mount) ─────────────────────────────────────────────
export interface MountUsage {
filesystem: string;
sizeKb: number;
usedKb: number;
availKb: number;
usePct: number;
mount: string;
}
const DF_CMD = "df -Pk 2>/dev/null | tail -n +2";
export function parseDfMounts(output: string): MountUsage[] {
const mounts: MountUsage[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/);
if (!m) continue;
const fs = m[1];
// Skip pseudo/virtual filesystems that clutter the view.
if (/^(tmpfs|devtmpfs|overlay|udev|none|shm)$/.test(fs)) continue;
mounts.push({
filesystem: fs,
sizeKb: Number(m[2]),
usedKb: Number(m[3]),
availKb: Number(m[4]),
usePct: Number(m[5]),
mount: m[6],
});
}
return mounts;
}
export function registerSimpleReadRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(runOnHost, "read", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
);
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(runOnHost, "read", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
);
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(runOnHost, "read", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
);
}
+317
View File
@@ -0,0 +1,317 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated, shellSingleQuote } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "./validation.js";
import { detectPlatform } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
export interface CertInfo {
client: AcmeClient | "other";
name: string;
domains: string[];
expiry: string | null;
path?: string;
}
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
/** Parse `certbot certificates` output. */
export function parseCertbotCertificates(output: string): CertInfo[] {
const certs: CertInfo[] = [];
let current: CertInfo | null = null;
for (const raw of output.split("\n")) {
const line = raw.trim();
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
if (nameM) {
if (current) certs.push(current);
current = {
client: "certbot",
name: nameM[1],
domains: [],
expiry: null,
};
continue;
}
if (!current) continue;
const domM = line.match(/^Domains:\s+(.+)$/);
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
if (expM) current.expiry = expM[1];
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
if (pathM) current.path = pathM[1];
}
if (current) certs.push(current);
return certs;
}
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
export function parseAcmeShList(output: string): CertInfo[] {
const certs: CertInfo[] = [];
const lines = output.split("\n").filter((l) => l.trim());
if (lines.length < 2) return certs;
for (const line of lines.slice(1)) {
const cols = line
.split(/\s{2,}|\t/)
.map((c) => c.trim())
.filter(Boolean);
if (cols.length < 1) continue;
certs.push({
client: "acme.sh",
name: cols[0],
domains: [cols[0]],
expiry: cols[cols.length - 1] || null,
});
}
return certs;
}
/**
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
*/
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
export function isValidCertName(name: unknown): name is string {
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
}
export interface IssueRequest {
client: AcmeClient;
domains: string[];
challenge: ChallengeType;
webroot?: string;
dnsProvider?: string;
}
/**
* Build the certificate issuance command for the chosen client/challenge.
* DNS-01 provider credentials are expected to already be present in the
* environment / provider config file; we never put secrets in argv.
*/
export function buildIssueCommand(req: IssueRequest): string {
const domains = req.domains;
if (req.client === "certbot") {
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
if (req.challenge === "dns") {
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
}
if (req.challenge === "http-webroot") {
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
req.webroot as string,
)} ${dFlags}`;
}
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
}
// acme.sh
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
if (req.challenge === "dns") {
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
}
if (req.challenge === "http-webroot") {
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
req.webroot as string,
)} ${dFlags}`;
}
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
}
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
if (client === "certbot") {
return `certbot renew${dryRun ? " --dry-run" : ""}`;
}
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
}
/**
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
* it from management. `name` is the certbot cert name or the acme.sh domain.
*/
export function buildRevokeCommand(client: AcmeClient, name: string): string {
if (client === "certbot") {
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
name,
)} --delete-after-revoke`;
}
const d = shellSingleQuote(name);
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
}
export function registerSslRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(runOnHost, "read", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
const r = await execElevated(
client,
CERTBOT_LIST_CMD,
host.sudoPassword,
).catch(() => null);
if (r) certs.push(...parseCertbotCertificates(r.stdout));
}
if (platform.hasAcmeSh) {
const { stdout } = await execCommand(
client,
ACMESH_LIST_CMD,
15000,
).catch(() => ({ stdout: "" }) as { stdout: string });
certs.push(...parseAcmeShList(stdout));
}
return {
clients: {
certbot: platform.hasCertbot,
acmeSh: platform.hasAcmeSh,
},
certs,
};
}),
);
app.post(
"/host-metrics/managers/ssl/:id/issue",
validateHostId,
managerHandler(
runOnHost,
"execute",
"ssl_issue",
async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
app.post(
"/host-metrics/managers/ssl/:id/renew",
validateHostId,
managerHandler(
runOnHost,
"execute",
"ssl_renew",
async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
/**
* @openapi
* /host-metrics/managers/ssl/{id}/revoke:
* post:
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* client: { type: string, enum: [certbot, acme.sh] }
* name: { type: string, description: certbot cert name or acme.sh domain }
* responses:
* 200: { description: Revoke result. }
* 400: { description: Invalid client or certificate name. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/ssl/:id/revoke",
validateHostId,
managerHandler(
runOnHost,
"execute",
"ssl_revoke",
async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
}
+177
View File
@@ -0,0 +1,177 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { isValidTailscaleAction } from "./validation.js";
export interface TailscalePeer {
hostname: string;
tailscaleIPs: string[];
online: boolean;
isExitNode: boolean;
}
export interface TailscaleData {
installed: boolean;
running: boolean;
tailscaleIPs: string[];
hostname: string | null;
peers: TailscalePeer[];
exitNodeInUse: boolean;
}
const PROBE_CMD = [
"command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0",
"tailscale status --json 2>/dev/null",
].join("; ");
export function parseTailscaleData(output: string): TailscaleData {
const notInstalled: TailscaleData = {
installed: false,
running: false,
tailscaleIPs: [],
hostname: null,
peers: [],
exitNodeInUse: false,
};
if (output.includes("ts_installed=0")) return notInstalled;
// Strip the installation probe line to get the raw JSON
const lines = output.split("\n");
const jsonLines = lines.filter(
(l) => !l.startsWith("ts_installed=") && l.trim() !== "",
);
const jsonStr = jsonLines.join("\n");
try {
const parsed = JSON.parse(jsonStr) as {
BackendState?: string;
Self?: { HostName?: string; TailscaleIPs?: string[] };
Peer?: Record<
string,
{
HostName?: string;
TailscaleIPs?: string[];
Online?: boolean;
ExitNode?: boolean;
}
>;
CurrentExitNode?: string;
};
const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map(
(p) => ({
hostname: p.HostName ?? "",
tailscaleIPs: p.TailscaleIPs ?? [],
online: p.Online ?? false,
isExitNode: p.ExitNode ?? false,
}),
);
return {
installed: true,
running: parsed.BackendState === "Running",
tailscaleIPs: parsed.Self?.TailscaleIPs ?? [],
hostname: parsed.Self?.HostName ?? null,
peers,
exitNodeInUse:
typeof parsed.CurrentExitNode === "string" &&
parsed.CurrentExitNode !== "",
};
} catch {
return {
installed: true,
running: false,
tailscaleIPs: [],
hostname: null,
peers: [],
exitNodeInUse: false,
};
}
}
export function registerTailscaleRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/tailscale/{id}:
* get:
* summary: Get Tailscale status and IPs
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Tailscale installation status, running state, IPs, and peer count.
*/
app.get(
"/host-metrics/managers/tailscale/:id",
validateHostId,
managerHandler(runOnHost, "read", "tailscale_read", async (client) => {
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
return parseTailscaleData(stdout);
}),
);
/**
* @openapi
* /host-metrics/managers/tailscale/{id}/action:
* post:
* summary: Connect or disconnect Tailscale
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* action:
* type: string
* enum: [up, down]
* responses:
* 200:
* description: Action result.
*/
app.post(
"/host-metrics/managers/tailscale/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"tailscale_action",
async (client, host, req) => {
const { action } = req.body as { action: unknown };
if (!isValidTailscaleAction(action)) {
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
}
const result = await execElevated(
client,
`tailscale ${action}`,
host.sudoPassword,
{ forceSudo: false, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: (result.stdout + result.stderr).trim(),
};
},
),
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
id: number;
userId: string;
sudoPassword?: string;
enableDocker?: boolean;
}
/**
* Runs `fn` against a pooled SSH connection for the host, after verifying the
* user has at least `level` access. Resolves the host (with sudoPassword) so
* managers can elevate. Rejects with an access error if not permitted.
*/
export type RunOnHost = <T>(
hostId: number,
userId: string,
level: "read" | "execute",
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
export interface ManagerRoutesDeps {
validateHostId: RequestHandler;
runOnHost: RunOnHost;
}
+151
View File
@@ -0,0 +1,151 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidUsername, isValidGroupName } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
name: string;
uid: number;
gid: number;
home: string;
shell: string;
}
export interface SystemGroup {
name: string;
gid: number;
members: string[];
}
// Human users only (uid >= 1000, excluding nobody at 65534).
const READ_USERS_CMD = "getent passwd 2>/dev/null";
const READ_GROUPS_CMD = "getent group 2>/dev/null";
const READ_SUDOERS_CMD = "getent group sudo wheel 2>/dev/null";
export function parsePasswd(output: string): SystemUser[] {
const users: SystemUser[] = [];
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 7) continue;
const uid = Number(parts[2]);
if (!Number.isFinite(uid)) continue;
if (uid < 1000 || uid === 65534) continue;
users.push({
name: parts[0],
uid,
gid: Number(parts[3]),
home: parts[5],
shell: parts[6],
});
}
return users;
}
export function parseGroups(output: string): SystemGroup[] {
const groups: SystemGroup[] = [];
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 4) continue;
groups.push({
name: parts[0],
gid: Number(parts[2]),
members: parts[3].split(",").filter(Boolean),
});
}
return groups;
}
export function parseSudoers(output: string): string[] {
const members = new Set<string>();
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 4) continue;
parts[3]
.split(",")
.filter(Boolean)
.forEach((m) => members.add(m));
}
return [...members];
}
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
export function registerUserRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(runOnHost, "read", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
execCommand(client, READ_SUDOERS_CMD, 15000),
]);
return {
users: parsePasswd(passwd.stdout),
groups: parseGroups(groups.stdout),
sudoers: parseSudoers(sudoers.stdout),
};
}),
);
app.post(
"/host-metrics/managers/users/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"users_action",
async (client, host, req) => {
const { action, username, group } = req.body as {
action?: UserAction;
username?: string;
group?: string;
};
if (!isValidUsername(username))
throw new ManagerInputError("Invalid username");
// Never modify/delete the user we're connected as, or root.
const who = (await execCommand(client, "id -un", 8000)).stdout.trim();
if (username === who || username === "root") {
throw new ManagerInputError(
"Refusing to modify the connected user or root",
);
}
let cmd: string;
switch (action) {
case "create":
cmd = `useradd -m ${username}`;
break;
case "delete":
cmd = `userdel -r ${username}`;
break;
case "addToGroup":
case "removeFromGroup":
if (!isValidGroupName(group))
throw new ManagerInputError("Invalid group");
cmd =
action === "addToGroup"
? `usermod -aG ${group} ${username}`
: `gpasswd -d ${username} ${group}`;
break;
default:
throw new ManagerInputError("Invalid action");
}
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
};
},
),
);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
| "start"
| "stop"
| "restart"
| "reload"
| "enable"
| "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
+223
View File
@@ -0,0 +1,223 @@
import type { Express } from "express";
import { execElevated } from "./exec-elevated.js";
import { managerHandler } from "./route-helpers.js";
import { ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "./validation.js";
export interface WireGuardPeer {
publicKey: string;
endpoint: string | null;
allowedIPs: string[];
latestHandshake: number | null;
rxBytes: number;
txBytes: number;
}
export interface WireGuardInterface {
name: string;
publicKey: string | null;
listenPort: number | null;
up: boolean;
peers: WireGuardPeer[];
}
export interface WireGuardData {
installed: boolean;
interfaces: WireGuardInterface[];
}
const PROBE_CMD = [
"command -v wg >/dev/null 2>&1 && echo wg_installed=1 || echo wg_installed=0",
"ip link show type wireguard 2>/dev/null",
"echo __DUMP__",
"wg show all dump 2>/dev/null",
].join("; ");
export function parseWireGuardData(output: string): WireGuardData {
if (output.includes("wg_installed=0")) {
return { installed: false, interfaces: [] };
}
const dumpIdx = output.indexOf("__DUMP__");
const ipLinkPart = dumpIdx >= 0 ? output.slice(0, dumpIdx) : "";
const dumpPart =
dumpIdx >= 0 ? output.slice(dumpIdx + "__DUMP__".length) : "";
// Collect up interface names from `ip link show type wireguard`
const upSet = new Set<string>();
for (const line of ipLinkPart.split("\n")) {
const m = line.match(/^\d+:\s+([A-Za-z0-9_-]+):/);
if (m) upSet.add(m[1]);
}
const ifaceMap = new Map<string, WireGuardInterface>();
for (const raw of dumpPart.split("\n")) {
const line = raw.trim();
if (!line) continue;
const cols = line.split("\t");
if (cols.length === 5) {
// Interface row: iface private_key public_key listen_port fwmark
const [name, , publicKey, listenPortStr] = cols;
if (!name) continue;
ifaceMap.set(name, {
name,
publicKey: publicKey && publicKey !== "(none)" ? publicKey : null,
listenPort:
listenPortStr && listenPortStr !== "(none)"
? Number(listenPortStr)
: null,
up: upSet.has(name),
peers: [],
});
} else if (cols.length === 9) {
// Peer row: iface public_key preshared_key endpoint allowed_ips latest_handshake rx_bytes tx_bytes persistent_keepalive
const [
name,
publicKey,
,
endpoint,
allowedIPsStr,
handshakeStr,
rxStr,
txStr,
] = cols;
if (!name) continue;
if (!ifaceMap.has(name)) {
ifaceMap.set(name, {
name,
publicKey: null,
listenPort: null,
up: upSet.has(name),
peers: [],
});
}
const handshake = Number(handshakeStr);
ifaceMap.get(name)!.peers.push({
publicKey: publicKey ?? "",
endpoint: endpoint && endpoint !== "(none)" ? endpoint : null,
allowedIPs:
allowedIPsStr && allowedIPsStr !== "(none)"
? allowedIPsStr.split(",").map((s) => s.trim())
: [],
latestHandshake: handshake > 0 ? handshake : null,
rxBytes: Number(rxStr) || 0,
txBytes: Number(txStr) || 0,
});
}
}
return { installed: true, interfaces: Array.from(ifaceMap.values()) };
}
export function registerWireGuardRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/wireguard/{id}:
* get:
* summary: Get WireGuard interfaces and peers
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: WireGuard installation status, interfaces, and peer details.
*/
app.get(
"/host-metrics/managers/wireguard/:id",
validateHostId,
managerHandler(
runOnHost,
"read",
"wireguard_read",
async (client, host) => {
const result = await execElevated(
client,
PROBE_CMD,
host.sudoPassword,
{
forceSudo: false,
timeoutMs: 15000,
},
);
return parseWireGuardData(result.stdout);
},
),
);
/**
* @openapi
* /host-metrics/managers/wireguard/{id}/action:
* post:
* summary: Bring a WireGuard interface up or down
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* interface:
* type: string
* action:
* type: string
* enum: [up, down]
* responses:
* 200:
* description: Action result.
*/
app.post(
"/host-metrics/managers/wireguard/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"wireguard_action",
async (client, host, req) => {
const { interface: iface, action } = req.body as {
interface: unknown;
action: unknown;
};
if (!isValidWireGuardInterface(iface)) {
throw new ManagerInputError("Invalid WireGuard interface name");
}
if (!isValidWireGuardAction(action)) {
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
}
const result = await execElevated(
client,
`wg-quick ${action} ${iface}`,
host.sudoPassword,
{ forceSudo: true, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: (result.stdout + result.stderr).trim(),
};
},
),
);
}
+937
View File
@@ -0,0 +1,937 @@
import { spawn, ChildProcess } from "child_process";
import { randomUUID } from "crypto";
import { WebSocket } from "ws";
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
import { sshLogger } from "../utils/logger.js";
import { createCurrentOpksshTokenRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { promises as fs } from "fs";
import path from "path";
import axios from "axios";
import { load as loadYaml } from "js-yaml";
const AUTH_TIMEOUT = 60 * 1000;
export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback";
interface OPKSSHAuthSession {
requestId: string;
userId: string;
hostId: number;
hostname: string;
process: ChildProcess;
localPort: number;
callbackPort: number;
remoteRedirectUri: string;
providers: Array<{ alias: string; issuer: string }>;
status:
| "starting"
| "waiting_for_auth"
| "authenticating"
| "completed"
| "error";
ws: WebSocket;
stdoutBuffer: string;
privateKeyBuffer: string;
sshCertBuffer: string;
identity: {
email?: string;
sub?: string;
issuer?: string;
audience?: string;
};
createdAt: Date;
approvalTimeout: NodeJS.Timeout;
cleanup: () => Promise<void>;
}
const activeAuthSessions = new Map<string, OPKSSHAuthSession>();
const oauthStateToRequestId = new Map<string, string>();
const cleanupInProgress = new Set<string>();
function getOPKConfigPath(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, ".opk", "config.yml");
}
async function ensureOPKConfigDir(): Promise<void> {
const configPath = getOPKConfigPath();
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
}
async function createTemplateConfig(): Promise<void> {
const configPath = getOPKConfigPath();
const template = `
# OPKSSH Configuration
# OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md
# Termix Documentation: https://docs.termix.site/features/authentication/opkssh
`;
try {
await ensureOPKConfigDir();
await fs.writeFile(configPath, template, "utf8");
sshLogger.info(`Created template OPKSSH config at ${configPath}`);
} catch (error) {
sshLogger.warn("Failed to create template OPKSSH config", error);
}
}
interface ProviderRedirectInfo {
alias: string;
issuer: string;
redirectUris: string[];
}
async function checkOPKConfigExists(): Promise<{
exists: boolean;
error?: string;
configPath?: string;
providers?: ProviderRedirectInfo[];
}> {
const configPath = getOPKConfigPath();
const isDocker =
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
const dockerHint = isDocker
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
: "";
try {
const content = await fs.readFile(configPath, "utf8");
if (!content.includes("providers:")) {
return {
exists: false,
configPath,
error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}\n\n.`,
};
}
const lines = content.split("\n");
const hasUncommentedProvider = lines.some((line) => {
const trimmed = line.trim();
return (
trimmed.startsWith("- alias:") ||
(trimmed.startsWith("issuer:") && !line.trimStart().startsWith("#"))
);
});
if (!hasUncommentedProvider) {
return {
exists: false,
configPath,
error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
};
}
let providers: ProviderRedirectInfo[] = [];
try {
const parsed = loadYaml(content) as {
providers?: Array<{
alias?: string;
issuer?: string;
redirect_uris?: string[];
}>;
};
if (parsed?.providers && Array.isArray(parsed.providers)) {
providers = parsed.providers
.filter(
(
p,
): p is {
alias: string;
issuer: string;
redirect_uris?: string[];
} => typeof p.alias === "string" && typeof p.issuer === "string",
)
.map((p) => ({
alias: p.alias,
issuer: p.issuer.replace(/^https?:\/\//, ""),
redirectUris: Array.isArray(p.redirect_uris)
? p.redirect_uris.filter(
(u): u is string => typeof u === "string",
)
: [],
}));
}
} catch (e) {
sshLogger.warn("Failed to parse OPKSSH config for providers", {
operation: "opkssh_config_parse_providers_error",
error: e,
});
}
return { exists: true, configPath, providers };
} catch {
await createTemplateConfig();
return {
exists: false,
configPath,
error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
};
}
}
// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener
// that OPKSSH binds on the host running the binary. The openpubkey library enforces these
// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime.
// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri
// (derived from request origin); users do NOT put that URL in this config field.
function validateRedirectUrisAreLocalhost(
providers: ProviderRedirectInfo[],
): { ok: true } | { ok: false; message: string } {
const isLocalHost = (host: string): boolean => {
const bare = host.replace(/^\[|\]$/g, "");
return (
bare === "localhost" ||
bare === "127.0.0.1" ||
bare === "::1" ||
bare === "0:0:0:0:0:0:0:1" ||
bare.startsWith("localhost:") ||
bare.startsWith("127.0.0.1:")
);
};
const issues: string[] = [];
for (const p of providers) {
const uris = p.redirectUris || [];
if (uris.length === 0) continue;
const nonLocal = uris.filter((u) => {
try {
return !isLocalHost(new URL(u).hostname);
} catch {
return true;
}
});
if (nonLocal.length > 0) {
issues.push(
`Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
);
}
}
if (issues.length > 0) {
return {
ok: false,
message:
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
`${issues.join("\n")}\n\n` +
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
`Docs: https://docs.termix.site/features/authentication/opkssh`,
};
}
return { ok: true };
}
export async function startOPKSSHAuth(
userId: string,
hostId: number,
hostname: string,
ws: WebSocket,
requestOrigin: string,
): Promise<string> {
try {
await ensureOPKConfigDir();
const configDir = path.dirname(getOPKConfigPath());
await fs.access(configDir, fs.constants.R_OK | fs.constants.W_OK);
} catch (error) {
sshLogger.error("OPKSSH directory not accessible", error);
const isDocker =
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
const dockerHint = isDocker
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
: "";
ws.send(
JSON.stringify({
type: "opkssh_error",
error: `OPKSSH directory initialization failed: ${error.message}${dockerHint}`,
}),
);
return "";
}
const configCheck = await checkOPKConfigExists();
if (!configCheck.exists) {
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId: "",
error: configCheck.error,
instructions: configCheck.error,
}),
);
return "";
}
const redirectValidation = validateRedirectUrisAreLocalhost(
configCheck.providers || [],
);
if (redirectValidation.ok === false) {
sshLogger.warn("OPKSSH config redirect_uris validation failed", {
operation: "opkssh_config_redirect_uris_not_localhost",
configPath: configCheck.configPath,
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId: "",
error: redirectValidation.message,
instructions: redirectValidation.message,
}),
);
return "";
}
const requestId = randomUUID();
const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`;
sshLogger.info("Starting OPKSSH auth session", {
operation: "opkssh_start_auth_remote_redirect_uri",
requestId,
userId,
hostId,
requestOrigin,
remoteRedirectUri,
providerAliases: (configCheck.providers || []).map((p) => p.alias),
});
const session: Partial<OPKSSHAuthSession> = {
requestId,
userId,
hostId,
hostname,
localPort: 0,
callbackPort: 0,
remoteRedirectUri,
providers: configCheck.providers || [],
status: "starting",
ws,
stdoutBuffer: "",
privateKeyBuffer: "",
sshCertBuffer: "",
identity: {},
createdAt: new Date(),
};
try {
const binaryPath = OPKSSHBinaryManager.getBinaryPath();
const configPath = getOPKConfigPath();
const args = [
"login",
"--print-key",
"--disable-browser-open",
`--config-path=${configPath}`,
`--remote-redirect-uri=${remoteRedirectUri}`,
];
const opksshProcess = spawn(binaryPath, args, {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
},
});
session.process = opksshProcess;
const cleanup = async () => {
await cleanupAuthSession(requestId);
};
session.cleanup = cleanup;
const timeout = setTimeout(async () => {
sshLogger.warn(`OPKSSH auth timeout for session ${requestId}`);
ws.send(
JSON.stringify({
type: "opkssh_timeout",
requestId,
}),
);
await cleanup();
}, AUTH_TIMEOUT);
session.approvalTimeout = timeout;
ws.on("close", () => {
cleanup();
});
activeAuthSessions.set(requestId, session as OPKSSHAuthSession);
opksshProcess.stdout?.on("data", (data) => {
const output = data.toString();
handleOPKSSHOutput(requestId, output);
});
opksshProcess.stderr?.on("data", async (data) => {
const stderr = data.toString();
if (
stderr.includes("Opening browser to") ||
stderr.includes("Open your browser to:")
) {
handleOPKSSHOutput(requestId, stderr);
}
if (stderr.includes("listening on")) {
handleOPKSSHOutput(requestId, stderr);
}
const lowerStderr = stderr.toLowerCase();
// OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime
// with the distinctive message "redirectURI must be localhost". Surface that
// directly with actionable guidance.
if (lowerStderr.includes("redirecturi must be localhost")) {
sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", {
operation: "opkssh_stderr_redirect_uris_not_localhost",
requestId,
remoteRedirectUri,
stderrSnippet: stderr.slice(0, 500),
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` +
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
return;
}
// Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.)
const genericRedirectIndicators = [
"redirect_uri",
"redirect uri",
"invalid redirect",
"no matching redirect",
"allowed redirect",
"mismatching redirection",
];
const hasGenericRedirectError = genericRedirectIndicators.some((s) =>
lowerStderr.includes(s),
);
if (hasGenericRedirectError) {
sshLogger.warn("OPKSSH stderr reported redirect_uri error", {
operation: "opkssh_stderr_redirect_uri_error",
requestId,
remoteRedirectUri,
stderrSnippet: stderr.slice(0, 500),
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
`Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
`(e.g. in Google Cloud Console → OAuth client). ` +
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
return;
}
if (
stderr.includes("provider not found") ||
stderr.includes("config error") ||
stderr.includes("invalid config") ||
stderr.includes("config not found")
) {
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
"OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.",
instructions:
"See documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md",
}),
);
cleanup();
}
if (
stderr.includes("level=error") ||
stderr.includes("Error:") ||
stderr.includes("failed")
) {
const isXdgOpenError = stderr.includes('exec: "xdg-open"');
if (!isXdgOpenError) {
if (
stderr.includes("bind: address already in use") ||
stderr.includes("error logging in") ||
stderr.includes("failed to start")
) {
await cleanup();
}
}
}
});
opksshProcess.on("error", (error) => {
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `OPKSSH process error: ${error.message}`,
}),
);
cleanup();
});
opksshProcess.on("exit", (code) => {
if (code !== 0 && session.status !== "completed") {
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `OPKSSH process exited with code ${code}`,
}),
);
}
cleanup();
});
return requestId;
} catch (error) {
sshLogger.error(`Failed to start OPKSSH auth session`, error);
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `Failed to start OPKSSH authentication: ${error instanceof Error ? error.message : "Unknown error"}`,
}),
);
return "";
}
}
function handleOPKSSHOutput(requestId: string, output: string): void {
const session = activeAuthSessions.get(requestId);
if (!session) {
return;
}
session.stdoutBuffer += output;
const chooserUrlMatch = session.stdoutBuffer.match(
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/,
);
if (chooserUrlMatch && session.status === "starting") {
const actualPort = parseInt(chooserUrlMatch[1], 10);
const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`;
session.localPort = actualPort;
const baseUrl = session.remoteRedirectUri
.replace(/\/host\/opkssh-callback$/, "")
// In direct dev mode the WS server (30002) is separate from the HTTP API (30001)
.replace(/:30002\b/, ":30001");
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
session.status = "waiting_for_auth";
session.ws.send(
JSON.stringify({
type: "opkssh_status",
requestId,
stage: "chooser",
url: proxiedChooserUrl,
providers: session.providers,
localUrl: localChooserUrl,
message: "Please authenticate in your browser",
}),
);
}
const callbackPortMatch = session.stdoutBuffer.match(
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//,
);
if (callbackPortMatch && !session.callbackPort) {
session.callbackPort = parseInt(callbackPortMatch[1], 10);
}
if (output.includes("BEGIN OPENSSH PRIVATE KEY")) {
session.status = "authenticating";
session.ws.send(
JSON.stringify({
type: "opkssh_status",
requestId,
stage: "authenticating",
message: "Processing authentication...",
}),
);
}
const privateKeyMatch = session.stdoutBuffer.match(
/(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/,
);
if (privateKeyMatch) {
session.privateKeyBuffer = privateKeyMatch[1].trim();
}
const certMatch = session.stdoutBuffer.match(
/(ecdsa-sha2-nistp256-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-rsa-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-ed25519-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/,
);
if (certMatch) {
session.sshCertBuffer = certMatch[1].trim();
}
const identityMatch = session.stdoutBuffer.match(
/Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/,
);
if (identityMatch) {
session.identity = {
email: identityMatch[1],
sub: identityMatch[2],
issuer: identityMatch[3],
audience: identityMatch[4],
};
}
if (session.privateKeyBuffer && session.sshCertBuffer) {
if (!session.privateKeyBuffer.includes("BEGIN OPENSSH PRIVATE KEY")) {
sshLogger.error(`Invalid private key extracted [${requestId}]`, {
bufferPrefix: session.privateKeyBuffer.substring(0, 50),
});
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to extract valid private key from OPKSSH output",
}),
);
return;
}
if (!session.sshCertBuffer.match(/-cert-v01@openssh\.com/)) {
sshLogger.error(`Invalid SSH certificate extracted [${requestId}]`, {
bufferPrefix: session.sshCertBuffer.substring(0, 50),
});
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to extract valid SSH certificate from OPKSSH output",
}),
);
return;
}
storeOPKSSHToken(session);
}
}
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
const userDataKey = DataCrypto.getUserDataKey(session.userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
const encryptedCert = FieldCrypto.encryptField(
session.sshCertBuffer,
userDataKey,
tokenId,
"ssh_cert",
);
const encryptedKey = FieldCrypto.encryptField(
session.privateKeyBuffer,
userDataKey,
tokenId,
"private_key",
);
await createCurrentOpksshTokenRepository().upsert({
userId: session.userId,
hostId: session.hostId,
sshCert: encryptedCert,
privateKey: encryptedKey,
email: session.identity.email,
sub: session.identity.sub,
issuer: session.identity.issuer,
audience: session.identity.audience,
expiresAt: expiresAt.toISOString(),
});
session.status = "completed";
session.ws.send(
JSON.stringify({
type: "opkssh_completed",
requestId: session.requestId,
expiresAt: expiresAt.toISOString(),
}),
);
await session.cleanup();
} catch (error) {
sshLogger.error(
`Failed to store OPKSSH token for session ${session.requestId}`,
error,
);
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: session.requestId,
error: "Failed to store authentication token",
}),
);
await session.cleanup();
}
}
export async function getOPKSSHToken(
userId: string,
hostId: number,
): Promise<{ sshCert: string; privateKey: string } | null> {
try {
const repository = createCurrentOpksshTokenRepository();
const tokenData = await repository.findByUserAndHost(userId, hostId);
if (!tokenData) {
return null;
}
const expiresAt = new Date(tokenData.expiresAt);
if (expiresAt < new Date()) {
await repository.deleteByUserAndHost(userId, hostId);
return null;
}
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const tokenId = `opkssh-${userId}-${hostId}`;
const decryptedCert = FieldCrypto.decryptField(
tokenData.sshCert,
userDataKey,
tokenId,
"ssh_cert",
);
const decryptedKey = FieldCrypto.decryptField(
tokenData.privateKey,
userDataKey,
tokenId,
"private_key",
);
await repository.updateLastUsed(userId, hostId);
return {
sshCert: decryptedCert,
privateKey: decryptedKey,
};
} catch (error) {
sshLogger.error(`Failed to retrieve OPKSSH token`, error);
return null;
}
}
export async function deleteOPKSSHToken(
userId: string,
hostId: number,
): Promise<void> {
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
userId,
hostId,
);
}
export async function invalidateOPKSSHToken(
userId: string,
hostId: number,
reason: string,
): Promise<void> {
try {
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
userId,
hostId,
);
} catch (error) {
sshLogger.error(`Failed to invalidate OPKSSH token`, {
userId,
hostId,
reason,
error,
});
}
}
export async function handleOAuthCallback(
requestId: string,
queryString: string,
): Promise<{ success: boolean; message?: string }> {
const session = activeAuthSessions.get(requestId);
if (!session) {
return { success: false, message: "Invalid authentication session" };
}
try {
const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`;
await axios.get(callbackUrl, {
timeout: 10000,
validateStatus: () => true,
});
return { success: true };
} catch {
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to complete authentication",
}),
);
await session.cleanup();
return { success: false, message: "Authentication failed" };
}
}
async function cleanupAuthSession(requestId: string): Promise<void> {
if (cleanupInProgress.has(requestId)) {
return;
}
cleanupInProgress.add(requestId);
try {
const session = activeAuthSessions.get(requestId);
if (!session) {
cleanupInProgress.delete(requestId);
return;
}
if (session.approvalTimeout) {
clearTimeout(session.approvalTimeout);
}
if (session.process) {
try {
session.process.kill("SIGTERM");
await new Promise<void>((resolve) => {
const killTimeout = setTimeout(() => {
if (session.process && !session.process.killed) {
session.process.kill("SIGKILL");
}
resolve();
}, 3000);
session.process.once("exit", () => {
clearTimeout(killTimeout);
resolve();
});
});
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (killError) {
sshLogger.warn(
`Failed to kill OPKSSH process for session ${requestId}`,
killError,
);
}
}
// Clean up any OAuth state mappings for this session
for (const [state, reqId] of oauthStateToRequestId.entries()) {
if (reqId === requestId) {
oauthStateToRequestId.delete(state);
}
}
activeAuthSessions.delete(requestId);
} finally {
cleanupInProgress.delete(requestId);
}
}
export function cancelAuthSession(requestId: string): void {
const session = activeAuthSessions.get(requestId);
if (session) {
session.cleanup();
}
}
export function getActiveAuthSession(
requestId: string,
): OPKSSHAuthSession | undefined {
return activeAuthSessions.get(requestId);
}
export function getActiveSessionsForUser(userId: string): OPKSSHAuthSession[] {
const sessions: OPKSSHAuthSession[] = [];
for (const session of activeAuthSessions.values()) {
if (session.userId === userId) {
sessions.push(session);
}
}
return sessions;
}
export function getActiveSessionsAll(): OPKSSHAuthSession[] {
return Array.from(activeAuthSessions.values());
}
export function registerOAuthState(state: string, requestId: string): void {
oauthStateToRequestId.set(state, requestId);
}
export function getRequestIdByOAuthState(state: string): string | undefined {
return oauthStateToRequestId.get(state);
}
export function clearOAuthState(state: string): void {
oauthStateToRequestId.delete(state);
}
export async function getUserIdFromRequest(req: {
cookies?: Record<string, string>;
headers: Record<string, string | undefined>;
}): Promise<string | null> {
try {
const { AuthManager } = await import("../utils/auth-manager.js");
const authManager = AuthManager.getInstance();
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return null;
}
const decoded = await authManager.verifyJWTToken(token);
if (!decoded?.userId || decoded.pendingTOTP) return null;
return decoded.userId;
} catch {
return null;
}
}
+368
View File
@@ -0,0 +1,368 @@
// SSH certificate authentication workarounds for ssh2.
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
// the certificate onto the parsed key, wraps ECDSA signing to convert
// DER → SSH wire format, and patches Protocol.authPK to use the base
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
//
// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase)
// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported)
import type {
AnyAuthMethod,
AuthHandlerMiddleware,
AuthenticationType,
Client,
ConnectConfig,
PublicKeyAuthMethod,
} from "ssh2";
interface OPKSSHToken {
privateKey: string;
sshCert: string;
}
type SignCallback = (
data: Buffer,
callback: (signature: Buffer) => void,
) => void;
interface ParsedPrivateKey {
type: string;
sign: (data: Buffer, algo?: string) => Buffer | Error;
getPublicSSH: () => Buffer;
[key: symbol]: unknown;
}
interface OPKSSHProtocol {
authPK: (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => unknown;
_kex: {
sessionID: Buffer;
};
_packetRW: {
write: {
alloc: (payloadLength: number) => Buffer;
allocStart: number;
finalize: (packet: Buffer) => Buffer;
};
};
_authsQueue: string[];
_debug?: (message: string) => void;
_cipher: {
encrypt: (packet: Buffer) => void;
};
}
type OPKSSHClient = Client & {
_protocol?: OPKSSHProtocol;
};
type OPKSSHNextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
// ── Internal implementation ──────────────────────────────────────────────────
// Grafts an OpenSSH certificate onto an already-parsed private key object and
// patches the ssh2 client so that certificate-based publickey auth succeeds.
async function _applyCertToConnection(
config: ConnectConfig,
client: Client,
privKey: ParsedPrivateKey,
certStr: string,
): Promise<void> {
// Extract cert type and blob from the stored certificate
const certParts = certStr.trim().split(/\s+/);
if (certParts.length < 2) {
throw new Error(
"Invalid certificate format: expected '<type> <base64>' string",
);
}
const certType = certParts[0];
const certBlob = Buffer.from(certParts[1], "base64");
// Graft cert type and blob onto the parsed private key
privKey.type = certType;
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
(s) => String(s) === "Symbol(Public key SSH)",
);
if (!pubSSHSym) {
throw new Error(
"Cannot find public SSH symbol on parsed key; ssh2 internals may have changed",
);
}
privKey[pubSSHSym] = certBlob;
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
if (privKey.type.startsWith("ecdsa-")) {
const origSign = privKey.sign.bind(privKey);
privKey.sign = (data: Buffer, algo?: string) => {
const sigAlgo = algo?.includes("-cert-")
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
: algo;
const sig = origSign(data, sigAlgo);
if (sig instanceof Error || sig[0] !== 0x30) return sig;
// Convert DER-encoded ECDSA signature to SSH wire format
try {
let pos = 2;
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
pos++;
const rLen = sig[pos++];
const r = sig.subarray(pos, pos + rLen);
pos += rLen + 1;
const sLen = sig[pos++];
const s = sig.subarray(pos, pos + sLen);
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
out.writeUInt32BE(r.length, 0);
r.copy(out, 4);
out.writeUInt32BE(s.length, 4 + r.length);
s.copy(out, 4 + r.length + 4);
return out;
} catch {
return sig;
}
};
}
// Set up authHandler to bypass ssh2's cert type rejection
let certAuthAttempted = false;
const authHandler: AuthHandlerMiddleware = (
methodsLeft: string[],
_partialSuccess: boolean,
callback,
) => {
const next = callback as OPKSSHNextAuthHandler;
if (
!certAuthAttempted &&
(!methodsLeft || methodsLeft.includes("publickey"))
) {
certAuthAttempted = true;
next({
type: "publickey",
username: (config as Record<string, unknown>).username as string,
key: privKey as unknown as PublicKeyAuthMethod["key"],
});
} else {
next(false);
}
};
config.authHandler = authHandler;
// Monkey-patch Protocol.authPK after connect() to fix the signature
// wrapper algorithm for cert types.
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
const origConnect = client.connect.bind(client);
const patchedClient = client as OPKSSHClient;
patchedClient.connect = (cfg: ConnectConfig) => {
const connectedClient = origConnect(cfg);
const proto = patchedClient._protocol;
if (!proto) return connectedClient;
const origAuthPK = proto.authPK.bind(proto);
proto.authPK = (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => {
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
if (!isCertAuth) {
return origAuthPK(user, pubKey, keyAlgo, cbSign);
}
// Signed auth with cert type: rebuild packet with base algo in
// the signature wrapper. keyAlgo may be undefined for ECDSA.
const certAlgo = keyAlgo || pubKey.type;
const pubSSH = pubKey.getPublicSSH();
const sessionID = proto._kex.sessionID;
const sesLen = sessionID.length;
const userLen = Buffer.byteLength(user);
const certAlgoLen = Buffer.byteLength(certAlgo);
const baseAlgoLen = Buffer.byteLength(baseAlgo);
const pubKeyLen = pubSSH.length;
// Build data to sign (uses cert algo — matches server verification)
const sigDataLen =
4 +
sesLen +
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen;
const sigData = Buffer.allocUnsafe(sigDataLen);
let sp = 0;
sigData.writeUInt32BE(sesLen, sp);
sp += 4;
sessionID.copy(sigData, sp);
sp += sesLen;
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
sigData.writeUInt32BE(userLen, sp);
sp += 4;
sigData.write(user, sp, userLen, "utf8");
sp += userLen;
sigData.writeUInt32BE(14, sp);
sp += 4;
sigData.write("ssh-connection", sp, 14, "utf8");
sp += 14;
sigData.writeUInt32BE(9, sp);
sp += 4;
sigData.write("publickey", sp, 9, "utf8");
sp += 9;
sigData[sp++] = 1; // TRUE
sigData.writeUInt32BE(certAlgoLen, sp);
sp += 4;
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
sp += certAlgoLen;
sigData.writeUInt32BE(pubKeyLen, sp);
sp += 4;
pubSSH.copy(sigData, sp);
cbSign(sigData, (signature: Buffer) => {
const sigLen = signature.length;
const payloadLen =
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen +
4 +
4 +
baseAlgoLen +
4 +
sigLen;
const packet = proto._packetRW.write.alloc(payloadLen);
let pp = proto._packetRW.write.allocStart;
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
packet.writeUInt32BE(userLen, ++pp);
pp += 4;
packet.write(user, pp, userLen, "utf8");
pp += userLen;
packet.writeUInt32BE(14, pp);
pp += 4;
packet.write("ssh-connection", pp, 14, "utf8");
pp += 14;
packet.writeUInt32BE(9, pp);
pp += 4;
packet.write("publickey", pp, 9, "utf8");
pp += 9;
packet[pp++] = 1; // TRUE
// Header: cert type
packet.writeUInt32BE(certAlgoLen, pp);
pp += 4;
packet.write(certAlgo, pp, certAlgoLen, "utf8");
pp += certAlgoLen;
// Public key blob
packet.writeUInt32BE(pubKeyLen, pp);
pp += 4;
pubSSH.copy(packet, pp);
pp += pubKeyLen;
// Signature wrapper: base algo (NOT cert type)
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
pp += 4;
packet.writeUInt32BE(baseAlgoLen, pp);
pp += 4;
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
pp += baseAlgoLen;
packet.writeUInt32BE(sigLen, pp);
pp += 4;
signature.copy(packet, pp);
proto._authsQueue.push("publickey");
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
const finalized = proto._packetRW.write.finalize(packet);
proto._cipher.encrypt(finalized);
});
};
return connectedClient;
};
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Set up OPKSSH certificate authentication on an ssh2 Client.
* The OPKSSH private key is assumed to be unencrypted (no passphrase).
*/
export async function setupOPKSSHCertAuth(
config: ConnectConfig,
client: Client,
token: OPKSSHToken,
username: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const parsed = parseKey(Buffer.from(token.privateKey));
if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key");
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, token.sshCert);
}
/**
* Set up CA-signed certificate authentication on an ssh2 Client.
* Supports passphrase-protected private keys.
* The cert content is the full text of the -cert.pub file
* (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...").
*/
export async function setupCACertAuth(
config: ConnectConfig,
client: Client,
privateKey: Buffer | string,
certPublicKey: string,
username: string,
passphrase?: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const keyBuf = Buffer.isBuffer(privateKey)
? privateKey
: Buffer.from(privateKey, "utf8");
const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf);
if (parsed instanceof Error || !parsed) {
const errMsg = parsed instanceof Error ? parsed.message : "unknown error";
throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`);
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, certPublicKey);
}
+203
View File
@@ -0,0 +1,203 @@
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { SerialPort } from "serialport";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { sshLogger } from "../utils/logger.js";
interface SerialConnectData {
path: string;
baudRate: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 2;
parity?: "none" | "even" | "odd";
}
interface WebSocketMessage {
type: string;
data?: SerialConnectData | string | unknown;
}
const authManager = AuthManager.getInstance();
const wss = new WebSocketServer({ port: 30011 });
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
try {
let token: string | undefined;
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
}
if (!token) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
}
if (!token) {
const urlObj = new URL(req.url || "", "http://localhost");
const qp = urlObj.searchParams.get("token");
if (qp) token = qp;
}
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
}
userId = payload.userId;
} catch {
ws.close(1008, "Authentication required");
return;
}
const dataKey = DataCrypto.getUserDataKey(userId);
if (!dataKey) {
ws.send(JSON.stringify({ type: "error", data: "Data locked" }));
ws.close(1008, "Data access required");
return;
}
let port: SerialPort | null = null;
const send = (msg: object) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
};
const cleanup = () => {
if (port?.isOpen) {
port.close();
}
port = null;
};
ws.on("message", async (raw: RawData) => {
let parsed: WebSocketMessage;
try {
parsed = JSON.parse(raw.toString()) as WebSocketMessage;
} catch {
return;
}
const { type, data } = parsed;
switch (type) {
case "list_ports": {
try {
const ports = await SerialPort.list();
send({ type: "ports_list", data: ports });
} catch (err) {
send({
type: "error",
data: err instanceof Error ? err.message : "Failed to list ports",
});
}
break;
}
case "connect": {
if (port?.isOpen) {
port.close();
port = null;
}
const cfg = data as SerialConnectData;
if (!cfg?.path || !cfg?.baudRate) {
send({ type: "error", data: "Missing port path or baud rate" });
break;
}
try {
port = new SerialPort({
path: cfg.path,
baudRate: cfg.baudRate,
dataBits: cfg.dataBits ?? 8,
stopBits: cfg.stopBits ?? 1,
parity: cfg.parity ?? "none",
autoOpen: false,
});
port.open((err) => {
if (err) {
sshLogger.error("Serial port open failed", err, {
operation: "serial_open",
path: cfg.path,
userId,
});
send({ type: "error", data: err.message });
port = null;
return;
}
sshLogger.info("Serial port opened", {
operation: "serial_open",
path: cfg.path,
baudRate: cfg.baudRate,
userId,
});
send({ type: "connected" });
});
port.on("data", (chunk: Buffer) => {
send({ type: "data", data: chunk.toString("binary") });
});
port.on("error", (err) => {
send({ type: "error", data: err.message });
});
port.on("close", () => {
send({ type: "disconnected" });
port = null;
});
} catch (err) {
send({
type: "error",
data:
err instanceof Error ? err.message : "Failed to open serial port",
});
}
break;
}
case "input": {
if (!port?.isOpen) break;
const input = typeof data === "string" ? data : "";
if (!input) break;
port.write(Buffer.from(input, "binary"), (err) => {
if (err) {
send({ type: "error", data: err.message });
}
});
break;
}
case "disconnect": {
cleanup();
send({ type: "disconnected" });
break;
}
}
});
ws.on("close", () => {
cleanup();
});
ws.on("error", () => {
cleanup();
});
});
+300
View File
@@ -0,0 +1,300 @@
import { Client } from "ssh2";
import { sshLogger } from "../utils/logger.js";
interface PooledConnection {
client: Client;
lastUsed: number;
inUse: boolean;
hostKey: string;
}
interface ConnectionWaiter {
resolve: (client: Client) => void;
reject: (error: Error) => void;
factory: () => Promise<Client>;
timer: NodeJS.Timeout;
}
const DEFAULT_MAX_CONNECTIONS_PER_HOST = 3;
const DEFAULT_MAX_WAIT_MS = 30_000;
const IDLE_MAX_AGE_MS = 10 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 2 * 60 * 1000;
class SSHConnectionPool {
private connections = new Map<string, PooledConnection[]>();
private waiters = new Map<string, ConnectionWaiter[]>();
private maxConnectionsPerHost = DEFAULT_MAX_CONNECTIONS_PER_HOST;
private maxWaitMs = DEFAULT_MAX_WAIT_MS;
private cleanupInterval: NodeJS.Timeout;
constructor() {
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, CLEANUP_INTERVAL_MS);
}
private isConnectionHealthy(client: Client): boolean {
try {
const sock = (
client as unknown as {
_sock?: { destroyed?: boolean; writable?: boolean };
}
)._sock;
if (sock && (sock.destroyed || !sock.writable)) {
return false;
}
return true;
} catch {
return false;
}
}
private removeUnhealthy(
key: string,
connections: PooledConnection[],
target: PooledConnection,
): PooledConnection[] {
sshLogger.warn("Removing unhealthy connection from pool", {
operation: "pool_remove_dead",
hostKey: key,
});
try {
target.client.end();
} catch {
// expected
}
const filtered = connections.filter((c) => c !== target);
this.connections.set(key, filtered);
return filtered;
}
private async createPooledClient(
key: string,
factory: () => Promise<Client>,
existing: PooledConnection[],
): Promise<Client> {
const client = await factory();
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey: key,
};
existing.push(pooled);
this.connections.set(key, existing);
client.on("end", () => {
this.removeConnection(key, client);
});
client.on("close", () => {
this.removeConnection(key, client);
});
return client;
}
private enqueueWaiter(
key: string,
factory: () => Promise<Client>,
): Promise<Client> {
return new Promise<Client>((resolve, reject) => {
const timer = setTimeout(() => {
const queue = this.waiters.get(key);
if (queue) {
const idx = queue.findIndex((w) => w.timer === timer);
if (idx >= 0) queue.splice(idx, 1);
if (queue.length === 0) this.waiters.delete(key);
}
const err = new Error(
`SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`,
);
sshLogger.warn("Connection pool wait timeout", {
operation: "pool_wait_timeout",
hostKey: key,
maxWaitMs: this.maxWaitMs,
});
reject(err);
}, this.maxWaitMs);
const waiter: ConnectionWaiter = { resolve, reject, factory, timer };
const queue = this.waiters.get(key) || [];
queue.push(waiter);
this.waiters.set(key, queue);
});
}
/** Hand a free connection (or new slot) to the next waiter, if any. */
private wakeWaiter(key: string): void {
const queue = this.waiters.get(key);
if (!queue || queue.length === 0) return;
let connections = this.connections.get(key) || [];
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again.
} else {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
available.inUse = true;
available.lastUsed = Date.now();
waiter.resolve(available.client);
return;
}
}
if (connections.length < this.maxConnectionsPerHost) {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
this.createPooledClient(key, waiter.factory, connections)
.then(waiter.resolve)
.catch(waiter.reject);
return;
}
}
private rejectWaiters(key: string, reason: string): void {
const queue = this.waiters.get(key);
if (!queue) return;
this.waiters.delete(key);
for (const waiter of queue) {
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
}
async getConnection(
key: string,
factory: () => Promise<Client>,
): Promise<Client> {
let connections = this.connections.get(key) || [];
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
} else {
available.inUse = true;
available.lastUsed = Date.now();
return available.client;
}
}
if (connections.length < this.maxConnectionsPerHost) {
return this.createPooledClient(key, factory, connections);
}
return this.enqueueWaiter(key, factory);
}
releaseConnection(key: string, client: Client): void {
const connections = this.connections.get(key) || [];
const pooled = connections.find((conn) => conn.client === client);
if (pooled) {
pooled.inUse = false;
pooled.lastUsed = Date.now();
this.wakeWaiter(key);
}
}
private removeConnection(key: string, client: Client): void {
const connections = this.connections.get(key);
if (!connections) return;
const filtered = connections.filter((c) => c.client !== client);
if (filtered.length === 0) {
this.connections.delete(key);
} else {
this.connections.set(key, filtered);
}
// A slot or idle connection may now be free for waiters.
this.wakeWaiter(key);
}
clearKeyConnections(key: string): void {
const connections = this.connections.get(key) || [];
for (const conn of connections) {
try {
conn.client.end();
} catch {
// expected
}
}
this.connections.delete(key);
this.rejectWaiters(key, `SSH connection pool cleared for ${key}`);
}
private cleanup(): void {
const now = Date.now();
for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter((conn) => {
if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) {
try {
conn.client.end();
} catch {
// expected
}
return false;
}
if (!this.isConnectionHealthy(conn.client)) {
try {
conn.client.end();
} catch {
// expected
}
return false;
}
return true;
});
if (activeConnections.length === 0) {
this.connections.delete(hostKey);
} else {
this.connections.set(hostKey, activeConnections);
}
this.wakeWaiter(hostKey);
}
}
clearAllConnections(): void {
for (const key of [...this.waiters.keys()]) {
this.rejectWaiters(key, "SSH connection pool destroyed");
}
for (const connections of this.connections.values()) {
for (const conn of connections) {
try {
conn.client.end();
} catch {
// expected
}
}
}
this.connections.clear();
}
destroy(): void {
clearInterval(this.cleanupInterval);
this.clearAllConnections();
}
}
export const connectionPool = new SSHConnectionPool();
export async function withConnection<T>(
key: string,
factory: () => Promise<Client>,
fn: (client: Client) => Promise<T>,
): Promise<T> {
const client = await connectionPool.getConnection(key, factory);
try {
return await fn(client);
} finally {
connectionPool.releaseConnection(key, client);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import {
isRetriableDnsError,
resolveHostForSshConnect,
resolveSshConnectConfigHost,
shouldResolveBeforeSshConnect,
} from "./ssh-dns.js";
describe("SSH DNS resolution", () => {
it("retries transient EAI_AGAIN errors before returning an address", async () => {
const lookup = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("try again"), { code: "EAI_AGAIN" }),
)
.mockResolvedValueOnce({ address: "10.0.0.5", family: 4 });
const wait = vi.fn().mockResolvedValue(undefined);
await expect(
resolveHostForSshConnect("alp", lookup, [10], wait),
).resolves.toEqual({
host: "10.0.0.5",
resolvedAddress: "10.0.0.5",
attempts: 2,
});
expect(wait).toHaveBeenCalledWith(10);
});
it("does not retry permanent DNS failures", async () => {
const error = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
const lookup = vi.fn().mockRejectedValue(error);
const wait = vi.fn().mockResolvedValue(undefined);
await expect(
resolveHostForSshConnect("missing", lookup, [10], wait),
).rejects.toBe(error);
expect(wait).not.toHaveBeenCalled();
});
it("skips DNS lookup for literal IP addresses", async () => {
const lookup = vi.fn();
await expect(
resolveHostForSshConnect("192.0.2.1", lookup),
).resolves.toEqual({
host: "192.0.2.1",
attempts: 0,
});
expect(lookup).not.toHaveBeenCalled();
});
it("detects retryable DNS errors by code or message", () => {
expect(isRetriableDnsError({ code: "EAI_AGAIN" })).toBe(true);
expect(isRetriableDnsError(new Error("getaddrinfo EAI_AGAIN alp"))).toBe(
true,
);
expect(isRetriableDnsError({ code: "ENOTFOUND" })).toBe(false);
});
it("only pre-resolves hostnames", () => {
expect(shouldResolveBeforeSshConnect("alp")).toBe(true);
expect(shouldResolveBeforeSshConnect("127.0.0.1")).toBe(false);
expect(shouldResolveBeforeSshConnect("[2001:db8::1]")).toBe(false);
});
it("updates SSH connect config hosts in place", async () => {
const lookup = vi
.fn()
.mockResolvedValue({ address: "10.0.0.6", family: 4 });
const config = { host: "alp", port: 22 };
await expect(resolveSshConnectConfigHost(config, lookup)).resolves.toEqual({
host: "10.0.0.6",
port: 22,
originalHost: "alp",
resolvedHost: "10.0.0.6",
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import dns from "dns/promises";
import net from "net";
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
type Lookup = typeof dns.lookup;
type Sleep = (ms: number) => Promise<void>;
type SshConnectConfigHost = {
host?: unknown;
};
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function isRetriableDnsError(error: unknown): boolean {
const err = error as { code?: unknown; message?: unknown };
return (
err.code === "EAI_AGAIN" ||
(typeof err.message === "string" && err.message.includes("EAI_AGAIN"))
);
}
export function shouldResolveBeforeSshConnect(host: string): boolean {
const normalized = host.replace(/^\[|\]$/g, "").trim();
if (!normalized) return false;
return net.isIP(normalized) === 0;
}
export async function resolveHostForSshConnect(
host: string,
lookup: Lookup = dns.lookup,
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
wait: Sleep = sleep,
): Promise<{ host: string; resolvedAddress?: string; attempts: number }> {
const normalized = host.replace(/^\[|\]$/g, "").trim();
if (!shouldResolveBeforeSshConnect(normalized)) {
return { host: normalized || host, attempts: 0 };
}
for (let attempt = 0; ; attempt += 1) {
try {
const result = await lookup(normalized);
return {
host: result.address,
resolvedAddress: result.address,
attempts: attempt + 1,
};
} catch (error) {
if (!isRetriableDnsError(error) || attempt >= retryDelaysMs.length) {
throw error;
}
await wait(retryDelaysMs[attempt]);
}
}
}
export async function resolveSshConnectConfigHost<
T extends SshConnectConfigHost,
>(
config: T,
lookup: Lookup = dns.lookup,
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
wait: Sleep = sleep,
): Promise<
T & { host?: unknown; resolvedHost?: string; originalHost?: string }
> {
if (typeof config.host !== "string") return config;
const originalHost = config.host;
const resolution = await resolveHostForSshConnect(
originalHost,
lookup,
retryDelaysMs,
wait,
);
if (!resolution.resolvedAddress) return config;
config.host = resolution.host;
return Object.assign(config, {
originalHost,
resolvedHost: resolution.resolvedAddress,
});
}
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockAccess = vi.fn();
vi.mock("fs/promises", () => ({
access: mockAccess,
}));
import { resolveAgentSocket } from "./terminal-auth-helpers.js";
describe("resolveAgentSocket", () => {
const originalEnv = process.env.SSH_AUTH_SOCK;
const originalPlatform = process.platform;
beforeEach(() => {
mockAccess.mockReset();
delete process.env.SSH_AUTH_SOCK;
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.SSH_AUTH_SOCK = originalEnv;
} else {
delete process.env.SSH_AUTH_SOCK;
}
Object.defineProperty(process, "platform", { value: originalPlatform });
});
it("uses explicit socket path from terminalConfig over SSH_AUTH_SOCK", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/ssh-env/agent.123";
mockAccess.mockResolvedValue(undefined);
const result = await resolveAgentSocket({
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
});
expect(result).toEqual({
socketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
});
expect(mockAccess).toHaveBeenCalledWith(
"/run/user/1000/gnupg/S.gpg-agent.ssh",
);
});
it("falls back to SSH_AUTH_SOCK when no explicit path is provided", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/ssh-XXXX/agent.456";
mockAccess.mockResolvedValue(undefined);
const result = await resolveAgentSocket({});
expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.456" });
});
it("falls back to SSH_AUTH_SOCK when agentSocketPath is empty string", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/ssh-XXXX/agent.789";
mockAccess.mockResolvedValue(undefined);
const result = await resolveAgentSocket({ agentSocketPath: " " });
expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.789" });
});
it("returns error when neither SSH_AUTH_SOCK nor explicit path is set", async () => {
const result = await resolveAgentSocket({});
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toContain("SSH_AUTH_SOCK");
});
it("returns error when terminalConfig is undefined and SSH_AUTH_SOCK is not set", async () => {
const result = await resolveAgentSocket(undefined);
expect(result).toHaveProperty("error");
});
it("returns error on non-Windows when socket file is missing", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/missing-agent.sock";
mockAccess.mockRejectedValue(new Error("ENOENT"));
const result = await resolveAgentSocket({});
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toContain(
"/tmp/missing-agent.sock",
);
});
it("skips file existence check on Windows", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
process.env.SSH_AUTH_SOCK = "\\\\.\\pipe\\openssh-ssh-agent";
const result = await resolveAgentSocket({});
expect(result).toEqual({
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
});
expect(mockAccess).not.toHaveBeenCalled();
});
});
+158
View File
@@ -0,0 +1,158 @@
import dgram from "dgram";
import net from "net";
import ssh2Pkg, {
type IdentityCallback,
type ParsedKey,
type SignCallback,
type SigningRequestOptions,
} from "ssh2";
const { BaseAgent } = ssh2Pkg;
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
type Sleep = (ms: number) => Promise<void>;
type PortKnockingOptions = {
tcpTimeoutMs?: number;
udpTimeoutMs?: number;
createTcpSocket?: () => net.Socket;
createUdpSocket?: () => dgram.Socket;
wait?: Sleep;
};
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class MemoryAgent extends BaseAgent {
private key: ParsedKey;
constructor(key: ParsedKey) {
super();
this.key = key;
}
getIdentities(cb: IdentityCallback<ParsedKey>): void {
cb(null, [this.key]);
}
sign(
_pubKey: ParsedKey | Buffer | string,
data: Buffer,
optionsOrCb: SigningRequestOptions | SignCallback,
cb?: SignCallback,
): void {
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
try {
const algo =
options.hash === "sha256"
? "rsa-sha2-256"
: options.hash === "sha512"
? "rsa-sha2-512"
: undefined;
const signature = this.key.sign(data, algo);
callback(null, signature);
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
}
}
export async function resolveAgentSocket(
terminalConfig: Record<string, unknown> | undefined,
): Promise<{ socketPath: string } | { error: string }> {
const explicit = (
terminalConfig?.agentSocketPath as string | undefined
)?.trim();
const resolved = explicit || process.env.SSH_AUTH_SOCK;
if (!resolved) {
return {
error: "SSH_AUTH_SOCK is not set and no socket path was provided.",
};
}
if (process.platform !== "win32") {
const { access } = await import("fs/promises");
try {
await access(resolved);
} catch {
return {
error: `SSH agent socket not found at ${resolved}. Make sure your agent is running.`,
};
}
}
return { socketPath: resolved };
}
export async function applyAgentAuth(
connectConfig: Record<string, unknown>,
terminalConfig: Record<string, unknown> | undefined,
): Promise<{ socketPath: string } | { error: string }> {
const result = await resolveAgentSocket(terminalConfig);
if ("error" in result) return result;
const { createAgent } = ssh2Pkg;
connectConfig.agent = createAgent(result.socketPath);
return result;
}
export async function performPortKnocking(
host: string,
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
options: PortKnockingOptions = {},
): Promise<void> {
const createTcpSocket = options.createTcpSocket ?? (() => new net.Socket());
const createUdpSocket =
options.createUdpSocket ?? (() => dgram.createSocket("udp4"));
const wait = options.wait ?? sleep;
const tcpTimeoutMs = options.tcpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS;
const udpTimeoutMs = options.udpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS;
for (const knock of sequence) {
const port = Number(knock.port);
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
const protocol = (knock.protocol || "tcp").toLowerCase();
const delay = Number(knock.delay ?? 100);
await new Promise<void>((resolve) => {
if (protocol === "udp") {
const client = createUdpSocket();
let settled = false;
const timeout = setTimeout(() => finish(), udpTimeoutMs);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
client.close();
resolve();
};
client.once("error", finish);
client.send(Buffer.alloc(0), port, host, () => {
finish();
});
} else {
const socket = createTcpSocket();
let settled = false;
const timeout = setTimeout(() => finish(), tcpTimeoutMs);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
socket.removeAllListeners("connect");
socket.removeAllListeners("error");
socket.destroy();
resolve();
};
socket.once("connect", finish);
socket.once("error", finish);
socket.connect(port, host);
}
});
if (delay > 0) {
await wait(delay);
}
}
}
@@ -0,0 +1,57 @@
import { EventEmitter } from "events";
import { describe, expect, it, vi } from "vitest";
import { performPortKnocking } from "./terminal-auth-helpers.js";
class FakeTcpSocket extends EventEmitter {
readonly connect = vi.fn();
readonly destroy = vi.fn();
}
describe("performPortKnocking", () => {
it("continues through TCP knock errors", async () => {
const first = new FakeTcpSocket();
const second = new FakeTcpSocket();
const wait = vi.fn().mockResolvedValue(undefined);
const knocking = performPortKnocking(
"192.0.2.10",
[
{ port: 1111, protocol: "tcp", delay: 10 },
{ port: 2222, protocol: "tcp", delay: 0 },
],
{
createTcpSocket: vi
.fn()
.mockReturnValueOnce(first)
.mockReturnValueOnce(second),
wait,
},
);
first.emit("error", new Error("closed"));
await Promise.resolve();
second.emit("connect");
await knocking;
expect(first.connect).toHaveBeenCalledWith(1111, "192.0.2.10");
expect(second.connect).toHaveBeenCalledWith(2222, "192.0.2.10");
expect(first.destroy).toHaveBeenCalled();
expect(second.destroy).toHaveBeenCalled();
expect(wait).toHaveBeenCalledWith(10);
});
it("times out TCP knocks that are silently dropped", async () => {
const socket = new FakeTcpSocket();
const wait = vi.fn().mockResolvedValue(undefined);
await performPortKnocking("192.0.2.10", [{ port: 1111, delay: 0 }], {
createTcpSocket: () => socket as never,
tcpTimeoutMs: 1,
wait,
});
expect(socket.connect).toHaveBeenCalledWith(1111, "192.0.2.10");
expect(socket.destroy).toHaveBeenCalled();
expect(wait).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Stub all external imports before loading the module under test
const mockCreate = vi.fn().mockResolvedValue({ id: 1 });
const mockUpdateEnded = vi.fn().mockResolvedValue(undefined);
vi.mock("../database/db/index.js", () => ({
getDb: () => ({}),
}));
vi.mock("../database/repositories/factory.js", () => ({
createCurrentSessionRecordingRepository: () => ({
create: mockCreate,
updateEnded: mockUpdateEnded,
}),
}));
vi.mock("../utils/logger.js", () => ({
sshLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock individual fs.promises methods via a stub object
const mockMkdir = vi.fn().mockResolvedValue(undefined);
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
const mockUnlink = vi.fn().mockResolvedValue(undefined);
vi.mock("fs", () => ({
default: {
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: mockUnlink,
},
},
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: mockUnlink,
},
}));
const { sessionManager } = await import("./terminal-session-manager.js");
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-apply resolved values after clearAllMocks
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
mockCreate.mockResolvedValue({ id: 1 });
mockUpdateEnded.mockResolvedValue(undefined);
});
it("createSession stores sessionLoggingEnabled=true by default", () => {
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(true);
sessionManager.destroySession(id);
});
it("createSession stores sessionLoggingEnabled=false when passed", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(false);
sessionManager.destroySession(id);
});
it("does not write log file when sessionLoggingEnabled=false", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
sessionManager.bufferOutput(id, "some output");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).not.toHaveBeenCalled();
});
it("writes log file and inserts DB row when sessionLoggingEnabled=true", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.bufferOutput(id, "terminal output data");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).toHaveBeenCalledOnce();
expect(mockCreate).toHaveBeenCalledOnce();
});
it("does not write log file when buffer is empty", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).not.toHaveBeenCalled();
});
it("bufferOutput trims old data when exceeding 512KB", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const chunk = "x".repeat(300 * 1024);
sessionManager.bufferOutput(id, chunk);
sessionManager.bufferOutput(id, chunk);
const session = sessionManager.getSession(id);
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
sessionManager.destroySession(id);
});
});
@@ -0,0 +1,619 @@
import { type Client, type ClientChannel } from "ssh2";
import { WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { sshLogger } from "../utils/logger.js";
import {
getCurrentSettingValue,
createCurrentSessionRecordingRepository,
} from "../database/repositories/factory.js";
const MAX_BUFFER_BYTES = 512 * 1024;
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const SESSION_LOGS_DIR = path.join(DATA_DIR, "session_logs");
const DEFAULT_TIMEOUT_MINUTES = 30;
const HEALTH_CHECK_INTERVAL_MS = 60_000;
const MAX_SESSIONS_PER_USER = 10;
export interface TerminalSession {
id: string;
userId: string;
hostId: number;
hostName: string;
tabInstanceId?: string;
attachedTabInstanceId?: string;
sshConn: Client | null;
sshStream: ClientChannel | null;
jumpClient: Client | null;
cols: number;
rows: number;
isConnected: boolean;
createdAt: number;
attachedWs: WebSocket | null;
lastDetachedAt: number | null;
detachTimeout: NodeJS.Timeout | null;
outputBuffer: string[];
outputBufferBytes: number;
recordingPath: string | null;
recordingHeader: string | null;
recordingBytes: number;
recordingId: number | null;
recordingWriteChain: Promise<void>;
recordingPersistChain: Promise<void>;
tmuxSessionName: string | null;
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
lastPersistedBytes: number;
}
class TerminalSessionManager {
private static instance: TerminalSessionManager;
private sessions = new Map<string, TerminalSession>();
private healthCheckTimer: NodeJS.Timeout | null = null;
private constructor() {
this.healthCheckTimer = setInterval(
() => this.healthCheck(),
HEALTH_CHECK_INTERVAL_MS,
);
}
static getInstance(): TerminalSessionManager {
if (!TerminalSessionManager.instance) {
TerminalSessionManager.instance = new TerminalSessionManager();
}
return TerminalSessionManager.instance;
}
createSession(
userId: string,
hostId: number,
hostName: string,
cols: number,
rows: number,
tabInstanceId?: string,
sessionLoggingEnabled = true,
): string {
const userSessions = this.getUserSessions(userId);
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
const detached = userSessions
.filter((s) => s.attachedWs === null)
.sort(
(a, b) =>
(a.lastDetachedAt ?? a.createdAt) -
(b.lastDetachedAt ?? b.createdAt),
);
if (detached.length > 0) {
this.destroySession(detached[0].id);
}
}
if (tabInstanceId) {
const tabSessions = userSessions.filter(
(s) => s.tabInstanceId === tabInstanceId,
);
for (const existing of tabSessions) {
const isLiveSession =
existing.isConnected &&
existing.sshStream != null &&
!existing.sshStream.destroyed;
if (isLiveSession) {
// Don't destroy a live session (even if detached) — the caller should attach instead
sshLogger.warn(
"Tab instance has live session, skipping duplicate create",
{
operation: "session_tab_duplicate_skip",
existingSessionId: existing.id,
tabInstanceId,
hasAttachedWs: existing.attachedWs !== null,
},
);
return existing.id;
}
sshLogger.warn("Tab instance already has session, destroying old", {
operation: "session_tab_duplicate_cleanup",
existingSessionId: existing.id,
tabInstanceId,
});
this.destroySession(existing.id);
}
}
const id = crypto.randomUUID();
const now = Date.now();
let recordingPath: string | null = null;
let recordingHeader: string | null = null;
if (sessionLoggingEnabled) {
const userLogDir = path.join(SESSION_LOGS_DIR, userId);
recordingPath = path.join(userLogDir, `${id}.cast`);
recordingHeader = `${JSON.stringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(now / 1000),
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
})}\n`;
}
const session: TerminalSession = {
id,
userId,
hostId,
hostName,
tabInstanceId,
sshConn: null,
sshStream: null,
jumpClient: null,
cols,
rows,
isConnected: false,
createdAt: now,
attachedWs: null,
lastDetachedAt: null,
detachTimeout: null,
outputBuffer: [],
outputBufferBytes: 0,
recordingPath,
recordingHeader,
recordingBytes: 0,
recordingId: null,
recordingWriteChain: Promise.resolve(),
recordingPersistChain: Promise.resolve(),
tmuxSessionName: null,
sessionLoggingEnabled,
sessionStartedAt: now,
lastPersistedBytes: 0,
};
this.sessions.set(id, session);
sshLogger.info("Terminal session created", {
operation: "session_created",
sessionId: id,
userId,
hostId,
});
return id;
}
getSession(sessionId: string | null): TerminalSession | null {
if (!sessionId) return null;
return this.sessions.get(sessionId) ?? null;
}
setSSHState(
sessionId: string,
conn: Client,
stream: ClientChannel,
jumpClient?: Client | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.sshConn = conn;
session.sshStream = stream;
session.jumpClient = jumpClient ?? null;
session.isConnected = true;
}
attachWs(
sessionId: string,
userId: string,
ws: WebSocket,
tabInstanceId?: string,
): TerminalSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
sshLogger.warn("Session not found for attachment", {
operation: "session_attach_not_found",
sessionId,
userId,
});
return null;
}
if (session.userId !== userId) {
sshLogger.warn("Session userId mismatch", {
operation: "session_attach_user_mismatch",
sessionId,
expectedUserId: session.userId,
providedUserId: userId,
});
return null;
}
if (!session.isConnected) {
sshLogger.warn("Session not connected", {
operation: "session_attach_not_connected",
sessionId,
userId,
createdAt: session.createdAt,
elapsed: Date.now() - session.createdAt,
});
return null;
}
const isDetached =
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
const isOriginalTab =
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
tabInstanceId;
if (
!isDetached &&
!isOriginalTab &&
session.tabInstanceId &&
tabInstanceId
) {
sshLogger.warn("Session actively attached to different tab instance", {
operation: "session_attach_instance_conflict",
sessionId,
sessionInstanceId: session.tabInstanceId,
providedInstanceId: tabInstanceId,
});
try {
ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: "Session belongs to a different tab instance",
}),
);
} catch {
/* ignore */
}
return null;
}
if (
session.tabInstanceId &&
tabInstanceId &&
session.tabInstanceId !== tabInstanceId
) {
sshLogger.info(
"Session attached to different tab instance (split-screen)",
{
operation: "session_attach_split_screen",
originalInstanceId: session.tabInstanceId,
newInstanceId: tabInstanceId,
sessionId,
},
);
}
if (session.attachedWs && session.attachedWs !== ws) {
try {
session.attachedWs.send(
JSON.stringify({
type: "sessionTakenOver",
sessionId,
message: "Session was attached from another tab",
}),
);
} catch {
/* ignore */
}
session.attachedWs = null;
}
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
session.attachedWs = ws;
session.attachedTabInstanceId = tabInstanceId;
session.lastDetachedAt = null;
sshLogger.info("WebSocket attached to session", {
operation: "session_attach",
sessionId,
userId,
tabInstanceId,
});
return session;
}
detachWs(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
session.attachedWs = null;
session.lastDetachedAt = Date.now();
// Persist log immediately when the user detaches so it appears right away,
// regardless of whether the session is later reattached or times out.
this.maybePersistLog(session);
const timeoutMs = this.getTimeoutMs();
session.detachTimeout = setTimeout(() => {
sshLogger.info("Session idle timeout expired", {
operation: "session_idle_timeout",
sessionId,
userId: session.userId,
});
this.destroySession(sessionId);
}, timeoutMs);
sshLogger.info("WebSocket detached from session", {
operation: "session_detach",
sessionId,
userId: session.userId,
timeoutMinutes: timeoutMs / 60_000,
});
}
destroySession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
this.maybePersistLog(session, true);
if (session.recordingPath && session.recordingBytes === 0) {
fs.promises.unlink(session.recordingPath).catch(() => {});
}
if (session.sshStream) {
try {
session.sshStream.end();
} catch {
/* ignore */
}
session.sshStream = null;
}
if (session.sshConn) {
try {
session.sshConn.end();
} catch {
/* ignore */
}
session.sshConn = null;
}
if (session.jumpClient) {
try {
session.jumpClient.end();
} catch {
/* ignore */
}
session.jumpClient = null;
}
session.isConnected = false;
session.outputBuffer = [];
session.outputBufferBytes = 0;
this.sessions.delete(sessionId);
sshLogger.info("Terminal session destroyed", {
operation: "session_destroyed",
sessionId,
userId: session.userId,
hostId: session.hostId,
});
}
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.recordingBytes === 0) return;
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
session.lastPersistedBytes = session.recordingBytes;
session.recordingPersistChain = session.recordingPersistChain
.then(() => this.persistSessionLog(session))
.catch((err) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async persistSessionLog(session: TerminalSession): Promise<void> {
if (!session.recordingPath) return;
await session.recordingWriteChain;
const endedAt = Date.now();
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
try {
const repo = createCurrentSessionRecordingRepository();
if (session.recordingId == null) {
const created = await repo.create({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: session.recordingPath,
protocol: "ssh",
format: "asciicast",
});
session.recordingId = created.id;
} else {
await repo.updateEnded(session.recordingId, {
endedAt: new Date(endedAt).toISOString(),
duration,
});
}
} catch (err) {
sshLogger.warn("Failed to insert session recording row", {
operation: "session_recording_insert_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
}
sshLogger.info("Session log persisted", {
operation: "session_log_persisted",
sessionId: session.id,
userId: session.userId,
hostId: session.hostId,
duration,
bytes: session.recordingBytes,
});
}
getUserSessions(userId: string): TerminalSession[] {
const result: TerminalSession[] = [];
for (const session of this.sessions.values()) {
if (session.userId === userId) {
result.push(session);
}
}
return result;
}
bufferOutput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.outputBuffer.push(data);
session.outputBufferBytes += data.length;
while (
session.outputBufferBytes > MAX_BUFFER_BYTES &&
session.outputBuffer.length > 0
) {
const removed = session.outputBuffer.shift();
if (removed) session.outputBufferBytes -= removed.length;
}
this.recordSessionEvent(session, "o", data);
}
bufferInput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "i", data);
}
bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
}
private recordSessionEvent(
session: TerminalSession,
type: "i" | "o" | "r",
data: string,
): void {
if (!session.sessionLoggingEnabled || !session.recordingPath || !data)
return;
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
const line = `${JSON.stringify([elapsed, type, data])}\n`;
const firstEvent = session.recordingBytes === 0;
session.recordingBytes += Buffer.byteLength(line);
session.recordingWriteChain = session.recordingWriteChain.then(async () => {
if (firstEvent) {
await fs.promises.mkdir(path.dirname(session.recordingPath!), {
recursive: true,
});
await fs.promises.writeFile(
session.recordingPath!,
`${session.recordingHeader}${line}`,
"utf8",
);
return;
}
await fs.promises.appendFile(session.recordingPath!, line, "utf8");
});
}
flushBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
const data = session.outputBuffer.join("");
session.outputBuffer = [];
session.outputBufferBytes = 0;
return data;
}
getBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
return session.outputBuffer.join("");
}
private getTimeoutMs(): number {
try {
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
if (value) {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes > 0) {
return minutes * 60_000;
}
}
} catch {
// DB not available, use default
}
return DEFAULT_TIMEOUT_MINUTES * 60_000;
}
private healthCheck(): void {
const toDestroy: string[] = [];
const now = Date.now();
const GRACE_PERIOD_MS = 10_000;
for (const [id, session] of this.sessions) {
if (!session.isConnected) continue;
if (
session.attachedWs &&
session.attachedWs.readyState === WebSocket.OPEN
) {
continue;
}
if (session.sshStream?.destroyed) {
const detachedDuration = session.lastDetachedAt
? now - session.lastDetachedAt
: 0;
if (detachedDuration > GRACE_PERIOD_MS) {
sshLogger.info(
"SSH stream destroyed during detach window, cleaning up",
{
operation: "session_health_check_stream_destroyed",
sessionId: id,
userId: session.userId,
detachedFor: detachedDuration,
},
);
toDestroy.push(id);
}
}
if (!session.sshConn) {
toDestroy.push(id);
}
}
for (const id of toDestroy) {
this.destroySession(id);
}
}
destroyAll(): void {
for (const id of [...this.sessions.keys()]) {
this.destroySession(id);
}
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
}
}
export const sessionManager = TerminalSessionManager.getInstance();
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
import { EventEmitter } from "node:events";
import type { Client } from "ssh2";
import { describe, expect, it } from "vitest";
import { detectTmux, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
describe("tmux command path handling", () => {
it("adds common non-login shell tmux paths", () => {
const command = withTmuxPath("command -v tmux");
expect(command).toMatch(/^\/bin\/sh -c '/);
expect(command).toContain("/opt/homebrew/bin");
expect(command).toContain("/usr/local/bin");
expect(command).toContain("/opt/bin");
expect(command).toContain("/usr/pkg/bin");
expect(command).toContain(":$PATH; export PATH; command -v tmux");
});
it("wraps tmux invocations with the same path", () => {
expect(tmuxCommand("list-sessions")).toMatch(
/^\/bin\/sh -c 'PATH=.*:\$PATH; export PATH; tmux list-sessions'$/,
);
});
it("detects suffixed tmux versions without parsing the version number", async () => {
const commands: string[] = [];
const conn = {
exec(command: string, callback: (error: null, stream: never) => void) {
commands.push(command);
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
};
stream.stderr = new EventEmitter();
callback(null, stream as never);
queueMicrotask(() => {
if (commands.length === 1) {
stream.emit("data", Buffer.from("tmux 3.7b\n"));
stream.emit("close", 0);
return;
}
stream.emit("close", 1);
});
},
} as unknown as Client;
await expect(detectTmux(conn)).resolves.toEqual({
available: true,
sessions: [],
});
expect(commands[0]).toContain("tmux -V");
});
});
+205
View File
@@ -0,0 +1,205 @@
import type { Client, ClientChannel } from "ssh2";
import { sshLogger } from "../utils/logger.js";
export interface TmuxSessionInfo {
name: string;
created: number;
lastActivity: number;
windows: number;
attachedClients: number;
}
export interface TmuxDetectionResult {
available: boolean;
sessions: TmuxSessionInfo[];
}
const TMUX_PATH_DIRS = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/opt/bin",
"/usr/pkg/bin",
];
export function withTmuxPath(command: string): string {
const script = `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; export PATH; ${command}`;
return `/bin/sh -c ${shellEscape(script)}`;
}
export function tmuxCommand(args: string): string {
return withTmuxPath(`tmux ${args}`);
}
/**
* Run a command on the remote host via a separate exec channel.
* Returns stdout as a string. Does not pollute the interactive shell.
*/
export function execCommand(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let stdout = "";
let stderr = "";
stream.on("data", (data: Buffer) => {
stdout += data.toString("utf-8");
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf-8");
});
stream.on("error", (err: Error) => {
reject(err);
});
stream.on("close", (code: number) => {
if (code !== 0 && stdout === "") {
reject(
new Error(stderr.trim() || `Command exited with code ${code}`),
);
} else {
resolve(stdout.trim());
}
});
});
});
}
/**
* Detect whether tmux is installed and list all existing sessions with details.
*/
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
try {
await execCommand(conn, tmuxCommand("-V"));
} catch {
return { available: false, sessions: [] };
}
let sessions: TmuxSessionInfo[] = [];
try {
const output = await execCommand(
conn,
tmuxCommand(
`list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
),
);
if (output) {
sessions = output
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const [name, created, activity, windows, attached] = line.split("|");
return {
name,
created: parseInt(created, 10) || 0,
lastActivity: parseInt(activity, 10) || 0,
windows: parseInt(windows, 10) || 0,
attachedClients: parseInt(attached, 10) || 0,
};
});
}
} catch {
// tmux server not running yet -- no sessions exist
}
return { available: true, sessions };
}
// tmux options applied on every attach/create:
// - mouse on: enables mouse wheel / touch scrollback through tmux history
// - history-limit: deep scrollback buffer on the remote host
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
// - mode-keys vi: use vi-style keys in copy mode
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
// adjust and press Enter to copy (or drag again)
// - Enter: copy the (possibly adjusted) selection and exit copy mode
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
// know to press Enter to copy the selection
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
const TMUX_OPTS =
`set -gq mouse on` +
` \\; set -gq history-limit 50000` +
` \\; set -gq set-clipboard on` +
` \\; set -gq aggressive-resize on` +
` \\; set -gq mode-keys vi` +
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
` \\; set-hook -g pane-mode-changed` +
` 'if -F "#{pane_in_mode}"` +
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
/**
* Wait for a tmux session to appear by polling via exec channel.
* Returns the session name once found, or null on timeout.
*/
export async function waitForTmuxSession(
conn: Client,
sessionName: string,
timeoutMs = 5000,
intervalMs = 100,
): Promise<string | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await execCommand(
conn,
tmuxCommand(`has-session -t ${shellEscape(sessionName)} 2>/dev/null`),
);
return sessionName;
} catch {
// session not ready yet
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return null;
}
/**
* Write tmux attach or new-session command to the interactive shell stream.
* Uses && exit so the shell only closes if tmux started successfully.
*/
export function attachOrCreateTmuxSession(
stream: ClientChannel,
existingSessionName?: string,
newSessionName?: string,
): void {
let command: string;
if (existingSessionName) {
command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`;
} else {
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`;
}
sshLogger.info("Writing tmux command to shell", {
operation: "tmux_attach_or_create",
sessionName: existingSessionName || "(auto)",
isReattach: !!existingSessionName,
});
stream.write(command);
}
/**
* Query the name of the most recently created tmux session via exec channel.
*/
export async function queryNewestTmuxSession(
conn: Client,
): Promise<string | null> {
try {
const output = await execCommand(
conn,
tmuxCommand(
`list-sessions -F "#{session_created}:#{session_name}" 2>/dev/null | sort -rn | head -1 | cut -d: -f2-`,
),
);
return output || null;
} catch {
return null;
}
}
function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
@@ -0,0 +1,225 @@
import { describe, it, expect } from "vitest";
import {
SEP,
parseSessions,
parseWindows,
parsePanes,
parsePsOutput,
parseGpuOutput,
buildPaneMetrics,
attachPanesToWindows,
shellEscape,
} from "./tmux-monitor-helpers.js";
function join(...fields: (string | number)[]): string {
return fields.join(SEP);
}
describe("parseSessions", () => {
it("parses tmux list-sessions output", () => {
const output = [
join("training", 1760000000, 1760001000, 1),
join("lab|with|pipes", 1760000500, 1760002000, 0),
].join("\n");
const sessions = parseSessions(output);
expect(sessions).toHaveLength(2);
expect(sessions[0]).toEqual({
name: "training",
created: 1760000000,
lastActivity: 1760001000,
attachedClients: 1,
});
// Session names containing "|" survive because SEP is a multi-char token
expect(sessions[1].name).toBe("lab|with|pipes");
expect(sessions[1].attachedClients).toBe(0);
});
it("returns empty array for empty output", () => {
expect(parseSessions("")).toEqual([]);
});
});
describe("parseWindows", () => {
it("groups windows by session", () => {
const output = [
join("training", 0, 1, "vim"),
join("training", 1, 0, "logs"),
join("api", 0, 1, "server"),
].join("\n");
const windows = parseWindows(output);
expect(windows.get("training")).toHaveLength(2);
expect(windows.get("training")![0]).toMatchObject({
index: 0,
name: "vim",
active: true,
});
expect(windows.get("api")![0].name).toBe("server");
});
});
describe("parsePanes", () => {
it("parses full pane lines including free-text fields", () => {
const output = join(
"training",
0,
"%3",
1,
12345,
1,
120,
40,
"python",
"/home/user/my|dir",
"gpu01: train.py",
);
const panes = parsePanes(output);
expect(panes).toHaveLength(1);
expect(panes[0]).toEqual({
sessionName: "training",
windowIndex: 0,
id: "%3",
index: 1,
pid: 12345,
active: true,
width: 120,
height: 40,
command: "python",
path: "/home/user/my|dir",
title: "gpu01: train.py",
});
});
});
describe("parsePsOutput", () => {
it("parses ps -eo pid,ppid,pcpu,pmem,rss,comm output", () => {
const output = [
" 1 0 0.0 0.1 1234 systemd",
"12345 1 2.5 1.0 50000 bash",
"12400 12345 95.3 12.5 800000 python3",
"garbage line",
].join("\n");
const procs = parsePsOutput(output);
expect(procs).toHaveLength(3);
expect(procs[2]).toEqual({
pid: 12400,
ppid: 12345,
cpu: 95.3,
mem: 12.5,
rss: 800000,
comm: "python3",
});
});
});
describe("parseGpuOutput", () => {
it("parses nvidia-smi csv output and sums per pid", () => {
const output = ["12400, 8000", "12400, 2000", "99999, 512"].join("\n");
const gpu = parseGpuOutput(output);
expect(gpu.get(12400)).toBe(10000);
expect(gpu.get(99999)).toBe(512);
});
it("handles empty output (no GPU)", () => {
expect(parseGpuOutput("").size).toBe(0);
});
});
describe("buildPaneMetrics", () => {
const panes = parsePanes(
[
join("training", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
join("idle", 0, "%2", 0, 200, 1, 80, 24, "bash", "/", "t"),
].join("\n"),
);
const processes = parsePsOutput(
[
// pane %1: bash(100) -> python3(110) -> worker(111)
" 100 1 0.1 0.1 4000 bash",
" 110 100 90.0 10.0 700000 python3",
" 111 110 9.5 2.0 100000 dataloader",
// pane %2: bash(200) only
" 200 1 0.0 0.1 4000 bash",
// unrelated process
" 300 1 50.0 5.0 200000 chrome",
].join("\n"),
);
it("aggregates descendant trees per pane", () => {
const metrics = buildPaneMetrics(panes, processes, new Map());
const m1 = metrics.find((m) => m.paneId === "%1")!;
expect(m1.processCount).toBe(3);
expect(m1.cpuPercent).toBeCloseTo(99.6, 1);
expect(m1.memRssKb).toBe(804000);
expect(m1.topCommand).toBe("python3");
const m2 = metrics.find((m) => m.paneId === "%2")!;
expect(m2.processCount).toBe(1);
expect(m2.cpuPercent).toBe(0);
// Unrelated process is never attributed
expect(m2.memRssKb).toBe(4000);
});
it("attributes GPU memory through the process tree", () => {
const gpu = new Map([
[110, 8000],
[300, 4000],
]);
const metrics = buildPaneMetrics(panes, processes, gpu);
expect(metrics.find((m) => m.paneId === "%1")!.gpuMemMb).toBe(8000);
expect(metrics.find((m) => m.paneId === "%2")!.gpuMemMb).toBe(0);
});
it("handles a pane whose pid is missing from ps output", () => {
const orphan = parsePanes(
join("gone", 0, "%9", 0, 99999, 0, 80, 24, "bash", "/", "t"),
);
const metrics = buildPaneMetrics(orphan, processes, new Map());
expect(metrics[0].processCount).toBe(0);
expect(metrics[0].cpuPercent).toBe(0);
expect(metrics[0].topCommand).toBeNull();
});
it("does not loop on cyclic ppid data", () => {
const cyclic = parsePsOutput(
[" 100 101 1.0 0.1 1000 a", " 101 100 1.0 0.1 1000 b"].join("\n"),
);
const pane = parsePanes(
join("s", 0, "%1", 0, 100, 1, 80, 24, "a", "/", "t"),
);
const metrics = buildPaneMetrics(pane, cyclic, new Map());
expect(metrics[0].processCount).toBe(2);
});
});
describe("attachPanesToWindows", () => {
it("places panes into their windows", () => {
const windows = parseWindows(
[join("s1", 0, 1, "main"), join("s1", 1, 0, "logs")].join("\n"),
);
const panes = parsePanes(
[
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
join("s1", 1, "%2", 0, 200, 0, 80, 24, "tail", "/", "t"),
join("unknown", 5, "%3", 0, 300, 0, 80, 24, "bash", "/", "t"),
].join("\n"),
);
attachPanesToWindows(windows, panes);
expect(windows.get("s1")![0].panes).toHaveLength(1);
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
});
});
describe("shellEscape", () => {
it("wraps in single quotes and escapes embedded quotes", () => {
expect(shellEscape("simple")).toBe("'simple'");
expect(shellEscape("it's")).toBe("'it'\\''s'");
expect(shellEscape("$(rm -rf /)")).toBe("'$(rm -rf /)'");
});
});
+240
View File
@@ -0,0 +1,240 @@
// Pure parsing/aggregation helpers for the tmux monitor module.
// Kept free of SSH/Express dependencies so they can be unit-tested.
// Field separator used in tmux -F format strings. Session names, pane titles
// and paths may contain "|", and tmux sanitizes control characters (and, under
// non-UTF-8 locales, multibyte characters) in format output to "_", so a
// printable ASCII token is the only separator that survives everywhere.
export const SEP = "<<TMX>>";
export interface TmuxPane {
id: string;
index: number;
pid: number;
active: boolean;
width: number;
height: number;
command: string;
path: string;
title: string;
}
export interface TmuxWindow {
index: number;
name: string;
active: boolean;
panes: TmuxPane[];
}
export interface TmuxSessionSummary {
name: string;
created: number;
lastActivity: number;
attachedClients: number;
}
export interface RawPane extends TmuxPane {
sessionName: string;
windowIndex: number;
}
export interface ProcessInfo {
pid: number;
ppid: number;
cpu: number;
mem: number;
rss: number;
comm: string;
}
export interface PaneMetrics {
paneId: string;
sessionName: string;
pid: number;
processCount: number;
cpuPercent: number;
memRssKb: number;
gpuMemMb: number;
topCommand: string | null;
}
export function parseSessions(output: string): TmuxSessionSummary[] {
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const [name, created, activity, attached] = line.split(SEP);
return {
name,
created: parseInt(created, 10) || 0,
lastActivity: parseInt(activity, 10) || 0,
attachedClients: parseInt(attached, 10) || 0,
};
});
}
export function parseWindows(output: string): Map<string, TmuxWindow[]> {
const bySession = new Map<string, TmuxWindow[]>();
for (const line of output.split("\n").filter(Boolean)) {
const [session, index, active, name] = line.split(SEP);
if (!bySession.has(session)) bySession.set(session, []);
bySession.get(session)!.push({
index: parseInt(index, 10) || 0,
name: name || "",
active: active === "1",
panes: [],
});
}
return bySession;
}
export function parsePanes(output: string): RawPane[] {
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const [
sessionName,
windowIndex,
id,
index,
pid,
active,
width,
height,
command,
path,
title,
] = line.split(SEP);
return {
sessionName,
windowIndex: parseInt(windowIndex, 10) || 0,
id,
index: parseInt(index, 10) || 0,
pid: parseInt(pid, 10) || 0,
active: active === "1",
width: parseInt(width, 10) || 0,
height: parseInt(height, 10) || 0,
command: command || "",
path: path || "",
title: title || "",
};
});
}
export function parsePsOutput(output: string): ProcessInfo[] {
const processes: ProcessInfo[] = [];
for (const line of output.split("\n")) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
const pid = parseInt(parts[0], 10);
const ppid = parseInt(parts[1], 10);
if (isNaN(pid) || isNaN(ppid)) continue;
processes.push({
pid,
ppid,
cpu: parseFloat(parts[2]) || 0,
mem: parseFloat(parts[3]) || 0,
rss: parseInt(parts[4], 10) || 0,
comm: parts.slice(5).join(" "),
});
}
return processes;
}
export function parseGpuOutput(output: string): Map<number, number> {
const gpuByPid = new Map<number, number>();
for (const line of output.split("\n").filter(Boolean)) {
const [pid, mem] = line.split(",").map((s) => s.trim());
const pidNum = parseInt(pid, 10);
const memNum = parseInt(mem, 10);
if (!isNaN(pidNum) && !isNaN(memNum)) {
gpuByPid.set(pidNum, (gpuByPid.get(pidNum) || 0) + memNum);
}
}
return gpuByPid;
}
/**
* Map each pane's shell pid to its descendant process tree and aggregate
* CPU/RAM/GPU usage per pane.
*/
export function buildPaneMetrics(
panes: RawPane[],
processes: ProcessInfo[],
gpuByPid: Map<number, number>,
): PaneMetrics[] {
const byPid = new Map<number, ProcessInfo>();
const childrenOf = new Map<number, number[]>();
for (const p of processes) {
byPid.set(p.pid, p);
if (!childrenOf.has(p.ppid)) childrenOf.set(p.ppid, []);
childrenOf.get(p.ppid)!.push(p.pid);
}
return panes.map((pane) => {
// Walk the descendant tree starting at (and including) the pane's shell
const treePids: number[] = [];
const queue = [pane.pid];
const seen = new Set<number>();
while (queue.length > 0) {
const pid = queue.shift()!;
if (seen.has(pid)) continue;
seen.add(pid);
if (byPid.has(pid)) treePids.push(pid);
for (const child of childrenOf.get(pid) || []) queue.push(child);
}
let cpuPercent = 0;
let memRssKb = 0;
let gpuMemMb = 0;
let topCommand: string | null = null;
let topCpu = -1;
for (const pid of treePids) {
const p = byPid.get(pid)!;
cpuPercent += p.cpu;
memRssKb += p.rss;
gpuMemMb += gpuByPid.get(pid) || 0;
// The pane shell itself is rarely the interesting process
if (p.cpu > topCpu && pid !== pane.pid) {
topCpu = p.cpu;
topCommand = p.comm;
}
}
if (topCommand === null && treePids.length > 0) {
topCommand = byPid.get(treePids[0])!.comm;
}
return {
paneId: pane.id,
sessionName: pane.sessionName,
pid: pane.pid,
processCount: treePids.length,
cpuPercent: Math.round(cpuPercent * 10) / 10,
memRssKb,
gpuMemMb,
topCommand,
};
});
}
/**
* Group panes into their windows (mutates the window objects' pane arrays).
*/
export function attachPanesToWindows(
windows: Map<string, TmuxWindow[]>,
panes: RawPane[],
): void {
for (const pane of panes) {
const sessionWindows = windows.get(pane.sessionName) || [];
const window = sessionWindows.find((w) => w.index === pane.windowIndex);
if (window) {
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
window.panes.push(paneFields);
}
}
}
export function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
+932
View File
@@ -0,0 +1,932 @@
import express from "express";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import {
createCurrentTmuxSessionTagRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.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[]>> {
return createCurrentTmuxSessionTagRepository().listByUserAndHost(
userId,
hostId,
);
}
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 (DataCrypto.getUserDataKey(userId) === null) {
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 createCurrentUserRepository().findById(userId);
username = actor?.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 createCurrentTmuxSessionTagRepository().renameSessionForHost(
host.id,
sessionName,
newName,
);
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 createCurrentTmuxSessionTagRepository().deleteSessionForHost(
host.id,
sessionName,
);
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 {
await createCurrentTmuxSessionTagRepository().replaceForUserHostSession(
userId,
host.id,
sessionName,
cleanTags,
);
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, () => {});
+151
View File
@@ -0,0 +1,151 @@
export type TransferPlatform = "windows" | "unix";
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
export function isWindowsSftpPath(path: string): boolean {
const normalized = path.replace(/\\/g, "/");
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
}
export function inferPlatformFromPath(path: string): TransferPlatform | null {
if (isWindowsSftpPath(path)) return "windows";
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
return "unix";
}
return null;
}
export function normalizeSftpPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return path.startsWith("/") ? "/" : ".";
return normalized.replace(/([^:])\/+/g, "$1/");
}
export function basename(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
return normalized.substring(idx + 1) || normalized;
}
export function dirname(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return normalized;
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
const parent = normalized.substring(0, idx);
if (!parent) return "/";
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
return parent;
}
return parent || "/";
}
export function joinPath(base: string, ...parts: string[]): string {
let result = base.replace(/\\/g, "/");
for (const part of parts) {
if (!part) continue;
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
if (!segment) continue;
if (result.endsWith("/")) {
result += segment;
} else {
result = `${result}/${segment}`;
}
}
return normalizeSftpPath(result);
}
export interface PathSegments {
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
root: string;
/** Path segments below the root (may be empty). */
segments: string[];
}
export function splitPathSegments(path: string): PathSegments {
const normalized = normalizeSftpPath(path);
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
if (posixDrive) {
return {
root: posixDrive[1],
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
};
}
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
if (drive) {
return {
root: drive[1],
segments: (drive[2] ?? "").split("/").filter(Boolean),
};
}
if (normalized.startsWith("/")) {
return {
root: "/",
segments: normalized.split("/").filter(Boolean),
};
}
return {
root: "",
segments: normalized.split("/").filter(Boolean),
};
}
export function buildPathFromSegments(
root: string,
segments: string[],
count: number,
): string {
const slice = segments.slice(0, count);
if (/^\/[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (/^[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (root === "/") {
return slice.length ? `/${slice.join("/")}` : "/";
}
if (root === "") {
return slice.join("/");
}
return slice.length ? `${root}/${slice.join("/")}` : root;
}
export function pathsOverlap(source: string, dest: string): boolean {
const norm = (p: string) => {
const n = normalizeSftpPath(p).toLowerCase();
return n || (isWindowsSftpPath(p) ? p : "/");
};
const s = norm(source);
const d = norm(dest);
const sep = "/";
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
}
export function getWorkingDir(paths: string[]): string {
if (paths.length === 0) return "/";
const parents = paths.map((p) => dirname(p));
const first = parents[0];
if (parents.every((p) => p === first)) {
return first;
}
return first;
}
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
export function sftpPathToLocalPath(sftpPath: string): string {
const normalized = sftpPath.replace(/\\/g, "/");
if (/^\/[A-Za-z]:\//.test(normalized)) {
return normalized.slice(1).replace(/\//g, "\\");
}
if (/^[A-Za-z]:\//.test(normalized)) {
return normalized.replace(/\//g, "\\");
}
return sftpPath;
}
+193
View File
@@ -0,0 +1,193 @@
import type { TransferPlatform } from "./transfer-paths.js";
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
/** Share of total bytes in likely incompressible file types (01). */
incompressibleRatio: number;
}
const INCOMPRESSIBLE_EXT =
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
export function isLikelyIncompressiblePath(path: string): boolean {
return INCOMPRESSIBLE_EXT.test(path);
}
export function buildTransferScanSummary(
items: Array<{ sourcePath: string; size: number }>,
): TransferScanSummary {
let totalBytes = 0;
let largestFileBytes = 0;
let incompressibleBytes = 0;
for (const item of items) {
totalBytes += item.size;
largestFileBytes = Math.max(largestFileBytes, item.size);
if (isLikelyIncompressiblePath(item.sourcePath)) {
incompressibleBytes += item.size;
}
}
return {
fileCount: items.length,
totalBytes,
largestFileBytes,
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
};
}
/**
* Choose tar vs per-item SFTP for directory / multi-file transfers.
* Single-file stream transfers bypass this entirely.
*/
export function resolveArchiveTransferMethod(
preference: TransferMethodPreference,
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): "tar" | "item_sftp" {
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "item_sftp";
}
if (preference === "item_sftp") {
return "item_sftp";
}
if (preference === "tar") {
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
}
// auto
if (!sourceHasTar || !destHasTar) {
return "item_sftp";
}
const { fileCount, totalBytes, largestFileBytes, incompressibleRatio } =
summary;
if (fileCount === 0) {
return "item_sftp";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "item_sftp";
}
// Multi-item sets with a large file: tar only when data is likely compressible
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "tar";
}
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
return "item_sftp";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "item_sftp";
}
if (incompressibleRatio >= 0.85) {
return "item_sftp";
}
if (fileCount >= 100) {
return "tar";
}
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
return "tar";
}
if (fileCount > 5 && incompressibleRatio < 0.7) {
return "tar";
}
return "item_sftp";
}
export type ArchiveTransferReasonKey =
| "user_item_sftp"
| "user_tar"
| "tar_unavailable"
| "windows_host"
| "auto_multi_large"
| "auto_single_large_in_archive"
| "auto_many_incompressible"
| "auto_many_files"
| "auto_default";
/** i18n key suffix under transfer.methodReason.* */
export function getArchiveTransferReasonKey(
preference: TransferMethodPreference,
resolvedMethod: "tar" | "item_sftp",
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): ArchiveTransferReasonKey {
if (preference === "item_sftp") return "user_item_sftp";
if (preference === "tar") {
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
}
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "windows_host";
}
if (!sourceHasTar || !destHasTar) {
return "tar_unavailable";
}
const { fileCount, totalBytes, largestFileBytes, incompressibleRatio } =
summary;
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "auto_multi_large";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "auto_single_large_in_archive";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "auto_many_incompressible";
}
if (incompressibleRatio >= 0.85) {
return "auto_many_incompressible";
}
if (
fileCount >= 100 ||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
(fileCount > 5 && incompressibleRatio < 0.7)
) {
return "auto_many_files";
}
if (resolvedMethod === "tar") {
return "auto_many_files";
}
return "auto_default";
}
+116
View File
@@ -0,0 +1,116 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type { ClientChannel } from "ssh2";
import type { WebSocket } from "ws";
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
export function extractRequestToken(req: IncomingMessage): string | undefined {
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) return decodeURIComponent(match[1]);
}
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice("Bearer ".length);
}
return undefined;
}
export function sendC2SError(ws: WebSocket, message: string): void {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: "error", error: message }));
}
}
export function describeC2SRelayError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const lowerMessage = message.toLowerCase();
if (
lowerMessage.includes("administratively prohibited") ||
lowerMessage.includes("forwarding disabled") ||
lowerMessage.includes("open failed")
) {
return `SSH forwarding was rejected by the endpoint server: ${message}`;
}
if (
lowerMessage.includes("address already in use") ||
lowerMessage.includes("unable to bind") ||
lowerMessage.includes("bind")
) {
return `Remote port is not available on the endpoint server: ${message}`;
}
if (
lowerMessage.includes("name or service not known") ||
lowerMessage.includes("enotfound") ||
lowerMessage.includes("econnrefused")
) {
return `Tunnel target is not reachable from the endpoint host: ${message}`;
}
return message || "Failed to open relay";
}
function pauseSourceForC2SWebSocket(ws: WebSocket, source?: Duplex): void {
if (!source) return;
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
source.pause();
const resumeTimer = setInterval(() => {
if (
ws.readyState !== 1 ||
source.destroyed ||
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
) {
clearInterval(resumeTimer);
if (ws.readyState === 1 && !source.destroyed) {
source.resume();
}
}
}, 25);
}
export function sendC2SMessage(
ws: WebSocket,
message: Record<string, unknown>,
source?: Duplex,
): void {
if (ws.readyState === 1) {
ws.send(JSON.stringify(message), (error) => {
if (error && source && !source.destroyed) {
source.destroy(error);
}
});
pauseSourceForC2SWebSocket(ws, source);
}
}
export function writeC2SRemoteChunk(
target: ClientChannel,
chunk: Buffer,
ws: WebSocket,
closeTarget: () => void,
): void {
if (!target || target.destroyed) return;
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
closeTarget();
return;
}
const canContinue = target.write(chunk);
if (!canContinue) {
ws.pause();
target.once("drain", () => {
if (ws.readyState === 1) {
ws.resume();
}
});
}
}
+409
View File
@@ -0,0 +1,409 @@
import { Client, type ClientChannel } from "ssh2";
import type { WebSocket } from "ws";
import type { TunnelConfig } from "../../types/index.js";
import { createSocks5Connection } from "../utils/socks5-helper.js";
import { tunnelLogger } from "../utils/logger.js";
import { PermissionManager } from "../utils/permission-manager.js";
import {
applyAuthOptions,
bindForwardIn,
connectClient,
forwardOut,
getManagedTunnelAlgorithms,
unbindForwardIn,
} from "./tunnel-ssh-primitives.js";
import {
sendC2SMessage,
writeC2SRemoteChunk,
} from "./tunnel-c2s-relay-utils.js";
import { getTunnelMode } from "./tunnel-utils.js";
export type C2SOpenMessage = {
type: "open" | "test";
tunnelConfig?: Partial<TunnelConfig>;
targetHost?: string;
targetPort?: number;
};
const permissionManager = PermissionManager.getInstance();
let c2sRemoteStreamCounter = 0;
async function resolveC2STunnelSource(
tunnelConfig: Partial<TunnelConfig>,
userId: string,
): Promise<TunnelConfig> {
if (!tunnelConfig.sourceHostId) {
throw new Error("Endpoint SSH host is required");
}
const accessInfo = await permissionManager.canAccessHost(
userId,
tunnelConfig.sourceHostId,
"read",
);
if (!accessInfo.hasAccess) {
throw new Error("Access denied to this host");
}
const { resolveHostById } = await import("./host-resolver.js");
const resolvedHost = await resolveHostById(tunnelConfig.sourceHostId, userId);
if (!resolvedHost) {
throw new Error("Endpoint SSH host not found");
}
return {
name: tunnelConfig.name || `c2s:${tunnelConfig.sourceHostId}`,
scope: "c2s",
mode: tunnelConfig.mode || "local",
tunnelType:
tunnelConfig.tunnelType ||
(tunnelConfig.mode === "remote" ? "remote" : "local"),
bindHost: tunnelConfig.bindHost,
targetHost: tunnelConfig.targetHost || "127.0.0.1",
sourceHostId: resolvedHost.id || tunnelConfig.sourceHostId,
tunnelIndex: tunnelConfig.tunnelIndex || 0,
requestingUserId: userId,
hostName:
resolvedHost.name || `${resolvedHost.username}@${resolvedHost.ip}`,
sourceIP: resolvedHost.ip,
sourceSSHPort: resolvedHost.port,
sourceUsername: resolvedHost.username,
sourcePassword: resolvedHost.password,
sourceAuthMethod: resolvedHost.authType,
sourceSSHKey: resolvedHost.key,
sourceKeyPassword: resolvedHost.keyPassword,
sourceKeyType: resolvedHost.keyType,
sourceCredentialId: resolvedHost.credentialId,
sourceUserId: resolvedHost.userId,
endpointIP: tunnelConfig.endpointIP || resolvedHost.ip,
endpointSSHPort: tunnelConfig.endpointSSHPort || resolvedHost.port,
endpointUsername: resolvedHost.username,
endpointHost:
tunnelConfig.endpointHost || resolvedHost.name || resolvedHost.ip,
endpointAuthMethod: resolvedHost.authType,
endpointSSHKey: resolvedHost.key,
endpointKeyPassword: resolvedHost.keyPassword,
endpointKeyType: resolvedHost.keyType,
endpointCredentialId: resolvedHost.credentialId,
endpointUserId: resolvedHost.userId,
sourcePort: Number(tunnelConfig.sourcePort) || 0,
endpointPort: Number(tunnelConfig.endpointPort) || 0,
maxRetries: Number(tunnelConfig.maxRetries) || 0,
retryInterval: Number(tunnelConfig.retryInterval) || 0,
autoStart: Boolean(tunnelConfig.autoStart),
isPinned: Boolean(resolvedHost.pin),
useSocks5: Boolean(resolvedHost.useSocks5),
socks5Host: resolvedHost.socks5Host,
socks5Port: resolvedHost.socks5Port,
socks5Username: resolvedHost.socks5Username,
socks5Password: resolvedHost.socks5Password,
socks5ProxyChain: resolvedHost.socks5ProxyChain,
keepaliveInterval:
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
? resolvedHost.terminalConfig.keepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
? resolvedHost.terminalConfig.keepaliveCountMax
: 5,
};
}
async function connectC2SSourceClient(
tunnelConfig: TunnelConfig,
): Promise<Client> {
const connOptions: Record<string, unknown> = {
host:
tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
tryKeyboard: true,
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 30000,
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: getManagedTunnelAlgorithms(),
};
applyAuthOptions(connOptions, {
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
keyPassword: tunnelConfig.sourceKeyPassword,
keyType: tunnelConfig.sourceKeyType,
authMethod: tunnelConfig.sourceAuthMethod,
});
if (
tunnelConfig.useSocks5 &&
(tunnelConfig.socks5Host ||
(tunnelConfig.socks5ProxyChain &&
tunnelConfig.socks5ProxyChain.length > 0))
) {
const socks5Socket = await createSocks5Connection(
tunnelConfig.sourceIP,
tunnelConfig.sourceSSHPort,
{
useSocks5: tunnelConfig.useSocks5,
socks5Host: tunnelConfig.socks5Host,
socks5Port: tunnelConfig.socks5Port,
socks5Username: tunnelConfig.socks5Username,
socks5Password: tunnelConfig.socks5Password,
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
},
);
if (socks5Socket) {
connOptions.sock = socks5Socket;
}
}
return connectClient(connOptions, tunnelConfig.name, "source");
}
async function handleC2SRemoteRelayOpen(
ws: WebSocket,
tunnelConfig: TunnelConfig,
): Promise<void> {
const tunnelName = tunnelConfig.name;
const sourceClient = await connectC2SSourceClient(tunnelConfig);
const bindHost = tunnelConfig.targetHost || "127.0.0.1";
const bindPort = Number(tunnelConfig.sourcePort);
let closed = false;
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
const streams = new Map<string, ClientChannel>();
const closeStream = (streamId: string): void => {
const stream = streams.get(streamId);
if (!stream) return;
streams.delete(streamId);
try {
stream.destroy();
} catch {
// expected during shutdown
}
};
const close = (): void => {
if (closed) return;
closed = true;
for (const streamId of streams.keys()) {
closeStream(streamId);
}
unbindForwardIn(sourceClient, bindHost, actualPort);
try {
sourceClient.end();
} catch {
// expected during shutdown
}
};
sourceClient.on("tcp connection", (info, accept, reject) => {
if (info.destPort !== actualPort) {
reject();
return;
}
const inbound = accept();
const streamId = `${Date.now()}-${++c2sRemoteStreamCounter}`;
streams.set(streamId, inbound);
sendC2SMessage(ws, { type: "connection", streamId });
inbound.on("data", (chunk) => {
sendC2SMessage(
ws,
{
type: "data",
streamId,
data: chunk.toString("base64"),
},
inbound,
);
});
inbound.on("close", () => {
streams.delete(streamId);
sendC2SMessage(ws, { type: "close", streamId });
});
inbound.on("error", (error) => {
streams.delete(streamId);
sendC2SMessage(ws, {
type: "close",
streamId,
error: error instanceof Error ? error.message : String(error),
});
});
});
ws.on("message", (data, isBinary) => {
if (isBinary) return;
try {
const message = JSON.parse(data.toString()) as {
type?: string;
streamId?: string;
data?: string;
};
if (!message.streamId) return;
if (message.type === "data" && message.data) {
const stream = streams.get(message.streamId);
if (stream) {
writeC2SRemoteChunk(
stream,
Buffer.from(message.data, "base64"),
ws,
() => closeStream(message.streamId as string),
);
}
} else if (message.type === "close") {
closeStream(message.streamId);
}
} catch (error) {
tunnelLogger.warn("Invalid C2S remote relay message", {
operation: "c2s_remote_relay_invalid_message",
tunnelName,
error: error instanceof Error ? error.message : String(error),
});
}
});
ws.on("close", close);
ws.on("error", close);
sourceClient.on("close", () => {
if (ws.readyState === 1) ws.close();
});
sourceClient.on("error", (error) => {
sendC2SMessage(ws, {
type: "error",
error: error instanceof Error ? error.message : String(error),
});
if (ws.readyState === 1) ws.close();
});
tunnelLogger.info("C2S remote tunnel ready", {
operation: "c2s_remote_tunnel_ready",
tunnelName,
bindHost,
bindPort: actualPort,
endpointHost: tunnelConfig.endpointHost,
});
sendC2SMessage(ws, { type: "ready", bindHost, bindPort: actualPort });
}
export async function handleC2SRelayOpen(
ws: WebSocket,
message: C2SOpenMessage,
userId: string,
): Promise<void> {
const tunnelConfig = await resolveC2STunnelSource(
message.tunnelConfig || {},
userId,
);
const mode = getTunnelMode(tunnelConfig);
if (mode === "remote") {
await handleC2SRemoteRelayOpen(ws, tunnelConfig);
return;
}
const targetHost =
mode === "dynamic"
? message.targetHost
: tunnelConfig.targetHost || "127.0.0.1";
const targetPort =
mode === "dynamic"
? Number(message.targetPort)
: Number(tunnelConfig.endpointPort);
if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) {
throw new Error("Invalid client tunnel target");
}
const sourceClient = await connectC2SSourceClient(tunnelConfig);
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
const close = () => {
try {
outbound.destroy();
} catch {
// expected during shutdown
}
try {
sourceClient.end();
} catch {
// expected during shutdown
}
};
outbound.on("data", (chunk) => {
if (ws.readyState === 1) {
ws.send(chunk);
}
});
outbound.on("close", () => {
if (ws.readyState === 1) ws.close();
});
outbound.on("error", () => {
if (ws.readyState === 1) ws.close();
});
ws.on("close", close);
ws.on("error", close);
ws.on("message", (data, isBinary) => {
if (!isBinary) return;
const chunk = Buffer.isBuffer(data)
? data
: Array.isArray(data)
? Buffer.concat(data)
: Buffer.from(data);
outbound.write(chunk);
});
ws.send(JSON.stringify({ type: "ready" }));
}
export async function handleC2SRelayTest(
ws: WebSocket,
message: C2SOpenMessage,
userId: string,
): Promise<void> {
const tunnelConfig = await resolveC2STunnelSource(
message.tunnelConfig || {},
userId,
);
const mode = getTunnelMode(tunnelConfig);
const sourceClient = await connectC2SSourceClient(tunnelConfig);
try {
if (mode === "remote") {
const bindHost = tunnelConfig.targetHost || "127.0.0.1";
const bindPort = Number(tunnelConfig.sourcePort);
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
unbindForwardIn(sourceClient, bindHost, actualPort);
} else if (mode === "local") {
const targetHost = tunnelConfig.targetHost || "127.0.0.1";
const targetPort = Number(tunnelConfig.endpointPort);
if (!Number.isInteger(targetPort) || targetPort < 1) {
throw new Error("Invalid remote target port");
}
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
outbound.destroy();
}
sendC2SMessage(ws, { type: "ready" });
} finally {
try {
sourceClient.end();
} catch {
// expected during shutdown
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { Duplex } from "stream";
import { tunnelLogger } from "../utils/logger.js";
function parseSocksAddress(buffer: Buffer): {
address: string;
port: number;
bytesRead: number;
} | null {
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
return null;
}
const addressType = buffer[3];
let offset = 4;
let address: string;
if (addressType === 0x01) {
if (buffer.length < offset + 4 + 2) return null;
address = Array.from(buffer.subarray(offset, offset + 4)).join(".");
offset += 4;
} else if (addressType === 0x03) {
const len = buffer[offset];
offset += 1;
if (buffer.length < offset + len + 2) return null;
address = buffer.subarray(offset, offset + len).toString("utf8");
offset += len;
} else if (addressType === 0x04) {
if (buffer.length < offset + 16 + 2) return null;
const parts: string[] = [];
for (let i = 0; i < 16; i += 2) {
parts.push(buffer.readUInt16BE(offset + i).toString(16));
}
address = parts.join(":");
offset += 16;
} else {
return null;
}
const port = buffer.readUInt16BE(offset);
return { address, port, bytesRead: offset + 2 };
}
export function handleSocks5Connect(
inbound: Duplex,
openOutbound: (host: string, port: number) => Promise<Duplex>,
tunnelName: string,
): void {
let buffer = Buffer.alloc(0);
let stage: "greeting" | "connect" | "piping" = "greeting";
const fail = (code = 0x01) => {
if (!inbound.destroyed) {
inbound.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
inbound.destroy();
}
};
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
if (stage === "greeting") {
if (buffer.length < 2) return;
if (buffer[0] !== 0x05) {
fail(0x01);
return;
}
const methodsLength = buffer[1];
if (buffer.length < 2 + methodsLength) return;
inbound.write(Buffer.from([0x05, 0x00]));
buffer = buffer.subarray(2 + methodsLength);
stage = "connect";
}
if (stage === "connect") {
const parsed = parseSocksAddress(buffer);
if (!parsed) return;
stage = "piping";
inbound.off("data", onData);
const remainder = buffer.subarray(parsed.bytesRead);
openOutbound(parsed.address, parsed.port)
.then((outbound) => {
inbound.write(
Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
);
if (remainder.length > 0) {
outbound.write(remainder);
}
inbound.pipe(outbound).pipe(inbound);
inbound.on("error", () => outbound.destroy());
outbound.on("error", () => inbound.destroy());
})
.catch((error) => {
tunnelLogger.error("SOCKS5 tunnel connect failed", error, {
operation: "managed_tunnel_socks_connect_failed",
tunnelName,
targetHost: parsed.address,
targetPort: parsed.port,
});
fail(0x05);
});
}
};
inbound.on("data", onData);
inbound.on("error", () => inbound.destroy());
}
+181
View File
@@ -0,0 +1,181 @@
import { Client, type ClientChannel } from "ssh2";
import type { Duplex } from "stream";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { tunnelLogger } from "../utils/logger.js";
export function getManagedTunnelAlgorithms() {
return {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
};
}
export function applyAuthOptions(
connOptions: Record<string, unknown>,
credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
keyType?: string;
authMethod?: string;
},
): void {
if (credentials.authMethod === "key" && credentials.sshKey) {
const cleanKey = credentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
if (credentials.keyPassword) {
connOptions.passphrase = credentials.keyPassword;
}
if (credentials.keyType && credentials.keyType !== "auto") {
connOptions.privateKeyType = credentials.keyType;
}
} else {
connOptions.password = credentials.password;
}
}
export function connectClient(
connOptions: Record<string, unknown>,
tunnelName: string,
role: "source" | "endpoint",
): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client();
let settled = false;
client.once("ready", () => {
settled = true;
resolve(client);
});
client.once("error", (error) => {
if (!settled) {
reject(error);
return;
}
tunnelLogger.error("Managed tunnel SSH client error", error, {
operation: "managed_tunnel_client_error",
tunnelName,
role,
});
});
client.connect(connOptions);
});
}
export function forwardOut(
client: Client,
targetHost: string,
targetPort: number,
tunnelName?: string,
): Promise<ClientChannel> {
return new Promise((resolve, reject) => {
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
if (err) {
if (tunnelName) {
tunnelLogger.error("Managed tunnel forwardOut failed", err, {
operation: "managed_tunnel_forward_out_failed",
tunnelName,
targetHost,
targetPort,
});
}
reject(err);
return;
}
resolve(stream);
});
});
}
export function bindForwardIn(
client: Client,
bindHost: string,
bindPort: number,
): Promise<number> {
return new Promise((resolve, reject) => {
client.forwardIn(bindHost, bindPort, (err, actualPort) => {
if (err) {
reject(err);
return;
}
resolve(actualPort || bindPort);
});
});
}
export function unbindForwardIn(
client: Client,
bindHost: string,
bindPort: number,
): void {
try {
client.unforwardIn(bindHost, bindPort, (err) => {
if (err) {
tunnelLogger.warn("Failed to unbind managed tunnel listener", {
operation: "managed_tunnel_unforward_failed",
bindHost,
bindPort,
error: err.message,
});
}
});
} catch {
// The connection may already be gone.
}
}
export function pipeTunnelStreams(
inbound: Duplex,
outboundPromise: Promise<Duplex>,
tunnelName: string,
): void {
outboundPromise
.then((outbound) => {
inbound.pipe(outbound).pipe(inbound);
inbound.on("error", () => outbound.destroy());
outbound.on("error", () => inbound.destroy());
})
.catch((error) => {
tunnelLogger.error(
"Failed to open managed tunnel outbound stream",
error,
{
operation: "managed_tunnel_outbound_failed",
tunnelName,
},
);
inbound.destroy();
});
}
+131
View File
@@ -0,0 +1,131 @@
import type { ErrorType, TunnelConfig } from "../../types/index.js";
import { tunnelLogger } from "../utils/logger.js";
export function classifyTunnelError(errorMessage: string): ErrorType {
if (!errorMessage) return "UNKNOWN";
const message = errorMessage.toLowerCase();
if (
message.includes("closed by remote host") ||
message.includes("connection reset by peer") ||
message.includes("connection refused") ||
message.includes("broken pipe")
) {
return "NETWORK_ERROR";
}
if (
message.includes("authentication failed") ||
message.includes("permission denied") ||
message.includes("incorrect password")
) {
return "AUTHENTICATION_FAILED";
}
if (
message.includes("connect etimedout") ||
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("keepalive timeout")
) {
return "TIMEOUT";
}
if (
message.includes("bind: address already in use") ||
message.includes("failed for listen port") ||
message.includes("port forwarding failed")
) {
return "CONNECTION_FAILED";
}
if (message.includes("permission") || message.includes("access denied")) {
return "CONNECTION_FAILED";
}
return "UNKNOWN";
}
export function getTunnelMarker(tunnelName: string): string {
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
}
export function normalizeTunnelName(
hostId: number,
tunnelIndex: number,
displayName: string,
sourcePort: number,
endpointHost: string,
endpointPort: number,
): string {
return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`;
}
export function getTunnelMode(
tunnelConfig: TunnelConfig,
): "local" | "remote" | "dynamic" {
return tunnelConfig.mode || tunnelConfig.tunnelType || "remote";
}
export function getTunnelScope(tunnelConfig: TunnelConfig): "s2s" | "c2s" {
return tunnelConfig.scope || "s2s";
}
export function getTunnelBindHost(tunnelConfig: TunnelConfig): string {
return tunnelConfig.bindHost || "127.0.0.1";
}
export function parseTunnelName(tunnelName: string): {
hostId?: number;
tunnelIndex?: number;
displayName: string;
sourcePort: string;
endpointHost: string;
endpointPort: string;
isLegacyFormat: boolean;
} {
const parts = tunnelName.split("::");
if (parts.length === 6) {
return {
hostId: parseInt(parts[0]),
tunnelIndex: parseInt(parts[1]),
displayName: parts[2],
sourcePort: parts[3],
endpointHost: parts[4],
endpointPort: parts[5],
isLegacyFormat: false,
};
}
tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`);
const legacyParts = tunnelName.split("_");
return {
displayName: legacyParts[0] || "unknown",
sourcePort: legacyParts[legacyParts.length - 3] || "0",
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
endpointPort: legacyParts[legacyParts.length - 1] || "0",
isLegacyFormat: true,
};
}
export function validateTunnelConfig(
tunnelName: string,
tunnelConfig: TunnelConfig,
): boolean {
const parsed = parseTunnelName(tunnelName);
if (parsed.isLegacyFormat) {
return true;
}
return (
parsed.hostId === tunnelConfig.sourceHostId &&
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
parsed.endpointHost === tunnelConfig.endpointHost &&
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
);
}
+92
View File
@@ -0,0 +1,92 @@
import express from "express";
import { createServer } from "http";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import cookieParser from "cookie-parser";
import { WebSocketServer } from "ws";
import { tunnelLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import {
describeC2SRelayError,
extractRequestToken,
sendC2SError,
} from "../tunnel-c2s-relay-utils.js";
import {
handleC2SRelayOpen,
handleC2SRelayTest,
type C2SOpenMessage,
} from "../tunnel-c2s-relay.js";
import { registerTunnelRoutes } from "./routes.js";
import { initializeAutoStartTunnels } from "./manager.js";
const authManager = AuthManager.getInstance();
const app = express();
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
app.use(express.json());
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
registerTunnelRoutes(app);
const PORT = 30003;
const server = createServer(app);
const c2sRelayWss = new WebSocketServer({
server,
path: "/ssh/tunnel/c2s/stream",
});
c2sRelayWss.on("connection", (ws, req) => {
let opened = false;
ws.once("message", async (raw) => {
try {
const token = extractRequestToken(req);
const payload = token ? await authManager.verifyJWTToken(token) : null;
if (!payload?.userId || payload.pendingTOTP) {
sendC2SError(ws, "Authentication required");
ws.close();
return;
}
const message = JSON.parse(raw.toString()) as C2SOpenMessage;
if (message.type !== "open" && message.type !== "test") {
throw new Error("Invalid client tunnel relay request");
}
opened = true;
if (message.type === "test") {
await handleC2SRelayTest(ws, message, payload.userId);
} else {
await handleC2SRelayOpen(ws, message, payload.userId);
}
} catch (error) {
const message = describeC2SRelayError(error);
tunnelLogger.error("Failed to open C2S relay", error, {
operation: "c2s_relay_open_failed",
});
sendC2SError(ws, message);
ws.close();
}
});
ws.on("close", () => {
if (!opened) {
tunnelLogger.info("C2S relay closed before opening", {
operation: "c2s_relay_closed_before_open",
});
}
});
});
server.listen(PORT, () => {
setTimeout(() => {
initializeAutoStartTunnels();
}, 2000);
});
File diff suppressed because it is too large Load Diff
+596
View File
@@ -0,0 +1,596 @@
import express, { type Response } from "express";
import axios from "axios";
import type {
SSHHost,
TunnelConfig,
AuthenticatedRequest,
} from "../../../types/index.js";
import { CONNECTION_STATES } from "../../../types/index.js";
import { tunnelLogger } from "../../utils/logger.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { getTunnelMode, validateTunnelConfig } from "../tunnel-utils.js";
import {
tunnelConfigs,
activeRetryTimers,
pendingTunnelOperations,
manualDisconnects,
retryExhaustedTunnels,
retryCounters,
countdownIntervals,
tunnelConnecting,
tunnelStatusClients,
connectionStatus,
cleanupTunnelResources,
broadcastTunnelStatus,
handleDisconnect,
sendTunnelStatusSnapshot,
isSingleHostTunnel,
getAllTunnelStatus,
findHostByTunnelEndpoint,
connectSSHTunnel,
} from "./manager.js";
const permissionManager = PermissionManager.getInstance();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
export function registerTunnelRoutes(app: express.Express): void {
app.get(
"/ssh/tunnel/status",
authenticateJWT,
(req: AuthenticatedRequest, res: Response) => {
if (!req.userId) {
return res.status(401).json({ error: "Authentication required" });
}
res.json(getAllTunnelStatus());
},
);
app.get(
"/ssh/tunnel/status/stream",
authenticateJWT,
(req: AuthenticatedRequest, res: Response) => {
if (!req.userId) {
return res.status(401).json({ error: "Authentication required" });
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
tunnelStatusClients.add(res);
sendTunnelStatusSnapshot(res);
const heartbeat = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
closeStream();
}
}, 30000);
const closeStream = () => {
clearInterval(heartbeat);
tunnelStatusClients.delete(res);
};
req.on("close", closeStream);
},
);
/**
* @openapi
* /ssh/tunnel/status/{tunnelName}:
* get:
* summary: Get tunnel status by name
* description: Retrieves the status of a specific SSH tunnel by its name.
* tags:
* - SSH Tunnels
* parameters:
* - in: path
* name: tunnelName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tunnel status.
* 404:
* description: Tunnel not found.
*/
app.get(
"/ssh/tunnel/status/:tunnelName",
authenticateJWT,
(req: AuthenticatedRequest, res: Response) => {
if (!req.userId) {
return res.status(401).json({ error: "Authentication required" });
}
const tunnelNameParam = req.params.tunnelName;
const tunnelName = Array.isArray(tunnelNameParam)
? tunnelNameParam[0]
: tunnelNameParam;
const status = connectionStatus.get(tunnelName);
if (!status) {
return res.status(404).json({ error: "Tunnel not found" });
}
res.json({ name: tunnelName, status });
},
);
/**
* @openapi
* /ssh/tunnel/connect:
* post:
* summary: Connect SSH tunnel
* description: Establishes an SSH tunnel connection with the specified configuration.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* sourceHostId:
* type: integer
* tunnelIndex:
* type: integer
* responses:
* 200:
* description: Connection request received.
* 400:
* description: Invalid tunnel configuration.
* 401:
* description: Authentication required.
* 403:
* description: Access denied to this host.
* 500:
* description: Failed to connect tunnel.
*/
app.post(
"/ssh/tunnel/connect",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const tunnelConfig: TunnelConfig = req.body;
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!tunnelConfig || !tunnelConfig.name) {
return res.status(400).json({ error: "Invalid tunnel configuration" });
}
const tunnelName = tunnelConfig.name;
tunnelConfig.requestingUserId = userId;
try {
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
tunnelLogger.error(`Tunnel config validation failed`, {
operation: "tunnel_connect",
tunnelName,
configHostId: tunnelConfig.sourceHostId,
configTunnelIndex: tunnelConfig.tunnelIndex,
});
return res.status(400).json({
error: "Tunnel configuration does not match tunnel name",
});
}
if (tunnelConfig.sourceHostId) {
const accessInfo = await permissionManager.canAccessHost(
userId,
tunnelConfig.sourceHostId,
"read",
);
if (!accessInfo.hasAccess) {
tunnelLogger.warn("User attempted tunnel connect without access", {
operation: "tunnel_connect_unauthorized",
userId,
hostId: tunnelConfig.sourceHostId,
tunnelName,
});
return res
.status(403)
.json({ error: "Access denied to this host" });
}
}
if (pendingTunnelOperations.has(tunnelName)) {
try {
await pendingTunnelOperations.get(tunnelName);
} catch {
tunnelLogger.warn(`Previous tunnel operation failed`, {
tunnelName,
});
}
}
const operation = (async () => {
manualDisconnects.delete(tunnelName);
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
await cleanupTunnelResources(tunnelName);
if (tunnelConfigs.has(tunnelName)) {
const existingConfig = tunnelConfigs.get(tunnelName);
if (
existingConfig &&
(existingConfig.sourceHostId !== tunnelConfig.sourceHostId ||
existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex)
) {
throw new Error(`Tunnel name collision detected: ${tunnelName}`);
}
}
if (
!isSingleHostTunnel(tunnelConfig) &&
(!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername)
) {
try {
const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken =
await systemCrypto.getInternalAuthToken();
const allHostsResponse = await axios.get(
"http://localhost:30001/host/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Auth-Token": internalAuthToken,
},
},
);
const allHosts: SSHHost[] = allHostsResponse.data || [];
const endpointHost = findHostByTunnelEndpoint(
allHosts,
tunnelConfig.endpointHost,
);
if (!endpointHost) {
if (getTunnelMode(tunnelConfig) !== "remote") {
tunnelConfig.endpointIP =
tunnelConfig.endpointIP || tunnelConfig.endpointHost;
} else {
throw new Error(
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
);
}
} else {
if (!endpointHost.id) {
throw new Error("Endpoint host not found");
}
const endpointAccess = await permissionManager.canAccessHost(
userId,
endpointHost.id,
"read",
);
if (!endpointAccess.hasAccess) {
tunnelLogger.warn(
"User attempted tunnel connect without endpoint access",
{
operation: "tunnel_connect_endpoint_unauthorized",
userId,
hostId: endpointHost.id,
tunnelName,
},
);
throw new Error("Endpoint host not found");
}
tunnelConfig.endpointIP = endpointHost.ip;
tunnelConfig.endpointSSHPort = endpointHost.port;
tunnelConfig.endpointUsername = endpointHost.username;
tunnelConfig.endpointAuthMethod = endpointHost.authType;
tunnelConfig.endpointKeyType = endpointHost.keyType;
tunnelConfig.endpointCredentialId =
endpointHost.userId === userId
? endpointHost.credentialId
: undefined;
tunnelConfig.endpointUserId = userId;
// Resolve credentials server-side instead of from HTTP response
if (endpointHost.id) {
try {
const { resolveHostById } =
await import("../host-resolver.js");
const resolved = await resolveHostById(
endpointHost.id,
userId,
);
if (resolved) {
tunnelConfig.endpointPassword = resolved.password;
tunnelConfig.endpointSSHKey = resolved.key;
tunnelConfig.endpointKeyPassword = resolved.keyPassword;
}
} catch (credError) {
tunnelLogger.warn(
"Failed to resolve endpoint credentials from DB",
{
operation: "tunnel_endpoint_credential_resolve",
endpointHostId: endpointHost.id,
error:
credError instanceof Error
? credError.message
: "Unknown",
},
);
}
}
}
} catch (resolveError) {
tunnelLogger.error(
"Failed to resolve endpoint host",
resolveError,
{
operation: "tunnel_connect_resolve_endpoint_failed",
tunnelName,
endpointHost: tunnelConfig.endpointHost,
},
);
throw new Error(
`Failed to resolve endpoint host: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`,
{ cause: resolveError },
);
}
}
tunnelConfigs.set(tunnelName, tunnelConfig);
await connectSSHTunnel(tunnelConfig, 0);
})();
pendingTunnelOperations.set(tunnelName, operation);
res.json({ message: "Connection request received", tunnelName });
operation
.catch((err) => {
tunnelLogger.error("Tunnel operation failed", err, {
operation: "tunnel_operation_failed",
tunnelName,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: err instanceof Error ? err.message : "Unknown error",
});
tunnelConnecting.delete(tunnelName);
})
.finally(() => {
pendingTunnelOperations.delete(tunnelName);
});
} catch (error) {
tunnelLogger.error("Failed to process tunnel connect", error, {
operation: "tunnel_connect",
tunnelName,
userId,
});
res.status(500).json({ error: "Failed to connect tunnel" });
}
},
);
/**
* @openapi
* /ssh/tunnel/disconnect:
* post:
* summary: Disconnect SSH tunnel
* description: Disconnects an active SSH tunnel.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Disconnect request received.
* 400:
* description: Tunnel name required.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to disconnect tunnel.
*/
app.post(
"/ssh/tunnel/disconnect",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const { tunnelName } = req.body;
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!tunnelName) {
return res.status(400).json({ error: "Tunnel name required" });
}
try {
const config = tunnelConfigs.get(tunnelName);
if (config && config.sourceHostId) {
const accessInfo = await permissionManager.canAccessHost(
userId,
config.sourceHostId,
"read",
);
if (!accessInfo.hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
}
tunnelLogger.info("Tunnel stop request received", {
operation: "tunnel_stop_request",
userId,
hostId: config?.sourceHostId,
tunnelName,
});
manualDisconnects.add(tunnelName);
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!);
activeRetryTimers.delete(tunnelName);
}
await cleanupTunnelResources(tunnelName, true);
tunnelLogger.info("Tunnel cleanup completed", {
operation: "tunnel_cleanup_complete",
userId,
hostId: config?.sourceHostId,
tunnelName,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false);
setTimeout(() => {
manualDisconnects.delete(tunnelName);
}, 5000);
res.json({ message: "Disconnect request received", tunnelName });
} catch (error) {
tunnelLogger.error("Failed to disconnect tunnel", error, {
operation: "tunnel_disconnect",
tunnelName,
userId,
});
res.status(500).json({ error: "Failed to disconnect tunnel" });
}
},
);
/**
* @openapi
* /ssh/tunnel/cancel:
* post:
* summary: Cancel tunnel retry
* description: Cancels the retry mechanism for a failed SSH tunnel connection.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Cancel request received.
* 400:
* description: Tunnel name required.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to cancel tunnel retry.
*/
app.post(
"/ssh/tunnel/cancel",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const { tunnelName } = req.body;
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!tunnelName) {
return res.status(400).json({ error: "Tunnel name required" });
}
try {
const config = tunnelConfigs.get(tunnelName);
if (config && config.sourceHostId) {
const accessInfo = await permissionManager.canAccessHost(
userId,
config.sourceHostId,
"read",
);
if (!accessInfo.hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
}
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!);
activeRetryTimers.delete(tunnelName);
}
if (countdownIntervals.has(tunnelName)) {
clearInterval(countdownIntervals.get(tunnelName)!);
countdownIntervals.delete(tunnelName);
}
await cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false);
setTimeout(() => {
manualDisconnects.delete(tunnelName);
}, 5000);
res.json({ message: "Cancel request received", tunnelName });
} catch (error) {
tunnelLogger.error("Failed to cancel tunnel retry", error, {
operation: "tunnel_cancel",
tunnelName,
userId,
});
res.status(500).json({ error: "Failed to cancel tunnel retry" });
}
},
);
}
+214
View File
@@ -0,0 +1,214 @@
// Orchestrates the interactive Vault OIDC login that yields a signed,
// short-lived SSH certificate. Mirrors the OPKSSH session manager but is
// driven entirely over Vault's HTTP API (no external binary).
//
// Lifecycle:
// - startVaultAuth(): generate ephemeral keypair, ask Vault for an OIDC
// auth_url, stash a pending session keyed by the Vault-issued `state`,
// and send the auth_url to the browser over the terminal WebSocket.
// - The browser completes OIDC; the IdP redirects to VAULT_OIDC_CALLBACK_PATH.
// - completeVaultAuth(): exchange the code for a Vault token, sign the
// ephemeral key, cache the cert, and notify the browser to reconnect.
import { WebSocket } from "ws";
import {
createCurrentHostResolutionRepository,
createCurrentVaultProfileRepository,
} from "../database/repositories/factory.js";
import { sshLogger } from "../utils/logger.js";
import {
type VaultProfileConfig,
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
storeVaultCert,
} from "./vault-signer-auth.js";
export const VAULT_OIDC_CALLBACK_PATH = "/vault/oidc/callback";
const AUTH_TIMEOUT = 5 * 60 * 1000;
interface VaultAuthSession {
state: string;
userId: string;
hostId: number;
profile: VaultProfileConfig;
clientNonce: string;
ephemeralPrivateKey: string;
ephemeralPublicKey: string;
ws: WebSocket;
timeout: NodeJS.Timeout;
}
// Keyed by the Vault-issued OIDC `state` so the unauthenticated browser callback
// can correlate back to the originating session.
const sessions = new Map<string, VaultAuthSession>();
function rowToProfileConfig(row: Record<string, unknown>): VaultProfileConfig {
return {
id: row.id as number,
vaultAddr: row.vaultAddr as string,
vaultNamespace: (row.vaultNamespace as string | null) ?? null,
oidcMount: (row.oidcMount as string | null) ?? null,
oidcRole: (row.oidcRole as string | null) ?? null,
sshMount: (row.sshMount as string | null) ?? null,
sshRole: row.sshRole as string,
validPrincipals: (row.validPrincipals as string | null) ?? null,
keyType: (row.keyType as string | null) ?? null,
};
}
/** Load the Vault profile referenced by a host, or null if not configured. */
export async function loadVaultProfileForHost(
hostId: number,
userId: string,
): Promise<VaultProfileConfig | null> {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
if (!host || host.vaultProfileId == null) return null;
const profile = await createCurrentVaultProfileRepository().findById(
host.vaultProfileId as number,
);
if (!profile) return null;
return rowToProfileConfig(profile as Record<string, unknown>);
}
/**
* Begin an interactive Vault OIDC login and send the auth URL to the browser.
*/
export async function startVaultAuth(
userId: string,
hostId: number,
profile: VaultProfileConfig,
ws: WebSocket,
requestOrigin: string,
): Promise<void> {
const ephemeral = generateEphemeralKeyPair(profile.keyType);
const redirectUri = `${requestOrigin}${VAULT_OIDC_CALLBACK_PATH}`;
const { authUrl, state, clientNonce } = await startVaultOidc(
profile,
redirectUri,
);
const existing = sessions.get(state);
if (existing) clearTimeout(existing.timeout);
const timeout = setTimeout(() => {
sessions.delete(state);
}, AUTH_TIMEOUT);
sessions.set(state, {
state,
userId,
hostId,
profile,
clientNonce,
ephemeralPrivateKey: ephemeral.privateKey,
ephemeralPublicKey: ephemeral.publicKey,
ws,
timeout,
});
sshLogger.info("Started Vault OIDC auth session", {
operation: "vault_oidc_start",
userId,
hostId,
profileId: profile.id,
});
ws.send(
JSON.stringify({
type: "vault_auth_url",
hostId,
url: authUrl,
}),
);
}
/**
* Complete a Vault OIDC login from the browser callback: exchange the code for a
* Vault token, sign the ephemeral key, cache the certificate, and notify the
* browser to resume the SSH connection.
*/
export async function completeVaultAuth(
state: string,
code: string,
): Promise<{ ok: boolean; error?: string }> {
const session = sessions.get(state);
if (!session) {
return { ok: false, error: "No matching Vault authentication session" };
}
try {
const token = await completeVaultOidc(session.profile, {
state,
code,
clientNonce: session.clientNonce,
});
const signedCert = await signWithVault(
session.profile,
token,
session.ephemeralPublicKey,
);
const expiresAt = await storeVaultCert(
session.userId,
session.profile.id,
session.ephemeralPrivateKey,
signedCert,
);
sshLogger.success("Completed Vault OIDC auth and cached certificate", {
operation: "vault_oidc_complete",
userId: session.userId,
hostId: session.hostId,
profileId: session.profile.id,
});
session.ws.send(
JSON.stringify({
type: "vault_completed",
hostId: session.hostId,
expiresAt,
}),
);
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
sshLogger.error("Vault OIDC completion failed", e, {
operation: "vault_oidc_complete_error",
userId: session.userId,
hostId: session.hostId,
});
try {
session.ws.send(
JSON.stringify({
type: "vault_error",
hostId: session.hostId,
error: msg,
}),
);
} catch {
// ws may be closed
}
return { ok: false, error: msg };
} finally {
clearTimeout(session.timeout);
sessions.delete(state);
}
}
/** Cancel a pending Vault auth session (e.g. user dismissed the dialog). */
export function cancelVaultAuthByHost(userId: string, hostId: number): void {
for (const [state, s] of sessions) {
if (s.userId === userId && s.hostId === hostId) {
clearTimeout(s.timeout);
sessions.delete(state);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
// HashiCorp Vault SSH signer authentication.
//
// Flow (mirrors the OPKSSH subsystem, but Vault-driven over HTTP):
// 1. Generate an ephemeral SSH keypair (never persisted long-term).
// 2. The user authenticates to Vault via an interactive OIDC flow
// (auth/<mount>/oidc/auth_url -> browser -> auth/<mount>/oidc/callback),
// yielding a short-lived Vault token.
// 3. Vault's SSH secrets engine signs the ephemeral public key
// (<sshMount>/sign/<role>) -> short-lived OpenSSH certificate.
// 4. The ephemeral private key + certificate are cached per-user (encrypted)
// until the certificate expires, then used to connect via setupOPKSSHCertAuth.
//
// No Vault tokens, AppRole secrets, or long-lived private keys are ever stored.
// Vault connection SETTINGS live in shareable vault_profiles rows.
//
// The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and
// is re-exported here so callers have a single import surface.
import { createCurrentVaultTokenRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { parseCertValidBefore } from "./vault-signer-core.js";
export type {
VaultProfileConfig,
EphemeralKeyPair,
} from "./vault-signer-core.js";
export {
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
parseCertValidBefore,
} from "./vault-signer-core.js";
// Re-sign once we're within this many seconds of expiry.
const EXPIRY_SKEW_SECONDS = 60;
function cacheRecordId(userId: string, profileId: number): string {
return `vault-${userId}-${profileId}`;
}
/**
* Store an ephemeral private key + signed certificate for a user/profile.
* Returns the expiry as an ISO string.
*/
export async function storeVaultCert(
userId: string,
profileId: number,
privateKey: string,
signedCert: string,
): Promise<string> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const validBefore = parseCertValidBefore(signedCert);
const expiresMs =
validBefore > 0 ? validBefore * 1000 : Date.now() + 5 * 60 * 1000;
const expiresAt = new Date(expiresMs).toISOString();
const recordId = cacheRecordId(userId, profileId);
const encryptedCert = FieldCrypto.encryptField(
signedCert,
userDataKey,
recordId,
"ssh_cert",
);
const encryptedKey = FieldCrypto.encryptField(
privateKey,
userDataKey,
recordId,
"private_key",
);
await createCurrentVaultTokenRepository().upsert({
userId,
profileId,
sshCert: encryptedCert,
privateKey: encryptedKey,
expiresAt,
});
return expiresAt;
}
/**
* Return a cached, still-valid ephemeral key + certificate for a user/profile,
* or null if none exists or it has (nearly) expired.
*/
export async function getVaultCert(
userId: string,
profileId: number,
): Promise<{ privateKey: string; sshCert: string } | null> {
const repository = createCurrentVaultTokenRepository();
const row = await repository.findByUserAndProfile(userId, profileId);
if (!row) return null;
const expiresMs = new Date(row.expiresAt).getTime();
if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) {
await deleteVaultCert(userId, profileId);
return null;
}
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const recordId = cacheRecordId(userId, profileId);
const sshCert = FieldCrypto.decryptField(
row.sshCert,
userDataKey,
recordId,
"ssh_cert",
);
const privateKey = FieldCrypto.decryptField(
row.privateKey,
userDataKey,
recordId,
"private_key",
);
await repository.updateLastUsed(userId, profileId);
return { privateKey, sshCert };
}
export async function deleteVaultCert(
userId: string,
profileId: number,
): Promise<void> {
await createCurrentVaultTokenRepository().deleteByUserAndProfile(
userId,
profileId,
);
}
+268
View File
@@ -0,0 +1,268 @@
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
import { execFileSync } from "child_process";
import { mkdtempSync, readFileSync, rmSync } from "fs";
import os from "os";
import path from "path";
import ssh2Pkg from "ssh2";
import {
generateEphemeralKeyPair,
parseCertValidBefore,
startVaultOidc,
completeVaultOidc,
signWithVault,
type VaultProfileConfig,
} from "./vault-signer-core.js";
const { utils: ssh2Utils } = ssh2Pkg;
describe("generateEphemeralKeyPair", () => {
for (const keyType of [
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ssh-rsa",
] as const) {
it(`generates a parseable ${keyType} keypair`, () => {
const pair = generateEphemeralKeyPair(keyType);
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
// Both halves must be parseable by the same library that signs/connects.
const priv = ssh2Utils.parseKey(pair.privateKey);
expect(priv instanceof Error).toBe(false);
const pub = ssh2Utils.parseKey(pair.publicKey);
expect(pub instanceof Error).toBe(false);
});
}
it("defaults to ed25519 for unknown key types", () => {
const pair = generateEphemeralKeyPair("nonsense");
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
});
});
describe("parseCertValidBefore", () => {
let cert = "";
let signedAt = 0;
let haveSshKeygen = true;
beforeAll(() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
try {
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/ca`,
"-N",
"",
"-q",
]);
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/user`,
"-N",
"",
"-q",
]);
signedAt = Math.floor(Date.now() / 1000);
execFileSync("ssh-keygen", [
"-s",
`${dir}/ca`,
"-I",
"test-id",
"-n",
"root",
"-V",
"+60m",
`${dir}/user.pub`,
]);
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
} catch {
haveSshKeygen = false;
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reads valid_before from a real ssh-keygen certificate", () => {
if (!haveSshKeygen) {
console.warn("ssh-keygen unavailable; skipping real-cert parse test");
return;
}
const validBefore = parseCertValidBefore(cert);
// -V +60m => valid_before is roughly signedAt + 3600 (start rounds to minute)
expect(validBefore).toBeGreaterThan(signedAt + 3300);
expect(validBefore).toBeLessThan(signedAt + 3900);
});
it("returns 0 for malformed input", () => {
expect(parseCertValidBefore("")).toBe(0);
expect(parseCertValidBefore("not-a-cert")).toBe(0);
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
});
});
describe("Vault HTTP flow (mocked fetch)", () => {
const profile: VaultProfileConfig = {
id: 1,
vaultAddr: "https://vault.example.com:8200/",
vaultNamespace: "team-a",
oidcMount: "oidc",
oidcRole: "developer",
sshMount: "ssh-client-signer",
sshRole: "my-role",
validPrincipals: "root,deploy",
keyType: "ssh-ed25519",
};
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetch(
impl: (
url: string,
init: RequestInit,
) => { status?: number; body: unknown },
) {
const calls: Array<{ url: string; init: RequestInit }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
const { status = 200, body } = impl(url, init);
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
} as Response;
}),
);
return calls;
}
it("startVaultOidc posts auth_url and extracts state", async () => {
const calls = mockFetch(() => ({
body: {
data: {
auth_url:
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
},
},
}));
const result = await startVaultOidc(
profile,
"https://termix/vault/oidc/callback",
);
expect(result.state).toBe("ST-abc123");
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
);
expect(calls[0].init.method).toBe("POST");
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
role: "developer",
redirect_uri: "https://termix/vault/oidc/callback",
});
expect(body.client_nonce).toBe(result.clientNonce);
});
it("completeVaultOidc returns the client token", async () => {
const calls = mockFetch(() => ({
body: { auth: { client_token: "hvs.TESTTOKEN" } },
}));
const token = await completeVaultOidc(profile, {
state: "ST-abc123",
code: "auth-code",
clientNonce: "nonce123",
});
expect(token).toBe("hvs.TESTTOKEN");
const url = new URL(calls[0].url);
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
expect(url.searchParams.get("state")).toBe("ST-abc123");
expect(url.searchParams.get("code")).toBe("auth-code");
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
expect(calls[0].init.method).toBe("GET");
});
it("signWithVault posts the public key and returns signed_key", async () => {
const calls = mockFetch(() => ({
body: {
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
},
}));
const cert = await signWithVault(
profile,
"hvs.TESTTOKEN",
"ssh-ed25519 AAAApub comment",
);
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
);
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
public_key: "ssh-ed25519 AAAApub comment",
cert_type: "user",
valid_principals: "root,deploy",
});
});
it("surfaces Vault error messages", async () => {
mockFetch(() => ({
status: 400,
body: { errors: ["role not found", "permission denied"] },
}));
await expect(
signWithVault(profile, "tok", "ssh-ed25519 AAAA"),
).rejects.toThrow(/role not found; permission denied/);
});
});
// Live integration against a real Vault (e.g. `vault server -dev` in Docker).
// Runs only when VAULT_ADDR + VAULT_TOKEN are set and the SSH signer mount
// (VAULT_SSH_MOUNT/VAULT_SSH_ROLE) has been configured by the test harness.
describe("Vault live signing", () => {
const addr = process.env.VAULT_ADDR;
const token = process.env.VAULT_TOKEN;
const run = !!addr && !!token;
it.skipIf(!run)("signs an ephemeral key against a real Vault", async () => {
const profile: VaultProfileConfig = {
id: 99,
vaultAddr: addr!,
sshMount: process.env.VAULT_SSH_MOUNT || "ssh-client-signer",
sshRole: process.env.VAULT_SSH_ROLE || "my-role",
validPrincipals: "root",
keyType: "ssh-ed25519",
};
const pair = generateEphemeralKeyPair(profile.keyType);
const before = Math.floor(Date.now() / 1000);
const cert = await signWithVault(profile, token!, pair.publicKey);
expect(cert).toMatch(/-cert-v01@openssh\.com /);
// The signed cert must parse with the same library used to connect.
const parsed = ssh2Utils.parseKey(cert);
expect(parsed instanceof Error).toBe(false);
const validBefore = parseCertValidBefore(cert);
expect(validBefore).toBeGreaterThan(before);
});
});
+265
View File
@@ -0,0 +1,265 @@
// Pure (DB-free) HashiCorp Vault SSH signer logic: ephemeral key generation,
// the Vault OIDC HTTP flow, certificate signing, and certificate parsing.
//
// Kept separate from vault-signer-auth.ts (which adds the encrypted per-user
// certificate cache) so this layer can be unit-tested without the native SQLite
// dependency.
import crypto from "crypto";
import ssh2Pkg from "ssh2";
import { sshLogger } from "../utils/logger.js";
const { utils: ssh2Utils } = ssh2Pkg;
export interface VaultProfileConfig {
id: number;
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
}
export interface EphemeralKeyPair {
privateKey: string;
publicKey: string;
}
export function normalizeAddr(addr: string): string {
return addr.trim().replace(/\/+$/, "");
}
export function trimMount(
mount: string | null | undefined,
fallback: string,
): string {
return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, "");
}
function vaultHeaders(profile: VaultProfileConfig): Record<string, string> {
const headers: Record<string, string> = {};
if (profile.vaultNamespace?.trim()) {
headers["X-Vault-Namespace"] = profile.vaultNamespace.trim();
}
return headers;
}
async function vaultRequest(
url: string,
method: "GET" | "POST" | "PUT",
headers: Record<string, string>,
body?: Record<string, unknown>,
): Promise<any> {
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
throw new Error(
`Failed to reach Vault at ${url}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
const text = await response.text();
let json: any;
if (text) {
try {
json = JSON.parse(text);
} catch {
// leave undefined for non-JSON bodies
}
}
if (!response.ok) {
const errs =
json && Array.isArray(json.errors) && json.errors.length
? json.errors.join("; ")
: text || `HTTP ${response.status}`;
throw new Error(`Vault request failed (${response.status}): ${errs}`);
}
return json;
}
/** Generate an ephemeral SSH keypair in OpenSSH format. */
export function generateEphemeralKeyPair(
keyType?: string | null,
): EphemeralKeyPair {
let ssh2Type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
const options: { bits?: number } = {};
switch ((keyType || "ssh-ed25519").trim()) {
case "ssh-rsa":
ssh2Type = "rsa";
options.bits = 4096;
break;
case "ecdsa-sha2-nistp256":
ssh2Type = "ecdsa";
options.bits = 256;
break;
case "ssh-ed25519":
default:
ssh2Type = "ed25519";
break;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return { privateKey: pair.private, publicKey: pair.public };
}
/**
* Start a Vault OIDC login: returns the IdP auth URL plus the state/nonce
* needed to complete the callback. `redirectUri` must be allowed both by the
* Vault role's allowed_redirect_uris and by the IdP.
*/
export async function startVaultOidc(
profile: VaultProfileConfig,
redirectUri: string,
): Promise<{ authUrl: string; state: string; clientNonce: string }> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const clientNonce = crypto.randomBytes(20).toString("hex");
const json = await vaultRequest(
`${addr}/v1/auth/${mount}/oidc/auth_url`,
"POST",
vaultHeaders(profile),
{
role: profile.oidcRole?.trim() || "",
redirect_uri: redirectUri,
client_nonce: clientNonce,
},
);
const authUrl: string | undefined = json?.data?.auth_url;
if (!authUrl) {
throw new Error("Vault did not return an OIDC auth_url");
}
// Vault embeds the state it generated in the auth_url; we need it to correlate
// the browser callback back to this login attempt.
let state = "";
try {
state = new URL(authUrl).searchParams.get("state") || "";
} catch {
const m = authUrl.match(/[?&]state=([^&]+)/);
state = m ? decodeURIComponent(m[1]) : "";
}
if (!state) {
throw new Error("Could not determine OIDC state from Vault auth_url");
}
return { authUrl, state, clientNonce };
}
/** Complete the Vault OIDC callback and return a short-lived Vault token. */
export async function completeVaultOidc(
profile: VaultProfileConfig,
params: { state: string; code: string; clientNonce: string },
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`);
url.searchParams.set("state", params.state);
url.searchParams.set("code", params.code);
url.searchParams.set("client_nonce", params.clientNonce);
const json = await vaultRequest(url.toString(), "GET", vaultHeaders(profile));
const token: string | undefined = json?.auth?.client_token;
if (!token) {
throw new Error("Vault OIDC callback did not return a client token");
}
return token;
}
/** Sign an SSH public key with Vault, returning the OpenSSH certificate line. */
export async function signWithVault(
profile: VaultProfileConfig,
vaultToken: string,
publicKey: string,
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.sshMount, "ssh-client-signer");
const headers = { ...vaultHeaders(profile), "X-Vault-Token": vaultToken };
const body: Record<string, unknown> = {
public_key: publicKey.trim(),
cert_type: "user",
};
if (profile.validPrincipals?.trim()) {
body.valid_principals = profile.validPrincipals.trim();
}
const json = await vaultRequest(
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
"POST",
headers,
body,
);
const signedKey: string | undefined = json?.data?.signed_key;
if (!signedKey) {
throw new Error("Vault sign response did not include a signed_key");
}
return signedKey.trim();
}
/**
* Parse the "valid before" Unix timestamp out of an OpenSSH certificate.
* Returns 0 if it can't be parsed (caller falls back to a conservative TTL).
*/
export function parseCertValidBefore(signedKey: string): number {
try {
const parts = signedKey.trim().split(/\s+/);
if (parts.length < 2) return 0;
const certType = parts[0];
const blob = Buffer.from(parts[1], "base64");
let pos = 0;
const readString = (): void => {
const len = blob.readUInt32BE(pos);
pos += 4 + len;
};
const readUint64 = (): number => {
const hi = blob.readUInt32BE(pos);
const lo = blob.readUInt32BE(pos + 4);
pos += 8;
return hi * 0x100000000 + lo;
};
readString(); // format id
readString(); // nonce
let keyFields: number;
if (certType.startsWith("ssh-ed25519")) keyFields = 1;
else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2;
else if (certType.startsWith("ssh-rsa")) keyFields = 2;
else if (certType.startsWith("ssh-dss")) keyFields = 4;
else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2;
else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3;
else return 0;
for (let i = 0; i < keyFields; i++) readString();
readUint64(); // serial
pos += 4; // type (uint32)
readString(); // key id
readString(); // valid principals
readUint64(); // valid after
return readUint64(); // valid before
} catch (e) {
sshLogger.warn("Failed to parse Vault-signed certificate expiry", {
operation: "vault_cert_parse",
error: e instanceof Error ? e.message : String(e),
});
return 0;
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Client, ConnectConfig } from "ssh2";
type VaultAuthHost = {
id: number;
username: string;
userId?: string | null;
vaultProfile?: { id?: number | null } | null;
};
export async function setupVaultSshSignerAuth(
config: ConnectConfig,
client: Client,
host: VaultAuthHost,
): Promise<void> {
if (!host.userId) {
throw new Error("Vault SSH signer authentication requires a user session");
}
const vaultProfileId = host.vaultProfile?.id;
if (!vaultProfileId) {
throw new Error("Host has no Vault signer profile configured");
}
const { getVaultCert } = await import("./vault-signer-auth.js");
const cert = await getVaultCert(host.userId, vaultProfileId);
if (!cert) {
throw new Error(
"Vault SSH signer authentication required. Please open a Terminal connection first.",
);
}
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config,
client,
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
host.username,
);
}
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { toFixedNum, kibToGiB } from "./common-utils.js";
describe("toFixedNum", () => {
it("rounds to the requested digit count", () => {
expect(toFixedNum(3.14159, 2)).toBe(3.14);
expect(toFixedNum(3.14159, 0)).toBe(3);
expect(toFixedNum(2.5, 0)).toBe(3);
});
it("defaults to 2 digits", () => {
expect(toFixedNum(1.23456)).toBe(1.23);
});
it("returns null for non-finite or non-number input", () => {
expect(toFixedNum(null)).toBeNull();
expect(toFixedNum(undefined)).toBeNull();
expect(toFixedNum(NaN)).toBeNull();
expect(toFixedNum(Infinity)).toBeNull();
});
});
describe("kibToGiB", () => {
it("converts kibibytes to gibibytes", () => {
expect(kibToGiB(1024 * 1024)).toBe(1);
expect(kibToGiB(0)).toBe(0);
expect(kibToGiB(2 * 1024 * 1024)).toBe(2);
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { Client, ClientChannel } from "ssh2";
export function execCommand(
client: Client,
command: string,
timeoutMs = 30000,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
let settled = false;
let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
if (stream) {
try {
stream.removeAllListeners();
if (stream.stderr) {
stream.stderr.removeAllListeners();
}
stream.destroy();
} catch {
// expected - cleanup errors ignored
}
}
};
client.exec(command, { pty: false }, (err, _stream) => {
if (err) {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
return;
}
stream = _stream;
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
if (!settled) {
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.on("error", (streamErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(streamErr);
}
});
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
})
.on("error", (stderrErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(stderrErr);
}
});
}
});
});
}
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { parseCpuLine } from "./cpu-collector.js";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
@@ -0,0 +1,90 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
export function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
export async function collectCpuMetrics(client: Client): Promise<{
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}> {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const [stat1, loadAvgOut, coresOut] = await Promise.race([
Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("CPU metrics collection timeout")),
25000,
),
),
]);
await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch {
cpuPercent = null;
loadTriplet = null;
}
return {
percent: toFixedNum(cpuPercent, 0),
cores,
load: loadTriplet,
};
}
@@ -0,0 +1,67 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
export async function collectDiskMetrics(client: Client): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
}> {
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
let availableHuman: string | null = null;
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"),
execCommand(client, "df -B1 -P / | tail -n +2"),
]);
const humanLine =
diskOutHuman.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/);
if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
if (
Number.isFinite(totalBytes) &&
Number.isFinite(usedBytes) &&
totalBytes > 0
) {
diskPercent = Math.max(
0,
Math.min(100, (usedBytes / totalBytes) * 100),
);
}
}
} catch {
diskPercent = null;
usedHuman = null;
totalHuman = null;
availableHuman = null;
}
return {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman,
};
}
@@ -0,0 +1,254 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../types/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
const rule: FirewallRule = {
chain: "",
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
const chainMatch = line.match(/^-A\s+(\S+)/);
if (chainMatch) {
rule.chain = chainMatch[1];
}
const targetMatch = line.match(/-j\s+(\S+)/);
if (targetMatch) {
rule.target = targetMatch[1];
}
const protocolMatch = line.match(/-p\s+(\S+)/);
if (protocolMatch) {
rule.protocol = protocolMatch[1];
}
const sourceMatch = line.match(/-s\s+(\S+)/);
if (sourceMatch) {
rule.source = sourceMatch[1];
}
const destMatch = line.match(/-d\s+(\S+)/);
if (destMatch) {
rule.destination = destMatch[1];
}
const dportMatch = line.match(/--dport\s+(\S+)/);
if (dportMatch) {
rule.dport = dportMatch[1];
}
const sportMatch = line.match(/--sport\s+(\S+)/);
if (sportMatch) {
rule.sport = sportMatch[1];
}
const stateMatch = line.match(/--state\s+(\S+)/);
if (stateMatch) {
rule.state = stateMatch[1];
}
const interfaceMatch = line.match(/-i\s+(\S+)/);
if (interfaceMatch) {
rule.interface = interfaceMatch[1];
}
return rule;
}
function parseIptablesOutput(output: string): FirewallChain[] {
const chains: Map<string, FirewallChain> = new Map();
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/);
if (policyMatch) {
const [, chainName, policy] = policyMatch;
chains.set(chainName, {
name: chainName,
policy: policy,
rules: [],
});
continue;
}
const rule = parseIptablesRule(trimmed);
if (rule) {
let chain = chains.get(rule.chain);
if (!chain) {
chain = {
name: rule.chain,
policy: "ACCEPT",
rules: [],
};
chains.set(rule.chain, chain);
}
chain.rules.push(rule);
}
}
return Array.from(chains.values());
}
function parseNftablesOutput(output: string): FirewallChain[] {
const chains: FirewallChain[] = [];
let currentChain: FirewallChain | null = null;
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const chainMatch = trimmed.match(
/chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/,
);
if (chainMatch) {
if (currentChain) {
chains.push(currentChain);
}
currentChain = {
name: chainMatch[1].toUpperCase(),
policy: "ACCEPT",
rules: [],
};
continue;
}
if (currentChain && trimmed.startsWith("policy ")) {
const policyMatch = trimmed.match(/policy\s+(\S+)/);
if (policyMatch) {
currentChain.policy = policyMatch[1].toUpperCase();
}
continue;
}
if (currentChain && trimmed && !trimmed.startsWith("}")) {
const rule: FirewallRule = {
chain: currentChain.name,
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
if (trimmed.includes("accept")) rule.target = "ACCEPT";
else if (trimmed.includes("drop")) rule.target = "DROP";
else if (trimmed.includes("reject")) rule.target = "REJECT";
const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/);
if (tcpMatch) {
rule.protocol = "tcp";
rule.dport = tcpMatch[1];
}
const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/);
if (udpMatch) {
rule.protocol = "udp";
rule.dport = udpMatch[1];
}
const saddrMatch = trimmed.match(/saddr\s+(\S+)/);
if (saddrMatch) {
rule.source = saddrMatch[1];
}
const daddrMatch = trimmed.match(/daddr\s+(\S+)/);
if (daddrMatch) {
rule.destination = daddrMatch[1];
}
const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/);
if (iifMatch) {
rule.interface = iifMatch[1].replace(/"/g, "");
}
const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/);
if (ctStateMatch) {
rule.state = ctStateMatch[1].toUpperCase();
}
if (rule.target) {
currentChain.rules.push(rule);
}
}
if (trimmed === "}") {
if (currentChain) {
chains.push(currentChain);
currentChain = null;
}
}
}
if (currentChain) {
chains.push(currentChain);
}
return chains;
}
export async function collectFirewallMetrics(
client: Client,
): Promise<FirewallMetrics> {
try {
const iptablesResult = await execCommand(
client,
"iptables-save 2>/dev/null",
15000,
);
if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) {
const chains = parseIptablesOutput(iptablesResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "iptables",
status: hasRules ? "active" : "inactive",
chains: chains.filter(
(c) =>
c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD",
),
};
}
const nftResult = await execCommand(
client,
"nft list ruleset 2>/dev/null",
15000,
);
if (nftResult.stdout && nftResult.stdout.trim()) {
const chains = parseNftablesOutput(nftResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "nftables",
status: hasRules ? "active" : "inactive",
chains,
};
}
return {
type: "none",
status: "unknown",
chains: [],
};
} catch {
return {
type: "none",
status: "unknown",
chains: [],
};
}
}

Some files were not shown because too many files have changed in this diff Show More