mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
v2.2.0 (#738)
* Improve Docker container list UI * Rework SSH tunnel forwarding * Update macOS Electron packaging * Optimize frontend bundle splitting * Add beta version update status * Add client tunnel preset management * Secure cookie authentication flows * Add client tunnel bridge support * Preserve sessions on restart * Update runtime to Node 24 * Add client remote tunnel support * Fix stale frontend cache handling * Fix Docker image platforms for Node 24 * Fix Electron packaging workflows * Fix client auth cache after upgrades * chore: cleanup files * fix: npm i error * Fix OIDC auth cookie readiness * Fix Docker npm ci config * Add react-is peer dependency * Fix Electron auth and cache handling * Improve terminal clipboard and refresh actions * feat: add API keys * feat: improve lazy loading with loading spinners * feat: Introduce FolderTree component with lazy-loading and motion animations for improved file manager UX (#735) * feat: integrate FolderTree component with lazy-loading for file manager sidebar - Add motion animation library (v12.38.0) for smooth UI transitions - Create new FolderTree component with advanced keyboard navigation support - Refactor kbd component: introduce KbdKey and KbdSeparator subcomponents - Implement lazy-loading strategy for directory tree in FileManagerSidebar - Refactor FileManagerSidebar with improved code organization and better separation of concerns - Update keyboard shortcut displays across CommandPalette, FileViewer, and Dashboard - Change React/ReactDOM dependency flags from dev to devOptional in package-lock.json BREAKING CHANGE: KbdGroup component has been replaced. Use <Kbd><KbdKey>...</KbdKey><KbdSeparator /></Kbd> instead. - Improves UX with smooth animations and better folder navigation - Reduces initial load time through lazy-loading subdirectories - Enhances accessibility with ARIA labels and keyboard navigation - Maintains dark mode support and proper styling * fix: incorrect use of the theme system and linked file manger sidebar with current folder --------- Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Enhance VNC token generation to include optional username parameter and refactor username input handling in HostGeneralTab (#733) * Fix Docker build info generation * Remove unused node-fetch dependency * feat: prompt user for SSH key passphrase on use (#715) When an encrypted SSH key has no stored passphrase, show a lightweight dialog prompting the user to enter it at connection time instead of failing with a parse error. Supports both desktop and mobile terminals. Closes Termix-SSH/Support#354 * fix: prevent session crash when uploading to permission-denied directory (#716) - Wrap writeFile sftp.stat callback in try-catch to prevent uncaught exceptions from escaping the callback into the event loop - Add missing stream.stderr error handler in writeFile fallback to prevent unhandled error events from crashing the process - Remove bogus activeOperations decrement in both writeFile and uploadFile fallback methods (counter was never incremented) - Add res.headersSent checks in fallback disconnect paths to prevent ERR_HTTP_HEADERS_SENT crashes Closes Termix-SSH/Support#652 * feat: add LOG_TIMESTAMP_FORMAT env var for 24h/ISO log timestamps (#718) Support LOG_TIMESTAMP_FORMAT environment variable with values: - "24h": 24-hour format (14:58:45) - "iso": ISO 8601 format (2026-04-25T14:58:45.000Z) - default: locale format (2:58:45 PM) Closes Termix-SSH/Support#650 * feat: open file manager at terminal current working directory (#719) * feat: open file manager at terminal current working directory When right-clicking in the terminal and selecting "Open File Manager Here", query the current working directory via a separate SSH exec channel and pass it as the initial path to the file manager tab. Closes Termix-SSH/Support#649 * chore: sync package-lock.json with node-fetch and deps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove undefined TerminalContextMenu from bad merge resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: show reconnect overlay when SSH server reboots (#720) When the remote server reboots, the SSH connection closes while the stream is still active. The close handler only sent the "disconnected" message when sshStream was null, so the frontend never received the disconnect notification and hung with a blinking cursor. Change the else-if condition to always send the "disconnected" message regardless of stream state. Closes Termix-SSH/Support#648 * feat: support read-only Docker container mode (#721) Move nginx runtime files (config, pid, logs, temp dirs) from /app/nginx/ to /tmp/nginx/ so the container can run with read_only: true. Template files remain in /app/nginx/ as read-only assets. Users can now harden the container with: read_only: true tmpfs: - /tmp Closes Termix-SSH/Support#647 * fix: allow editing host folder without re-entering password (#722) When editing an existing host, the password field is stripped by the backend for security. The form validation treated the empty password as invalid, disabling the Update Host button even for non-auth changes like folder assignment. Use an "existing_password" sentinel (mirroring the existing "existing_key" pattern) to represent an unchanged password during editing, skip validation for it, and omit it from the update payload. Closes Termix-SSH/Support#645 * fix: auto-close tab on graceful SSH disconnect (exit/Ctrl+D) (#723) Distinguish between graceful shell exit and unexpected disconnection using the stream close event's exit code. When the shell exits normally (code != null), send "session_ended" instead of "disconnected". The frontend auto-closes the tab on session_ended, and shows the reconnect overlay only on unexpected disconnections. Closes Termix-SSH/Support#643 * fix: reattach existing SSH session on WebSocket reconnect (#724) WebSocket reconnection was always creating a new SSH connection with full authentication instead of reattaching to the existing SSH session. The condition `!isReconnectingRef.current` prevented session reattach during reconnection, causing repeated password auth attempts that trigger SSHGuard/fail2ban blocking. Remove the guard so reconnection tries to reattach the persisted session first. If the session has expired, the backend sends sessionExpired and the frontend falls back to a new connection. Closes Termix-SSH/Support#644 * fix: prevent browser crash when uploading large files (>100MB) (#725) The file-to-base64 conversion used a byte-by-byte string concatenation loop (String.fromCharCode + btoa), which allocated ~3x the file size in intermediate strings, causing the browser tab to OOM on files over ~100MB. Replace with FileReader.readAsDataURL which delegates base64 encoding to the browser engine natively, avoiding the intermediate allocations. Closes Termix-SSH/Support#577 * fix: support SSH multi-factor auth with publickey + password (#726) When sshd requires AuthenticationMethods publickey,password, the connection failed because the key auth branch only set privateKey without also setting password. After publickey partial auth succeeded, ssh2 sent keyboard-interactive (due to tryKeyboard:true) instead of password, which the server rejected. Pass the credential password alongside the private key so ssh2 can complete the password step after publickey succeeds. Closes Termix-SSH/Support#629 * feat(oidc): add OIDC_ALLOW_REGISTRATION env to bypass allow_registration for OIDC (#727) The `allow_registration` setting blocks both password-based and OIDC user creation. Admins who want to close password registration but still onboard new users via a trusted IdP (with the existing `OIDC_ALLOWED_USERS` whitelist) have no way to do that today. Introduce an `OIDC_ALLOW_REGISTRATION` env var. When set to `true`, the OIDC callback skips the `allow_registration` settings check while still honoring the `OIDC_ALLOWED_USERS` whitelist. Password registration via `POST /users/create` continues to respect `allow_registration`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: lazy load locales, file previews, and decouple startup imports (#729) * perf: lazy load locale bundles * perf: lazy load file preview modules * perf: avoid eager api client load on startup * chore: remove dead code, tighten types, fix lint warnings (#730) * chore: clean up low-risk lint warnings * chore: tighten utility types * chore: preserve backend error causes * chore: simplify command palette host state * chore: remove unused frontend code * chore: prune stale frontend state * chore: trim unused navigation code * chore: prune unused user settings props * chore: trim unused sidebar state * chore: remove stale host editor imports * chore: tighten shared frontend types * chore: narrow desktop helper types * chore: type network topology data * chore: type connection log errors * chore: use typed tab context * chore: type api client error metadata * chore: tighten terminal config types * chore: type host proxy chains * chore: type host editor form data * chore: use typed host viewer fields * chore: format app builder patch script * Fix client auth cache after upgrades * chore: fix pr checks after dev merge * fix: remove duplicate session-expired useEffect in FullScreenAppWrapper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: npm package warnings * feat: reconnect after file manager disconnects * feat: add docs button in api keys * feat: change colors for server tunnels * fix: fetch password from API for Copy Password button (#736) * chore: update readme's * feat: improve c2s UI in user profile * feat: improve ssh key detection and move open file manager at path for terminal button * fix: restore missing getHostPassword import in Tab.tsx (#737) * fix: security related fixes * feat: improve alert code * Fix Electron clipboard handling * fix: untranslated alert text --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: PT Kelana Tech Solutions <ptkelanatechsolutions@gmail.com> Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: zimmra <28514085+zimmra@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Fuad <funtik1229@yandex.ru>
This commit is contained in:
@@ -8,12 +8,12 @@ import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -118,6 +118,31 @@ class GitHubCache {
|
||||
|
||||
const githubCache = new GitHubCache();
|
||||
|
||||
function parseSemver(
|
||||
version: string | undefined,
|
||||
): [number, number, number] | null {
|
||||
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||
if (!match) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
|
||||
}
|
||||
|
||||
function compareSemver(
|
||||
a: string | undefined,
|
||||
b: string | undefined,
|
||||
): number | null {
|
||||
const parsedA = parseSemver(a);
|
||||
const parsedB = parseSemver(b);
|
||||
if (!parsedA || !parsedB) return null;
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (parsedA[i] > parsedB[i]) return 1;
|
||||
if (parsedA[i] < parsedB[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Termix";
|
||||
@@ -143,7 +168,7 @@ async function fetchGitHubAPI<T>(
|
||||
"User-Agent": "TermixUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -299,12 +324,19 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
return res.status(401).send("Remote Version Not Found");
|
||||
}
|
||||
|
||||
const isUpToDate = localVersion === remoteVersion;
|
||||
const versionComparison = compareSemver(localVersion, remoteVersion);
|
||||
const status =
|
||||
versionComparison === null || versionComparison === 0
|
||||
? "up_to_date"
|
||||
: versionComparison > 0
|
||||
? "beta"
|
||||
: "requires_update";
|
||||
|
||||
const response = {
|
||||
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||
status,
|
||||
localVersion: localVersion,
|
||||
version: remoteVersion,
|
||||
remoteVersion: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
@@ -624,7 +656,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
operation: "export_temp_dir_error",
|
||||
tempDir,
|
||||
});
|
||||
throw new Error(`Failed to create temp directory: ${dirError.message}`);
|
||||
throw new Error(`Failed to create temp directory: ${dirError.message}`, {
|
||||
cause: dirError,
|
||||
});
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
@@ -1161,7 +1195,7 @@ app.post(
|
||||
mimetype: req.file.mimetype,
|
||||
});
|
||||
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
@@ -1719,6 +1753,7 @@ app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
@@ -1738,10 +1773,38 @@ if (frontendDist) {
|
||||
databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
|
||||
operation: "static_files",
|
||||
});
|
||||
app.use(express.static(frontendDist));
|
||||
app.use(
|
||||
express.static(frontendDist, {
|
||||
setHeaders: (res, filePath) => {
|
||||
const relativePath = path
|
||||
.relative(frontendDist, filePath)
|
||||
.replaceAll(path.sep, "/");
|
||||
|
||||
if (relativePath.startsWith("assets/")) {
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
relativePath === "index.html" ||
|
||||
relativePath === "sw.js" ||
|
||||
relativePath === "manifest.json"
|
||||
) {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === "GET" && req.accepts("html")) {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
|
||||
);
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
} else {
|
||||
next();
|
||||
@@ -1750,13 +1813,13 @@ if (frontendDist) {
|
||||
}
|
||||
|
||||
app.use(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction,
|
||||
) => {
|
||||
void _next;
|
||||
apiLogger.error("Unhandled error in request", err, {
|
||||
operation: "error_handler",
|
||||
method: req.method,
|
||||
|
||||
@@ -118,6 +118,7 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -317,6 +318,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -424,10 +437,31 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM sessions").run();
|
||||
const result = sqlite
|
||||
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
|
||||
.run(new Date().toISOString());
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info("Expired sessions cleaned up on startup", {
|
||||
operation: "db_init_session_cleanup",
|
||||
deletedSessions: result.changes,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear expired sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
@@ -803,6 +837,31 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create c2s_tunnel_presets table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
@@ -1175,6 +1234,32 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create api_keys table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
||||
|
||||
|
||||
@@ -298,6 +298,23 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
platform: text("platform"),
|
||||
computerName: text("computer_name"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippetAccess = sqliteTable("snippet_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
snippetId: integer("snippet_id")
|
||||
@@ -572,3 +589,17 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
tokenPrefix: text("token_prefix").notNull(),
|
||||
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { getProxyAgent } from "../../utils/proxy-agent.js";
|
||||
@@ -61,7 +60,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
TunnelConnection,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { c2sTunnelPresets } from "../db/schema.js";
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function parsePreset(row: typeof c2sTunnelPresets.$inferSelect) {
|
||||
return {
|
||||
...row,
|
||||
config: JSON.parse(row.config) as TunnelConnection[],
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfig(config: unknown): config is TunnelConnection[] {
|
||||
if (!Array.isArray(config)) return false;
|
||||
return config.every((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const tunnel = item as Partial<TunnelConnection>;
|
||||
const mode = tunnel.mode || tunnel.tunnelType;
|
||||
return (
|
||||
tunnel.scope === "c2s" &&
|
||||
(mode === "local" || mode === "remote" || mode === "dynamic") &&
|
||||
typeof tunnel.sourcePort === "number" &&
|
||||
tunnel.sourcePort >= 1 &&
|
||||
tunnel.sourcePort <= 65535 &&
|
||||
(mode === "dynamic" ||
|
||||
(typeof tunnel.endpointPort === "number" &&
|
||||
tunnel.endpointPort >= 1 &&
|
||||
tunnel.endpointPort <= 65535))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId))
|
||||
.orderBy(asc(c2sTunnelPresets.name));
|
||||
res.json(result.map(parsePreset));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch C2S tunnel presets", error);
|
||||
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(
|
||||
eq(c2sTunnelPresets.userId, userId),
|
||||
eq(c2sTunnelPresets.name, trimmedName),
|
||||
),
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(c2sTunnelPresets)
|
||||
.values({
|
||||
userId,
|
||||
name: trimmedName,
|
||||
config: JSON.stringify(config),
|
||||
platform: platform?.trim() || null,
|
||||
computerName: computerName?.trim() || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
databaseLogger.info("C2S tunnel preset created", {
|
||||
operation: "c2s_tunnel_preset_create",
|
||||
userId,
|
||||
presetId: result[0].id,
|
||||
});
|
||||
res.status(201).json(parsePreset(result[0]));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to create C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
const updateFields: Record<string, unknown> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (name !== undefined) {
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
const duplicate = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(
|
||||
eq(c2sTunnelPresets.userId, userId),
|
||||
eq(c2sTunnelPresets.name, trimmedName),
|
||||
),
|
||||
);
|
||||
if (duplicate.some((preset) => preset.id !== id)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
updateFields.name = trimmedName;
|
||||
}
|
||||
|
||||
if (config !== undefined) {
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
updateFields.config = JSON.stringify(config);
|
||||
}
|
||||
if (platform !== undefined)
|
||||
updateFields.platform = platform?.trim() || null;
|
||||
if (computerName !== undefined)
|
||||
updateFields.computerName = computerName?.trim() || null;
|
||||
|
||||
await db
|
||||
.update(c2sTunnelPresets)
|
||||
.set(updateFields)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.id, id));
|
||||
res.json(parsePreset(updated[0]));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to update C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to delete C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import axios from "axios";
|
||||
import multer from "multer";
|
||||
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
@@ -42,6 +43,32 @@ const router = express.Router();
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
function notifyStatsHostUpdated(
|
||||
hostId: number,
|
||||
headers: Pick<Request["headers"], "authorization" | "cookie">,
|
||||
operation: string,
|
||||
): void {
|
||||
axios
|
||||
.post(
|
||||
"http://localhost:30005/host-updated",
|
||||
{ hostId },
|
||||
{
|
||||
headers: {
|
||||
Authorization: headers.authorization || "",
|
||||
Cookie: headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
sshLogger.warn("Failed to notify stats server of host update", {
|
||||
operation,
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
@@ -581,29 +608,12 @@ router.post(
|
||||
name,
|
||||
});
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: createdHost.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of new host", {
|
||||
operation: "host_create",
|
||||
hostId: createdHost.id as number,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
notifyStatsHostUpdated(
|
||||
createdHost.id as number,
|
||||
req.headers,
|
||||
"host_create",
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
operation: "host_create",
|
||||
@@ -1189,29 +1199,8 @@ router.put(
|
||||
hostId: parseInt(hostId),
|
||||
});
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: parseInt(hostId) },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host update", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
operation: "host_update",
|
||||
@@ -3319,7 +3308,7 @@ router.patch(
|
||||
.update(hosts)
|
||||
.set({ statsConfig: JSON.stringify(merged) })
|
||||
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
errors.push(`Failed to update statsConfig for host ${host.id}`);
|
||||
}
|
||||
}
|
||||
@@ -5346,7 +5335,7 @@ router.post(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = parseInt(req.params.id);
|
||||
const hostId = Number.parseInt(String(req.params.id), 10);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
networkTopology,
|
||||
dashboardPreferences,
|
||||
opksshTokens,
|
||||
apiKeys,
|
||||
} from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -1142,7 +1143,11 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFirstUser) {
|
||||
const oidcAllowRegistration =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
|
||||
if (!isFirstUser && !oidcAllowRegistration) {
|
||||
try {
|
||||
const regRow = db.$client
|
||||
.prepare(
|
||||
@@ -1321,10 +1326,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
if (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") {
|
||||
redirectUrl.searchParams.set("token", token);
|
||||
}
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -1576,14 +1577,6 @@ router.post("/login", async (req, res) => {
|
||||
username: userRecord.username,
|
||||
};
|
||||
|
||||
const isElectron =
|
||||
req.headers["x-electron-app"] === "true" ||
|
||||
req.headers["X-Electron-App"] === "true";
|
||||
|
||||
if (isElectron) {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
@@ -1621,18 +1614,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
const userId = authReq.userId;
|
||||
|
||||
if (userId) {
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1];
|
||||
let sessionId: string | undefined;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
sessionId = payload?.sessionId;
|
||||
} catch {
|
||||
// expected - token verification may fail during logout
|
||||
}
|
||||
}
|
||||
const sessionId = authReq.sessionId;
|
||||
|
||||
await authManager.logoutUser(userId, sessionId);
|
||||
authLogger.info("User logged out", {
|
||||
@@ -1693,6 +1675,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
is_oidc: !!user[0].isOidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
totp_enabled: !!user[0].totpEnabled,
|
||||
data_unlocked: authManager.isUserUnlocked(userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get username", err);
|
||||
@@ -3396,10 +3379,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
const isElectron =
|
||||
req.headers["x-electron-app"] === "true" ||
|
||||
req.headers["X-Electron-App"] === "true";
|
||||
|
||||
authLogger.success("TOTP verification successful", {
|
||||
operation: "totp_verify_success",
|
||||
userId: userRecord.id,
|
||||
@@ -3416,10 +3395,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
};
|
||||
|
||||
if (isElectron) {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
@@ -3560,9 +3535,14 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to unlock data.
|
||||
*/
|
||||
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = authReq.userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: "Password is required" });
|
||||
}
|
||||
@@ -3570,6 +3550,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (unlocked) {
|
||||
const refreshedSession =
|
||||
userId && authReq.sessionId
|
||||
? await authManager.refreshSessionToken(userId, authReq.sessionId)
|
||||
: null;
|
||||
|
||||
if (refreshedSession) {
|
||||
res.cookie(
|
||||
"jwt",
|
||||
refreshedSession.token,
|
||||
authManager.getSecureCookieOptions(req, refreshedSession.maxAge),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data unlocked successfully",
|
||||
@@ -3608,9 +3601,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const unlocked = authManager.isUserUnlocked(userId);
|
||||
res.json({
|
||||
unlocked: true,
|
||||
message: "Data is unlocked",
|
||||
unlocked,
|
||||
message: unlocked ? "Data is unlocked" : "Data is locked",
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to check data status", err, {
|
||||
@@ -3638,7 +3632,9 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to get sessions.
|
||||
*/
|
||||
router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = authReq.userId;
|
||||
const currentSessionId = authReq.sessionId;
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
@@ -3661,8 +3657,16 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
...session,
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
username: sessionUser[0]?.username || "Unknown",
|
||||
deviceType: session.deviceType,
|
||||
deviceInfo: session.deviceInfo,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
isRevoked: session.isRevoked,
|
||||
isCurrentSession: session.id === currentSessionId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -3670,7 +3674,19 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
return res.json({ sessions: enrichedSessions });
|
||||
} else {
|
||||
sessionList = await authManager.getUserSessions(userId);
|
||||
return res.json({ sessions: sessionList });
|
||||
return res.json({
|
||||
sessions: sessionList.map((session) => ({
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
deviceType: session.deviceType,
|
||||
deviceInfo: session.deviceInfo,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
isRevoked: session.isRevoked,
|
||||
isCurrentSession: session.id === currentSessionId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get sessions", err);
|
||||
@@ -3812,12 +3828,7 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
||||
|
||||
let currentSessionId: string | undefined;
|
||||
if (exceptCurrent) {
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers?.authorization?.split(" ")[1];
|
||||
if (token) {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
currentSessionId = payload?.sessionId;
|
||||
}
|
||||
currentSessionId = (req as AuthenticatedRequest).sessionId;
|
||||
}
|
||||
|
||||
const revokedCount = await authManager.revokeAllUserSessions(
|
||||
@@ -4205,7 +4216,7 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
|
||||
* 500:
|
||||
* description: Failed to get guacamole settings.
|
||||
*/
|
||||
router.get("/guacamole-settings", async (req, res) => {
|
||||
router.get("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const enabledRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
@@ -4305,7 +4316,7 @@ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
* 200:
|
||||
* description: Current log level.
|
||||
*/
|
||||
router.get("/log-level", async (_req, res) => {
|
||||
router.get("/log-level", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
|
||||
@@ -4374,7 +4385,7 @@ router.patch("/log-level", authenticateJWT, async (req, res) => {
|
||||
* 200:
|
||||
* description: Current session timeout hours.
|
||||
*/
|
||||
router.get("/session-timeout", async (_req, res) => {
|
||||
router.get("/session-timeout", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
@@ -4433,4 +4444,209 @@ router.patch("/session-timeout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys:
|
||||
* post:
|
||||
* summary: Create an API key (admin only)
|
||||
* description: Creates a new API key scoped to a specific user. The full token is returned only once.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - userId
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Human-readable name for the key.
|
||||
* userId:
|
||||
* type: string
|
||||
* description: ID of the user this key is scoped to.
|
||||
* expiresAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Optional expiration date. Null means the key never expires.
|
||||
* responses:
|
||||
* 201:
|
||||
* description: API key created. Contains the full token (shown only once).
|
||||
* 400:
|
||||
* description: Invalid input.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: Target user not found.
|
||||
* 500:
|
||||
* description: Failed to create API key.
|
||||
*/
|
||||
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
||||
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (typeof targetUserId !== "string" || !targetUserId.trim()) {
|
||||
return res.status(400).json({ error: "userId is required" });
|
||||
}
|
||||
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
|
||||
let expiresAtValue: string | null = null;
|
||||
if (expiresAt) {
|
||||
const parsed = new Date(expiresAt);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid expiresAt date" });
|
||||
}
|
||||
if (parsed <= new Date()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "expiresAt must be in the future" });
|
||||
}
|
||||
expiresAtValue = parsed.toISOString();
|
||||
}
|
||||
|
||||
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||
const tokenPrefix = rawToken.substring(0, 12);
|
||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||
const keyId = nanoid();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
id: keyId,
|
||||
userId: targetUserId,
|
||||
name: name.trim(),
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
lastUsedAt: null,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
return res.status(201).json({
|
||||
id: keyId,
|
||||
name: name.trim(),
|
||||
userId: targetUserId,
|
||||
username: targetUser[0].username,
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
token: rawToken,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create API key", err);
|
||||
return res.status(500).json({ error: "Failed to create API key" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys:
|
||||
* get:
|
||||
* summary: List all API keys (admin only)
|
||||
* description: Returns all API keys with associated usernames. Token hashes are never returned.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of API keys.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to fetch API keys.
|
||||
*/
|
||||
router.get("/api-keys", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const keys = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
userId: apiKeys.userId,
|
||||
username: users.username,
|
||||
tokenPrefix: apiKeys.tokenPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||
.orderBy(apiKeys.createdAt);
|
||||
|
||||
return res.json({ apiKeys: keys });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list API keys", err);
|
||||
return res.status(500).json({ error: "Failed to fetch API keys" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys/{keyId}:
|
||||
* delete:
|
||||
* summary: Delete an API key (admin only)
|
||||
* description: Permanently deletes an API key. It can no longer be used to authenticate.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: keyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: The ID of the API key to delete.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: API key deleted.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: API key not found.
|
||||
* 500:
|
||||
* description: Failed to delete API key.
|
||||
*/
|
||||
router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const keyId = String(req.params.keyId);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.id, keyId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "API key not found" });
|
||||
}
|
||||
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, keyId));
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete API key", err, {
|
||||
keyId: String(req.params.keyId),
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to delete API key" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
function parseGuacUrl(url: string): { host: string; port: number } {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -31,7 +31,6 @@ router.use(authManager.createAuthMiddleware());
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { type, hostname, port, username, password, domain, ...options } =
|
||||
req.body;
|
||||
|
||||
@@ -63,10 +62,15 @@ router.post("/token", async (req, res) => {
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...options,
|
||||
});
|
||||
token = tokenService.createVncToken(
|
||||
hostname,
|
||||
username || undefined,
|
||||
password,
|
||||
{
|
||||
port: port || 5900,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
@@ -129,7 +133,7 @@ router.post(
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
|
||||
if (!hostId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
@@ -206,10 +210,15 @@ router.post(
|
||||
});
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...guacConfig,
|
||||
});
|
||||
token = tokenService.createVncToken(
|
||||
hostname,
|
||||
username || undefined,
|
||||
password,
|
||||
{
|
||||
port: port || 5900,
|
||||
...guacConfig,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
|
||||
@@ -141,6 +141,7 @@ export class GuacamoleTokenService {
|
||||
|
||||
createVncToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
@@ -149,6 +150,7 @@ export class GuacamoleTokenService {
|
||||
type: "vnc",
|
||||
settings: {
|
||||
hostname,
|
||||
...(username ? { username } : {}),
|
||||
password,
|
||||
port: 5900,
|
||||
...options,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -27,14 +26,18 @@ const wss = new WebSocketServer({
|
||||
port: 30009,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url || "", true);
|
||||
let token = url.query.token as string;
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
const authHeader = info.req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,14 +242,18 @@ async function createJumpHostChain(
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const url = parseUrl(req.url || "", true);
|
||||
let token = url.query.token as string;
|
||||
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 cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -971,7 +971,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
userId,
|
||||
});
|
||||
|
||||
let errorStage: ConnectionStage = "error";
|
||||
let errorStage: ConnectionStage;
|
||||
if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("getaddrinfo")
|
||||
|
||||
+347
-131
@@ -361,6 +361,31 @@ async function createJumpHostChain(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (one exec per command is short-lived,
|
||||
// the SFTP channel is long-lived but only opened once).
|
||||
class ChannelOpenSerializer {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
// Enqueue an action that opens a channel. The action runs after the previous
|
||||
// one completes (success or failure). Returns a promise that resolves with
|
||||
// the action's result.
|
||||
run<T>(action: () => Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(
|
||||
() => action(),
|
||||
() => action(), // run even if the previous open failed
|
||||
);
|
||||
// Advance tail past this slot (swallow result so the chain keeps going)
|
||||
this.tail = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
@@ -369,6 +394,8 @@ interface SSHSession {
|
||||
activeOperations: number;
|
||||
sudoPassword?: string;
|
||||
sftp?: import("ssh2").SFTPWrapper;
|
||||
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
|
||||
channelOpener: ChannelOpenSerializer;
|
||||
poolKey?: string;
|
||||
userId?: string;
|
||||
}
|
||||
@@ -393,9 +420,11 @@ interface PendingTOTPSession {
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
|
||||
const activeListRequests: Record<string, boolean> = {};
|
||||
|
||||
function execWithSudo(
|
||||
client: SSHClient,
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
@@ -403,7 +432,7 @@ function execWithSudo(
|
||||
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
|
||||
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
|
||||
|
||||
client.exec(sudoCommand, (err, stream) => {
|
||||
execChannel(session, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: "", stderr: err.message, code: 1 });
|
||||
return;
|
||||
@@ -438,21 +467,76 @@ function getSessionSftp(
|
||||
if (session.sftp) {
|
||||
return Promise.resolve(session.sftp);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
session.client.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
|
||||
// Serialization: if a channel open is already in flight, join it
|
||||
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) {
|
||||
// Single retry after 500ms for transient server-side rate limiting
|
||||
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
|
||||
setTimeout(() => openOnce().then(resolve, reject), 500),
|
||||
);
|
||||
}
|
||||
session.sftp = sftp;
|
||||
sftp.on("error", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
sftp.on("close", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
resolve(sftp);
|
||||
return Promise.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
session.sftpPending = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
// Wraps client.exec through the channel serializer so only one SSH channel
|
||||
// negotiation is in-flight at a time. The serializer slot is released as soon
|
||||
// as the channel is established (not when it closes), so channels run
|
||||
// concurrently once open — we only serialize the *open handshake*.
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
@@ -840,11 +924,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
port,
|
||||
username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
tcpKeepAliveInitialDelay: 5000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
hostId,
|
||||
ip,
|
||||
@@ -1108,6 +1192,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
channelOpener: new ChannelOpenSerializer(),
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
@@ -1173,7 +1258,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
error: err.message,
|
||||
});
|
||||
|
||||
let errorStage: ConnectionStage = "error";
|
||||
let errorStage: ConnectionStage;
|
||||
if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("getaddrinfo")
|
||||
@@ -1752,6 +1837,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
channelOpener: new ChannelOpenSerializer(),
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
@@ -1954,6 +2040,7 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
channelOpener: new ChannelOpenSerializer(),
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
@@ -2048,6 +2135,10 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
|
||||
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const session = sshSessions[sessionId];
|
||||
if (session && !verifySessionOwnership(session, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
fileLogger.info("File manager disconnection requested", {
|
||||
operation: "file_disconnect_request",
|
||||
sessionId,
|
||||
@@ -2149,7 +2240,7 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
|
||||
* 400:
|
||||
* description: Session ID is required or session not found.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
app.post("/ssh/file_manager/ssh/keepalive", async (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
@@ -2173,6 +2264,18 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Probe the cached SFTP channel. If stale, clear it so the next operation
|
||||
// opens a fresh one via the serialized getSessionSftp.
|
||||
if (session.sftp && !session.sftpPending) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.sftp!.stat("/", (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
} catch {
|
||||
session.sftp = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: "success",
|
||||
connected: true,
|
||||
@@ -2226,6 +2329,17 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
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 = () => {
|
||||
@@ -2327,6 +2441,13 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
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) {
|
||||
@@ -2340,11 +2461,14 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "SSH session disconnected", disconnected: true });
|
||||
}
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(
|
||||
execChannel(
|
||||
sshConn,
|
||||
`command ls -la --color=never '${escapedPath}'`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
@@ -2472,7 +2596,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
|
||||
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
|
||||
|
||||
sshConn.client.exec(sudoCommand, (err, stream) => {
|
||||
execChannel(sshConn, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH sudo listFiles error:", err);
|
||||
@@ -2628,6 +2752,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const linkPath = decodeURIComponent(req.query.path as string);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
@@ -2637,6 +2762,10 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!linkPath) {
|
||||
return res.status(400).json({ error: "Link path is required" });
|
||||
}
|
||||
@@ -2646,7 +2775,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
|
||||
const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
|
||||
const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
|
||||
|
||||
sshConn.client.exec(command, (err, stream) => {
|
||||
execChannel(sshConn, command, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH identifySymlink error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
@@ -2722,6 +2851,7 @@ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const rawPath = decodeURIComponent(req.query.path as string);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
@@ -2731,21 +2861,26 @@ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!rawPath) {
|
||||
return res.status(400).json({ error: "Path is required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
let expandPath = rawPath;
|
||||
if (expandPath.startsWith("~")) {
|
||||
expandPath = "$HOME" + expandPath.substring(1);
|
||||
let command: string;
|
||||
if (rawPath.startsWith("~")) {
|
||||
const rest = rawPath.substring(1).replace(/'/g, "'\"'\"'");
|
||||
command = `echo ~'${rest}'`;
|
||||
} else {
|
||||
const escapedPath = rawPath.replace(/'/g, "'\"'\"'");
|
||||
command = `echo '${escapedPath}'`;
|
||||
}
|
||||
|
||||
const escapedPath = expandPath.replace(/"/g, '\\"');
|
||||
const command = `echo "${escapedPath}"`;
|
||||
|
||||
sshConn.client.exec(command, (err, stream) => {
|
||||
execChannel(sshConn, command, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH resolvePath error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
@@ -2826,6 +2961,10 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
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) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
@@ -2841,7 +2980,8 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
const MAX_READ_SIZE = 500 * 1024 * 1024;
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
sshConn.client.exec(
|
||||
execChannel(
|
||||
sshConn,
|
||||
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
|
||||
(sizeErr, sizeStream) => {
|
||||
if (sizeErr) {
|
||||
@@ -2899,7 +3039,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||
execChannel(sshConn, `cat '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH readFile error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
@@ -3006,6 +3146,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
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) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
@@ -3067,7 +3211,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
const chmodCommand = `chmod ${permissions} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
sshConn.client.exec(chmodCommand, (err, stream) => {
|
||||
execChannel(sshConn, chmodCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.warn("Failed to restore file permissions after save", {
|
||||
operation: "file_write_restore_permissions",
|
||||
@@ -3170,83 +3314,90 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
}
|
||||
|
||||
sftp.stat(filePath, (statErr, stats) => {
|
||||
if (statErr) {
|
||||
fileLogger.warn(
|
||||
"Failed to read existing file permissions before save",
|
||||
{
|
||||
operation: "file_write_stat",
|
||||
try {
|
||||
if (statErr) {
|
||||
fileLogger.warn(
|
||||
"Failed to read existing file permissions before save",
|
||||
{
|
||||
operation: "file_write_stat",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
error: statErr.message,
|
||||
},
|
||||
);
|
||||
} else if (stats.isFile()) {
|
||||
preservedMode = stats.mode & 0o7777;
|
||||
}
|
||||
|
||||
const writeStream = sftp.createWriteStream(filePath);
|
||||
|
||||
let hasError = false;
|
||||
let hasFinished = false;
|
||||
let isFinalizing = false;
|
||||
|
||||
const finalizeSuccess = () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.success("File written successfully", {
|
||||
operation: "file_write_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
error: statErr.message,
|
||||
},
|
||||
);
|
||||
} else if (stats.isFile()) {
|
||||
preservedMode = stats.mode & 0o7777;
|
||||
}
|
||||
|
||||
const writeStream = sftp.createWriteStream(filePath);
|
||||
|
||||
let hasError = false;
|
||||
let hasFinished = false;
|
||||
let isFinalizing = false;
|
||||
|
||||
const finalizeSuccess = () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.success("File written successfully", {
|
||||
operation: "file_write_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
bytes: fileBuffer.length,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File written: ${filePath}`,
|
||||
},
|
||||
bytes: fileBuffer.length,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File written: ${filePath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
writeStream.on("error", (streamErr) => {
|
||||
if (hasError || hasFinished || isFinalizing) return;
|
||||
hasError = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.warn(
|
||||
`SFTP write failed, trying fallback method: ${streamErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
});
|
||||
|
||||
const finishWrite = () => {
|
||||
if (hasError || hasFinished || isFinalizing) return;
|
||||
isFinalizing = true;
|
||||
restoreOriginalMode(sftp, finalizeSuccess);
|
||||
};
|
||||
|
||||
writeStream.on("finish", () => {
|
||||
finishWrite();
|
||||
});
|
||||
|
||||
writeStream.on("close", () => {
|
||||
finishWrite();
|
||||
});
|
||||
|
||||
try {
|
||||
writeStream.write(fileBuffer);
|
||||
writeStream.end();
|
||||
} catch (writeErr) {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.warn(
|
||||
`SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
};
|
||||
|
||||
writeStream.on("error", (streamErr) => {
|
||||
if (hasError || hasFinished || isFinalizing) return;
|
||||
hasError = true;
|
||||
isFinalizing = false;
|
||||
} catch (callbackErr) {
|
||||
fileLogger.warn(
|
||||
`SFTP write failed, trying fallback method: ${streamErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
});
|
||||
|
||||
const finishWrite = () => {
|
||||
if (hasError || hasFinished || isFinalizing) return;
|
||||
isFinalizing = true;
|
||||
restoreOriginalMode(sftp, finalizeSuccess);
|
||||
};
|
||||
|
||||
writeStream.on("finish", () => {
|
||||
finishWrite();
|
||||
});
|
||||
|
||||
writeStream.on("close", () => {
|
||||
finishWrite();
|
||||
});
|
||||
|
||||
try {
|
||||
writeStream.write(fileBuffer);
|
||||
writeStream.end();
|
||||
} catch (writeErr) {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.warn(
|
||||
`SFTP write operation failed, trying fallback method: ${writeErr.message}`,
|
||||
`SFTP stat callback error, trying fallback method: ${(callbackErr as Error).message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
@@ -3268,8 +3419,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let contentBuffer: Buffer;
|
||||
@@ -3292,7 +3445,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
|
||||
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
execChannel(sshConn, writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("Fallback write command failed:", err);
|
||||
if (!res.headersSent) {
|
||||
@@ -3318,6 +3471,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("error", (stderrErr) => {
|
||||
fileLogger.error("Fallback write stderr error:", stderrErr);
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
restoreOriginalMode(null, () => {
|
||||
@@ -3357,9 +3514,9 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
} catch (fallbackErr) {
|
||||
fileLogger.error("Fallback method failed:", fallbackErr);
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `All write methods failed: ${fallbackErr.message}` });
|
||||
res.status(500).json({
|
||||
error: `All write methods failed: ${(fallbackErr as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3411,6 +3568,10 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
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 || content === undefined) {
|
||||
return res
|
||||
.status(400)
|
||||
@@ -3560,8 +3721,10 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let contentBuffer: Buffer;
|
||||
@@ -3606,7 +3769,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
|
||||
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
execChannel(sshConn, writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("Fallback upload command failed:", err);
|
||||
if (!res.headersSent) {
|
||||
@@ -3680,7 +3843,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
|
||||
writeCommand += ` && echo "SUCCESS"`;
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
execChannel(sshConn, writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("Chunked fallback upload failed:", err);
|
||||
if (!res.headersSent) {
|
||||
@@ -3797,6 +3960,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
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" });
|
||||
@@ -3806,6 +3970,10 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||||
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" });
|
||||
}
|
||||
@@ -3819,7 +3987,7 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||||
|
||||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(createCommand, (err, stream) => {
|
||||
execChannel(sshConn, createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH createFile error:", err);
|
||||
if (!res.headersSent) {
|
||||
@@ -3939,6 +4107,10 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||||
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" });
|
||||
}
|
||||
@@ -3958,7 +4130,7 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||||
|
||||
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(createCommand, (err, stream) => {
|
||||
execChannel(sshConn, createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH createFolder error:", err);
|
||||
if (!res.headersSent) {
|
||||
@@ -4090,6 +4262,10 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
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" });
|
||||
}
|
||||
@@ -4111,7 +4287,7 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (useSudo && sshConn.sudoPassword) {
|
||||
execWithSudo(sshConn.client, deleteCommand, sshConn.sudoPassword).then(
|
||||
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
|
||||
(result) => {
|
||||
if (
|
||||
result.code === 0 ||
|
||||
@@ -4137,7 +4313,8 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.client.exec(
|
||||
execChannel(
|
||||
sshConn,
|
||||
`${deleteCommand} && echo "SUCCESS"`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
@@ -4259,6 +4436,10 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
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)
|
||||
@@ -4281,7 +4462,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
|
||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(renameCommand, (err, stream) => {
|
||||
execChannel(sshConn, renameCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH renameItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
@@ -4412,6 +4593,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
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" });
|
||||
@@ -4421,6 +4603,10 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
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)
|
||||
@@ -4446,7 +4632,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
sshConn.client.exec(moveCommand, (err, stream) => {
|
||||
execChannel(sshConn, moveCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH moveItem error:", err);
|
||||
@@ -4566,7 +4752,8 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
* description: Failed to download file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, hostId, userId } = req.body;
|
||||
const { sessionId, path: filePath, hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const downloadStartTime = Date.now();
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
@@ -4597,6 +4784,10 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
.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);
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
@@ -4713,7 +4904,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
* description: Failed to copy item.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body;
|
||||
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" });
|
||||
@@ -4726,6 +4918,10 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
.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);
|
||||
|
||||
@@ -4757,7 +4953,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
sshConn.client.exec(copyCommand, (err, stream) => {
|
||||
execChannel(sshConn, copyCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH copyItem error:", err);
|
||||
@@ -4905,6 +5101,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
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(
|
||||
@@ -4919,6 +5116,10 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
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" });
|
||||
}
|
||||
@@ -4927,7 +5128,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
|
||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||
|
||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
fileLogger.error("SSH executeFile check error:", checkErr);
|
||||
return res
|
||||
@@ -4947,7 +5148,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
|
||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||
|
||||
sshConn.client.exec(executeCommand, (err, stream) => {
|
||||
execChannel(sshConn, executeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH executeFile error:", err);
|
||||
return res.status(500).json({ error: "Failed to execute file" });
|
||||
@@ -5034,6 +5235,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
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(
|
||||
@@ -5048,6 +5250,10 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
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" });
|
||||
}
|
||||
@@ -5086,7 +5292,7 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
sshConn.client.exec(command, (err, stream) => {
|
||||
execChannel(sshConn, command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions exec error:", err, {
|
||||
@@ -5232,6 +5438,7 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
const { sessionId, archivePath, extractPath } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId || !archivePath) {
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
@@ -5242,13 +5449,17 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
return res.status(400).json({ error: "SSH session not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(session, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const fileName = archivePath.split("/").pop() || "";
|
||||
const fileExt = fileName.toLowerCase();
|
||||
|
||||
let extractCommand = "";
|
||||
let extractCommand: string;
|
||||
const targetPath =
|
||||
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
|
||||
|
||||
@@ -5290,7 +5501,7 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
command: extractCommand,
|
||||
});
|
||||
|
||||
session.client.exec(extractCommand, (err, stream) => {
|
||||
execChannel(session, extractCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH exec error during extract:", err, {
|
||||
operation: "extract_archive",
|
||||
@@ -5438,6 +5649,7 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
const { sessionId, paths, archiveName, format } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (
|
||||
!sessionId ||
|
||||
@@ -5454,11 +5666,15 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
return res.status(400).json({ error: "SSH session not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(session, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const compressionFormat = format || "zip";
|
||||
let compressCommand = "";
|
||||
let compressCommand: string;
|
||||
|
||||
const firstPath = paths[0];
|
||||
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
|
||||
@@ -5509,7 +5725,7 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
command: compressCommand,
|
||||
});
|
||||
|
||||
session.client.exec(compressCommand, (err, stream) => {
|
||||
execChannel(session, compressCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH exec error during compress:", err, {
|
||||
operation: "compress_files",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { randomUUID } from "crypto";
|
||||
import { WebSocket } from "ws";
|
||||
import { IncomingMessage } from "http";
|
||||
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
@@ -13,7 +12,6 @@ import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import yaml from "js-yaml";
|
||||
import { getRequestOrigin } from "../utils/request-origin.js";
|
||||
|
||||
const AUTH_TIMEOUT = 60 * 1000;
|
||||
|
||||
|
||||
@@ -4,12 +4,64 @@
|
||||
// DER → SSH wire format, and patches Protocol.authPK to use the base
|
||||
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
|
||||
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
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;
|
||||
|
||||
export async function setupOPKSSHCertAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
@@ -26,7 +78,9 @@ export async function setupOPKSSHCertAuth(
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
throw new Error("Failed to parse OPKSSH private key");
|
||||
}
|
||||
const privKey: any = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
const privKey = (
|
||||
Array.isArray(parsed) ? parsed[0] : parsed
|
||||
) as ParsedPrivateKey;
|
||||
|
||||
// Extract cert type and blob from the stored certificate
|
||||
const certParts = token.sshCert.trim().split(/\s+/);
|
||||
@@ -78,36 +132,43 @@ export async function setupOPKSSHCertAuth(
|
||||
|
||||
// Set up authHandler to bypass ssh2's cert type rejection
|
||||
let certAuthAttempted = false;
|
||||
config.authHandler = (
|
||||
const authHandler: AuthHandlerMiddleware = (
|
||||
methodsLeft: string[],
|
||||
_partialSuccess: boolean,
|
||||
callback: (authInfo: any) => void,
|
||||
callback,
|
||||
) => {
|
||||
const next = callback as OPKSSHNextAuthHandler;
|
||||
if (
|
||||
!certAuthAttempted &&
|
||||
(!methodsLeft || methodsLeft.includes("publickey"))
|
||||
) {
|
||||
certAuthAttempted = true;
|
||||
callback({ type: "publickey", username, key: privKey });
|
||||
next({
|
||||
type: "publickey",
|
||||
username,
|
||||
key: privKey as unknown as PublicKeyAuthMethod["key"],
|
||||
});
|
||||
} else {
|
||||
callback(false);
|
||||
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);
|
||||
(client as any).connect = (cfg: any) => {
|
||||
origConnect(cfg);
|
||||
const proto = (client as any)._protocol;
|
||||
if (!proto) return;
|
||||
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: any,
|
||||
pubKey: ParsedPrivateKey,
|
||||
keyAlgo: string | undefined,
|
||||
cbSign?: Function,
|
||||
cbSign?: SignCallback,
|
||||
) => {
|
||||
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
|
||||
if (!isCertAuth) {
|
||||
@@ -232,5 +293,6 @@ export async function setupOPKSSHCertAuth(
|
||||
proto._cipher.encrypt(finalized);
|
||||
});
|
||||
};
|
||||
return connectedClient;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1088,8 +1088,6 @@ class PollingManager {
|
||||
for (const { host, viewerUserId } of hostsToRefresh) {
|
||||
await this.startPollingForHost(host, { statusOnly: true, viewerUserId });
|
||||
}
|
||||
|
||||
const skipped = this.pollingConfigs.size - hostsToRefresh.length;
|
||||
}
|
||||
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void {
|
||||
@@ -1331,9 +1329,8 @@ async function resolveHostCredentials(
|
||||
const isSharedHost = userId !== ownerId;
|
||||
|
||||
if (isSharedHost) {
|
||||
const { SharedCredentialManager } = await import(
|
||||
"../utils/shared-credential-manager.js"
|
||||
);
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
host.id as number,
|
||||
@@ -1552,7 +1549,9 @@ async function buildSshConfig(
|
||||
statsLogger.error(
|
||||
`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
);
|
||||
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||
throw new Error(`Invalid SSH key format for host ${host.ip}`, {
|
||||
cause: keyError,
|
||||
});
|
||||
}
|
||||
} else if (host.authType === "none") {
|
||||
// no credentials needed
|
||||
@@ -1654,6 +1653,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
||||
(proxyError instanceof Error
|
||||
? proxyError.message
|
||||
: "Unknown error"),
|
||||
{ cause: proxyError },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2581,7 +2581,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
let errorStage: ConnectionStage = "error";
|
||||
let errorStage: ConnectionStage;
|
||||
|
||||
if (
|
||||
errorMessage.includes("ENOTFOUND") ||
|
||||
|
||||
+162
-31
@@ -3,7 +3,6 @@ import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import net from "net";
|
||||
import dgram from "dgram";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { parse as parseUrl } from "url";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials, hosts } from "../database/db/schema.js";
|
||||
@@ -367,14 +366,18 @@ const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
let token = url.query.token as string;
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
const authHeader = info.req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,14 +417,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
const url = parseUrl(req.url!, true);
|
||||
let token = url.query.token as string;
|
||||
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 cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +494,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let isConnecting = false;
|
||||
let isConnected = false;
|
||||
let isCleaningUp = false;
|
||||
let cwdPending = false;
|
||||
let cwdBuffer = "";
|
||||
let isShellInitializing = false;
|
||||
let warpgateAuthPromptSent = false;
|
||||
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -590,6 +599,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
connectData.hostConfig.userId = userId;
|
||||
}
|
||||
handleConnectToHost(connectData).catch((error) => {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
if (
|
||||
errMsg.includes("Cannot parse privateKey") &&
|
||||
errMsg.includes("no passphrase")
|
||||
) {
|
||||
isAwaitingAuthCredentials = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "passphrase_required",
|
||||
message:
|
||||
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sshLogger.error("Failed to connect to host", error, {
|
||||
operation: "ssh_connect",
|
||||
userId,
|
||||
@@ -599,9 +624,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to connect to host: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
message: "Failed to connect to host: " + errMsg,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -729,6 +752,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshStream = null;
|
||||
break;
|
||||
|
||||
case "get_cwd": {
|
||||
const activeStream =
|
||||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||||
if (!activeStream) {
|
||||
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
|
||||
break;
|
||||
}
|
||||
cwdPending = true;
|
||||
cwdBuffer = "";
|
||||
// Split the sentinel across shell variables so the echoed command
|
||||
// itself never contains "TERMIX_CWD:" — only the output line does.
|
||||
activeStream.write('a=TERMIX_CWD; echo "$a:$(pwd)"\r');
|
||||
break;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
const inputData = data as string;
|
||||
const inputStream =
|
||||
@@ -898,6 +936,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
credentialsData.hostConfig.key = credentialsData.sshKey;
|
||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||||
credentialsData.hostConfig.authType = "key";
|
||||
} else if (credentialsData.keyPassword) {
|
||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||||
}
|
||||
|
||||
isAwaitingAuthCredentials = false;
|
||||
@@ -916,6 +956,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
};
|
||||
|
||||
handleConnectToHost(reconnectData).catch((error) => {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
if (
|
||||
errMsg.includes("Cannot parse privateKey") &&
|
||||
errMsg.includes("no passphrase")
|
||||
) {
|
||||
isAwaitingAuthCredentials = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "passphrase_required",
|
||||
message:
|
||||
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sshLogger.error("Failed to reconnect with credentials", error, {
|
||||
operation: "ssh_reconnect_with_credentials",
|
||||
userId,
|
||||
@@ -925,9 +981,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to connect with provided credentials: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
message: "Failed to connect with provided credentials: " + errMsg,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1196,7 +1250,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
@@ -1222,7 +1276,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
@@ -1439,9 +1494,47 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
const boundSessionId = currentSessionId;
|
||||
|
||||
const CWD_SENTINEL = "TERMIX_CWD:";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
try {
|
||||
const utf8String = data.toString("utf-8");
|
||||
let utf8String = data.toString("utf-8");
|
||||
|
||||
if (cwdPending) {
|
||||
cwdBuffer += utf8String;
|
||||
const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL);
|
||||
if (sentinelIdx !== -1) {
|
||||
const afterSentinel = cwdBuffer.slice(
|
||||
sentinelIdx + CWD_SENTINEL.length,
|
||||
);
|
||||
const newlineIdx = afterSentinel.search(/[\r\n]/);
|
||||
if (newlineIdx !== -1) {
|
||||
const cwd =
|
||||
afterSentinel.slice(0, newlineIdx).trim() || "/";
|
||||
cwdPending = false;
|
||||
// Strip the sentinel line from output sent to terminal
|
||||
const beforeSentinel = cwdBuffer.slice(0, sentinelIdx);
|
||||
const afterNewline = afterSentinel.slice(newlineIdx);
|
||||
utf8String = beforeSentinel + afterNewline;
|
||||
cwdBuffer = "";
|
||||
const attachedWs =
|
||||
sessionManager.getSession(boundSessionId)?.attachedWs ??
|
||||
ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(
|
||||
JSON.stringify({ type: "cwd", path: cwd }),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!utf8String) return;
|
||||
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
||||
@@ -1472,15 +1565,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
stream.on("close", (code: number | null) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
if (code != null) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "session_ended",
|
||||
code,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (boundSessionId) {
|
||||
sessionManager.destroySession(boundSessionId);
|
||||
@@ -1740,6 +1842,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
err.message.includes("Cannot parse privateKey") &&
|
||||
err.message.includes("no passphrase")
|
||||
) {
|
||||
sendLog(
|
||||
"auth",
|
||||
"error",
|
||||
"SSH key is encrypted but no passphrase was provided",
|
||||
);
|
||||
isAwaitingAuthCredentials = true;
|
||||
if (currentSessionId) {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
currentSessionId = null;
|
||||
}
|
||||
cleanupAuthState(connectionTimeout);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "passphrase_required",
|
||||
message:
|
||||
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
authMethodNotAvailable &&
|
||||
resolvedCredentials.authType === "none" &&
|
||||
@@ -1913,7 +2040,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (!sshStream) {
|
||||
} else {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -2102,6 +2229,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
|
||||
if (resolvedCredentials.password) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
} catch (keyError) {
|
||||
sshLogger.error("SSH key format error: " + keyError.message);
|
||||
ws.send(
|
||||
@@ -2191,7 +2322,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
{ operation: "port_knock", hostId: hostConfig.id },
|
||||
);
|
||||
await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
sshLogger.warn("Port knocking failed, attempting connection anyway", {
|
||||
operation: "port_knock",
|
||||
hostId: hostConfig.id,
|
||||
|
||||
+1240
-256
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,6 @@ export async function collectCpuMetrics(client: Client): Promise<{
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
cores = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeEncryptionKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
await AutoSSLSetup.initialize();
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import swaggerJSDoc from "swagger-jsdoc";
|
||||
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { promises as fs } from "fs";
|
||||
import { systemLogger } from "./utils/logger.js";
|
||||
|
||||
interface SwaggerOptions {
|
||||
definition: object;
|
||||
apis: string[];
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const projectRoot = path.join(__dirname, "..", "..", "..");
|
||||
|
||||
const swaggerOptions: swaggerJSDoc.Options = {
|
||||
const swaggerOptions: SwaggerOptions = {
|
||||
definition: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
@@ -130,7 +135,7 @@ async function generateOpenAPISpec() {
|
||||
operation: "openapi_generate_start",
|
||||
});
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(swaggerOptions);
|
||||
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
|
||||
|
||||
const outputPath = path.join(projectRoot, "openapi.json");
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import crypto from "crypto";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger, authLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
db,
|
||||
getSqlite,
|
||||
saveMemoryDatabaseToFile,
|
||||
} from "../database/db/index.js";
|
||||
import { sessions, trustedDevices } from "../database/db/schema.js";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
@@ -29,12 +27,21 @@ interface JWTPayload {
|
||||
userId: string;
|
||||
sessionId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
dataKeyWrap?: WrappedDataKey;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
interface WrappedDataKey {
|
||||
version: "v1";
|
||||
iv: string;
|
||||
tag: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
dataKey?: Buffer;
|
||||
}
|
||||
@@ -198,6 +205,113 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getDataKeyAAD(userId: string, sessionId?: string): Buffer {
|
||||
return Buffer.from(`${userId}:${sessionId || ""}`, "utf8");
|
||||
}
|
||||
|
||||
private async wrapUserDataKey(
|
||||
userId: string,
|
||||
sessionId: string | undefined,
|
||||
dataKey: Buffer,
|
||||
): Promise<WrappedDataKey> {
|
||||
const encryptionKey = await this.systemCrypto.getEncryptionKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv);
|
||||
cipher.setAAD(this.getDataKeyAAD(userId, sessionId));
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(dataKey), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
version: "v1",
|
||||
iv: iv.toString("base64url"),
|
||||
tag: tag.toString("base64url"),
|
||||
data: encrypted.toString("base64url"),
|
||||
};
|
||||
}
|
||||
|
||||
private async unwrapUserDataKey(
|
||||
userId: string,
|
||||
sessionId: string | undefined,
|
||||
wrapped: WrappedDataKey,
|
||||
): Promise<Buffer> {
|
||||
if (wrapped.version !== "v1") {
|
||||
throw new Error(
|
||||
`Unsupported wrapped data key version: ${wrapped.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionKey = await this.systemCrypto.getEncryptionKey();
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
encryptionKey,
|
||||
Buffer.from(wrapped.iv, "base64url"),
|
||||
);
|
||||
decipher.setAAD(this.getDataKeyAAD(userId, sessionId));
|
||||
decipher.setAuthTag(Buffer.from(wrapped.tag, "base64url"));
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(wrapped.data, "base64url")),
|
||||
decipher.final(),
|
||||
]);
|
||||
}
|
||||
|
||||
private async addWrappedDataKey(payload: JWTPayload): Promise<void> {
|
||||
if (payload.pendingTOTP) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataKey = this.userCrypto.getUserDataKey(payload.userId);
|
||||
if (!dataKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
payload.dataKeyWrap = await this.wrapUserDataKey(
|
||||
payload.userId,
|
||||
payload.sessionId,
|
||||
dataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private async restoreDataKeyFromPayload(
|
||||
payload: JWTPayload,
|
||||
sessionExpiresAt?: string,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!payload.dataKeyWrap ||
|
||||
this.userCrypto.getUserDataKey(payload.userId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAt = sessionExpiresAt
|
||||
? new Date(sessionExpiresAt).getTime()
|
||||
: payload.exp
|
||||
? payload.exp * 1000
|
||||
: Date.now();
|
||||
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataKey = await this.unwrapUserDataKey(
|
||||
payload.userId,
|
||||
payload.sessionId,
|
||||
payload.dataKeyWrap,
|
||||
);
|
||||
this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt);
|
||||
dataKey.fill(0);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to restore data key from session token", {
|
||||
operation: "session_data_key_restore_failed",
|
||||
userId: payload.userId,
|
||||
sessionId: payload.sessionId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async generateJWTToken(
|
||||
userId: string,
|
||||
options: {
|
||||
@@ -234,6 +348,7 @@ class AuthManager {
|
||||
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
||||
const sessionId = nanoid();
|
||||
payload.sessionId = sessionId;
|
||||
await this.addWrappedDataKey(payload);
|
||||
|
||||
const token = jwt.sign(payload, jwtSecret, {
|
||||
expiresIn,
|
||||
@@ -281,6 +396,7 @@ class AuthManager {
|
||||
return token;
|
||||
}
|
||||
|
||||
await this.addWrappedDataKey(payload);
|
||||
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
|
||||
}
|
||||
|
||||
@@ -327,6 +443,11 @@ class AuthManager {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.restoreDataKeyFromPayload(
|
||||
payload,
|
||||
sessionRecords[0].expiresAt,
|
||||
);
|
||||
} catch (dbError) {
|
||||
databaseLogger.error(
|
||||
"Failed to check session in database during JWT verification",
|
||||
@@ -338,6 +459,8 @@ class AuthManager {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
await this.restoreDataKeyFromPayload(payload);
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
@@ -350,12 +473,63 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async refreshSessionToken(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ token: string; maxAge: number } | null> {
|
||||
const sessionRecords = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(sessionRecords[0].expiresAt).getTime();
|
||||
const maxAge = expiresAt - Date.now();
|
||||
if (!Number.isFinite(maxAge) || maxAge <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: JWTPayload = { userId, sessionId };
|
||||
await this.addWrappedDataKey(payload);
|
||||
|
||||
const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), {
|
||||
expiresIn: Math.ceil(maxAge / 1000),
|
||||
} as jwt.SignOptions);
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
jwtToken: token,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
"Failed to save database after session token refresh",
|
||||
saveError,
|
||||
{
|
||||
operation: "session_token_refresh_db_save_failed",
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { token, maxAge };
|
||||
}
|
||||
|
||||
invalidateJWTToken(_token: string): void {
|
||||
// expected - no-op, JWT tokens are stateless
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
invalidateUserTokens(_userId: string): void {
|
||||
// expected - no-op, handled by session management
|
||||
}
|
||||
@@ -537,9 +711,9 @@ class AuthManager {
|
||||
maxAge: number = 24 * 60 * 60 * 1000,
|
||||
) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||
sameSite: "strict" as const,
|
||||
sameSite: "lax" as const,
|
||||
maxAge: maxAge,
|
||||
path: "/",
|
||||
};
|
||||
@@ -547,13 +721,88 @@ class AuthManager {
|
||||
|
||||
getClearCookieOptions(req: RequestWithHeaders) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||
sameSite: "strict" as const,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
private async handleApiKeyAuth(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
token: string,
|
||||
requireAdmin = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const tokenPrefix = token.substring(0, 12);
|
||||
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(
|
||||
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
res.status(401).json({ error: "Invalid API key" });
|
||||
return;
|
||||
}
|
||||
|
||||
let matchedKey: (typeof candidates)[0] | null = null;
|
||||
for (const candidate of candidates) {
|
||||
if (await bcrypt.compare(token, candidate.tokenHash)) {
|
||||
matchedKey = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedKey) {
|
||||
res.status(401).json({ error: "Invalid API key" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedKey.expiresAt && new Date(matchedKey.expiresAt) < new Date()) {
|
||||
res.status(401).json({ error: "API key has expired" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireAdmin) {
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, matchedKey.userId))
|
||||
.limit(1);
|
||||
if (!userRows[0]?.isAdmin) {
|
||||
res.status(403).json({ error: "Admin access required" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
db.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date().toISOString() })
|
||||
.where(eq(apiKeys.id, matchedKey.id))
|
||||
.then(() => {})
|
||||
.catch((err) => {
|
||||
databaseLogger.warn("Failed to update API key lastUsedAt", {
|
||||
operation: "api_key_update_last_used",
|
||||
keyId: matchedKey!.id,
|
||||
error: err instanceof Error ? err.message : "Unknown",
|
||||
});
|
||||
});
|
||||
|
||||
req.userId = matchedKey.userId;
|
||||
next();
|
||||
} catch (error) {
|
||||
databaseLogger.error("API key authentication failed", error, {
|
||||
operation: "api_key_auth_failed",
|
||||
});
|
||||
res.status(500).json({ error: "API key authentication failed" });
|
||||
}
|
||||
}
|
||||
|
||||
createAuthMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
@@ -570,10 +819,17 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Missing authentication token" });
|
||||
}
|
||||
|
||||
if (token.startsWith("tmx_")) {
|
||||
return this.handleApiKeyAuth(authReq, res, next, token);
|
||||
}
|
||||
|
||||
const payload = await this.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
return res
|
||||
.clearCookie("jwt", this.getClearCookieOptions(req))
|
||||
.status(401)
|
||||
.json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
@@ -597,10 +853,13 @@ class AuthManager {
|
||||
sessionId: payload.sessionId,
|
||||
userId: payload.userId,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Session not found",
|
||||
code: "SESSION_NOT_FOUND",
|
||||
});
|
||||
return res
|
||||
.clearCookie("jwt", this.getClearCookieOptions(req))
|
||||
.status(401)
|
||||
.json({
|
||||
error: "Session not found",
|
||||
code: "SESSION_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const session = sessionRecords[0];
|
||||
@@ -657,10 +916,13 @@ class AuthManager {
|
||||
);
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
error: "Session has expired",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
return res
|
||||
.clearCookie("jwt", this.getClearCookieOptions(req))
|
||||
.status(401)
|
||||
.json({
|
||||
error: "Session has expired",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
db.update(sessions)
|
||||
@@ -684,6 +946,7 @@ class AuthManager {
|
||||
}
|
||||
|
||||
authReq.userId = payload.userId;
|
||||
authReq.sessionId = payload.sessionId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
};
|
||||
@@ -718,10 +981,23 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Missing authentication token" });
|
||||
}
|
||||
|
||||
if (token.startsWith("tmx_")) {
|
||||
return this.handleApiKeyAuth(
|
||||
req as AuthenticatedRequest,
|
||||
res,
|
||||
next,
|
||||
token,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await this.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
return res
|
||||
.clearCookie("jwt", this.getClearCookieOptions(req))
|
||||
.status(401)
|
||||
.json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
@@ -755,6 +1031,7 @@ class AuthManager {
|
||||
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
authReq.userId = payload.userId;
|
||||
authReq.sessionId = payload.sessionId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -171,6 +171,7 @@ IP.3 = 0.0.0.0
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ export function createCorsMiddleware(
|
||||
return callback(null, true);
|
||||
|
||||
const configured = getAllowedOrigins();
|
||||
if (configured.length === 0) return callback(null, true);
|
||||
if (configured.includes("*") || configured.includes(origin))
|
||||
return callback(null, true);
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ class DatabaseFileEncryption {
|
||||
});
|
||||
throw new Error(
|
||||
`Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -197,6 +198,7 @@ class DatabaseFileEncryption {
|
||||
});
|
||||
throw new Error(
|
||||
`Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -237,6 +239,7 @@ class DatabaseFileEncryption {
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
throw new Error(
|
||||
`Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`,
|
||||
{ cause: singleFileError },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,6 +250,7 @@ class DatabaseFileEncryption {
|
||||
} catch (twoFileError) {
|
||||
throw new Error(
|
||||
`Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`,
|
||||
{ cause: twoFileError },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -358,6 +362,7 @@ class DatabaseFileEncryption {
|
||||
`- .env file readable: ${envFileReadable}\n` +
|
||||
`- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` +
|
||||
`Original error: ${errorMessage}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,7 +371,9 @@ class DatabaseFileEncryption {
|
||||
encryptedPath,
|
||||
errorMessage,
|
||||
});
|
||||
throw new Error(`Database buffer decryption failed: ${errorMessage}`);
|
||||
throw new Error(`Database buffer decryption failed: ${errorMessage}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +405,7 @@ class DatabaseFileEncryption {
|
||||
});
|
||||
throw new Error(
|
||||
`Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ export class DatabaseMigration {
|
||||
}
|
||||
}
|
||||
|
||||
let needsMigration = false;
|
||||
let reason = "";
|
||||
let needsMigration: boolean;
|
||||
let reason: string;
|
||||
|
||||
if (hasEncryptedDb && hasUnencryptedDb) {
|
||||
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
@@ -119,6 +119,7 @@ export class DatabaseMigration {
|
||||
});
|
||||
throw new Error(
|
||||
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import chalk from "chalk";
|
||||
import type { ChalkInstance } from "chalk";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||
|
||||
@@ -78,7 +79,17 @@ export class Logger {
|
||||
}
|
||||
|
||||
private getTimeStamp(): string {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const now = new Date();
|
||||
const format = process.env.LOG_TIMESTAMP_FORMAT?.toLowerCase();
|
||||
let time: string;
|
||||
if (format === "iso") {
|
||||
time = now.toISOString();
|
||||
} else if (format === "24h") {
|
||||
time = now.toLocaleTimeString("en-GB", { hour12: false });
|
||||
} else {
|
||||
time = now.toLocaleTimeString();
|
||||
}
|
||||
return chalk.gray(`[${time}]`);
|
||||
}
|
||||
|
||||
private sanitizeContext(context: LogContext): LogContext {
|
||||
@@ -149,7 +160,7 @@ export class Logger {
|
||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
private getLevelColor(level: LogLevel): chalk.Chalk {
|
||||
private getLevelColor(level: LogLevel): ChalkInstance {
|
||||
switch (level) {
|
||||
case "debug":
|
||||
return chalk.magenta;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import type { Agent } from "http";
|
||||
import { ProxyAgent } from "undici";
|
||||
import type { Dispatcher } from "undici-types";
|
||||
|
||||
export function getProxyAgent(targetUrl?: string): Agent | undefined {
|
||||
export function getProxyAgent(targetUrl?: string): Dispatcher | undefined {
|
||||
const proxyUrl =
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
@@ -26,5 +26,5 @@ export function getProxyAgent(targetUrl?: string): Agent | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpsProxyAgent(proxyUrl);
|
||||
return new ProxyAgent(proxyUrl) as unknown as Dispatcher;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class SystemCrypto {
|
||||
private static instance: SystemCrypto;
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
private encryptionKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
private credentialSharingKey: Buffer | null = null;
|
||||
|
||||
@@ -61,7 +62,7 @@ class SystemCrypto {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
operation: "jwt_init_failed",
|
||||
});
|
||||
throw new Error("JWT secret initialization failed");
|
||||
throw new Error("JWT secret initialization failed", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +104,7 @@ class SystemCrypto {
|
||||
operation: "db_key_init_failed",
|
||||
dataDir: process.env.DATA_DIR || "./db/data",
|
||||
});
|
||||
throw new Error("Database key initialization failed");
|
||||
throw new Error("Database key initialization failed", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +115,46 @@ class SystemCrypto {
|
||||
return this.databaseKey!;
|
||||
}
|
||||
|
||||
async initializeEncryptionKey(): Promise<void> {
|
||||
try {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const envKey = process.env.ENCRYPTION_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.encryptionKey = Buffer.from(envKey, "hex");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const keyMatch = envContent.match(/^ENCRYPTION_KEY=(.+)$/m);
|
||||
if (keyMatch && keyMatch[1] && keyMatch[1].length >= 64) {
|
||||
this.encryptionKey = Buffer.from(keyMatch[1], "hex");
|
||||
process.env.ENCRYPTION_KEY = keyMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// expected - env file may not exist
|
||||
}
|
||||
|
||||
await this.generateAndGuideEncryptionKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize encryption key", error, {
|
||||
operation: "encryption_key_init_failed",
|
||||
dataDir: process.env.DATA_DIR || "./db/data",
|
||||
});
|
||||
throw new Error("Encryption key initialization failed", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async getEncryptionKey(): Promise<Buffer> {
|
||||
if (!this.encryptionKey) {
|
||||
await this.initializeEncryptionKey();
|
||||
}
|
||||
return this.encryptionKey!;
|
||||
}
|
||||
|
||||
async initializeInternalAuthToken(): Promise<void> {
|
||||
try {
|
||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||
@@ -142,7 +183,9 @@ class SystemCrypto {
|
||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||
operation: "internal_auth_init_failed",
|
||||
});
|
||||
throw new Error("Internal auth token initialization failed");
|
||||
throw new Error("Internal auth token initialization failed", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +229,9 @@ class SystemCrypto {
|
||||
dataDir: process.env.DATA_DIR || "./db/data",
|
||||
},
|
||||
);
|
||||
throw new Error("Credential sharing key initialization failed");
|
||||
throw new Error("Credential sharing key initialization failed", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +275,23 @@ class SystemCrypto {
|
||||
});
|
||||
}
|
||||
|
||||
private async generateAndGuideEncryptionKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32);
|
||||
const newKeyHex = newKey.toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.encryptionKey = newKey;
|
||||
|
||||
await this.updateEnvFile("ENCRYPTION_KEY", newKeyHex);
|
||||
|
||||
databaseLogger.success("Encryption key auto-generated and saved to .env", {
|
||||
operation: "encryption_key_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "ENCRYPTION_KEY",
|
||||
note: "Used to wrap session data keys - no restart required",
|
||||
});
|
||||
}
|
||||
|
||||
private async generateAndGuideInternalAuthToken(): Promise<void> {
|
||||
const newToken = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
@@ -255,16 +255,12 @@ function parseMacVersion(userAgent: string): string {
|
||||
* Ignores minor version numbers to handle browser auto-updates.
|
||||
*/
|
||||
export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string {
|
||||
let fingerprintString = "";
|
||||
|
||||
if (deviceInfo.type === "desktop") {
|
||||
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
|
||||
} else if (deviceInfo.type === "mobile") {
|
||||
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
|
||||
} else {
|
||||
const browserMajor = deviceInfo.version.split(".")[0];
|
||||
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser} ${browserMajor}|${deviceInfo.os}`;
|
||||
}
|
||||
const fingerprintString =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`
|
||||
: `${deviceInfo.type}|${deviceInfo.browser} ${
|
||||
deviceInfo.version.split(".")[0]
|
||||
}|${deviceInfo.os}`;
|
||||
|
||||
return crypto.createHash("sha256").update(fingerprintString).digest("hex");
|
||||
}
|
||||
|
||||
@@ -265,6 +265,19 @@ class UserCrypto {
|
||||
return session.dataKey;
|
||||
}
|
||||
|
||||
restoreUserDataKey(userId: string, dataKey: Buffer, expiresAt: number): void {
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(dataKey),
|
||||
expiresAt,
|
||||
lastActivity: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
logoutUser(userId: string): void {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (session) {
|
||||
|
||||
@@ -482,7 +482,7 @@ class UserDataImport {
|
||||
return await this.importUserData(targetUserId, exportData, options);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error("Invalid JSON format in import data");
|
||||
throw new Error("Invalid JSON format in import data", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user