* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh)

- Remove passwordHash from /users/list API response
- Require both password and TOTP code for MFA-critical operations
- Restrict tunnel kill commands to tunnelMarker-only matching
- Add session ownership middleware for file manager endpoints

* fix: allow navigating away from split-view to non-pane tabs

Show the normal view container on top of the split view when the active
tab is not assigned to any pane, so users can switch to dashboard or
other tabs while split mode is active.

Closes #739

* fix: add inline quick-action buttons on host name row

Show Terminal, Files, RDP, and VNC shortcut icons on the host name row
on hover, so users can launch connections with a single click without
expanding the full action tray.

Closes #736

* fix: restore SSH keepalive interval to 30s to prevent random disconnects

Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel,
and server-stats SSH connections. The 60s interval introduced in 2.3.0
causes firewalls and NAT devices to drop idle connections before the
next keepalive probe.

Closes #733

* fix: apply guacamole-lite protocol patch in Docker builds

The Dockerfile uses --ignore-scripts which skips the postinstall hook
that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0.
Without this patch, the timezone handshake instruction is not sent for
protocol versions above 1.1.0, causing VNC connections to fail
immediately on connect.

Closes #734

* fix: show correct icons for network interface types

Detect interface type from name pattern and show appropriate icons:
WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for
docker/bridge/virtual, generic Network for others.

Closes #720

* fix: resolve sudo password for shared host users

The password endpoint required hosts.userId to match the requesting
user, which fails for shared hosts. Now falls back to decrypting with
the owner's key when the requesting user doesn't own the host.

Closes #717

* fix: use jump hosts for online status check and metrics collection

Status polling now pings the first jump host instead of the unreachable
target when jump hosts are configured. The /metrics/start endpoint now
tunnels through the jump host chain to reach the target host.

Closes #716

* fix: broaden sudo prompt detection for newer distros

Add patterns for 'password for <user>:' and bare 'Password:' prompts
in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu
26.04 and other distros that use different sudo prompt formats.

Closes #718

* fix: recalculate terminal layout after web fonts load

xterm.js measures character widths at open() time. If custom fonts
haven't loaded yet, measurements use the fallback font and spacing
becomes incorrect. Now refresh and re-fit the terminal once
document.fonts.ready resolves.

Closes #710

* fix: improve terminal cwd detection and initial directory command

Remove '&& pwd' from initial directory command — the shell prompt
shows the new directory naturally. Fixes PowerShell 5.1 which doesn't
support '&&' as a statement separator.

Prepend Ctrl+U to get_cwd command to clear any pending input before
injecting the cwd probe, reducing interference with foreground programs.

Closes #713, #714

* fix: decode base64 file content as UTF-8 in file manager

Replace bare atob() with TextDecoder('utf-8') for base64 content
decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters
like 'é' were decoded as 'é'.

Closes #719

* fix: normalize lazy import default exports for iOS compatibility

Wrap all lazy() imports with explicit .then(m => ({ default: m.default }))
to ensure consistent module resolution across platforms. iOS Safari/WebView
may handle bare lazy(() => import(...)) differently, returning the module
object instead of extracting the default export.

Closes #721

* fix: prevent RDP display from snapping back after container resize

Remove immediate rescaleDisplay() from ResizeObserver callback. The
display.onresize event already triggers rescaling when the RDP server
responds with the new resolution. Calling rescaleDisplay before the
server responds uses stale display dimensions, causing the bottom of
the screen to be truncated.

Closes #725

* fix: add portal Desktop DBus permission for Flatpak URL opening

Flatpak sandbox blocks window.open() without the portal permission,
causing terminal link clicks to open about:blank. Add talk-name for
org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL
handling.

Closes #704

* chore: remove unused code and fix PR checks (#851)

* chore: remove unused frontend code

* chore: prune unused theme exports

* ci: fix pr check failures

* chore: reduce lint warnings

* feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828)

The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group`
to sync the user's admin flag from OIDC group membership on each login.
That field is only populated when the OIDC config is stored in the
in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments
using the env-var config path (declarative IaC: Helm/Compose/Puppet)
cannot enable the feature without abandoning env vars and pasting the
client_secret into the admin UI.

Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config
return type and object. Backward compatible: when unset, the existing
`if (config.admin_group)` guard at users.ts:1336 keeps the sync block
skipped, matching today's behavior.

* chore: reduce explicit-any warnings

* chore: reduce more explicit-any warnings

* chore: reduce lint warnings

* chore: silence intentional hook dependency warnings

* chore: clean dependency tooling

* chore: narrow frontend tsconfig scope

* chore: reduce type assertion debt

* refactor: split host manager components

* refactor: split host editor sections

* refactor: split api client modules

* refactor: split more api clients

* refactor: split user settings api clients

* refactor: split tab and history api clients

* refactor: split tunnel api clients

* refactor: split server stats api client

* refactor: split file manager data api

* refactor: split ssh file operations api

* refactor: split host editor general tab

* refactor: split host editor guacamole tabs

* refactor: split ssh host management api

* refactor: split admin general settings sections

* refactor: split admin database section

* refactor: split admin management sections

* refactor: split admin keys and dialogs

* refactor: split system status api clients

* refactor: split user route helpers

* refactor: split host route helpers

* refactor: split file manager ssh helpers

* refactor: split file manager session helpers

* refactor: split file manager listing routes

* refactor: split host opkssh routes

* refactor: split file manager content routes

* refactor: split user api key routes

* refactor: split host folder routes

* refactor: split user settings routes

* refactor: split user totp routes

* refactor: split host file manager bookmark routes

* refactor: split file manager operation routes

* refactor: split server stats settings routes

* refactor: split user session routes

* refactor: split host command history routes

* refactor: split server stats viewer routes

* refactor: split docker container routes

* refactor: split user oidc account routes

* refactor: split host autostart routes

* refactor: split host internal routes

* refactor: split host network routes

* refactor: split user password reset routes

* refactor: split user admin routes

* refactor: split user data access routes

* refactor: split credential key routes

* refactor: split credential deploy routes

* refactor: split host bulk routes

* refactor: split server stats connection helpers

* refactor: split tunnel helpers

* refactor: split file manager action routes

* refactor: split terminal auth helpers

* refactor: split terminal jump host helpers

* refactor: split tunnel relay helpers

* refactor: split tunnel socks relay helpers

* refactor: split tunnel c2s relay handlers

* refactor: split server stats session helpers

* refactor: split terminal presentation helpers

* refactor: split file manager presentation helpers

* refactor: split file manager toolbar

* fix(guacamole-lite): send name instruction for protocol >= 1.3.0

The Guacamole protocol added the `name` handshake instruction in 1.3.0
(an optional human-readable identifier for the joining user). guacd 1.6.0
began requiring it during the VNC handshake even when negotiating older
protocol versions, causing connections to silently drop right after the
"User joined" log line with no client-visible error.

This patch extends scripts/patch-guacamole-lite.cjs with a third
idempotent string-replacement that injects the `name` instruction send
when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0.

Verified end-to-end: guacd debug logs now show `Processing instruction:
name` and `Client is using protocol version "VERSION_1_5_0"` (previously
stuck at VERSION_1_1_0). VNC session connects successfully against
guacd 1.5.5 / macOS Tahoe target.

Related: Termix-SSH/Support#567, #734

* fix: resolve recent support bugs

* fix(admin): wire up OIDC-to-password link dialog submit + visibility

The admin user-management UI already shipped a link icon and a "Link
Account" dialog, but two things blocked the flow:

1. The submit button had no onClick handler and the username input was
   uncontrolled (no value/onChange). Clicking "Link Accounts" was a
   no-op — no network request, no console error, no toast.
2. The link icon's visibility condition was `user.isOidc &&
   !user.passwordHash`, which hid the button on OIDC users that had
   been auto-provisioned with a passwordHash. Termix's OIDC provisioning
   sets a passwordHash by default, so the button was hidden on virtually
   every OIDC-provisioned user.

This change:
- Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`.
- Adds two pieces of dialog state: `linkAccountTargetUsername` and
  `linkAccountSubmitting`.
- Makes the dialog's Input field a controlled component.
- Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`,
  emit success/error toasts, refresh the local user list, and close
  the dialog.
- Loosens the visibility condition to `user.isOidc` (the backend
  handler already enforces all integrity checks).
- Adds `linkAccountSuccess`, `linkAccountFailed`, and
  `linkAccountInProgress` translation keys to `en.json`.

Verified locally: full Docker build via docker/Dockerfile passes;
`tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces
the same warning count as upstream (16 pre-existing `any`-type warnings,
0 errors).

* fix: support native oidc callbacks (#856)

* docs: add cloudflare tunnel guidance (#857)

* fix: sync appearance preferences (#858)

* fix: pass through terminal tab completion (#859)

* fix: resolve terminal jump hosts server-side (#860)

* fix(electron): auto-allow SSL certificates for private network hosts (#861)

Add private network IP detection (RFC 1918, link-local, loopback, IPv6
ULA) to the Electron certificate-error handler so that connections to
local/private servers like 192.168.x.x bypass SSL validation
automatically. Also add an explicit "Allow invalid certificate" toggle
in the server config UI for public HTTPS servers with self-signed certs.

* fix: restore host password copy actions (#862)

* feat: support single-host direct tunnels (ssh -L style) (#863)

Add direct tunnel mode that uses a single SSH host for port forwarding,
matching the behavior of ssh -L / ssh -R / ssh -D without requiring a
second endpoint host in the Termix database. The Termix server creates a
local TCP listener and forwards through the SSH channel directly.

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* fix: backend build errors (Type)

* fix: mobile auth failing to login with webview

* fix: mobile app geting incorrectly sent auth token

* feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing)

* feat: host-to-host file transfer via server relay

* feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts

* fix: credentials not indexing into host manager until refresh

* feat: update credentials lists to match hosts list UI/UX

* feat: add rename folder UI

* feat: improve transfer to host UI/UX

* chore: increment ver

* feat: improve transfer to host UI

* feat: implement initial auto release system

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com>
Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com>
Co-authored-by: luc <luc_cook@hotmail.co.uk>
This commit is contained in:
Luke Gustafson
2026-06-04 15:16:53 -04:00
committed by GitHub
parent da79b01db4
commit 52f4e51ae0
223 changed files with 40358 additions and 26189 deletions
@@ -0,0 +1,554 @@
import type {
AuthenticatedRequest,
CredentialBackend,
} from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { eq } from "drizzle-orm";
import ssh2Pkg from "ssh2";
import { db } from "../db/index.js";
import { hosts, sshCredentials } from "../db/schema.js";
const { Client } = ssh2Pkg;
async function deploySSHKeyToHost(
hostConfig: Record<string, unknown>,
credData: CredentialBackend,
): Promise<{ success: boolean; message?: string; error?: string }> {
const publicKey = credData.publicKey as string;
return new Promise((resolve) => {
const conn = new Client();
const connectionTimeout = setTimeout(() => {
conn.destroy();
resolve({ success: false, error: "Connection timeout" });
}, 120000);
conn.on("ready", async () => {
clearTimeout(connectionTimeout);
try {
await new Promise<void>((resolveCmd, rejectCmd) => {
const cmdTimeout = setTimeout(() => {
rejectCmd(new Error("mkdir command timeout"));
}, 10000);
conn.exec(
"test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh",
(err, stream) => {
if (err) {
clearTimeout(cmdTimeout);
return rejectCmd(err);
}
stream.on("close", (code) => {
clearTimeout(cmdTimeout);
if (code === 0) {
resolveCmd();
} else {
rejectCmd(
new Error(`mkdir command failed with code ${code}`),
);
}
});
stream.on("data", () => {
// Ignore output
});
},
);
});
const keyExists = await new Promise<boolean>(
(resolveCheck, rejectCheck) => {
const checkTimeout = setTimeout(() => {
rejectCheck(new Error("Key check timeout"));
}, 5000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
clearTimeout(checkTimeout);
return rejectCheck(
new Error(
"Invalid public key format - must contain at least 2 parts",
),
);
}
const keyPattern = keyParts[1];
conn.exec(
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
(err, stream) => {
if (err) {
clearTimeout(checkTimeout);
return rejectCheck(err);
}
let output = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.on("close", () => {
clearTimeout(checkTimeout);
const exists = output.trim() === "0";
resolveCheck(exists);
});
},
);
},
);
if (keyExists) {
conn.end();
resolve({ success: true, message: "SSH key already deployed" });
return;
}
await new Promise<void>((resolveAdd, rejectAdd) => {
const addTimeout = setTimeout(() => {
rejectAdd(new Error("Key add timeout"));
}, 30000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const escapedKey = actualPublicKey
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''");
const escapedName = credData.name
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''");
conn.exec(
`printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
(err, stream) => {
if (err) {
clearTimeout(addTimeout);
return rejectAdd(err);
}
stream.on("data", () => {
// Consume output
});
stream.on("close", (code) => {
clearTimeout(addTimeout);
if (code === 0) {
resolveAdd();
} else {
rejectAdd(
new Error(`Key deployment failed with code ${code}`),
);
}
});
},
);
});
const verifySuccess = await new Promise<boolean>(
(resolveVerify, rejectVerify) => {
const verifyTimeout = setTimeout(() => {
rejectVerify(new Error("Key verification timeout"));
}, 5000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
clearTimeout(verifyTimeout);
return rejectVerify(
new Error(
"Invalid public key format - must contain at least 2 parts",
),
);
}
const keyPattern = keyParts[1];
conn.exec(
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
(err, stream) => {
if (err) {
clearTimeout(verifyTimeout);
return rejectVerify(err);
}
let output = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.on("close", () => {
clearTimeout(verifyTimeout);
const verified = output.trim() === "0";
resolveVerify(verified);
});
},
);
},
);
conn.end();
if (verifySuccess) {
resolve({ success: true, message: "SSH key deployed successfully" });
} else {
resolve({
success: false,
error: "Key deployment verification failed",
});
}
} catch (error) {
conn.end();
resolve({
success: false,
error: error instanceof Error ? error.message : "Deployment failed",
});
}
});
conn.on("error", (err) => {
clearTimeout(connectionTimeout);
let errorMessage = err.message;
if (
err.message.includes("All configured authentication methods failed")
) {
errorMessage =
"Authentication failed. Please check your credentials and ensure the SSH service is running.";
} else if (
err.message.includes("ENOTFOUND") ||
err.message.includes("ENOENT")
) {
errorMessage = "Could not resolve hostname or connect to server.";
} else if (err.message.includes("ECONNREFUSED")) {
errorMessage =
"Connection refused. The server may not be running or the port may be incorrect.";
} else if (err.message.includes("ETIMEDOUT")) {
errorMessage =
"Connection timed out. Check your network connection and server availability.";
} else if (
err.message.includes("authentication failed") ||
err.message.includes("Permission denied")
) {
errorMessage =
"Authentication failed. Please check your username and password/key.";
}
resolve({ success: false, error: errorMessage });
});
try {
const connectionConfig: Record<string, unknown> = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (hostConfig.authType === "password" && hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
try {
const privateKey = hostConfig.privateKey as string;
if (
!privateKey.includes("-----BEGIN") ||
!privateKey.includes("-----END")
) {
throw new Error("Invalid private key format");
}
const cleanKey = privateKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectionConfig.privateKey = Buffer.from(cleanKey, "utf8");
if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword;
}
} catch (keyError) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
});
return;
}
} else {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
});
return;
}
conn.connect(connectionConfig);
} catch (error) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: error instanceof Error ? error.message : "Connection failed",
});
}
});
}
export function registerCredentialDeployRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /credentials/{id}/deploy-to-host:
* post:
* summary: Deploy SSH key to a host
* description: Deploys an SSH public key to a target host's authorized_keys file.
* tags:
* - Credentials
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* targetHostId:
* type: integer
* responses:
* 200:
* description: SSH key deployed successfully.
* 400:
* description: Credential ID and target host ID are required.
* 401:
* description: Authentication required.
* 404:
* description: Credential or target host not found.
* 500:
* description: Failed to deploy SSH key.
*/
router.post(
"/:id/deploy-to-host",
authenticateJWT,
async (req: Request, res: Response) => {
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const credentialId = parseInt(id);
const { targetHostId } = req.body;
if (!credentialId || !targetHostId) {
return res.status(400).json({
success: false,
error: "Credential ID and target host ID are required",
});
}
try {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({
success: false,
error: "Authentication required",
});
}
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const credential = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1),
"ssh_credentials",
userId,
);
if (!credential || credential.length === 0) {
return res.status(404).json({
success: false,
error: "Credential not found",
});
}
const credData = credential[0] as unknown as CredentialBackend;
if (credData.authType !== "key") {
return res.status(400).json({
success: false,
error: "Only SSH key-based credentials can be deployed",
});
}
const publicKey = credData.publicKey;
if (!publicKey) {
return res.status(400).json({
success: false,
error: "Public key is required for deployment",
});
}
const targetHost = await SimpleDBOps.select(
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
"ssh_data",
userId,
);
if (!targetHost || targetHost.length === 0) {
return res.status(404).json({
success: false,
error: "Target host not found",
});
}
const hostData = targetHost[0];
const hostConfig = {
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
authType: hostData.authType,
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword,
};
if (hostData.authType === "credential" && hostData.credentialId) {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(400).json({
success: false,
error: "Authentication required for credential resolution",
});
}
try {
const { SimpleDBOps } =
await import("../../utils/simple-db-ops.js");
const hostCredential = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, hostData.credentialId as number))
.limit(1),
"ssh_credentials",
userId,
);
if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0];
hostConfig.authType = cred.authType;
hostConfig.username = cred.username;
if (cred.authType === "password") {
hostConfig.password = cred.password;
} else if (cred.authType === "key") {
hostConfig.privateKey = cred.privateKey || cred.key;
hostConfig.keyPassword = cred.keyPassword;
}
} else {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
} catch {
return res.status(500).json({
success: false,
error: "Failed to resolve host credentials",
});
}
}
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
if (deployResult.success) {
res.json({
success: true,
message: deployResult.message || "SSH key deployed successfully",
});
} else {
res.status(500).json({
success: false,
error: deployResult.error || "Deployment failed",
});
}
} catch (error) {
res.status(500).json({
success: false,
error:
error instanceof Error ? error.message : "Failed to deploy SSH key",
});
}
},
);
}
@@ -0,0 +1,512 @@
import type { Request, RequestHandler, Response, Router } from "express";
import crypto from "crypto";
import ssh2Pkg from "ssh2";
import { authLogger } from "../../utils/logger.js";
import {
parsePublicKey,
parseSSHKey,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
const { utils: ssh2Utils } = ssh2Pkg;
function generateSSHKeyPair(
keyType: string,
keySize?: number,
passphrase?: string,
): {
success: boolean;
privateKey?: string;
publicKey?: string;
error?: string;
} {
try {
let ssh2Type = keyType;
const options: {
bits?: number;
passphrase?: string;
cipher?: string;
} = {};
if (keyType === "ssh-rsa") {
ssh2Type = "rsa";
options.bits = keySize || 2048;
} else if (keyType === "ssh-ed25519") {
ssh2Type = "ed25519";
} else if (keyType === "ecdsa-sha2-nistp256") {
ssh2Type = "ecdsa";
options.bits = 256;
}
if (passphrase && passphrase.trim()) {
options.passphrase = passphrase;
options.cipher = "aes128-cbc";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return {
success: true,
privateKey: keyPair.private,
publicKey: keyPair.public,
};
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : "SSH key generation failed",
};
}
}
export function registerCredentialKeyRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /credentials/detect-key-type:
* post:
* summary: Detect SSH key type
* description: Detects the type of an SSH private key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: Key type detection result.
* 400:
* description: Private key is required.
* 500:
* description: Failed to detect key type.
*/
router.post(
"/detect-key-type",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
hasPublicKey: !!keyInfo.publicKey,
error: keyInfo.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to detect key type", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to detect key type",
});
}
},
);
/**
* @openapi
* /credentials/detect-public-key-type:
* post:
* summary: Detect SSH public key type
* description: Detects the type of an SSH public key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* publicKey:
* type: string
* responses:
* 200:
* description: Key type detection result.
* 400:
* description: Public key is required.
* 500:
* description: Failed to detect public key type.
*/
router.post(
"/detect-public-key-type",
authenticateJWT,
async (req: Request, res: Response) => {
const { publicKey } = req.body;
if (!publicKey || typeof publicKey !== "string") {
return res.status(400).json({ error: "Public key is required" });
}
try {
const keyInfo = parsePublicKey(publicKey);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
error: keyInfo.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to detect public key type", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to detect public key type",
});
}
},
);
/**
* @openapi
* /credentials/validate-key-pair:
* post:
* summary: Validate SSH key pair
* description: Validates if a given SSH private key and public key match.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* publicKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: Key pair validation result.
* 400:
* description: Private key and public key are required.
* 500:
* description: Failed to validate key pair.
*/
router.post(
"/validate-key-pair",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
if (!publicKey || typeof publicKey !== "string") {
return res.status(400).json({ error: "Public key is required" });
}
try {
const validationResult = validateKeyPair(
privateKey,
publicKey,
keyPassword,
);
const response = {
isValid: validationResult.isValid,
privateKeyType: validationResult.privateKeyType,
publicKeyType: validationResult.publicKeyType,
generatedPublicKey: validationResult.generatedPublicKey,
error: validationResult.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to validate key pair", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to validate key pair",
});
}
},
);
/**
* @openapi
* /credentials/generate-key-pair:
* post:
* summary: Generate new SSH key pair
* description: Generates a new SSH key pair.
* tags:
* - Credentials
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* keyType:
* type: string
* keySize:
* type: integer
* passphrase:
* type: string
* responses:
* 200:
* description: The new key pair.
* 500:
* description: Failed to generate SSH key pair.
*/
router.post(
"/generate-key-pair",
authenticateJWT,
async (req: Request, res: Response) => {
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
try {
const result = generateSSHKeyPair(keyType, keySize, passphrase);
if (result.success && result.privateKey && result.publicKey) {
const response = {
success: true,
privateKey: result.privateKey,
publicKey: result.publicKey,
keyType: keyType,
format: "ssh",
algorithm: keyType,
keySize: keyType === "ssh-rsa" ? keySize : undefined,
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
};
res.json(response);
} else {
res.status(500).json({
success: false,
error: result.error || "Failed to generate SSH key pair",
});
}
} catch (error) {
authLogger.error("Failed to generate key pair", error);
res.status(500).json({
success: false,
error:
error instanceof Error
? error.message
: "Failed to generate key pair",
});
}
},
);
/**
* @openapi
* /credentials/generate-public-key:
* post:
* summary: Generate public key from private key
* description: Generates a public key from a given private key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: The generated public key.
* 400:
* description: Private key is required.
* 500:
* description: Failed to generate public key.
*/
router.post(
"/generate-public-key",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
let privateKeyObj;
const parseAttempts = [];
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
passphrase: keyPassword,
});
} catch (error) {
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
}
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey(privateKey);
} catch (error) {
parseAttempts.push(
`Method 2 (without passphrase): ${error.message}`,
);
}
}
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs8",
});
} catch (error) {
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
}
}
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs1",
});
} catch (error) {
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
}
}
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "sec1",
});
} catch (error) {
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
}
}
if (!privateKeyObj) {
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
if (keyInfo.success && keyInfo.publicKey) {
const publicKeyString = String(keyInfo.publicKey);
return res.json({
success: true,
publicKey: publicKeyString,
keyType: keyInfo.keyType,
});
} else {
parseAttempts.push(
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
);
}
} catch (error) {
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
}
}
if (!privateKeyObj) {
return res.status(400).json({
success: false,
error: "Unable to parse private key. Tried multiple formats.",
details: parseAttempts,
});
}
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
const publicKeyPem = publicKeyObj.export({
type: "spki",
format: "pem",
});
const publicKeyString =
typeof publicKeyPem === "string"
? publicKeyPem
: (publicKeyPem as Buffer).toString("utf8");
let keyType = "unknown";
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
if (asymmetricKeyType === "rsa") {
keyType = "ssh-rsa";
} else if (asymmetricKeyType === "ed25519") {
keyType = "ssh-ed25519";
} else if (asymmetricKeyType === "ec") {
keyType = "ecdsa-sha2-nistp256";
}
let finalPublicKey = publicKeyString;
let formatType = "pem";
try {
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
if (!(ssh2PrivateKey instanceof Error)) {
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
const base64Data = publicKeyBuffer.toString("base64");
finalPublicKey = `${keyType} ${base64Data}`;
formatType = "ssh";
}
} catch {
// Ignore validation errors
}
const response = {
success: true,
publicKey: finalPublicKey,
keyType: keyType,
format: formatType,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to generate public key", error);
res.status(500).json({
success: false,
error:
error instanceof Error
? error.message
: "Failed to generate public key",
});
}
},
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import {
auditLogs,
commandHistory,
dashboardPreferences,
dismissedAlerts,
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
hostAccess,
hosts,
networkTopology,
opksshTokens,
recentActivity,
sessionRecordings,
sessions,
sharedCredentials,
snippetFolders,
snippets,
sshCredentialUsage,
sshCredentials,
sshFolders,
transferRecent,
userOpenTabs,
userPreferences,
userRoles,
users,
} from "../db/schema.js";
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
try {
await db
.delete(sharedCredentials)
.where(eq(sharedCredentials.targetUserId, userId));
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId));
await db.delete(hostAccess).where(eq(hostAccess.userId, userId));
await db.delete(hostAccess).where(eq(hostAccess.grantedBy, userId));
await db.delete(sessions).where(eq(sessions.userId, userId));
await db.delete(userRoles).where(eq(userRoles.userId, userId));
await db.delete(auditLogs).where(eq(auditLogs.userId, userId));
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
await db.delete(transferRecent).where(eq(transferRecent.userId, userId));
await db.delete(recentActivity).where(eq(recentActivity.userId, userId));
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId));
await db.delete(sshFolders).where(eq(sshFolders.userId, userId));
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
await db.delete(hosts).where(eq(hosts.userId, userId));
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
await db
.delete(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId));
await db.delete(userPreferences).where(eq(userPreferences.userId, userId));
db.$client
.prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_%_${userId}`);
await db.delete(users).where(eq(users.id, userId));
authLogger.success("User and all related data deleted successfully", {
operation: "delete_user_and_related_data_complete",
userId,
});
} catch (error) {
authLogger.error("Failed to delete user and related data", error, {
operation: "delete_user_and_related_data_failed",
userId,
});
throw error;
}
}
@@ -0,0 +1,322 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { and, eq, isNotNull, or } from "drizzle-orm";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import { hosts } from "../db/schema.js";
type HostAutostartRoutesDeps = {
authenticateJWT: RequestHandler;
requireDataAccess: RequestHandler;
};
export function registerHostAutostartRoutes(
router: Router,
{ authenticateJWT, requireDataAccess }: HostAutostartRoutesDeps,
): void {
/**
* @openapi
* /host/autostart/enable:
* post:
* summary: Enable autostart for SSH configuration
* description: Enables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart enabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 404:
* description: SSH configuration not found.
* 500:
* description: Internal server error.
*/
router.post(
"/autostart/enable",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart enable request",
{
operation: "autostart_enable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
sshLogger.warn(
"User attempted to enable autostart without unlocked data",
{
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked",
},
);
return res.status(400).json({
error: "Failed to enable autostart. Ensure user data is unlocked.",
});
}
const sshConfig = await db
.select()
.from(hosts)
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
if (sshConfig.length === 0) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "config_not_found",
});
return res.status(404).json({
error: "SSH configuration not found",
});
}
const config = sshConfig[0];
const decryptedConfig = DataCrypto.decryptRecord(
"ssh_data",
config,
userId,
userDataKey,
);
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
if (
tunnel.autoStart &&
tunnel.endpointHost &&
!tunnel.endpointPassword &&
!tunnel.endpointKey
) {
const endpointHosts = await db
.select()
.from(hosts)
.where(eq(hosts.userId, userId));
const endpointHost = endpointHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (endpointHost) {
const decryptedEndpoint = DataCrypto.decryptRecord(
"ssh_data",
endpointHost,
userId,
userDataKey,
);
return {
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword:
decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType,
};
}
}
return tunnel;
}),
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
} catch (error) {
sshLogger.warn("Failed to update tunnel connections", {
operation: "tunnel_connections_update_failed",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
await db
.update(hosts)
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(hosts.id, sshConfigId));
try {
await DatabaseSaveTrigger.triggerSave();
} catch (saveError) {
sshLogger.warn("Database save failed after autostart", {
operation: "autostart_db_save_failed",
error:
saveError instanceof Error ? saveError.message : "Unknown error",
});
}
res.json({
message: "AutoStart enabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error enabling autostart", error, {
operation: "autostart_enable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/disable:
* delete:
* summary: Disable autostart for SSH configuration
* description: Disables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart disabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 500:
* description: Internal server error.
*/
router.delete(
"/autostart/disable",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart disable request",
{
operation: "autostart_disable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
await db
.update(hosts)
.set({
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
})
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
res.json({
message: "AutoStart disabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error disabling autostart", error, {
operation: "autostart_disable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/status:
* get:
* summary: Get autostart status
* description: Retrieves the autostart status for the user's SSH configurations.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart configurations.
* 500:
* description: Internal server error.
*/
router.get(
"/autostart/status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const autostartConfigs = await db
.select()
.from(hosts)
.where(
and(
eq(hosts.userId, userId),
or(
isNotNull(hosts.autostartPassword),
isNotNull(hosts.autostartKey),
),
),
);
const statusList = autostartConfigs.map((config) => ({
sshConfigId: config.id,
host: config.ip,
port: config.port,
username: config.username,
authType: config.authType,
}));
res.json({
autostart_configs: statusList,
total_count: statusList.length,
});
} catch (error) {
sshLogger.error("Error getting autostart status", error, {
operation: "autostart_status_error",
userId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
}
@@ -0,0 +1,470 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, eq, inArray } from "drizzle-orm";
import { sshLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import { hosts, sshCredentials } from "../db/schema.js";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
} from "./host-normalizers.js";
export function registerHostBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /host/bulk-import:
* post:
* summary: Bulk import SSH hosts
* description: Bulk imports multiple SSH hosts.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hosts:
* type: array
* items:
* type: object
* responses:
* 200:
* description: Import completed.
* 400:
* description: Invalid request body.
*/
/**
* @swagger
* /host/bulk-update:
* patch:
* summary: Bulk update partial fields on multiple SSH hosts
* tags: [SSH]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostIds:
* type: array
* items:
* type: number
* updates:
* type: object
* responses:
* 200:
* description: Bulk update completed.
* 400:
* description: Invalid request body.
*/
router.patch(
"/bulk-update",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostIds, updates } = req.body;
if (!Array.isArray(hostIds) || hostIds.length === 0) {
return res
.status(400)
.json({ error: "hostIds array is required and must not be empty" });
}
if (hostIds.length > 1000) {
return res
.status(400)
.json({ error: "Maximum 1000 hosts allowed per bulk update" });
}
if (
!updates ||
typeof updates !== "object" ||
Object.keys(updates).length === 0
) {
return res.status(400).json({
error:
"updates object is required and must contain at least one field",
});
}
try {
const ownedHosts = await db
.select({ id: hosts.id, statsConfig: hosts.statsConfig })
.from(hosts)
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
const ownedIds = ownedHosts.map((h) => h.id);
const unauthorizedIds = hostIds.filter(
(id: number) => !ownedIds.includes(id),
);
if (ownedIds.length === 0) {
return res.status(404).json({ error: "No matching hosts found" });
}
const errors: string[] = [];
if (unauthorizedIds.length > 0) {
errors.push(
`${unauthorizedIds.length} host(s) not found or not owned`,
);
}
const simpleUpdates: Record<string, unknown> = {};
if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin;
if (typeof updates.folder === "string")
simpleUpdates.folder = updates.folder || null;
if (typeof updates.enableTerminal === "boolean")
simpleUpdates.enableTerminal = updates.enableTerminal;
if (typeof updates.enableTunnel === "boolean")
simpleUpdates.enableTunnel = updates.enableTunnel;
if (typeof updates.enableFileManager === "boolean")
simpleUpdates.enableFileManager = updates.enableFileManager;
if (typeof updates.enableDocker === "boolean")
simpleUpdates.enableDocker = updates.enableDocker;
if (Object.keys(simpleUpdates).length > 0) {
await db
.update(hosts)
.set(simpleUpdates)
.where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId)));
}
if (updates.statsConfig && typeof updates.statsConfig === "object") {
for (const host of ownedHosts) {
try {
const existing = host.statsConfig
? JSON.parse(host.statsConfig as string)
: {};
const merged = { ...existing, ...updates.statsConfig };
await db
.update(hosts)
.set({ statsConfig: JSON.stringify(merged) })
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
} catch {
errors.push(`Failed to update statsConfig for host ${host.id}`);
}
}
}
DatabaseSaveTrigger.triggerSave("bulk_update");
return res.json({
updated: ownedIds.length,
failed: unauthorizedIds.length,
errors,
});
} catch (error) {
sshLogger.error("Failed to bulk update hosts:", error);
return res.status(500).json({ error: "Failed to bulk update hosts" });
}
},
);
router.post(
"/bulk-import",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hosts: hostsToImport, overwrite } = req.body;
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
return res
.status(400)
.json({ error: "Hosts array is required and must not be empty" });
}
if (hostsToImport.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
}
const results = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [] as string[],
};
let existingHostMap: Map<string, { id: number }> | undefined;
if (overwrite) {
try {
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
db.select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
}
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(hostsToImport[i]);
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Missing required fields (ip, port)`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
!isNonEmptyString(hostData.username)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Username required for SSH connections`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
!["password", "key", "credential", "none", "opkssh"].includes(
hostData.authType,
)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', or 'opkssh'`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "password" &&
!isNonEmptyString(hostData.password)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Password required for password authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "key" &&
!isNonEmptyString(hostData.key)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Key required for key authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId required for credential authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
hostData.credentialId
) {
const cred = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, hostData.credentialId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
if (cred.length === 0) {
const fallback = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.limit(1);
if (fallback.length > 0) {
hostData.credentialId = fallback[0].id;
} else {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
);
continue;
}
}
}
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
name: hostData.name || `${hostData.username || ""}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
username: hostData.username || null,
pin: hostData.pin || false,
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableDocker: hostData.enableDocker || false,
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
defaultPath: hostData.defaultPath || "/",
sudoPassword: hostData.sudoPassword || null,
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
: null,
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
forceKeyboardInteractive: hostData.forceKeyboardInteractive
? "true"
: "false",
notes: hostData.notes || null,
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain
? JSON.stringify(hostData.socks5ProxyChain)
: null,
portKnockSequence: hostData.portKnockSequence
? JSON.stringify(hostData.portKnockSequence)
: null,
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
enableRdp: hostData.enableRdp ?? false,
enableVnc: hostData.enableVnc ?? false,
enableTelnet: hostData.enableTelnet ?? false,
updatedAt: new Date().toISOString(),
};
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = hostData.password || null;
sshDataObj.authType = "password";
sshDataObj.credentialId = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
sshDataObj.rdpUser = hostData.rdpUser || null;
sshDataObj.rdpPassword = hostData.rdpPassword || null;
sshDataObj.rdpDomain = hostData.rdpDomain || null;
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
sshDataObj.rdpPort = hostData.rdpPort || 3389;
sshDataObj.vncUser = hostData.vncUser || null;
sshDataObj.vncPassword = hostData.vncPassword || null;
sshDataObj.vncPort = hostData.vncPort || 5900;
sshDataObj.telnetUser = hostData.telnetUser || null;
sshDataObj.telnetPassword = hostData.telnetPassword || null;
sshDataObj.telnetPort = hostData.telnetPort || 23;
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
? JSON.stringify(hostData.guacamoleConfig)
: null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
sshDataObj.authType = hostData.authType || "password";
sshDataObj.credentialId =
hostData.authType === "credential" ? hostData.credentialId : null;
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
sshDataObj.keyPassword =
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
sshDataObj.domain = null;
sshDataObj.security = null;
sshDataObj.ignoreCert = 0;
sshDataObj.guacamoleConfig = null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing) {
await SimpleDBOps.update(
hosts,
"ssh_data",
eq(hosts.id, existing.id),
sshDataObj,
userId,
);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
results.success++;
}
} catch (error) {
results.failed++;
results.errors.push(
`Host ${i + 1}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
});
},
);
}
@@ -0,0 +1,148 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, desc, eq } from "drizzle-orm";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { commandHistory } from "../db/schema.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostCommandHistoryRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /host/command-history/{hostId}:
* get:
* summary: Get command history
* description: Retrieves the command history for a specific host.
* tags:
* - SSH
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch command history.
*/
router.get(
"/command-history/:hostId",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdParam = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostId = parseInt(hostIdParam, 10);
if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn("Invalid userId or hostId for command history fetch", {
operation: "command_history_fetch",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const history = await db
.select({
id: commandHistory.id,
command: commandHistory.command,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(200);
res.json(history.map((h) => h.command));
} catch (err) {
sshLogger.error("Failed to fetch command history from database", err, {
operation: "command_history_fetch",
hostId,
userId,
});
res.status(500).json({ error: "Failed to fetch command history" });
}
},
);
/**
* @openapi
* /host/command-history:
* delete:
* summary: Delete command from history
* description: Deletes a specific command from the history of a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted from history.
* 400:
* description: Invalid data.
* 500:
* description: Failed to delete command.
*/
router.delete(
"/command-history",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !command) {
sshLogger.warn("Invalid data for command history deletion", {
operation: "command_history_delete",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid data" });
}
try {
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
eq(commandHistory.command, command),
),
);
res.json({ message: "Command deleted from history" });
} catch (err) {
sshLogger.error("Failed to delete command from history", err, {
operation: "command_history_delete",
hostId,
userId,
command,
});
res.status(500).json({ error: "Failed to delete command" });
}
},
);
}
@@ -0,0 +1,603 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, desc, eq } from "drizzle-orm";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import {
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
} from "../db/schema.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostFileManagerBookmarkRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /host/file_manager/recent:
* get:
* summary: Get recent files
* description: Retrieves a list of recent files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch recent files.
*/
router.get(
"/file_manager/recent",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for recent files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for recent files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const recentFiles = await db
.select()
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
),
)
.orderBy(desc(fileManagerRecent.lastOpened))
.limit(20);
res.json(recentFiles);
} catch (err) {
sshLogger.error("Failed to fetch recent files", err);
res.status(500).json({ error: "Failed to fetch recent files" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* post:
* summary: Add recent file
* description: Adds a file to the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Recent file added.
* 400:
* description: Invalid data.
* 500:
* description: Failed to add recent file.
*/
router.post(
"/file_manager/recent",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const existing = await db
.select()
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
eq(fileManagerRecent.path, path),
),
);
if (existing.length > 0) {
await db
.update(fileManagerRecent)
.set({ lastOpened: new Date().toISOString() })
.where(eq(fileManagerRecent.id, existing[0].id));
} else {
await db.insert(fileManagerRecent).values({
userId,
hostId,
path,
name: name || path.split("/").pop() || "Unknown",
lastOpened: new Date().toISOString(),
});
}
res.json({ message: "Recent file added" });
} catch (err) {
sshLogger.error("Failed to add recent file", err);
res.status(500).json({ error: "Failed to add recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* delete:
* summary: Remove recent file
* description: Removes a file from the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Recent file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove recent file.
*/
router.delete(
"/file_manager/recent",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await db
.delete(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
eq(fileManagerRecent.path, path),
),
);
res.json({ message: "Recent file removed" });
} catch (err) {
sshLogger.error("Failed to remove recent file", err);
res.status(500).json({ error: "Failed to remove recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* get:
* summary: Get pinned files
* description: Retrieves a list of pinned files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of pinned files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch pinned files.
*/
router.get(
"/file_manager/pinned",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for pinned files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for pinned files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const pinnedFiles = await db
.select()
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
),
)
.orderBy(desc(fileManagerPinned.pinnedAt));
res.json(pinnedFiles);
} catch (err) {
sshLogger.error("Failed to fetch pinned files", err);
res.status(500).json({ error: "Failed to fetch pinned files" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* post:
* summary: Add pinned file
* description: Adds a file to the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: File pinned.
* 400:
* description: Invalid data.
* 409:
* description: File already pinned.
* 500:
* description: Failed to pin file.
*/
router.post(
"/file_manager/pinned",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const existing = await db
.select()
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
eq(fileManagerPinned.path, path),
),
);
if (existing.length > 0) {
return res.status(409).json({ error: "File already pinned" });
}
await db.insert(fileManagerPinned).values({
userId,
hostId,
path,
name: name || path.split("/").pop() || "Unknown",
pinnedAt: new Date().toISOString(),
});
res.json({ message: "File pinned" });
} catch (err) {
sshLogger.error("Failed to pin file", err);
res.status(500).json({ error: "Failed to pin file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* delete:
* summary: Remove pinned file
* description: Removes a file from the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Pinned file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove pinned file.
*/
router.delete(
"/file_manager/pinned",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await db
.delete(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
eq(fileManagerPinned.path, path),
),
);
res.json({ message: "Pinned file removed" });
} catch (err) {
sshLogger.error("Failed to remove pinned file", err);
res.status(500).json({ error: "Failed to remove pinned file" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* get:
* summary: Get shortcuts
* description: Retrieves a list of shortcuts for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of shortcuts.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch shortcuts.
*/
router.get(
"/file_manager/shortcuts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for shortcuts fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for shortcuts fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const shortcuts = await db
.select()
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
),
)
.orderBy(desc(fileManagerShortcuts.createdAt));
res.json(shortcuts);
} catch (err) {
sshLogger.error("Failed to fetch shortcuts", err);
res.status(500).json({ error: "Failed to fetch shortcuts" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* post:
* summary: Add shortcut
* description: Adds a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Shortcut added.
* 400:
* description: Invalid data.
* 409:
* description: Shortcut already exists.
* 500:
* description: Failed to add shortcut.
*/
router.post(
"/file_manager/shortcuts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const existing = await db
.select()
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
eq(fileManagerShortcuts.path, path),
),
);
if (existing.length > 0) {
return res.status(409).json({ error: "Shortcut already exists" });
}
await db.insert(fileManagerShortcuts).values({
userId,
hostId,
path,
name: name || path.split("/").pop() || "Unknown",
createdAt: new Date().toISOString(),
});
res.json({ message: "Shortcut added" });
} catch (err) {
sshLogger.error("Failed to add shortcut", err);
res.status(500).json({ error: "Failed to add shortcut" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* delete:
* summary: Remove shortcut
* description: Removes a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Shortcut removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove shortcut.
*/
router.delete(
"/file_manager/shortcuts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await db
.delete(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
eq(fileManagerShortcuts.path, path),
),
);
res.json({ message: "Shortcut removed" });
} catch (err) {
sshLogger.error("Failed to remove shortcut", err);
res.status(500).json({ error: "Failed to remove shortcut" });
}
},
);
}
@@ -0,0 +1,423 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { and, eq, inArray, or } from "drizzle-orm";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import {
commandHistory,
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
hostAccess,
hosts,
recentActivity,
sessionRecordings,
sshCredentialUsage,
sshCredentials,
sshFolders,
transferRecent,
} from "../db/schema.js";
import { isNonEmptyString } from "./host-normalizers.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
statsServerUrl: string;
};
export function registerHostFolderRoutes(
router: Router,
{ authenticateJWT, statsServerUrl }: HostFolderRoutesDeps,
): void {
/**
* @openapi
* /host/folders/rename:
* put:
* summary: Rename folder
* description: Renames a folder for SSH hosts and credentials.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* oldName:
* type: string
* newName:
* type: string
* responses:
* 200:
* description: Folder renamed successfully.
* 400:
* description: Old name and new name are required.
* 500:
* description: Failed to rename folder.
*/
router.put(
"/folders/rename",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
if (!isNonEmptyString(userId) || !oldName || !newName) {
sshLogger.warn("Invalid data for folder rename");
return res
.status(400)
.json({ error: "Old name and new name are required" });
}
if (oldName === newName) {
return res.json({ message: "Folder name unchanged" });
}
try {
const updatedHosts = await SimpleDBOps.update(
hosts,
"ssh_data",
and(eq(hosts.userId, userId), eq(hosts.folder, oldName)),
{
folder: newName,
updatedAt: new Date().toISOString(),
},
userId,
);
const updatedCredentials = await db
.update(sshCredentials)
.set({
folder: newName,
updatedAt: new Date().toISOString(),
})
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
),
)
.returning();
DatabaseSaveTrigger.triggerSave("folder_rename");
await db
.update(sshFolders)
.set({
name: newName,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
);
res.json({
message: "Folder renamed successfully",
updatedHosts: updatedHosts.length,
updatedCredentials: updatedCredentials.length,
});
} catch (err) {
sshLogger.error("Failed to rename folder", err, {
operation: "folder_rename",
userId,
oldName,
newName,
});
res.status(500).json({ error: "Failed to rename folder" });
}
},
);
/**
* @openapi
* /host/folders:
* get:
* summary: Get all folders
* description: Retrieves all folders for the authenticated user.
* tags:
* - SSH
* responses:
* 200:
* description: A list of folders.
* 400:
* description: Invalid user ID.
* 500:
* description: Failed to fetch folders.
*/
router.get(
"/folders",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid user ID" });
}
try {
const folders = await db
.select()
.from(sshFolders)
.where(eq(sshFolders.userId, userId));
res.json(folders);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
userId,
});
res.status(500).json({ error: "Failed to fetch folders" });
}
},
);
/**
* @openapi
* /host/folders/metadata:
* put:
* summary: Update folder metadata
* description: Updates the metadata (color, icon) of a folder.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* color:
* type: string
* icon:
* type: string
* responses:
* 200:
* description: Folder metadata updated successfully.
* 400:
* description: Folder name is required.
* 500:
* description: Failed to update folder metadata.
*/
router.put(
"/folders/metadata",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
const existing = await db
.select()
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
if (existing.length > 0) {
databaseLogger.info("Updating SSH folder", {
operation: "folder_update",
userId,
folderId: existing[0].id,
});
await db
.update(sshFolders)
.set({
color,
icon,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
} else {
databaseLogger.info("Creating SSH folder", {
operation: "folder_create",
userId,
name,
});
await db.insert(sshFolders).values({
userId,
name,
color,
icon,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
res.json({ message: "Folder metadata updated successfully" });
} catch (err) {
sshLogger.error("Failed to update folder metadata", err, {
operation: "update_folder_metadata",
userId,
name,
});
res.status(500).json({ error: "Failed to update folder metadata" });
}
},
);
/**
* @openapi
* /host/folders/{name}/hosts:
* delete:
* summary: Delete all hosts in folder
* description: Deletes all SSH hosts within a specific folder.
* tags:
* - SSH
* parameters:
* - in: path
* name: name
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Hosts deleted successfully.
* 400:
* description: Invalid folder name.
* 500:
* description: Failed to delete hosts in folder.
*/
router.delete(
"/folders/:name/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = Array.isArray(req.params.name)
? req.params.name[0]
: req.params.name;
if (!isNonEmptyString(userId) || !folderName) {
return res.status(400).json({ error: "Invalid folder name" });
}
databaseLogger.info("Deleting SSH folder", {
operation: "folder_delete",
userId,
folderId: folderName,
});
try {
const hostsToDelete = await db
.select()
.from(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({
message: "No hosts found in folder",
deletedCount: 0,
});
}
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
await db
.delete(fileManagerRecent)
.where(inArray(fileManagerRecent.hostId, hostIds));
await db
.delete(fileManagerPinned)
.where(inArray(fileManagerPinned.hostId, hostIds));
await db
.delete(fileManagerShortcuts)
.where(inArray(fileManagerShortcuts.hostId, hostIds));
await db
.delete(transferRecent)
.where(
or(
inArray(transferRecent.sourceHostId, hostIds),
inArray(transferRecent.destHostId, hostIds),
),
);
await db
.delete(commandHistory)
.where(inArray(commandHistory.hostId, hostIds));
await db
.delete(sshCredentialUsage)
.where(inArray(sshCredentialUsage.hostId, hostIds));
await db
.delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds));
await db
.delete(hostAccess)
.where(inArray(hostAccess.hostId, hostIds));
await db
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds));
}
await db
.delete(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
await db
.delete(sshFolders)
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
);
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
try {
const axios = (await import("axios")).default;
for (const host of hostsToDelete) {
try {
await axios.post(
`${statsServerUrl}/host-deleted`,
{ hostId: host.id },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of host deletion", {
operation: "folder_hosts_delete",
hostId: host.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
} catch (err) {
sshLogger.warn("Failed to notify stats server of folder deletion", {
operation: "folder_hosts_delete",
folderName,
error: err instanceof Error ? err.message : String(err),
});
}
res.json({
message: "All hosts in folder deleted successfully",
deletedCount: hostsToDelete.length,
});
} catch (err) {
sshLogger.error("Failed to delete hosts in folder", err, {
operation: "delete_folder_hosts",
userId,
folderName,
});
res.status(500).json({ error: "Failed to delete hosts in folder" });
}
},
);
}
@@ -0,0 +1,176 @@
import type { Request, Response, Router } from "express";
import { and, eq, isNotNull } from "drizzle-orm";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { hosts } from "../db/schema.js";
export function registerHostInternalRoutes(router: Router): void {
/**
* @openapi
* /host/db/host/internal:
* get:
* summary: Get internal SSH host data
* description: Returns internal SSH host data for autostart tunnels. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart hosts.
* 403:
* description: Forbidden.
* 500:
* description: Failed to fetch autostart SSH data.
*/
router.get("/db/host/internal", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
sshLogger.warn(
"Unauthorized attempt to access internal SSH host endpoint",
{
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing",
},
);
return res.status(403).json({ error: "Forbidden" });
}
} catch (error) {
sshLogger.error("Failed to validate internal auth token", error);
return res.status(500).json({ error: "Internal server error" });
}
try {
const autostartHosts = await db
.select()
.from(hosts)
.where(
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
);
const result = autostartHosts
.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
const hasAutoStartTunnels = tunnelConnections.some(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
);
if (!hasAutoStartTunnels) {
return null;
}
return {
id: host.id,
userId: host.userId,
name: host.name || `autostart-${host.id}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
),
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: !!host.enableFileManager,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
tags: ["autostart"],
};
})
.filter(Boolean);
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch autostart SSH data", err);
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
}
});
/**
* @openapi
* /host/db/host/internal/all:
* get:
* summary: Get all internal SSH host data
* description: Returns all internal SSH host data. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of all hosts.
* 401:
* description: Invalid or missing internal authentication token.
* 500:
* description: Failed to fetch all hosts.
*/
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
if (!internalToken) {
return res
.status(401)
.json({ error: "Internal authentication token required" });
}
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
return res
.status(401)
.json({ error: "Invalid internal authentication token" });
}
const allHosts = await db.select().from(hosts);
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
return {
id: host.id,
userId: host.userId,
name: host.name || `${host.username}@${host.ip}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: !!host.enableTunnel,
tunnelConnections: tunnelConnections,
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: !!host.enableFileManager,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch all hosts for internal use", err);
res.status(500).json({ error: "Failed to fetch all hosts" });
}
});
}
@@ -0,0 +1,143 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, eq } from "drizzle-orm";
import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { hosts } from "../db/schema.js";
interface HostNetworkRoutesDeps {
authenticateJWT: RequestHandler;
requireDataAccess: RequestHandler;
}
export function registerHostNetworkRoutes(
router: Router,
{ authenticateJWT, requireDataAccess }: HostNetworkRoutesDeps,
): void {
/**
* @openapi
* /host/db/proxy/test:
* post:
* summary: Test proxy connectivity
* description: Tests connectivity through a proxy configuration to a target host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* singleProxy:
* type: object
* properties:
* host:
* type: string
* port:
* type: number
* type:
* type: string
* username:
* type: string
* password:
* type: string
* proxyChain:
* type: array
* items:
* type: object
* testTarget:
* type: object
* properties:
* host:
* type: string
* port:
* type: number
* responses:
* 200:
* description: Test result
* 500:
* description: Proxy connection failed
*/
router.post(
"/db/proxy/test",
authenticateJWT,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { singleProxy, proxyChain, testTarget } = req.body;
const { testProxyConnectivity } =
await import("../../utils/proxy-helper.js");
const result = await testProxyConnectivity({
singleProxy,
proxyChain,
testTarget,
});
res.json(result);
} catch (error) {
sshLogger.error("Proxy connectivity test failed", error, {
operation: "proxy_test",
userId: req.userId,
});
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
});
}
},
);
router.post(
"/db/host/:id/wake",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Number.parseInt(String(req.params.id), 10);
const userId = (req as AuthenticatedRequest).userId;
try {
const host = await db
.select({ macAddress: hosts.macAddress })
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.then((rows) => rows[0]);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
if (!host.macAddress || !isValidMac(host.macAddress)) {
return res
.status(400)
.json({ error: "No valid MAC address configured" });
}
await sendWakeOnLan(host.macAddress);
sshLogger.info("Wake-on-LAN packet sent", {
operation: "wake_on_lan",
userId,
hostId,
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Wake-on-LAN failed", error, {
operation: "wake_on_lan",
userId,
hostId,
});
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to send WoL packet",
});
}
},
);
}
@@ -0,0 +1,156 @@
import { describe, it, expect } from "vitest";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
stripSensitiveFields,
transformHostResponse,
} from "./host-normalizers.js";
describe("isNonEmptyString", () => {
it("accepts non-blank strings", () => {
expect(isNonEmptyString("hello")).toBe(true);
expect(isNonEmptyString(" x ")).toBe(true);
});
it("rejects blank strings and non-strings", () => {
expect(isNonEmptyString("")).toBe(false);
expect(isNonEmptyString(" ")).toBe(false);
expect(isNonEmptyString(123)).toBe(false);
expect(isNonEmptyString(null)).toBe(false);
expect(isNonEmptyString(undefined)).toBe(false);
});
});
describe("isValidPort", () => {
it("accepts ports in range", () => {
expect(isValidPort(1)).toBe(true);
expect(isValidPort(22)).toBe(true);
expect(isValidPort(65535)).toBe(true);
});
it("rejects out-of-range or non-number ports", () => {
expect(isValidPort(0)).toBe(false);
expect(isValidPort(65536)).toBe(false);
expect(isValidPort(-1)).toBe(false);
expect(isValidPort("22")).toBe(false);
});
});
describe("normalizeImportedHost", () => {
it("defaults connectionType to ssh with port 22", () => {
const host = normalizeImportedHost({ ip: "10.0.0.1" });
expect(host.connectionType).toBe("ssh");
expect(host.port).toBe(22);
expect(host.enableSsh).toBe(true);
expect(host.enableRdp).toBe(false);
});
it("infers rdp from enableRdp and uses default rdp port", () => {
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
expect(host.connectionType).toBe("rdp");
expect(host.port).toBe(3389);
expect(host.enableRdp).toBe(true);
});
it("honors an explicit port over protocol defaults", () => {
const host = normalizeImportedHost({
connectionType: "ssh",
port: 2222,
});
expect(host.port).toBe(2222);
});
it("resolves ip from common aliases", () => {
expect(normalizeImportedHost({ address: "a.example" }).ip).toBe(
"a.example",
);
expect(normalizeImportedHost({ hostname: "h.example" }).ip).toBe(
"h.example",
);
});
it("normalizes tags from a comma string", () => {
const host = normalizeImportedHost({ tags: "prod, db , , web" });
expect(host.tags).toEqual(["prod", "db", "web"]);
});
it("normalizes tags from an array", () => {
const host = normalizeImportedHost({ tags: ["a", " b ", "", "c"] });
expect(host.tags).toEqual(["a", "b", "c"]);
});
it("infers authType credential when credentialId present", () => {
const host = normalizeImportedHost({ credentialId: 7 });
expect(host.credentialId).toBe(7);
expect(host.authType).toBe("credential");
});
});
describe("stripSensitiveFields", () => {
it("removes secret fields and adds boolean presence flags", () => {
const result = stripSensitiveFields({
name: "web",
password: "secret",
key: "PRIVATE KEY",
keyPassword: "kp",
sudoPassword: "sp",
});
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.keyPassword).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.hasPassword).toBe(true);
expect(result.hasKey).toBe(true);
expect(result.hasKeyPassword).toBe(true);
expect(result.hasSudoPassword).toBe(true);
expect(result.name).toBe("web");
});
it("marks presence flags false when secrets are absent", () => {
const result = stripSensitiveFields({ name: "web" });
expect(result.hasPassword).toBe(false);
expect(result.hasKey).toBe(false);
});
});
describe("transformHostResponse", () => {
it("parses tags and coerces enable flags to booleans", () => {
const result = transformHostResponse({
tags: "a,b,c",
enableTerminal: 1,
enableTunnel: 0,
pin: 1,
});
expect(result.tags).toEqual(["a", "b", "c"]);
expect(result.enableTerminal).toBe(true);
expect(result.enableTunnel).toBe(false);
expect(result.pin).toBe(true);
});
it("parses JSON array fields and defaults them to []", () => {
const result = transformHostResponse({
tunnelConnections: '[{"sourcePort":8080}]',
jumpHosts: null,
});
expect(result.tunnelConnections).toEqual([{ sourcePort: 8080 }]);
expect(result.jumpHosts).toEqual([]);
});
it("infers protocol flags for a migrated non-ssh host", () => {
const result = transformHostResponse({
connectionType: "rdp",
enableSsh: true,
});
expect(result.enableSsh).toBe(false);
expect(result.enableRdp).toBe(true);
});
it("applies default protocol ports", () => {
const result = transformHostResponse({ port: 22 });
expect(result.sshPort).toBe(22);
expect(result.rdpPort).toBe(3389);
expect(result.vncPort).toBe(5900);
expect(result.telnetPort).toBe(23);
});
});
@@ -0,0 +1,281 @@
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function isValidPort(port: unknown): port is number {
return typeof port === "number" && port > 0 && port <= 65535;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asPort(value: unknown): number | undefined {
const port =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: NaN;
return isValidPort(port) ? port : undefined;
}
function asInteger(value: unknown): number | undefined {
const number =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: NaN;
return Number.isInteger(number) ? number : undefined;
}
function asBoolean(value: unknown, fallback = false): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) return true;
if (["false", "0", "no", "off"].includes(normalized)) return false;
}
return fallback;
}
function normalizeImportTags(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((tag) => asString(tag))
.filter((tag): tag is string => !!tag);
}
if (typeof value === "string") {
return value
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
return [];
}
export type NormalizedImportedHost = Record<string, unknown> & {
connectionType: string;
name?: string;
ip?: string;
port: number;
username?: string;
folder?: string;
tags: string[];
authType?: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
pin?: unknown;
enableTerminal?: unknown;
enableTunnel?: unknown;
enableFileManager?: unknown;
enableDocker?: unknown;
showTerminalInSidebar?: unknown;
showFileManagerInSidebar?: unknown;
showTunnelInSidebar?: unknown;
showDockerInSidebar?: unknown;
showServerStatsInSidebar?: unknown;
defaultPath?: unknown;
sudoPassword?: unknown;
tunnelConnections?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statsConfig?: unknown;
dockerConfig?: unknown;
terminalConfig?: unknown;
forceKeyboardInteractive?: unknown;
notes?: unknown;
useSocks5?: unknown;
socks5Host?: unknown;
socks5Port?: unknown;
socks5Username?: unknown;
socks5Password?: unknown;
socks5ProxyChain?: unknown;
portKnockSequence?: unknown;
overrideCredentialUsername?: unknown;
domain?: unknown;
security?: unknown;
ignoreCert?: unknown;
guacamoleConfig?: unknown;
enableSsh: boolean;
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
};
export function normalizeImportedHost(
hostData: Record<string, unknown>,
): NormalizedImportedHost {
const connectionType =
asString(hostData.connectionType) ||
(asBoolean(hostData.enableRdp)
? "rdp"
: asBoolean(hostData.enableVnc)
? "vnc"
: asBoolean(hostData.enableTelnet)
? "telnet"
: "ssh");
const port =
asPort(hostData.port) ||
(connectionType === "rdp"
? asPort(hostData.rdpPort) || 3389
: connectionType === "vnc"
? asPort(hostData.vncPort) || 5900
: connectionType === "telnet"
? asPort(hostData.telnetPort) || 23
: asPort(hostData.sshPort) || 22);
return {
...hostData,
connectionType,
name: asString(hostData.name) || asString(hostData.label),
ip:
asString(hostData.ip) ||
asString(hostData.address) ||
asString(hostData.host) ||
asString(hostData.hostname),
port,
username: asString(hostData.username) || asString(hostData.user),
folder: asString(hostData.folder) || asString(hostData.group),
tags: normalizeImportTags(hostData.tags),
credentialId: asInteger(hostData.credentialId),
authType:
asString(hostData.authType) ||
asString(hostData.authMethod) ||
(hostData.credentialId ? "credential" : hostData.key ? "key" : undefined),
enableSsh:
hostData.enableSsh === undefined
? connectionType === "ssh"
: asBoolean(hostData.enableSsh),
enableRdp:
hostData.enableRdp === undefined
? connectionType === "rdp"
: asBoolean(hostData.enableRdp),
enableVnc:
hostData.enableVnc === undefined
? connectionType === "vnc"
: asBoolean(hostData.enableVnc),
enableTelnet:
hostData.enableTelnet === undefined
? connectionType === "telnet"
: asBoolean(hostData.enableTelnet),
};
}
const SENSITIVE_FIELDS = [
"key",
"keyPassword",
"autostartKey",
"autostartKeyPassword",
"password",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"autostartPassword",
];
export function stripSensitiveFields(
host: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...host };
result.hasKey = !!host.key;
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
result.hasSudoPassword = !!host.sudoPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
return result;
}
export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
return {
...host,
tags:
typeof host.tags === "string"
? host.tags
? host.tags.split(",").filter(Boolean)
: []
: [],
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: !!host.enableFileManager,
enableDocker: !!host.enableDocker,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
...(() => {
const ct = host.connectionType;
const rdp = !!host.enableRdp;
const vnc = !!host.enableVnc;
const tel = !!host.enableTelnet;
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
return {
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
};
})(),
sshPort: host.sshPort ?? host.port ?? 22,
rdpPort: host.rdpPort ?? 3389,
vncPort: host.vncPort ?? 5900,
telnetPort: host.telnetPort ?? 23,
rdpUser: host.rdpUser || undefined,
rdpDomain: host.rdpDomain || undefined,
rdpSecurity: host.rdpSecurity || undefined,
rdpIgnoreCert: !!host.rdpIgnoreCert,
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
: [],
statsConfig: host.statsConfig
? JSON.parse(host.statsConfig as string)
: undefined,
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig as string)
: undefined,
dockerConfig: host.dockerConfig
? JSON.parse(host.dockerConfig as string)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: [],
portKnockSequence: host.portKnockSequence
? JSON.parse(host.portKnockSequence as string)
: [],
domain: host.domain || undefined,
security: host.security || undefined,
ignoreCert: !!host.ignoreCert,
guacamoleConfig: host.guacamoleConfig
? JSON.parse(host.guacamoleConfig as string)
: undefined,
};
}
@@ -0,0 +1,841 @@
import type { Router, Request, Response } from "express";
import { sshLogger } from "../../utils/logger.js";
import {
normalizeSelectOpParam,
renderOpksshErrorPage,
rewriteOPKSSHHtml,
} from "./opkssh-html.js";
export function registerHostOpksshRoutes(router: Router): void {
/**
* @openapi
* /host/opkssh-chooser/{requestId}:
* get:
* summary: Proxy OPKSSH provider chooser page and all related resources
* tags: [SSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* description: Authentication request ID
* responses:
* 200:
* description: Chooser page content
* 404:
* description: Session not found
* 500:
* description: Proxy error
*/
router.use(
"/opkssh-chooser/:requestId",
async (req: Request, res: Response) => {
const requestId = Array.isArray(req.params.requestId)
? req.params.requestId[0]
: req.params.requestId;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestIdTemp =
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
sshLogger.info("OPKSSH chooser proxy request", {
operation: "opkssh_chooser_proxy_request",
requestId,
url: req.url,
originalUrl: req.originalUrl,
fullPath,
pathAfterRequestId: pathAfterRequestIdTemp,
method: req.method,
});
try {
const { getActiveAuthSession, registerOAuthState } =
await import("../../ssh/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
sshLogger.error("Session not found for chooser request", {
operation: "opkssh_chooser_session_not_found",
requestId,
});
res.status(404).send(
renderOpksshErrorPage({
title: "Session Not Found",
heading: "Session Not Found",
message: "This authentication session has expired or is invalid.",
requestId,
}),
);
return;
}
const axios = (await import("axios")).default;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestId =
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
const targetPath = pathAfterRequestId || "/chooser";
if (!session.localPort || session.localPort === 0) {
sshLogger.error("OPKSSH session has no local port", {
operation: "opkssh_chooser_proxy",
requestId,
sessionStatus: session.status,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Authentication Error",
message:
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
requestId,
}),
);
return;
}
// /select on OPKSSH's chooser redirects (possibly via multiple local hops) to the
// external OAuth provider URL. The hops we may see:
// 1. /select -> /select/ (Go ServeMux canonicalization, same chooser port)
// 2. /select/?op=ALIAS -> http://localhost:CALLBACK_PORT/login (OPKSSH's separate callback listener)
// 3. /login on the callback listener -> https://<provider>/authorize?... (external OAuth URL)
if (targetPath.startsWith("/select")) {
const selectaxios = (await import("axios")).default;
const rawQs = targetPath.includes("?")
? targetPath.slice(targetPath.indexOf("?"))
: "";
let qs = rawQs;
let opMappedFrom: string | undefined;
if (rawQs) {
try {
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
const rawOp = params.get("op");
if (rawOp) {
const mappedOp = normalizeSelectOpParam(
rawOp,
session.providers || [],
);
if (mappedOp !== rawOp) {
params.set("op", mappedOp);
qs = `?${params.toString()}`;
opMappedFrom = rawOp;
}
}
} catch {
/* keep rawQs if parsing fails */
}
}
const chooserHost = `127.0.0.1:${session.localPort}`;
const startUrl = `http://${chooserHost}/select/${qs}`;
sshLogger.info("Proxying OPKSSH /select", {
operation: "opkssh_select_proxy",
requestId,
targetUrl: startUrl,
opMappedFrom,
});
const isLocalHostname = (host: string): boolean => {
const bare = host.split(":")[0];
return (
bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]"
);
};
interface UpstreamResponse {
status: number;
location?: string;
contentType: string;
body: string;
targetUrl: string;
elapsedMs: number;
}
const fetchUpstream = async (
url: string,
): Promise<UpstreamResponse> => {
const started = Date.now();
let hostHeader = chooserHost;
try {
hostHeader = new URL(url).host;
} catch {
/* fall back to chooser host */
}
const r = await selectaxios({
method: "GET",
url,
maxRedirects: 0,
validateStatus: () => true,
timeout: 10000,
responseType: "text",
transformResponse: (v) => v,
headers: { host: hostHeader },
});
const locHeader = r.headers["location"];
const location = Array.isArray(locHeader)
? locHeader[0]
: locHeader;
const ctHeader = r.headers["content-type"];
const ctRaw = Array.isArray(ctHeader) ? ctHeader[0] : ctHeader;
const contentType = typeof ctRaw === "string" ? ctRaw : "";
const body =
typeof r.data === "string" ? r.data : String(r.data ?? "");
return {
status: r.status,
location: typeof location === "string" ? location : undefined,
contentType,
body,
targetUrl: url,
elapsedMs: Date.now() - started,
};
};
const logResponse = (response: UpstreamResponse): void => {
sshLogger.info("OPKSSH /select upstream response", {
operation: "opkssh_select_upstream_response",
requestId,
targetUrl: response.targetUrl,
status: response.status,
location: response.location,
contentType: response.contentType,
elapsedMs: response.elapsedMs,
bodyPreview: response.body.slice(0, 256),
});
};
const MAX_HOPS = 4;
try {
let response = await fetchUpstream(startUrl);
logResponse(response);
for (let hop = 0; hop < MAX_HOPS; hop++) {
if (
response.status < 300 ||
response.status >= 400 ||
!response.location
) {
break;
}
const loc = response.location;
// Relative path: resolve against the current upstream.
if (loc.startsWith("/")) {
let currentHost = chooserHost;
try {
currentHost = new URL(response.targetUrl).host;
} catch {
/* keep default */
}
response = await fetchUpstream(`http://${currentHost}${loc}`);
logResponse(response);
continue;
}
// Absolute URL: if it points to a localhost OPKSSH endpoint, capture
// the port. Then redirect the BROWSER to the proxied path so that
// Set-Cookie headers from OPKSSH's /login handler reach the browser
// directly — following them server-side would swallow the cookie.
if (/^https?:\/\//i.test(loc)) {
try {
const parsed = new URL(loc);
if (isLocalHostname(parsed.host)) {
// Capture callback listener port if not yet known.
if (!session.callbackPort) {
const port = parseInt(parsed.port, 10);
if (!Number.isNaN(port)) {
session.callbackPort = port;
sshLogger.info(
"Captured OPKSSH callback listener port from /select redirect",
{
operation: "opkssh_select_callback_port_detected",
requestId,
callbackPort: port,
},
);
}
}
// Redirect browser through the chooser proxy so it can receive
// the state cookie that OPKSSH sets on /login.
const browserPath = `/host/opkssh-chooser/${requestId}${parsed.pathname}${parsed.search}`;
sshLogger.info(
"Redirecting browser to OPKSSH callback listener via proxy",
{
operation: "opkssh_select_browser_redirect_to_login",
requestId,
browserPath,
callbackPort: session.callbackPort,
},
);
res.redirect(302, browserPath);
return;
}
// External OAuth provider URL — done, handled below.
break;
} catch {
break;
}
}
break;
}
const isExternalRedirect =
response.status >= 300 &&
response.status < 400 &&
!!response.location &&
/^https?:\/\//i.test(response.location) &&
(() => {
try {
return !isLocalHostname(
new URL(response.location as string).host,
);
} catch {
return false;
}
})();
if (isExternalRedirect) {
const oauthUrl = response.location as string;
try {
const parsed = new URL(oauthUrl);
const oauthState = parsed.searchParams.get("state");
if (oauthState) registerOAuthState(oauthState, requestId);
} catch {
/* already validated above */
}
sshLogger.info(
"OPKSSH /select redirecting browser to OAuth provider",
{
operation: "opkssh_select_redirect",
requestId,
oauthUrl,
},
);
res.redirect(302, oauthUrl);
return;
}
const bodyPreview = response.body.slice(0, 512);
const detailLines = [
`Upstream: ${response.targetUrl}`,
`Status: ${response.status}`,
response.location ? `Location: ${response.location}` : undefined,
`Content-Type: ${response.contentType || "(none)"}`,
`Elapsed: ${response.elapsedMs}ms`,
"",
bodyPreview
? `Body (first 512 chars):\n${bodyPreview}`
: "Body: (empty)",
].filter(Boolean) as string[];
sshLogger.error(
"OPKSSH /select did not produce an OAuth redirect",
{
operation: "opkssh_select_no_oauth_redirect",
requestId,
status: response.status,
location: response.location,
contentType: response.contentType,
bodyPreview,
},
);
res.status(502).send(
renderOpksshErrorPage({
title: "OPKSSH error",
heading: "Failed to get OAuth redirect",
message:
"OPKSSH did not return an external OAuth provider URL. " +
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
"and the Termix callback path. Check the server log for the OPKSSH response body.",
details: detailLines.join("\n"),
requestId,
}),
);
} catch (err) {
sshLogger.error("Error proxying OPKSSH /select", err, {
operation: "opkssh_select_proxy_error",
requestId,
targetUrl: startUrl,
});
const errMsg = err instanceof Error ? err.message : String(err);
res.status(502).send(
renderOpksshErrorPage({
title: "OPKSSH error",
heading: "Failed to reach OPKSSH service",
message:
"Termix could not connect to the local OPKSSH authentication service. " +
"The OPKSSH process may have exited or is not listening yet.",
details: `Upstream: ${startUrl}\nError: ${errMsg}`,
requestId,
}),
);
}
return;
}
// Paths served by the callback listener, not the chooser.
// The browser is redirected here so it receives Set-Cookie from OPKSSH.
const isCallbackListenerPath =
targetPath === "/login" ||
targetPath.startsWith("/login?") ||
targetPath === "/login-callback" ||
targetPath.startsWith("/login-callback?");
const upstreamPort =
isCallbackListenerPath && session.callbackPort
? session.callbackPort
: session.localPort;
const targetUrl = `http://127.0.0.1:${upstreamPort}${targetPath}`;
sshLogger.info("Proxying to OPKSSH chooser", {
operation: "opkssh_chooser_proxy_request_to_opkssh",
requestId,
targetUrl,
upstreamPort,
targetPath,
});
const response = await axios({
method: req.method,
url: targetUrl,
headers: {
...req.headers,
host: `127.0.0.1:${upstreamPort}`,
},
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
sshLogger.info("OPKSSH chooser response received", {
operation: "opkssh_chooser_proxy_response",
requestId,
statusCode: response.status,
contentType: response.headers["content-type"],
contentLength: response.headers["content-length"],
hasLocation: !!response.headers.location,
});
Object.entries(response.headers).forEach(([key, value]) => {
if (key.toLowerCase() === "transfer-encoding") {
return;
}
if (key.toLowerCase() === "location") {
const location = value as string;
if (location.startsWith("/")) {
res.setHeader(
key,
`/host/opkssh-chooser/${requestId}${location}`,
);
} else {
const localhostMatch = location.match(
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
);
if (localhostMatch) {
const port = parseInt(localhostMatch[1], 10);
const path = localhostMatch[2] || "/";
if (session.callbackPort && port === session.callbackPort) {
res.setHeader(
key,
`/host/opkssh-callback/${requestId}${path}`,
);
} else if (port === session.localPort) {
res.setHeader(
key,
`/host/opkssh-chooser/${requestId}${path}`,
);
} else {
const isCallback =
path.includes("login") || path.includes("callback");
const prefix = isCallback
? "opkssh-callback"
: "opkssh-chooser";
res.setHeader(key, `/host/${prefix}/${requestId}${path}`);
}
} else {
// External redirect (e.g. to OIDC provider) — capture OAuth state for session binding
try {
const redirectUrl = new URL(location);
const oauthState = redirectUrl.searchParams.get("state");
if (oauthState) {
registerOAuthState(oauthState, requestId);
}
} catch {
// Not a valid URL, skip state capture
}
res.setHeader(key, value as string);
}
}
} else if (key.toLowerCase() === "set-cookie") {
// Rewrite cookies from OPKSSH's internal listener so they are scoped
// to the Termix proxy path instead of OPKSSH's internal path.
// The state cookie set by /login must survive to /login-callback.
const cookies = Array.isArray(value) ? value : [value as string];
const rewritten = cookies.map((cookie) => {
return cookie
.replace(/;\s*domain=[^;]*/gi, "")
.replace(/;\s*path=[^;]*/gi, "; Path=/host/opkssh-callback/")
.concat(
cookie.match(/;\s*path=/i)
? ""
: "; Path=/host/opkssh-callback/",
);
});
res.setHeader(key, rewritten);
} else {
res.setHeader(key, value as string);
}
});
// Set a cookie to correlate this browser with the requestId.
// OAuth state capture from Location headers only works for 3xx redirects;
// if OPKSSH redirects via JavaScript, the state is never registered.
// This cookie survives the OIDC round-trip and identifies the session on callback.
res.cookie("opkssh_request_id", requestId, {
path: "/host/",
httpOnly: true,
sameSite: "lax",
maxAge: 5 * 60 * 1000,
});
const contentType = String(response.headers["content-type"] || "");
if (contentType.includes("text/html")) {
const html = rewriteOPKSSHHtml(
response.data.toString("utf-8"),
requestId,
"opkssh-chooser",
);
res.status(response.status).send(html);
} else {
res.status(response.status).send(response.data);
}
} catch (error) {
sshLogger.error("Error proxying OPKSSH chooser", error, {
operation: "opkssh_chooser_proxy_error",
requestId,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Error",
message: "Failed to load authentication page. Please try again.",
requestId,
}),
);
}
},
);
/**
* @openapi
* /host/opkssh-callback:
* get:
* summary: Static OAuth callback from OIDC provider for OPKSSH authentication
* tags: [SSH]
* responses:
* 200:
* description: Callback processed successfully
* 404:
* description: No active authentication session found
* 500:
* description: Authentication failed
*/
router.get("/opkssh-callback", async (req: Request, res: Response) => {
try {
sshLogger.info("OAuth callback received", {
operation: "opkssh_static_callback_received",
host: req.headers.host,
});
const {
getUserIdFromRequest,
getActiveSessionsForUser,
getActiveAuthSession,
getRequestIdByOAuthState,
clearOAuthState,
} = await import("../../ssh/opkssh-auth.js");
const userId = await getUserIdFromRequest({
cookies: req.cookies,
headers: req.headers as Record<string, string | undefined>,
});
sshLogger.info("User ID resolved", {
operation: "opkssh_callback_user_lookup",
userId: userId || "null",
hasCookies: !!req.cookies?.jwt,
cookieKeys: Object.keys(req.cookies || {}),
});
let userSessions: Awaited<ReturnType<typeof getActiveSessionsForUser>> =
[];
if (userId) {
userSessions = getActiveSessionsForUser(userId);
} else {
// No JWT cookie (e.g. OAuth redirect landed in external browser).
// Try to find the correct session via the OAuth state parameter.
const oauthState = req.query.state as string | undefined;
if (oauthState) {
const mappedRequestId = getRequestIdByOAuthState(oauthState);
if (mappedRequestId) {
const mappedSession = getActiveAuthSession(mappedRequestId);
if (mappedSession) {
userSessions = [mappedSession];
clearOAuthState(oauthState);
sshLogger.info("Resolved session via OAuth state parameter", {
operation: "opkssh_callback_state_lookup",
requestId: mappedRequestId,
});
}
}
}
// Fallback: use the opkssh_request_id cookie set by the chooser proxy.
// State capture only works for 3xx redirects; if OPKSSH redirects via
// JavaScript in the HTML, the state is never registered in the map.
if (userSessions.length === 0) {
const cookieRequestId = req.cookies?.opkssh_request_id;
if (cookieRequestId) {
const cookieSession = getActiveAuthSession(cookieRequestId);
if (cookieSession) {
userSessions = [cookieSession];
res.clearCookie("opkssh_request_id", { path: "/host/" });
sshLogger.info("Resolved session via opkssh_request_id cookie", {
operation: "opkssh_callback_cookie_lookup",
requestId: cookieRequestId,
});
}
}
}
if (userSessions.length === 0) {
sshLogger.warn(
"OAuth callback with no JWT, no matching state, and no session cookie",
{
operation: "opkssh_callback_no_session_match",
hasState: !!oauthState,
hasCookie: !!req.cookies?.opkssh_request_id,
},
);
res
.status(401)
.send("Authentication callback failed: unable to identify session");
return;
}
}
sshLogger.info("Active sessions for user", {
operation: "opkssh_callback_session_lookup",
userId,
sessionCount: userSessions.length,
sessions: userSessions.map((s) => ({
requestId: s.requestId,
status: s.status,
hasCallbackPort: !!s.callbackPort,
callbackPort: s.callbackPort,
hasLocalPort: !!s.localPort,
localPort: s.localPort,
})),
});
if (userSessions.length === 0) {
sshLogger.error("No active sessions for callback", {
operation: "opkssh_callback_no_sessions",
userId,
});
res.status(404).send("No active authentication session found");
return;
}
const session = userSessions[userSessions.length - 1];
if (!session.callbackPort) {
sshLogger.error("Session callback port not ready", {
operation: "opkssh_callback_port_not_ready",
userId,
requestId: session.requestId,
sessionStatus: session.status,
hasLocalPort: !!session.localPort,
});
res.status(503).send("OPKSSH callback listener not ready yet");
return;
}
const queryString = req.url.includes("?")
? req.url.substring(req.url.indexOf("?"))
: "";
// OPKSSH's internal callback listener handles `/login-callback` regardless of the
// path used in --remote-redirect-uri. The dynamic route below defaults to that path.
const redirectUrl = `/host/opkssh-callback/${session.requestId}${queryString}`;
sshLogger.info("Redirecting OAuth callback to dynamic route", {
operation: "opkssh_static_callback_redirect",
userId,
requestId: session.requestId,
callbackPort: session.callbackPort,
queryParams: Object.keys(req.query),
redirectUrl,
});
res.redirect(302, redirectUrl);
} catch (error) {
sshLogger.error("Error handling OPKSSH static callback", error, {
operation: "opkssh_static_callback_error",
url: req.url,
originalUrl: req.originalUrl,
});
res.status(500).send("Authentication callback failed");
}
});
/**
* @openapi
* /host/opkssh-callback/{requestId}:
* get:
* summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths)
* tags: [SSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* description: Authentication request ID
* responses:
* 200:
* description: Callback processed successfully
* 404:
* description: Invalid authentication session
* 500:
* description: Authentication failed
*/
router.use(
"/opkssh-callback/:requestId",
async (req: Request, res: Response) => {
const requestId = Array.isArray(req.params.requestId)
? req.params.requestId[0]
: req.params.requestId;
try {
const { getActiveAuthSession } =
await import("../../ssh/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
res.status(404).send(
renderOpksshErrorPage({
title: "Session Not Found",
heading: "Session Not Found",
message:
"Authentication session expired or invalid. Please close this window and try again.",
requestId,
}),
);
return;
}
const axios = (await import("axios")).default;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestId =
fullPath.split(`/host/opkssh-callback/${requestId}`)[1] || "";
// pathAfterRequestId may be "", "?query=...", "/subpath", or "/subpath?query=..."
// OPKSSH's internal listener serves /login-callback, so when no sub-path is present
// (query-only or empty), prepend it.
const targetPath =
pathAfterRequestId === "" || pathAfterRequestId.startsWith("?")
? `/login-callback${pathAfterRequestId}`
: pathAfterRequestId;
if (!session.callbackPort || session.callbackPort === 0) {
sshLogger.error("OPKSSH callback session has no callback port", {
operation: "opkssh_callback_proxy",
requestId,
sessionStatus: session.status,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Callback Error",
message:
"OPKSSH callback listener not ready. Please try authenticating again.",
requestId,
}),
);
return;
}
const targetUrl = `http://127.0.0.1:${session.callbackPort}${targetPath}`;
const response = await axios({
method: req.method,
url: targetUrl,
headers: {
...req.headers,
host: `127.0.0.1:${session.callbackPort}`,
},
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
Object.entries(response.headers).forEach(([key, value]) => {
if (key.toLowerCase() === "transfer-encoding") {
return;
}
if (key.toLowerCase() === "location") {
const location = value as string;
if (location.startsWith("/")) {
res.setHeader(
key,
`/host/opkssh-callback/${requestId}${location}`,
);
} else {
res.setHeader(key, value as string);
}
} else {
res.setHeader(key, value as string);
}
});
const contentType = String(response.headers["content-type"] || "");
if (contentType.includes("text/html")) {
const html = rewriteOPKSSHHtml(
response.data.toString("utf-8"),
requestId,
"opkssh-callback",
);
res.status(response.status).send(html);
} else {
res.status(response.status).send(response.data);
}
} catch (error) {
sshLogger.error("Error handling OPKSSH OAuth callback", error, {
operation: "opkssh_oauth_callback_error",
requestId,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Error",
message: "An unexpected error occurred. Please try again.",
requestId,
}),
);
}
},
);
}
File diff suppressed because it is too large Load Diff
+110 -50
View File
@@ -32,12 +32,20 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const tabs = db
.select()
.from(userOpenTabs)
.where(and(eq(userOpenTabs.userId, userId), sql`${userOpenTabs.updatedAt} > ${cutoff}`))
.where(
and(
eq(userOpenTabs.userId, userId),
sql`${userOpenTabs.updatedAt} > ${cutoff}`,
),
)
.orderBy(userOpenTabs.tabOrder)
.all();
return res.json(tabs);
} catch (e) {
databaseLogger.error("Failed to get open tabs", e, { operation: "get_open_tabs", userId });
databaseLogger.error("Failed to get open tabs", e, {
operation: "get_open_tabs",
userId,
});
return res.status(500).json({ error: "Failed to get open tabs" });
}
});
@@ -77,35 +85,67 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
*/
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id, tabType, hostId, label, tabOrder, backendSessionId } = req.body as {
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
};
const { id, tabType, hostId, label, tabOrder, backendSessionId } =
req.body as {
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
};
if (!id || !tabType || !label) {
return res.status(400).json({ error: "id, tabType, and label are required" });
return res
.status(400)
.json({ error: "id, tabType, and label are required" });
}
try {
const now = new Date().toISOString();
const existing = db.select().from(userOpenTabs).where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))).all();
const existing = db
.select()
.from(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.all();
if (existing.length > 0) {
// Preserve existing backendSessionId when not explicitly provided
const sessionId = backendSessionId !== undefined ? backendSessionId : existing[0].backendSessionId;
const sessionId =
backendSessionId !== undefined
? backendSessionId
: existing[0].backendSessionId;
db.update(userOpenTabs)
.set({ tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: sessionId ?? null, updatedAt: now })
.set({
tabType,
hostId: hostId ?? null,
label,
tabOrder,
backendSessionId: sessionId ?? null,
updatedAt: now,
})
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
} else {
db.insert(userOpenTabs).values({ id, userId, tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: backendSessionId ?? null, updatedAt: now }).run();
db.insert(userOpenTabs)
.values({
id,
userId,
tabType,
hostId: hostId ?? null,
label,
tabOrder,
backendSessionId: backendSessionId ?? null,
updatedAt: now,
})
.run();
}
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to upsert open tab", e, { operation: "upsert_open_tab", userId, id });
databaseLogger.error("Failed to upsert open tab", e, {
operation: "upsert_open_tab",
userId,
id,
});
return res.status(500).json({ error: "Failed to upsert open tab" });
}
});
@@ -151,22 +191,27 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run();
if (tabs.length > 0) {
const now = new Date().toISOString();
db.insert(userOpenTabs).values(
tabs.map((t) => ({
id: t.id,
userId,
tabType: t.tabType,
hostId: t.hostId ?? null,
label: t.label,
tabOrder: t.tabOrder,
backendSessionId: t.backendSessionId ?? null,
updatedAt: now,
})),
).run();
db.insert(userOpenTabs)
.values(
tabs.map((t) => ({
id: t.id,
userId,
tabType: t.tabType,
hostId: t.hostId ?? null,
label: t.label,
tabOrder: t.tabOrder,
backendSessionId: t.backendSessionId ?? null,
updatedAt: now,
})),
)
.run();
}
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to sync open tabs", e, { operation: "sync_open_tabs", userId });
databaseLogger.error("Failed to sync open tabs", e, {
operation: "sync_open_tabs",
userId,
});
return res.status(500).json({ error: "Failed to sync open tabs" });
}
});
@@ -211,7 +256,11 @@ router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
}
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to update open tab", e, { operation: "update_open_tab", userId, id });
databaseLogger.error("Failed to update open tab", e, {
operation: "update_open_tab",
userId,
id,
});
return res.status(500).json({ error: "Failed to update open tab" });
}
});
@@ -243,7 +292,11 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
.run();
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to delete open tab", e, { operation: "delete_open_tab", userId, id });
databaseLogger.error("Failed to delete open tab", e, {
operation: "delete_open_tab",
userId,
id,
});
return res.status(500).json({ error: "Failed to delete open tab" });
}
});
@@ -279,24 +332,31 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
* createdAt:
* type: number
*/
router.get("/active-sessions", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const sessions = sessionManager.getUserSessions(userId);
return res.json(
sessions.map((s) => ({
sessionId: s.id,
hostId: s.hostId,
hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
isConnected: s.isConnected,
createdAt: s.createdAt,
})),
);
} catch (e) {
databaseLogger.error("Failed to get active sessions", e, { operation: "get_active_sessions", userId });
return res.status(500).json({ error: "Failed to get active sessions" });
}
});
router.get(
"/active-sessions",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const sessions = sessionManager.getUserSessions(userId);
return res.json(
sessions.map((s) => ({
sessionId: s.id,
hostId: s.hostId,
hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
isConnected: s.isConnected,
createdAt: s.createdAt,
})),
);
} catch (e) {
databaseLogger.error("Failed to get active sessions", e, {
operation: "get_active_sessions",
userId,
});
return res.status(500).json({ error: "Failed to get active sessions" });
}
},
);
export default router;
+280
View File
@@ -0,0 +1,280 @@
import { sshLogger } from "../../utils/logger.js";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
// `alias` field in config.yml. We need the same mapping so we can normalize any
// `op=` query param we receive (which can be alias, issuer with protocol, or
// issuer without protocol depending on client version) to what OPKSSH expects.
function opksshIssuerToName(issuer: string): string | null {
if (!issuer) return null;
const withScheme =
issuer.startsWith("http://") || issuer.startsWith("https://")
? issuer
: `https://${issuer}`;
if (withScheme.startsWith("https://accounts.google.com")) return "google";
if (withScheme.startsWith("https://login.microsoftonline.com"))
return "azure";
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
if (withScheme.startsWith("https://")) {
const host = withScheme.slice("https://".length).split("/")[0];
return host || null;
}
return null;
}
export function normalizeSelectOpParam(
rawOp: string,
providers: Array<{ alias: string; issuer: string }>,
): string {
if (!rawOp) return rawOp;
const knownNames = new Set(
providers
.map((p) => opksshIssuerToName(p.issuer))
.filter((n): n is string => typeof n === "string" && n.length > 0),
);
if (knownNames.has(rawOp)) return rawOp;
const derivedFromRaw = opksshIssuerToName(rawOp);
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
const matchByAlias = providers.find((p) => p.alias === rawOp);
if (matchByAlias) {
const name = opksshIssuerToName(matchByAlias.issuer);
if (name) return name;
}
return rawOp;
}
type OpksshErrorPageOptions = {
title: string;
heading: string;
message: string;
details?: string;
requestId?: string;
statusCode?: number;
};
export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string {
const title = escapeHtml(opts.title);
const heading = escapeHtml(opts.heading);
const message = escapeHtml(opts.message);
const detailsBlock = opts.details
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
: "";
const requestIdBlock = opts.requestId
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
: "";
return `<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #18181b;
color: #fafafa;
padding: 1rem;
}
.container {
text-align: center;
background: #27272a;
padding: 3rem 2rem;
border-radius: 0.625rem;
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 720px;
width: 100%;
}
h1 {
color: #fafafa;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
p {
color: #9ca3af;
font-size: 0.95rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
p + p { margin-top: 0.5rem; }
.details {
color: #d4d4d8;
text-align: left;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.45;
margin-top: 1.25rem;
padding: 0.875rem 1rem;
background: #0f0f11;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.request-id {
color: #6b7280;
font-size: 0.75rem;
margin-top: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>${heading}</h1>
<p>${message}</p>
${detailsBlock}
${requestIdBlock}
</div>
</body>
</html>`;
}
export function rewriteOPKSSHHtml(
html: string,
requestId: string,
routePrefix: "opkssh-chooser" | "opkssh-callback",
): string {
const basePath = `/host/${routePrefix}/${requestId}`;
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
const attrPatterns = ["action", "href", "src", "formaction"];
for (const attr of attrPatterns) {
html = html.replace(
new RegExp(`${attr}="(/[^"]*)`, "g"),
`${attr}="${basePath}$1`,
);
html = html.replace(
new RegExp(`${attr}='(/[^']*)`, "g"),
`${attr}='${basePath}$1`,
);
}
for (const attr of ["href", "action", "src", "formaction"]) {
html = html.replace(
new RegExp(
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
"g",
),
`${attr}="${basePath}/$1`,
);
}
html = html.replace(
new RegExp(
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
html = html.replace(
new RegExp(
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
"gi",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
const baseTag = `<base href="${basePath}/">`;
if (html.includes("<base")) {
sshLogger.info("Replacing existing base tag", {
operation: "opkssh_html_rewrite_base_tag",
requestId,
basePath,
});
html = html.replace(/<base[^>]*>/i, baseTag);
} else if (html.includes("<head>")) {
sshLogger.info("Inserting base tag into head", {
operation: "opkssh_html_rewrite_base_tag_insert",
requestId,
basePath,
});
html = html.replace(/<head>/i, `<head>${baseTag}`);
} else {
sshLogger.warn("No <head> tag found, wrapping HTML", {
operation: "opkssh_html_rewrite_no_head",
requestId,
htmlLength: html.length,
htmlPreview: html.substring(0, 200),
});
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
}
sshLogger.info("HTML rewrite complete", {
operation: "opkssh_html_rewrite_complete",
requestId,
routePrefix,
hasBaseTag: html.includes("<base href="),
staticAssetCount: (html.match(/\/static\//g) || []).length,
});
return html;
}
@@ -0,0 +1,276 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserAdminRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/list:
* get:
* summary: List all users
* description: Retrieves a list of all users in the system.
* tags:
* - Users
* responses:
* 200:
* description: A list of users.
* 403:
* description: Not authorized.
* 500:
* description: Failed to list users.
*/
router.get("/list", authenticateJWT, async (req, res) => {
try {
const allUsers = await db
.select({
id: users.id,
username: users.username,
isAdmin: users.isAdmin,
isOidc: users.isOidc,
})
.from(users);
res.json({ users: allUsers });
} catch (err) {
authLogger.error("Failed to list users", err);
res.status(500).json({ error: "Failed to list users" });
}
});
/**
* @openapi
* /users/make-admin:
* post:
* summary: Make user admin
* description: Grants admin privileges to a user.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* description: Preferred unique user identifier.
* username:
* type: string
* description: Legacy fallback identifier.
* responses:
* 200:
* description: User is now an admin.
* 400:
* description: User ID or username is required, or the user is already an admin.
* 403:
* description: Not authorized.
* 404:
* description: User not found.
* 500:
* description: Failed to make user admin.
*/
router.post("/make-admin", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { userId: targetUserId, username } = req.body;
const resolvedUserId = isNonEmptyString(targetUserId)
? targetUserId.trim()
: null;
const resolvedUsername = isNonEmptyString(username)
? username.trim()
: null;
if (!resolvedUserId && !resolvedUsername) {
return res.status(400).json({ error: "User ID or username is required" });
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const targetUser = await db
.select()
.from(users)
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
)
.limit(1);
if (!targetUser || targetUser.length === 0) {
return res.status(404).json({ error: "User not found" });
}
if (targetUser[0].isAdmin) {
return res.status(400).json({ error: "User is already an admin" });
}
await db
.update(users)
.set({ isAdmin: true })
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
);
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error(
"Failed to persist admin promotion to disk",
saveError,
{
operation: "make_admin_save_failed",
userId: targetUser[0].id,
username: targetUser[0].username,
},
);
}
authLogger.info("Admin privileges granted", {
operation: "admin_grant",
adminId: userId,
targetUserId: targetUser[0].id,
targetUsername: targetUser[0].username,
});
res.json({ message: `User ${targetUser[0].username} is now an admin` });
} catch (err) {
authLogger.error("Failed to make user admin", err);
res.status(500).json({ error: "Failed to make user admin" });
}
});
/**
* @openapi
* /users/remove-admin:
* post:
* summary: Remove admin status
* description: Revokes admin privileges from a user.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* description: Preferred unique user identifier.
* username:
* type: string
* description: Legacy fallback identifier.
* responses:
* 200:
* description: Admin status removed from user.
* 400:
* description: User ID or username is required, or cannot remove your own admin status.
* 403:
* description: Not authorized.
* 404:
* description: User not found.
* 500:
* description: Failed to remove admin status.
*/
router.post("/remove-admin", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { userId: targetUserId, username } = req.body;
const resolvedUserId = isNonEmptyString(targetUserId)
? targetUserId.trim()
: null;
const resolvedUsername = isNonEmptyString(username)
? username.trim()
: null;
if (!resolvedUserId && !resolvedUsername) {
return res.status(400).json({ error: "User ID or username is required" });
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
if (
(resolvedUserId && adminUser[0].id === resolvedUserId) ||
(resolvedUsername && adminUser[0].username === resolvedUsername)
) {
return res
.status(400)
.json({ error: "Cannot remove your own admin status" });
}
const targetUser = await db
.select()
.from(users)
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
)
.limit(1);
if (!targetUser || targetUser.length === 0) {
return res.status(404).json({ error: "User not found" });
}
if (!targetUser[0].isAdmin) {
return res.status(400).json({ error: "User is not an admin" });
}
await db
.update(users)
.set({ isAdmin: false })
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
);
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist admin removal to disk", saveError, {
operation: "remove_admin_save_failed",
userId: targetUser[0].id,
username: targetUser[0].username,
});
}
authLogger.info("Admin privileges revoked", {
operation: "admin_revoke",
adminId: userId,
targetUserId: targetUser[0].id,
targetUsername: targetUser[0].username,
});
res.json({
message: `Admin status removed from ${targetUser[0].username}`,
});
} catch (err) {
authLogger.error("Failed to remove admin status", err);
res.status(500).json({ error: "Failed to remove admin status" });
}
});
}
@@ -0,0 +1,218 @@
import type { RequestHandler, Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { apiKeys, users } from "../db/schema.js";
export function registerUserApiKeyRoutes(
router: Router,
requireAdmin: RequestHandler,
): void {
/**
* @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" });
}
});
}
@@ -0,0 +1,122 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
interface UserDataAccessRoutesDeps {
authenticateJWT: RequestHandler;
authManager: AuthManager;
}
export function registerUserDataAccessRoutes(
router: Router,
{ authenticateJWT, authManager }: UserDataAccessRoutesDeps,
): void {
/**
* @openapi
* /users/unlock-data:
* post:
* summary: Unlock user data
* description: Re-authenticates user with password to unlock encrypted data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Data unlocked successfully.
* 400:
* description: Password is required.
* 401:
* description: Invalid password.
* 500:
* description: Failed to unlock data.
*/
router.post("/unlock-data", authenticateJWT, async (req, res) => {
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" });
}
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",
});
} else {
authLogger.warn("Failed to unlock user data - invalid password", {
operation: "user_data_unlock_failed",
userId,
});
res.status(401).json({ error: "Invalid password" });
}
} catch (err) {
authLogger.error("Data unlock failed", err, {
operation: "user_data_unlock_error",
userId,
});
res.status(500).json({ error: "Failed to unlock data" });
}
});
/**
* @openapi
* /users/data-status:
* get:
* summary: Check user data unlock status
* description: Checks if user data is currently unlocked.
* tags:
* - Users
* responses:
* 200:
* description: Data status returned.
* 500:
* description: Failed to check data status.
*/
router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const unlocked = authManager.isUserUnlocked(userId);
res.json({
unlocked,
message: unlocked ? "Data is unlocked" : "Data is locked",
});
} catch (err) {
authLogger.error("Failed to check data status", err, {
operation: "data_status_check_failed",
userId,
});
res.status(500).json({ error: "Failed to check data status" });
}
});
}
@@ -0,0 +1,371 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
type UserOidcAccountRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserOidcAccountRoutes(
router: Router,
{ authenticateJWT, authManager }: UserOidcAccountRoutesDeps,
): void {
/**
* @openapi
* /users/link-oidc-to-password:
* post:
* summary: Link OIDC user to password account
* description: Merges an OIDC-only account into a password-based account (admin only).
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* oidcUserId:
* type: string
* targetUsername:
* type: string
* responses:
* 200:
* description: Accounts linked successfully.
* 400:
* description: Invalid request or incompatible accounts.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to link accounts.
*/
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { oidcUserId, targetUsername } = req.body;
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
return res.status(400).json({
error: "OIDC user ID and target username are required",
});
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, adminUserId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const oidcUserRecords = await db
.select()
.from(users)
.where(eq(users.id, oidcUserId));
if (!oidcUserRecords || oidcUserRecords.length === 0) {
return res.status(404).json({ error: "OIDC user not found" });
}
const oidcUser = oidcUserRecords[0];
if (!oidcUser.isOidc) {
return res.status(400).json({
error: "Source user is not an OIDC user",
});
}
const targetUserRecords = await db
.select()
.from(users)
.where(eq(users.username, targetUsername));
if (!targetUserRecords || targetUserRecords.length === 0) {
return res
.status(404)
.json({ error: "Target password user not found" });
}
const targetUser = targetUserRecords[0];
if (targetUser.isOidc || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if (targetUser.clientId && targetUser.oidcIdentifier) {
return res.status(400).json({
error: "Target user already has OIDC authentication configured",
});
}
authLogger.info("Linking OIDC user to password account", {
operation: "link_oidc_to_password",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
await db
.update(users)
.set({
isOidc: true,
oidcIdentifier: oidcUser.oidcIdentifier,
clientId: oidcUser.clientId,
clientSecret: oidcUser.clientSecret,
issuerUrl: oidcUser.issuerUrl,
authorizationUrl: oidcUser.authorizationUrl,
tokenUrl: oidcUser.tokenUrl,
identifierPath: oidcUser.identifierPath,
namePath: oidcUser.namePath,
scopes: oidcUser.scopes || "openid email profile",
})
.where(eq(users.id, targetUser.id));
try {
await authManager.convertToOIDCEncryption(targetUser.id);
} catch (encryptionError) {
authLogger.error(
"Failed to convert encryption to OIDC during linking",
encryptionError,
{
operation: "link_convert_encryption_failed",
userId: targetUser.id,
},
);
await db
.update(users)
.set({
isOidc: false,
oidcIdentifier: null,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
})
.where(eq(users.id, targetUser.id));
return res.status(500).json({
error:
"Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.",
details:
encryptionError instanceof Error
? encryptionError.message
: "Unknown error",
});
}
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
await deleteUserAndRelatedData(oidcUserId);
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error(
"Failed to persist account linking to disk",
saveError,
{
operation: "link_oidc_save_failed",
oidcUserId,
targetUserId: targetUser.id,
},
);
}
authLogger.success(
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
{
operation: "link_oidc_to_password_success",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
},
);
res.json({
success: true,
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
});
} catch (err) {
authLogger.error("Failed to link OIDC user to password account", err, {
operation: "link_oidc_to_password_failed",
oidcUserId,
targetUsername,
adminUserId,
});
res.status(500).json({
error: "Failed to link accounts",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
/**
* @openapi
* /users/unlink-oidc-from-password:
* post:
* summary: Unlink OIDC from password account
* description: Removes OIDC authentication from a dual-auth account (admin only).
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: OIDC unlinked successfully.
* 400:
* description: Invalid request or user doesn't have OIDC.
* 403:
* description: Admin privileges required.
* 404:
* description: User not found.
* 500:
* description: Failed to unlink OIDC.
*/
router.post(
"/unlink-oidc-from-password",
authenticateJWT,
async (req, res) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { userId } = req.body;
if (!userId) {
return res.status(400).json({
error: "User ID is required",
});
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, adminUserId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
operation: "unlink_oidc_unauthorized",
adminUserId,
targetUserId: userId,
});
return res.status(403).json({
error: "Admin privileges required",
});
}
const targetUserRecords = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (!targetUserRecords || targetUserRecords.length === 0) {
return res.status(404).json({
error: "User not found",
});
}
const targetUser = targetUserRecords[0];
if (!targetUser.isOidc) {
return res.status(400).json({
error: "User does not have OIDC authentication enabled",
});
}
if (!targetUser.passwordHash || targetUser.passwordHash === "") {
return res.status(400).json({
error:
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
});
}
authLogger.info("Unlinking OIDC from password account", {
operation: "unlink_oidc_from_password_start",
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
await db
.update(users)
.set({
isOidc: false,
oidcIdentifier: null,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
})
.where(eq(users.id, targetUser.id));
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error(
"Failed to save database after unlinking OIDC",
saveError,
{
operation: "unlink_oidc_save_failed",
targetUserId: targetUser.id,
},
);
}
authLogger.success("OIDC unlinked from password account successfully", {
operation: "unlink_oidc_from_password_success",
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
res.json({
success: true,
message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`,
});
} catch (err) {
authLogger.error("Failed to unlink OIDC from password account", err, {
operation: "unlink_oidc_from_password_failed",
targetUserId: userId,
adminUserId,
});
res.status(500).json({
error: "Failed to unlink OIDC",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
vi.mock("../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { isOIDCUserAllowed, getOIDCConfigFromEnv } =
await import("./user-oidc-utils.js");
describe("isOIDCUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
});
it("allows everyone with the '*' wildcard", () => {
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
});
it("matches an exact identifier (case-insensitive)", () => {
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
});
it("matches against the email as well as the identifier", () => {
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
true,
);
});
it("matches an @domain suffix pattern", () => {
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
true,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
true,
);
});
it("denies users not on the list", () => {
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
false,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
false,
);
});
it("ignores blank entries and surrounding whitespace in the list", () => {
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
});
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
});
});
describe("getOIDCConfigFromEnv", () => {
const REQUIRED = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
];
const OPTIONAL = [
"OIDC_USERINFO_URL",
"OIDC_IDENTIFIER_PATH",
"OIDC_NAME_PATH",
"OIDC_SCOPES",
"OIDC_ALLOWED_USERS",
"OIDC_ADMIN_GROUP",
];
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
it("returns null when any required variable is missing", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
// issuer/authorization/token urls intentionally missing
expect(getOIDCConfigFromEnv()).toBeNull();
});
it("builds a config with defaults when all required vars are present", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
const config = getOIDCConfigFromEnv();
expect(config).not.toBeNull();
expect(config?.client_id).toBe("id");
expect(config?.identifier_path).toBe("sub");
expect(config?.name_path).toBe("name");
expect(config?.scopes).toBe("openid email profile");
expect(config?.userinfo_url).toBe("");
});
it("honors overrides for optional vars", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
process.env.OIDC_IDENTIFIER_PATH = "email";
process.env.OIDC_SCOPES = "openid";
const config = getOIDCConfigFromEnv();
expect(config?.identifier_path).toBe("email");
expect(config?.scopes).toBe("openid");
});
});
@@ -0,0 +1,172 @@
import { authLogger } from "../../utils/logger.js";
export type OIDCConfig = {
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
identifier_path: string;
name_path: string;
scopes: string;
allowed_users: string;
admin_group: string;
};
export function getOIDCConfigFromEnv(): OIDCConfig | null {
const client_id = process.env.OIDC_CLIENT_ID;
const client_secret = process.env.OIDC_CLIENT_SECRET;
const issuer_url = process.env.OIDC_ISSUER_URL;
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
const token_url = process.env.OIDC_TOKEN_URL;
if (
!client_id ||
!client_secret ||
!issuer_url ||
!authorization_url ||
!token_url
) {
return null;
}
return {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
userinfo_url: process.env.OIDC_USERINFO_URL || "",
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
name_path: process.env.OIDC_NAME_PATH || "name",
scopes: process.env.OIDC_SCOPES || "openid email profile",
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
admin_group: process.env.OIDC_ADMIN_GROUP || "",
};
}
export function isOIDCUserAllowed(
allowedUsers: string,
identifier: string,
email?: string,
): boolean {
if (!allowedUsers || !allowedUsers.trim()) return true;
const patterns = allowedUsers
.split(",")
.map((p) => p.trim())
.filter(Boolean);
if (patterns.length === 0) return true;
const values = [
identifier,
...(email && email !== identifier ? [email] : []),
];
for (const pattern of patterns) {
if (pattern === "*") return true;
for (const value of values) {
if (!value) continue;
if (pattern.toLowerCase().startsWith("@")) {
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
} else {
if (value.toLowerCase() === pattern.toLowerCase()) return true;
}
}
}
return false;
}
export async function verifyOIDCToken(
idToken: string,
issuerUrl: string,
clientId: string,
): Promise<Record<string, unknown>> {
const normalizedIssuerUrl = issuerUrl.endsWith("/")
? issuerUrl.slice(0, -1)
: issuerUrl;
const possibleIssuers = [
issuerUrl,
normalizedIssuerUrl,
issuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
];
const jwksUrls = [
`${normalizedIssuerUrl}/.well-known/jwks.json`,
`${normalizedIssuerUrl}/jwks/`,
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
];
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
if (discoveryResponse.ok) {
const discovery = (await discoveryResponse.json()) as Record<
string,
unknown
>;
if (discovery.jwks_uri) {
jwksUrls.unshift(discovery.jwks_uri as string);
}
}
} catch (discoveryError) {
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
}
let jwks: Record<string, unknown> | null = null;
for (const url of jwksUrls) {
try {
const response = await fetch(url);
if (response.ok) {
const jwksData = (await response.json()) as Record<string, unknown>;
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
jwks = jwksData;
break;
} else {
authLogger.error(
`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`,
);
}
} else {
// expected - non-ok response, try next URL
}
} catch {
continue;
}
}
if (!jwks) {
throw new Error("Failed to fetch JWKS from any URL");
}
if (!jwks.keys || !Array.isArray(jwks.keys)) {
throw new Error(
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
);
}
const header = JSON.parse(
Buffer.from(idToken.split(".")[0], "base64").toString(),
);
const keyId = header.kid;
const publicKey = jwks.keys.find(
(key: Record<string, unknown>) => key.kid === keyId,
);
if (!publicKey) {
throw new Error(
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record<string, unknown>) => k.kid).join(", ")}`,
);
}
const { importJWK, jwtVerify } = await import("jose");
const key = await importJWK(publicKey);
const { payload } = await jwtVerify(idToken, key, {
issuer: possibleIssuers,
audience: clientId,
});
return payload;
}
@@ -0,0 +1,489 @@
import type { Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import { db } from "../db/index.js";
import {
users,
hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
sshCredentialUsage,
recentActivity,
snippets,
} from "../db/schema.js";
interface UserPasswordResetRoutesDeps {
authManager: AuthManager;
}
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserPasswordResetRoutes(
router: Router,
{ authManager }: UserPasswordResetRoutesDeps,
): void {
/**
* @openapi
* /users/initiate-reset:
* post:
* summary: Initiate password reset
* description: Initiates the password reset process for a user.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* username:
* type: string
* responses:
* 200:
* description: Password reset code has been generated.
* 400:
* description: Username is required.
* 403:
* description: Password reset not available for external authentication users.
* 404:
* description: User not found.
* 500:
* description: Failed to initiate password reset.
*/
router.post("/initiate-reset", async (req, res) => {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
)
.get();
if (row && (row as { value: string }).value !== "true") {
return res
.status(403)
.json({ error: "Password reset is currently disabled" });
}
} catch (e) {
authLogger.warn("Failed to check password reset status", {
operation: "password_reset_check",
error: e,
});
}
const { username } = req.body;
if (!isNonEmptyString(username)) {
return res.status(400).json({ error: "Username is required" });
}
try {
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
authLogger.warn(
`Password reset attempted for non-existent user: ${username}`,
);
return res.json({
message:
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
});
}
if (user[0].isOidc) {
return res.json({
message:
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
});
}
const resetCode = crypto.randomInt(100000, 1000000).toString();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(
`reset_code_${username}`,
JSON.stringify({
code: resetCode,
expiresAt: expiresAt.toISOString(),
}),
);
authLogger.info(
`Password reset code generated for user ${username} (expires at ${expiresAt.toLocaleString()}). Check admin panel or database settings table for code.`,
);
res.json({
message:
"Password reset code has been generated and logged. Check docker logs for the code.",
});
} catch (err) {
authLogger.error("Failed to initiate password reset", err);
res.status(500).json({ error: "Failed to initiate password reset" });
}
});
/**
* @openapi
* /users/verify-reset-code:
* post:
* summary: Verify reset code
* description: Verifies the password reset code.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* username:
* type: string
* resetCode:
* type: string
* responses:
* 200:
* description: Reset code verified.
* 400:
* description: Invalid or expired reset code.
* 500:
* description: Failed to verify reset code.
*/
router.post("/verify-reset-code", async (req, res) => {
const { username, resetCode } = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) {
return res
.status(400)
.json({ error: "Username and reset code are required" });
}
try {
const lockStatus = loginRateLimiter.isResetCodeLocked(username);
if (lockStatus.locked) {
authLogger.warn(
"Reset code verification blocked due to rate limiting",
{
operation: "reset_code_verify_blocked",
username,
remainingTime: lockStatus.remainingTime,
},
);
return res.status(429).json({
error: `Rate limited: Too many verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
remainingTime: lockStatus.remainingTime,
code: "RESET_CODE_RATE_LIMITED",
});
}
loginRateLimiter.recordResetCodeAttempt(username);
const resetDataRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`reset_code_${username}`);
if (!resetDataRow) {
authLogger.warn("Reset code verification failed - no code found", {
operation: "reset_code_verify_failed",
username,
remainingAttempts:
loginRateLimiter.getRemainingResetCodeAttempts(username),
});
return res.status(400).json({
error: "No reset code found for this user",
remainingAttempts:
loginRateLimiter.getRemainingResetCodeAttempts(username),
});
}
const resetData = JSON.parse(
(resetDataRow as Record<string, unknown>).value as string,
);
const now = new Date();
const expiresAt = new Date(resetData.expiresAt);
if (now > expiresAt) {
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`reset_code_${username}`);
authLogger.warn("Reset code verification failed - code expired", {
operation: "reset_code_verify_failed",
username,
remainingAttempts:
loginRateLimiter.getRemainingResetCodeAttempts(username),
});
return res.status(400).json({
error: "Reset code has expired",
remainingAttempts:
loginRateLimiter.getRemainingResetCodeAttempts(username),
});
}
if (resetData.code !== resetCode) {
authLogger.warn("Reset code verification failed - invalid code", {
operation: "reset_code_verify_failed",
username,
remainingAttempts:
loginRateLimiter.getRemainingResetCodeAttempts(username),
});
return res.status(400).json({
error: "Invalid reset code",
remainingAttempts:
loginRateLimiter.getRemainingResetCodeAttempts(username),
});
}
loginRateLimiter.resetResetCodeAttempts(username);
const tempToken = nanoid();
const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000);
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(
`temp_reset_token_${username}`,
JSON.stringify({
token: tempToken,
expiresAt: tempTokenExpiry.toISOString(),
}),
);
res.json({ message: "Reset code verified", tempToken });
} catch (err) {
authLogger.error("Failed to verify reset code", err);
res.status(500).json({ error: "Failed to verify reset code" });
}
});
/**
* @openapi
* /users/complete-reset:
* post:
* summary: Complete password reset
* description: Completes the password reset process with a new password.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* username:
* type: string
* tempToken:
* type: string
* newPassword:
* type: string
* responses:
* 200:
* description: Password has been successfully reset.
* 400:
* description: Invalid or expired temporary token.
* 404:
* description: User not found.
* 500:
* description: Failed to complete password reset.
*/
router.post("/complete-reset", async (req, res) => {
const { username, tempToken, newPassword } = req.body;
if (
!isNonEmptyString(username) ||
!isNonEmptyString(tempToken) ||
!isNonEmptyString(newPassword)
) {
return res.status(400).json({
error: "Username, temporary token, and new password are required",
});
}
try {
const tempTokenRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`temp_reset_token_${username}`);
if (!tempTokenRow) {
return res.status(400).json({ error: "No temporary token found" });
}
const tempTokenData = JSON.parse(
(tempTokenRow as Record<string, unknown>).value as string,
);
const now = new Date();
const expiresAt = new Date(tempTokenData.expiresAt);
if (now > expiresAt) {
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`temp_reset_token_${username}`);
return res.status(400).json({ error: "Temporary token has expired" });
}
if (tempTokenData.token !== tempToken) {
return res.status(400).json({ error: "Invalid temporary token" });
}
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userId = user[0].id;
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
let userIdFromJwt: string | null = null;
const cookie = req.cookies?.jwt;
let header: string | undefined;
if (req.headers?.authorization?.startsWith("Bearer ")) {
header = req.headers?.authorization?.split(" ")[1];
}
const token = cookie || header;
if (token) {
const payload = await authManager.verifyJWTToken(token);
if (payload) {
userIdFromJwt = payload.userId;
}
}
if (userIdFromJwt === userId) {
try {
const success = await authManager.resetUserPasswordWithPreservedDEK(
userId,
newPassword,
);
if (!success) {
throw new Error(
"Failed to re-encrypt user data with new password.",
);
}
await db
.update(users)
.set({ passwordHash: password_hash })
.where(eq(users.id, userId));
authManager.logoutUser(userId);
authLogger.success(
`Password reset (data preserved) for user: ${username}`,
{
operation: "password_reset_preserved",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed_preserved",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
} else {
await db
.update(users)
.set({ passwordHash: password_hash })
.where(eq(users.username, username));
try {
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
await db
.delete(recentActivity)
.where(eq(recentActivity.userId, userId));
await db
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(hosts).where(eq(hosts.userId, userId));
await db
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId));
await authManager.registerUser(userId, newPassword);
authManager.logoutUser(userId);
await db
.update(users)
.set({
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
})
.where(eq(users.id, userId));
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`,
{
operation: "password_reset_data_deleted",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
}
authLogger.success(`Password successfully reset for user: ${username}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`reset_code_${username}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`temp_reset_token_${username}`);
res.json({ message: "Password has been successfully reset" });
} catch (err) {
authLogger.error("Failed to complete password reset", err);
res.status(500).json({ error: "Failed to complete password reset" });
}
});
}
+64 -16
View File
@@ -11,6 +11,14 @@ const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
reopenTabsOnLogin: row?.reopenTabsOnLogin ?? false,
theme: row?.theme ?? null,
fontSize: row?.fontSize ?? null,
accentColor: row?.accentColor ?? null,
language: row?.language ?? null,
});
/**
* @openapi
* /user-preferences:
@@ -38,12 +46,12 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
.where(eq(userPreferences.userId, userId))
.all();
if (rows.length === 0) {
return res.json({ reopenTabsOnLogin: false });
}
return res.json({ reopenTabsOnLogin: rows[0].reopenTabsOnLogin });
return res.json(pickPreferences(rows[0]));
} catch (e) {
databaseLogger.error("Failed to get user preferences", e, { operation: "get_user_preferences", userId });
databaseLogger.error("Failed to get user preferences", e, {
operation: "get_user_preferences",
userId,
});
return res.status(500).json({ error: "Failed to get user preferences" });
}
});
@@ -70,10 +78,46 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
*/
router.put("/", authenticateJWT, (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { reopenTabsOnLogin } = req.body as { reopenTabsOnLogin?: boolean };
const { reopenTabsOnLogin, theme, fontSize, accentColor, language } =
req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
fontSize?: string | null;
accentColor?: string | null;
language?: string | null;
};
if (typeof reopenTabsOnLogin !== "boolean") {
return res.status(400).json({ error: "reopenTabsOnLogin must be a boolean" });
const updates: Partial<typeof userPreferences.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
if (reopenTabsOnLogin !== undefined) {
if (typeof reopenTabsOnLogin !== "boolean") {
return res
.status(400)
.json({ error: "reopenTabsOnLogin must be a boolean" });
}
updates.reopenTabsOnLogin = reopenTabsOnLogin;
}
for (const [key, value] of Object.entries({
theme,
fontSize,
accentColor,
language,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
}
}
if (theme !== undefined) updates.theme = theme;
if (fontSize !== undefined) updates.fontSize = fontSize;
if (accentColor !== undefined) updates.accentColor = accentColor;
if (language !== undefined) updates.language = language;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
}
try {
@@ -84,21 +128,25 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
.all();
if (existing.length === 0) {
db.insert(userPreferences).values({
userId,
reopenTabsOnLogin,
updatedAt: new Date().toISOString(),
}).run();
db.insert(userPreferences)
.values({
userId,
...updates,
})
.run();
} else {
db.update(userPreferences)
.set({ reopenTabsOnLogin, updatedAt: new Date().toISOString() })
.set(updates)
.where(eq(userPreferences.userId, userId))
.run();
}
return res.json({ success: true, reopenTabsOnLogin });
return res.json({ success: true, ...updates });
} catch (e) {
databaseLogger.error("Failed to update user preferences", e, { operation: "update_user_preferences", userId });
databaseLogger.error("Failed to update user preferences", e, {
operation: "update_user_preferences",
userId,
});
return res.status(500).json({ error: "Failed to update user preferences" });
}
});
@@ -0,0 +1,256 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { sessions, users } from "../db/schema.js";
type UserSessionRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
export function registerUserSessionRoutes(
router: Router,
{ authenticateJWT, authManager }: UserSessionRoutesDeps,
): void {
/**
* @openapi
* /users/sessions:
* get:
* summary: Get sessions
* description: Retrieves all sessions for authenticated user (or all sessions for admins).
* tags:
* - Users
* responses:
* 200:
* description: Sessions list returned.
* 404:
* description: User not found.
* 500:
* description: Failed to get sessions.
*/
router.get("/sessions", authenticateJWT, async (req, res) => {
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));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
let sessionList;
if (userRecord.isAdmin) {
sessionList = await authManager.getAllSessions();
const enrichedSessions = await Promise.all(
sessionList.map(async (session) => {
const sessionUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
return {
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,
};
}),
);
return res.json({ sessions: enrichedSessions });
} else {
sessionList = await authManager.getUserSessions(userId);
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);
res.status(500).json({ error: "Failed to get sessions" });
}
});
/**
* @openapi
* /users/sessions/{sessionId}:
* delete:
* summary: Revoke a specific session
* description: Revokes a specific session by ID.
* tags:
* - Users
* parameters:
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* description: The session ID to revoke
* responses:
* 200:
* description: Session revoked successfully.
* 400:
* description: Session ID is required.
* 403:
* description: Not authorized to revoke this session.
* 404:
* description: Session not found.
* 500:
* description: Failed to revoke session.
*/
router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const sessionId = Array.isArray(req.params.sessionId)
? req.params.sessionId[0]
: req.params.sessionId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length === 0) {
return res.status(404).json({ error: "Session not found" });
}
const session = sessionRecords[0];
if (!userRecord.isAdmin && session.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to revoke this session" });
}
const success = await authManager.revokeSession(sessionId);
if (success) {
authLogger.success("Session revoked", {
operation: "session_revoke",
sessionId,
revokedBy: userId,
sessionUserId: session.userId,
});
res.json({ success: true, message: "Session revoked successfully" });
} else {
res.status(500).json({ error: "Failed to revoke session" });
}
} catch (err) {
authLogger.error("Failed to revoke session", err);
res.status(500).json({ error: "Failed to revoke session" });
}
});
/**
* @openapi
* /users/sessions/revoke-all:
* post:
* summary: Revoke all sessions for a user
* description: Revokes all sessions with option to exclude current session.
* tags:
* - Users
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* targetUserId:
* type: string
* exceptCurrent:
* type: boolean
* responses:
* 200:
* description: Sessions revoked successfully.
* 403:
* description: Not authorized to revoke sessions for other users.
* 404:
* description: User not found.
* 500:
* description: Failed to revoke sessions.
*/
router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { targetUserId, exceptCurrent } = req.body;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
let revokeUserId = userId;
if (targetUserId && userRecord.isAdmin) {
revokeUserId = targetUserId;
} else if (targetUserId && targetUserId !== userId) {
return res.status(403).json({
error: "Not authorized to revoke sessions for other users",
});
}
let currentSessionId: string | undefined;
if (exceptCurrent) {
currentSessionId = (req as AuthenticatedRequest).sessionId;
}
const revokedCount = await authManager.revokeAllUserSessions(
revokeUserId,
currentSessionId,
);
authLogger.success("User sessions revoked", {
operation: "user_sessions_revoke_all",
revokeUserId,
revokedBy: userId,
exceptCurrent,
revokedCount,
});
res.json({
message: `${revokedCount} session(s) revoked successfully`,
count: revokedCount,
});
} catch (err) {
authLogger.error("Failed to revoke user sessions", err);
res.status(500).json({ error: "Failed to revoke sessions" });
}
});
}
@@ -0,0 +1,276 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { restartGuacServer } from "../../guacamole/guacamole-server.js";
import {
authLogger,
getGlobalLogLevel,
setGlobalLogLevel,
} from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
function getDefaultGuacUrl(): string {
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
}
export function registerUserSettingsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/guacamole-settings:
* get:
* summary: Get Guacamole settings
* description: Returns current guacd enabled status and host:port URL. No authentication required.
* tags:
* - Users
* responses:
* 200:
* description: Guacamole settings.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* url:
* type: string
* 500:
* description: Failed to get guacamole settings.
*/
router.get("/guacamole-settings", authenticateJWT, async (_req, res) => {
try {
const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
.get() as { value: string } | undefined;
const urlRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get() as { value: string } | undefined;
res.json({
enabled: enabledRow ? enabledRow.value !== "false" : true,
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
});
} catch (err) {
authLogger.error("Failed to get guacamole settings", err);
res.status(500).json({ error: "Failed to get guacamole settings" });
}
});
/**
* @openapi
* /users/guacamole-settings:
* patch:
* summary: Update Guacamole settings
* description: Admin-only. Updates guacd enabled status and/or host:port URL.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* url:
* type: string
* responses:
* 200:
* description: Guacamole settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update guacamole settings.
*/
router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled, url } = req.body;
if (typeof enabled === "boolean") {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)",
)
.run(enabled ? "true" : "false");
}
if (typeof url === "string") {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)",
)
.run(url);
try {
await restartGuacServer();
} catch (err) {
authLogger.error(
"Failed to restart guac server after URL update",
err,
);
}
}
const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
.get() as { value: string } | undefined;
const urlRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get() as { value: string } | undefined;
res.json({
enabled: enabledRow ? enabledRow.value !== "false" : true,
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
});
} catch (err) {
authLogger.error("Failed to update guacamole settings", err);
res.status(500).json({ error: "Failed to update guacamole settings" });
}
});
/**
* @openapi
* /users/log-level:
* get:
* summary: Get log level setting
* description: Returns the configured log verbosity level.
* tags:
* - Users
* responses:
* 200:
* description: Current log level.
*/
router.get("/log-level", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
.get() as { value: string } | undefined;
res.json({
level: row ? row.value : getGlobalLogLevel(),
});
} catch (err) {
authLogger.error("Failed to get log level", err);
res.status(500).json({ error: "Failed to get log level" });
}
});
/**
* @openapi
* /users/log-level:
* patch:
* summary: Update log level setting (admin only)
* description: Sets the log verbosity level.
* tags:
* - Users
* responses:
* 200:
* description: Log level updated.
* 400:
* description: Invalid log level.
* 403:
* description: Not authorized.
*/
router.patch("/log-level", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { level } = req.body;
const validLevels = ["debug", "info", "warn", "error"];
if (typeof level !== "string" || !validLevels.includes(level)) {
return res
.status(400)
.json({ error: "level must be one of: debug, info, warn, error" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('log_level', ?)",
)
.run(level);
setGlobalLogLevel(level);
res.json({ level });
} catch (err) {
authLogger.error("Failed to set log level", err);
res.status(500).json({ error: "Failed to set log level" });
}
});
/**
* @openapi
* /users/session-timeout:
* get:
* summary: Get session timeout setting
* description: Returns the configured session timeout in hours.
* tags:
* - Users
* responses:
* 200:
* description: Current session timeout hours.
*/
router.get("/session-timeout", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_timeout_hours'",
)
.get() as { value: string } | undefined;
res.json({
timeoutHours: row ? parseInt(row.value, 10) : 24,
});
} catch (err) {
authLogger.error("Failed to get session timeout", err);
res.status(500).json({ error: "Failed to get session timeout" });
}
});
/**
* @openapi
* /users/session-timeout:
* patch:
* summary: Update session timeout setting (admin only)
* description: Sets the session timeout in hours.
* tags:
* - Users
* responses:
* 200:
* description: Session timeout updated.
* 400:
* description: Invalid value.
* 403:
* description: Not authorized.
*/
router.patch("/session-timeout", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { timeoutHours } = req.body;
if (
typeof timeoutHours !== "number" ||
timeoutHours < 1 ||
timeoutHours > 720
) {
return res
.status(400)
.json({ error: "timeoutHours must be between 1 and 720" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)",
)
.run(String(timeoutHours));
res.json({ timeoutHours });
} catch (err) {
authLogger.error("Failed to set session timeout", err);
res.status(500).json({ error: "Failed to set session timeout" });
}
});
}
@@ -0,0 +1,583 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
import { AuthManager } from "../../utils/auth-manager.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import {
generateDeviceFingerprint,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import { db } from "../db/index.js";
import { sessions, trustedDevices, users } from "../db/schema.js";
type NativeAppRequestChecker = (req: Request) => boolean;
interface UserTotpRoutesDeps {
authenticateJWT: RequestHandler;
authManager: AuthManager;
isNativeAppRequest: NativeAppRequestChecker;
}
export function registerUserTotpRoutes(
router: Router,
{ authenticateJWT, authManager, isNativeAppRequest }: UserTotpRoutesDeps,
): void {
/**
* @openapi
* /users/totp/setup:
* post:
* summary: Setup TOTP
* description: Initiates TOTP setup by generating a secret and QR code.
* tags:
* - Users
* responses:
* 200:
* description: TOTP setup initiated with secret and QR code.
* 400:
* description: TOTP is already enabled.
* 404:
* description: User not found.
* 500:
* description: Failed to setup TOTP.
*/
router.post("/totp/setup", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is already enabled" });
}
const secret = speakeasy.generateSecret({
name: `Termix (${userRecord.username})`,
length: 32,
});
await db
.update(users)
.set({ totpSecret: secret.base32 })
.where(eq(users.id, userId));
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
res.json({
secret: secret.base32,
qr_code: qrCodeUrl,
});
} catch (err) {
authLogger.error("Failed to setup TOTP", err);
res.status(500).json({ error: "Failed to setup TOTP" });
}
});
/**
* @openapi
* /users/totp/enable:
* post:
* summary: Enable TOTP
* description: Enables TOTP after verifying the initial code.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* totp_code:
* type: string
* responses:
* 200:
* description: TOTP enabled successfully with backup codes.
* 400:
* description: TOTP code is required or TOTP already enabled.
* 401:
* description: Invalid TOTP code.
* 404:
* description: User not found.
* 500:
* description: Failed to enable TOTP.
*/
router.post("/totp/enable", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { totp_code } = req.body;
if (!totp_code) {
return res.status(400).json({ error: "TOTP code is required" });
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is already enabled" });
}
if (!userRecord.totpSecret) {
return res.status(400).json({ error: "TOTP setup not initiated" });
}
const verified = speakeasy.totp.verify({
secret: userRecord.totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
}
const backupCodes = Array.from({ length: 8 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
await db
.update(users)
.set({
totpEnabled: true,
totpBackupCodes: JSON.stringify(backupCodes),
})
.where(eq(users.id, userId));
await db.delete(sessions).where(eq(sessions.userId, userId));
await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId));
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error(
"Failed to persist TOTP enablement to disk",
saveError,
{
operation: "totp_enable_db_save_failed",
userId,
},
);
}
res.json({
message: "TOTP enabled successfully",
backup_codes: backupCodes,
});
} catch (err) {
authLogger.error("Failed to enable TOTP", err);
res.status(500).json({ error: "Failed to enable TOTP" });
}
});
/**
* @openapi
* /users/totp/disable:
* post:
* summary: Disable TOTP
* description: Disables TOTP for a user.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* totp_code:
* type: string
* responses:
* 200:
* description: TOTP disabled successfully.
* 400:
* description: Password or TOTP code is required.
* 401:
* description: Incorrect password or invalid TOTP code.
* 404:
* description: User not found.
* 500:
* description: Failed to disable TOTP.
*/
router.post("/totp/disable", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
if (!password || !totp_code) {
return res
.status(400)
.json({ error: "Both password and TOTP code are required" });
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
}
if (!userRecord.isOidc) {
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Incorrect password" });
}
}
const verified = speakeasy.totp.verify({
secret: userRecord.totpSecret!,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
}
await db
.update(users)
.set({
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
})
.where(eq(users.id, userId));
authLogger.info("Two-factor authentication disabled", {
operation: "totp_disable",
userId,
});
res.json({ message: "TOTP disabled successfully" });
} catch (err) {
authLogger.error("Failed to disable TOTP", err);
res.status(500).json({ error: "Failed to disable TOTP" });
}
});
/**
* @openapi
* /users/totp/backup-codes:
* post:
* summary: Generate new backup codes
* description: Generates new TOTP backup codes.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* totp_code:
* type: string
* responses:
* 200:
* description: New backup codes generated.
* 400:
* description: Password or TOTP code is required.
* 401:
* description: Incorrect password or invalid TOTP code.
* 404:
* description: User not found.
* 500:
* description: Failed to generate backup codes.
*/
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
if (!password || !totp_code) {
return res
.status(400)
.json({ error: "Both password and TOTP code are required" });
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
}
if (!userRecord.isOidc) {
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Incorrect password" });
}
}
const verified = speakeasy.totp.verify({
secret: userRecord.totpSecret!,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
}
const backupCodes = Array.from({ length: 8 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
await db
.update(users)
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
.where(eq(users.id, userId));
res.json({ backup_codes: backupCodes });
} catch (err) {
authLogger.error("Failed to generate backup codes", err);
res.status(500).json({ error: "Failed to generate backup codes" });
}
});
/**
* @openapi
* /users/totp/verify-login:
* post:
* summary: Verify TOTP during login
* description: Verifies the TOTP code during login.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* temp_token:
* type: string
* totp_code:
* type: string
* responses:
* 200:
* description: TOTP verification successful.
* 400:
* description: Token and TOTP code are required.
* 401:
* description: Invalid temporary token or TOTP code.
* 404:
* description: User not found.
* 500:
* description: TOTP verification failed.
*/
router.post("/totp/verify-login", async (req, res) => {
const { temp_token, totp_code, rememberMe } = req.body;
if (!temp_token || !totp_code) {
return res
.status(400)
.json({ error: "Token and TOTP code are required" });
}
try {
const decoded = await authManager.verifyJWTToken(temp_token);
if (!decoded || !decoded.pendingTOTP) {
return res.status(401).json({ error: "Invalid temporary token" });
}
const user = await db
.select()
.from(users)
.where(eq(users.id, decoded.userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id);
if (lockStatus.locked) {
authLogger.warn("TOTP verification blocked due to rate limiting", {
operation: "totp_verify_blocked",
userId: userRecord.id,
remainingTime: lockStatus.remainingTime,
});
return res.status(429).json({
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
remainingTime: lockStatus.remainingTime,
code: "TOTP_RATE_LIMITED",
});
}
loginRateLimiter.recordFailedTOTPAttempt(userRecord.id);
if (!userRecord.totpEnabled || !userRecord.totpSecret) {
return res
.status(400)
.json({ error: "TOTP not enabled for this user" });
}
const userDataKey = authManager.getUserDataKey(userRecord.id);
if (!userDataKey) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userRecord.id,
"totp_secret",
);
if (!totpSecret) {
await db
.update(users)
.set({
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
})
.where(eq(users.id, userRecord.id));
return res.status(400).json({
error:
"TOTP has been disabled due to password reset. Please set up TOTP again.",
});
}
const verified = speakeasy.totp.verify({
secret: totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
let backupCodes = [];
try {
backupCodes = userRecord.totpBackupCodes
? JSON.parse(userRecord.totpBackupCodes)
: [];
} catch {
backupCodes = [];
}
if (!Array.isArray(backupCodes)) {
backupCodes = [];
}
const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) {
authLogger.warn("TOTP verification failed - invalid code", {
operation: "totp_verify_failed",
userId: userRecord.id,
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
userRecord.id,
),
});
return res.status(401).json({
error: "Invalid TOTP code",
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
userRecord.id,
),
});
}
backupCodes.splice(backupIndex, 1);
await db
.update(users)
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
.where(eq(users.id, userRecord.id));
}
loginRateLimiter.resetTOTPAttempts(userRecord.id);
const deviceInfo = parseUserAgent(req);
if (rememberMe) {
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
await authManager.addTrustedDevice(
userRecord.id,
deviceFingerprint,
deviceInfo.type,
deviceInfo.deviceInfo,
);
authLogger.info("Device automatically trusted via Remember Me", {
operation: "totp_auto_trust",
userId: userRecord.id,
deviceType: deviceInfo.type,
});
}
const token = await authManager.generateJWTToken(userRecord.id, {
rememberMe: !!rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
authLogger.success("TOTP verification successful", {
operation: "totp_verify_success",
userId: userRecord.id,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const response: Record<string, unknown> = {
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
userId: userRecord.id,
is_oidc: !!userRecord.isOidc,
totp_enabled: !!userRecord.totpEnabled,
...(isNativeAppRequest(req) ? { token } : {}),
};
const timeoutRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_timeout_hours'",
)
.get() as { value: string } | undefined;
const timeoutHours = timeoutRow
? parseInt(timeoutRow.value, 10) || 24
: 24;
const maxAge = rememberMe
? 30 * 24 * 60 * 60 * 1000
: timeoutHours * 60 * 60 * 1000;
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json(response);
} catch (err) {
authLogger.error("TOTP verification failed", err);
return res.status(500).json({ error: "TOTP verification failed" });
}
});
}
File diff suppressed because it is too large Load Diff