release-2.4.1 (#921)

* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910)

Bumps the dev-minor-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` |
| [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` |
| [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` |
| [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` |



Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `cytoscape` from 3.33.4 to 3.34.0
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0)

Updates `electron` from 42.2.0 to 42.4.1
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1)

Updates `electron-builder` from 26.8.1 to 26.15.3
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder)

Updates `lucide-react` from 1.16.0 to 1.20.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react)

Updates `radix-ui` from 1.4.3 to 1.6.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-hook-form` from 7.76.1 to 7.79.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0)

Updates `sharp` from 0.34.5 to 0.35.1
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1)

Updates `typescript-eslint` from 8.60.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: cytoscape
  dependency-version: 3.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: electron
  dependency-version: 42.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: electron-builder
  dependency-version: 26.15.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: lucide-react
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: radix-ui
  dependency-version: 1.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.79.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: sharp
  dependency-version: 0.35.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump node in /docker in the docker-major-updates group (#914)

Bumps the docker-major-updates group in /docker with 1 update: node.


Updates `node` from 24-slim to 26-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump the github-actions group with 2 updates (#915)

Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916)

Bumps the prod-minor-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` |
| [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` |
| [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` |
| [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` |



Updates `axios` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

Updates `better-sqlite3` from 12.10.0 to 12.11.1
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1)

Updates `body-parser` from 2.2.2 to 2.3.0
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

Updates `multer` from 2.1.1 to 2.2.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0)

Updates `undici` from 8.4.0 to 8.5.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: better-sqlite3
  dependency-version: 12.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: multer
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add unlink button for dual auth

* fix: general bug fixes

* fix: general qol/small feature additions

* fix: 100mb max upload size

* fix: terminal syntax not handling carriage returns or shell prompt lines properly

* feat: continued general improvements

* fix: terminal outputting success right after folder path

* chore: update release notes

* feat: continued fixes and improvements

* chore: lint, format, and bump version to 2.4.1

* chore: sync Crowdin translations for 2.4.1

* fix: rebase dev branch onto main before Crowdin download

* fix: use merge instead of rebase to sync main before Crowdin

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-06-21 15:55:41 -05:00
committed by GitHub
parent 8072b4ede9
commit d1a8dcf3c6
165 changed files with 87244 additions and 60766 deletions
+30 -3
View File
@@ -2,12 +2,18 @@ import express from "express";
import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "./utils/cors-config.js";
import { getDb } from "./database/db/index.js";
import { recentActivity, hosts, hostAccess } from "./database/db/schema.js";
import { eq, and, desc, inArray } from "drizzle-orm";
import {
recentActivity,
hosts,
hostAccess,
userRoles,
} from "./database/db/schema.js";
import { eq, and, desc, inArray, or, isNull, gte, sql } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
import type { AuthenticatedRequest } from "../types/index.js";
import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js";
const app = express();
const authManager = AuthManager.getInstance();
@@ -227,11 +233,30 @@ app.post("/activity/log", async (req, res) => {
);
if (ownedHosts.length === 0) {
const userRoleIds = await getDb()
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleIds.map((r) => r.roleId);
const now = new Date().toISOString();
const sharedHosts = await getDb()
.select()
.from(hostAccess)
.where(
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
and(
eq(hostAccess.hostId, hostId),
or(
eq(hostAccess.userId, userId),
roleIds.length > 0
? sql`${hostAccess.roleId} IN (${sql.join(
roleIds.map((id) => sql`${id}`),
sql`, `,
)})`
: sql`false`,
),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
),
);
if (sharedHosts.length === 0) {
@@ -349,6 +374,8 @@ app.delete("/activity/reset", async (req, res) => {
}
});
app.use("/service-links", dashboardServiceLinksRouter);
const PORT = 30006;
app.listen(PORT, async () => {
try {
+5 -1
View File
@@ -1832,7 +1832,11 @@ if (frontendDist) {
);
app.use((req, res, next) => {
if (req.method === "GET" && req.accepts("html")) {
if (
req.method === "GET" &&
req.accepts("html") &&
!req.headers.authorization
) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
+31
View File
@@ -694,6 +694,7 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
@@ -753,6 +754,11 @@ const migrateSchema = () => {
"enable_file_manager",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"scp_legacy",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
addColumnIfNotExists(
"ssh_data",
@@ -1197,6 +1203,14 @@ const migrateSchema = () => {
{ column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" },
{ column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" },
{ column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" },
{ column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "wol_broadcast_address", sql: "ALTER TABLE ssh_data ADD COLUMN wol_broadcast_address TEXT" },
{ column: "use_warpgate", sql: "ALTER TABLE ssh_data ADD COLUMN use_warpgate INTEGER NOT NULL DEFAULT 0" },
{ column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
];
for (const migration of sshDataMigrations) {
@@ -1214,6 +1228,23 @@ const migrateSchema = () => {
}
}
// Migrate legacy authType="warpgate" hosts to useWarpgate=1 with authType="none"
try {
const result = sqlite
.prepare("UPDATE ssh_data SET use_warpgate = 1, auth_type = 'none' WHERE auth_type = 'warpgate'")
.run();
if (result.changes > 0) {
databaseLogger.info(`Migrated ${result.changes} host(s) from authType='warpgate' to useWarpgate=true`, {
operation: "warpgate_auth_migration",
});
}
} catch (e) {
databaseLogger.warn("Failed to migrate legacy warpgate authType hosts", {
operation: "warpgate_auth_migration",
error: e,
});
}
// Copy unencrypted username/domain into protocol-specific columns for old guac hosts.
// Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
const usernameDomainBackfills = [
+25
View File
@@ -94,6 +94,7 @@ export const hosts = sqliteTable("ssh_data", {
tags: text("tags"),
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false),
forceKeyboardInteractive: text("force_keyboard_interactive"),
password: text("password"),
@@ -127,6 +128,7 @@ export const hosts = sqliteTable("ssh_data", {
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
scpLegacy: integer("scp_legacy", { mode: "boolean" }).notNull().default(false),
enableDocker: integer("enable_docker", { mode: "boolean" })
.notNull()
.default(false),
@@ -168,17 +170,24 @@ export const hosts = sqliteTable("ssh_data", {
vncPort: integer("vnc_port").default(5900),
telnetPort: integer("telnet_port").default(23),
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpUser: text("rdp_user"),
rdpPassword: text("rdp_password"),
rdpDomain: text("rdp_domain"),
rdpSecurity: text("rdp_security"),
rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false),
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
vncPassword: text("vnc_password"),
vncUser: text("vnc_user"),
telnetUser: text("telnet_user"),
telnetPassword: text("telnet_password"),
telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpAuthType: text("rdp_auth_type"),
vncAuthType: text("vnc_auth_type"),
telnetAuthType: text("telnet_auth_type"),
domain: text("domain"),
security: text("security"),
@@ -193,6 +202,7 @@ export const hosts = sqliteTable("ssh_data", {
socks5ProxyChain: text("socks5_proxy_chain"),
macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"),
hostKeyFingerprint: text("host_key_fingerprint"),
@@ -705,6 +715,8 @@ export const userPreferences = sqliteTable("user_preferences", {
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -763,6 +775,19 @@ export const hostHealthHistory = sqliteTable("host_health_history", {
detail: text("detail"),
});
export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
label: text("label").notNull(),
url: text("url").notNull(),
order: integer("order").notNull().default(0),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- tmux-monitor begin ---
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -0,0 +1,399 @@
import { execSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
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";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
DATA_DIR,
"ssl",
"cloudflare.ini",
);
export type AcmeSettings = {
enabled: boolean;
domain: string;
email: string;
challengeType: "http-webroot" | "dns-cloudflare";
cloudflareToken: string;
lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired";
certExpiresAt: string | null;
};
function getCertInfo(): {
status: "none" | "valid" | "expiring" | "expired";
expiresAt: string | null;
} {
const certFile = path.join(SSL_DIR, "termix.crt");
try {
execSync(`openssl x509 -in "${certFile}" -noout 2>/dev/null`, {
stdio: "pipe",
});
} catch {
return { status: "none", expiresAt: null };
}
try {
const endDateRaw = execSync(
`openssl x509 -in "${certFile}" -noout -enddate`,
{ stdio: "pipe" },
)
.toString()
.trim()
.replace("notAfter=", "");
const expiresAt = new Date(endDateRaw).toISOString();
try {
execSync(`openssl x509 -in "${certFile}" -checkend 0 -noout`, {
stdio: "pipe",
});
} catch {
return { status: "expired", expiresAt };
}
try {
execSync(`openssl x509 -in "${certFile}" -checkend 2592000 -noout`, {
stdio: "pipe",
});
return { status: "valid", expiresAt };
} catch {
return { status: "expiring", expiresAt };
}
} catch {
return { status: "none", expiresAt: null };
}
}
function getAcmeSettingsFromDb(): AcmeSettings {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
.get() as { value: string } | undefined;
const { status, expiresAt } = getCertInfo();
const stored = row ? JSON.parse(row.value) : {};
return {
enabled: stored.enabled ?? false,
domain: stored.domain ?? "",
email: stored.email ?? "",
challengeType: stored.challengeType ?? "http-webroot",
cloudflareToken: stored.cloudflareToken
? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}`
: "",
lastIssuedAt: stored.lastIssuedAt ?? null,
certStatus: status,
certExpiresAt: expiresAt,
};
}
export function registerAcmeSSLRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/acme-ssl-settings:
* get:
* summary: Get ACME SSL settings
* description: Returns current ACME/Let's Encrypt configuration and certificate status.
* tags:
* - Users
* responses:
* 200:
* description: ACME SSL settings and certificate status.
* 500:
* description: Failed to get ACME SSL settings.
*/
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
try {
res.json(getAcmeSettingsFromDb());
} catch (err) {
authLogger.error("Failed to get ACME SSL settings", err);
res.status(500).json({ error: "Failed to get ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-settings:
* patch:
* summary: Update ACME SSL settings (admin only)
* description: Saves ACME/Let's Encrypt configuration.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* domain:
* type: string
* email:
* type: string
* challengeType:
* type: string
* enum: [http-webroot, dns-cloudflare]
* cloudflareToken:
* type: string
* responses:
* 200:
* description: ACME SSL settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update ACME SSL settings.
*/
router.patch("/acme-ssl-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 existing = db.$client
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
.get() as { value: string } | undefined;
const current = existing ? JSON.parse(existing.value) : {};
const { enabled, domain, email, challengeType, cloudflareToken } =
req.body;
const updated = {
...current,
...(typeof enabled === "boolean" && { enabled }),
...(typeof domain === "string" && { domain }),
...(typeof email === "string" && { email }),
...(typeof challengeType === "string" && { challengeType }),
...(typeof cloudflareToken === "string" &&
cloudflareToken &&
!cloudflareToken.includes("*") && { cloudflareToken }),
};
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)",
)
.run(JSON.stringify(updated));
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "update_acme_ssl_settings",
resourceType: "setting",
details: JSON.stringify({
enabled,
domain,
email,
challengeType,
hasCloudflareToken: !!updated.cloudflareToken,
}),
ipAddress,
userAgent,
success: true,
});
res.json(getAcmeSettingsFromDb());
} catch (err) {
authLogger.error("Failed to update ACME SSL settings", err);
res.status(500).json({ error: "Failed to update ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-request:
* post:
* summary: Request or renew Let's Encrypt certificate (admin only)
* description: Triggers certbot to issue or renew a certificate using the configured challenge method.
* tags:
* - Users
* responses:
* 200:
* description: Certificate issued or renewed successfully.
* 400:
* description: Invalid configuration.
* 403:
* description: Not authorized.
* 500:
* description: Certificate issuance failed.
*/
router.post("/acme-ssl-request", 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 row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
.get() as { value: string } | undefined;
if (!row) {
return res.status(400).json({ error: "ACME settings not configured" });
}
const settings = JSON.parse(row.value);
const { domain, email, challengeType, cloudflareToken } = settings;
if (!domain || !email) {
return res.status(400).json({ error: "Domain and email are required" });
}
try {
execSync("certbot --version", { stdio: "pipe" });
} catch {
return res
.status(500)
.json({ error: "certbot is not available in this environment" });
}
await fs.mkdir(SSL_DIR, { recursive: true });
await fs.mkdir(ACME_WEBROOT, { recursive: true });
let certbotCmd: string;
if (challengeType === "dns-cloudflare") {
if (!cloudflareToken) {
return res.status(400).json({
error: "Cloudflare API token is required for DNS challenge",
});
}
await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), {
recursive: true,
});
await fs.writeFile(
CLOUDFLARE_CREDENTIALS_FILE,
`dns_cloudflare_api_token = ${cloudflareToken}\n`,
{ mode: 0o600 },
);
certbotCmd = [
"certbot",
"certonly",
"--non-interactive",
"--agree-tos",
"--dns-cloudflare",
`--dns-cloudflare-credentials "${CLOUDFLARE_CREDENTIALS_FILE}"`,
"--dns-cloudflare-propagation-seconds",
"30",
"-d",
`"${domain}"`,
"--email",
`"${email}"`,
"--cert-name",
"termix",
].join(" ");
} else {
certbotCmd = [
"certbot",
"certonly",
"--non-interactive",
"--agree-tos",
"--webroot",
"-w",
`"${ACME_WEBROOT}"`,
"-d",
`"${domain}"`,
"--email",
`"${email}"`,
"--cert-name",
"termix",
].join(" ");
}
authLogger.info("Requesting Let's Encrypt certificate", {
domain,
challengeType,
operation: "acme_cert_request",
});
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
const liveDir = `/etc/letsencrypt/live/termix`;
const fullchainSrc = path.join(liveDir, "fullchain.pem");
const privkeySrc = path.join(liveDir, "privkey.pem");
const certDest = path.join(SSL_DIR, "termix.crt");
const keyDest = path.join(SSL_DIR, "termix.key");
await fs.copyFile(fullchainSrc, certDest);
await fs.copyFile(privkeySrc, keyDest);
await fs.chmod(keyDest, 0o600);
await fs.chmod(certDest, 0o644);
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)",
)
.run(JSON.stringify(updated));
authLogger.info("Let's Encrypt certificate issued and installed", {
domain,
operation: "acme_cert_installed",
});
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ domain, challengeType, success: true }),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true, ...getAcmeSettingsFromDb() });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
authLogger.error("ACME certificate request failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ error: message }),
ipAddress,
userAgent,
success: false,
});
res.status(500).json({ error: `Certificate request failed: ${message}` });
}
});
}
+7 -3
View File
@@ -154,7 +154,9 @@ router.post(
error: keyInfo.error,
});
return res.status(400).json({
error: `Invalid SSH key: ${keyInfo.error}`,
error: keyInfo.error
? `Invalid SSH key: ${keyInfo.error}`
: "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
});
}
}
@@ -525,7 +527,9 @@ router.put(
error: keyInfo.error,
});
return res.status(400).json({
error: `Invalid SSH key: ${keyInfo.error}`,
error: keyInfo.error
? `Invalid SSH key: ${keyInfo.error}`
: "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
});
}
updateFields.privateKey = keyInfo.privateKey;
@@ -986,7 +990,7 @@ function formatSSHHostOutput(
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
enableFileManager: !!host.enableFileManager,
enableFileManager: host.enableFileManager !== false,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
@@ -0,0 +1,281 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import { and, asc, eq } from "drizzle-orm";
import { dashboardLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { dashboardServiceLinks } from "../db/schema.js";
import { isNonEmptyString } from "./host-normalizers.js";
import express from "express";
export const dashboardServiceLinksRouter = express.Router();
function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/**
* @openapi
* /service-links:
* get:
* summary: Get service links
* description: Returns all dashboard service links for the authenticated user.
* tags:
* - Dashboard
* responses:
* 200:
* description: List of service links.
* 500:
* description: Failed to fetch service links.
*/
dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const links = await db
.select()
.from(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
res.json(links);
} catch (err) {
dashboardLogger.error("Failed to fetch service links", err);
res.status(500).json({ error: "Failed to fetch service links" });
}
});
/**
* @openapi
* /service-links:
* post:
* summary: Create service link
* description: Creates a new dashboard service link for the authenticated user.
* tags:
* - Dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - label
* - url
* properties:
* label:
* type: string
* url:
* type: string
* responses:
* 201:
* description: Service link created.
* 400:
* description: Invalid data.
* 500:
* description: Failed to create service link.
*/
dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { label, url } = req.body;
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
return res.status(400).json({ error: "label and url are required" });
}
if (!isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const existing = await db
.select({ order: dashboardServiceLinks.order })
.from(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.orderBy(asc(dashboardServiceLinks.order));
const nextOrder =
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
const [created] = await db
.insert(dashboardServiceLinks)
.values({
userId,
label: label.trim(),
url: url.trim(),
order: nextOrder,
createdAt: new Date().toISOString(),
})
.returning();
res.status(201).json(created);
} catch (err) {
dashboardLogger.error("Failed to create service link", err);
res.status(500).json({ error: "Failed to create service link" });
}
});
/**
* @openapi
* /service-links/{id}:
* delete:
* summary: Delete service link
* description: Deletes a dashboard service link by ID.
* tags:
* - Dashboard
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Service link deleted.
* 400:
* description: Invalid id.
* 404:
* description: Not found.
* 500:
* description: Failed to delete service link.
*/
dashboardServiceLinksRouter.delete(
"/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const idParam = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(idParam);
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
try {
const existing = await db
.select()
.from(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Not found" });
}
await db
.delete(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
res.json({ message: "Service link deleted" });
} catch (err) {
dashboardLogger.error("Failed to delete service link", err);
res.status(500).json({ error: "Failed to delete service link" });
}
},
);
/**
* @openapi
* /service-links/{id}:
* put:
* summary: Update service link
* description: Updates label or url of a dashboard service link.
* tags:
* - Dashboard
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* label:
* type: string
* url:
* type: string
* responses:
* 200:
* description: Service link updated.
* 400:
* description: Invalid data.
* 404:
* description: Not found.
* 500:
* description: Failed to update service link.
*/
dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const idParam = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(idParam);
const { label, url } = req.body;
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
if (url !== undefined && !isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const existing = await db
.select()
.from(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Not found" });
}
const updates: Partial<{ label: string; url: string }> = {};
if (isNonEmptyString(label)) updates.label = label.trim();
if (isNonEmptyString(url)) updates.url = url.trim();
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "Nothing to update" });
}
const [updated] = await db
.update(dashboardServiceLinks)
.set(updates)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
)
.returning();
res.json(updated);
} catch (err) {
dashboardLogger.error("Failed to update service link", err);
res.status(500).json({ error: "Failed to update service link" });
}
});
@@ -0,0 +1,104 @@
import { describe, it, expect } from "vitest";
import { parseSSHConfig } from "./host-bulk-routes.js";
describe("parseSSHConfig", () => {
it("parses a basic Host block", () => {
const config = `
Host myserver
HostName 192.168.1.10
User alice
Port 2222
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: "myserver",
hostname: "192.168.1.10",
user: "alice",
port: 2222,
});
});
it("parses multiple Host blocks", () => {
const config = `
Host web
HostName web.example.com
User deploy
Host db
HostName db.example.com
User postgres
Port 5432
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("web");
expect(result[1].name).toBe("db");
expect(result[1].port).toBe(5432);
});
it("ignores comment lines", () => {
const config = `
# This is a comment
Host server
# Another comment
HostName 10.0.0.1
User root
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].hostname).toBe("10.0.0.1");
});
it("skips wildcard Host entries", () => {
const config = `
Host *
ServerAliveInterval 60
Host prod
HostName prod.example.com
User ubuntu
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("prod");
});
it("captures IdentityFile and ProxyJump", () => {
const config = `
Host bastion
HostName bastion.example.com
User ec2-user
IdentityFile ~/.ssh/id_rsa
ProxyJump jumphost.example.com
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].identityFile).toBe("~/.ssh/id_rsa");
expect(result[0].proxyJump).toBe("jumphost.example.com");
});
it("skips Host blocks without a HostName", () => {
const config = `
Host alias-only
User foo
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(0);
});
it("defaults port to undefined when not specified", () => {
const config = `
Host server
HostName 1.2.3.4
User root
`;
const result = parseSSHConfig(config);
expect(result[0].port).toBeUndefined();
});
it("returns empty array for empty input", () => {
expect(parseSSHConfig("")).toHaveLength(0);
expect(parseSSHConfig(" \n\n ")).toHaveLength(0);
});
});
@@ -11,6 +11,68 @@ import {
normalizeImportedHost,
} from "./host-normalizers.js";
type SSHConfigHost = {
name: string;
hostname?: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
};
export function parseSSHConfig(content: string): SSHConfigHost[] {
const results: SSHConfigHost[] = [];
let current: SSHConfigHost | null = null;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const spaceIdx = line.indexOf(" ");
if (spaceIdx === -1) continue;
const key = line.slice(0, spaceIdx).toLowerCase();
const value = line.slice(spaceIdx + 1).trim();
if (key === "host") {
if (current && current.hostname) results.push(current);
// Skip wildcard patterns
if (value === "*" || value.includes("*") || value.includes("?")) {
current = null;
} else {
current = { name: value };
}
continue;
}
if (!current) continue;
switch (key) {
case "hostname":
current.hostname = value;
break;
case "user":
current.user = value;
break;
case "port": {
const p = Number.parseInt(value, 10);
if (p > 0 && p <= 65535) current.port = p;
break;
}
case "identityfile":
if (!current.identityFile) current.identityFile = value;
break;
case "proxyjump":
current.proxyJump = value;
break;
}
}
if (current && current.hostname) results.push(current);
return results;
}
export function registerHostBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
@@ -363,6 +425,12 @@ export function registerHostBulkRoutes(
if (fallback.length > 0) {
hostData.credentialId = fallback[0].id;
} else if (isNonEmptyString(hostData.key)) {
hostData.authType = "key";
hostData.credentialId = undefined;
} else if (isNonEmptyString(hostData.password)) {
hostData.authType = "password";
hostData.credentialId = undefined;
} else {
results.failed++;
results.errors.push(
@@ -519,4 +587,212 @@ export function registerHostBulkRoutes(
});
},
);
/**
* @openapi
* /host/ssh-config-import:
* post:
* summary: Import hosts from an OpenSSH config file
* description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - content
* properties:
* content:
* type: string
* description: Raw text content of the SSH config file.
* overwrite:
* type: boolean
* responses:
* 200:
* description: Import completed.
* 400:
* description: Invalid request body.
*/
router.post(
"/ssh-config-import",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { content, overwrite } = req.body;
if (!isNonEmptyString(content)) {
return res.status(400).json({
error: "content is required and must be a non-empty string",
});
}
let parsed: SSHConfigHost[];
try {
parsed = parseSSHConfig(content);
} catch (err) {
return res
.status(400)
.json({ error: "Failed to parse SSH config file" });
}
if (parsed.length === 0) {
return res.status(400).json({
error: "No valid Host entries found in the SSH config file",
});
}
if (parsed.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
}
const hostsToImport = parsed.map((h) => ({
name: h.name,
ip: h.hostname,
port: h.port ?? 22,
username: h.user,
authType: h.identityFile ? "key" : undefined,
connectionType: "ssh",
enableSsh: true,
...(h.proxyJump
? {
jumpHosts: [{ host: h.proxyJump, port: 22 }],
}
: {}),
}));
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] as Record<string, unknown>,
);
try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
);
continue;
}
const sshDataObj: Record<string, unknown> = {
userId,
connectionType: "ssh",
name: hostData.name || hostData.ip,
folder: "Default",
tags: "",
ip: hostData.ip,
port: hostData.port,
username: hostData.username || null,
authType: hostData.authType || "none",
password: null,
key: null,
keyPassword: null,
keyType: null,
credentialId: null,
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
enableDocker: false,
enableProxmox: false,
enableTmuxMonitor: false,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
defaultPath: "/",
sudoPassword: null,
tunnelConnections: "[]",
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: null,
statsConfig: null,
dockerConfig: null,
proxmoxConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
notes: null,
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
overrideCredentialUsername: 0,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
updatedAt: new Date().toISOString(),
};
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 "${parsed[i].name}": ${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,
});
},
);
}
@@ -82,7 +82,7 @@ export function registerHostInternalRoutes(router: Router): void {
),
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: !!host.enableFileManager,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
@@ -155,7 +155,7 @@ export function registerHostInternalRoutes(router: Router): void {
tunnelConnections: tunnelConnections,
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: !!host.enableFileManager,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
@@ -101,7 +101,10 @@ export function registerHostNetworkRoutes(
try {
const host = await db
.select({ macAddress: hosts.macAddress })
.select({
macAddress: hosts.macAddress,
wolBroadcastAddress: hosts.wolBroadcastAddress,
})
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.then((rows) => rows[0]);
@@ -116,7 +119,10 @@ export function registerHostNetworkRoutes(
.json({ error: "No valid MAC address configured" });
}
await sendWakeOnLan(host.macAddress);
await sendWakeOnLan(
host.macAddress,
host.wolBroadcastAddress ?? undefined,
);
sshLogger.info("Wake-on-LAN packet sent", {
operation: "wake_on_lan",
@@ -237,7 +237,7 @@ export function transformHostResponse(
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: !!host.enableFileManager,
enableFileManager: host.enableFileManager !== false,
enableDocker: !!host.enableDocker,
enableProxmox: !!host.enableProxmox,
enableTmuxMonitor: !!host.enableTmuxMonitor,
@@ -293,6 +293,7 @@ export function transformHostResponse(
? JSON.parse(host.proxmoxConfig as string)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
useWarpgate: !!host.useWarpgate,
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: [],
+57 -12
View File
@@ -144,6 +144,7 @@ router.post(
password,
authMethod,
authType,
useWarpgate,
credentialId,
key,
keyPassword,
@@ -153,6 +154,7 @@ router.post(
enableTerminal,
enableTunnel,
enableFileManager,
scpLegacy,
enableDocker,
enableProxmox,
enableTmuxMonitor,
@@ -184,6 +186,7 @@ router.post(
portKnockSequence,
overrideCredentialUsername,
macAddress,
wolBroadcastAddress,
enableSsh,
enableRdp,
enableVnc,
@@ -243,6 +246,7 @@ router.post(
port,
username: effectiveUsername,
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
@@ -256,6 +260,7 @@ router.post(
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
@@ -301,6 +306,7 @@ router.post(
? JSON.stringify(socks5ProxyChain)
: null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -713,6 +719,7 @@ router.put(
password,
authMethod,
authType,
useWarpgate,
credentialId,
key,
keyPassword,
@@ -722,6 +729,7 @@ router.put(
enableTerminal,
enableTunnel,
enableFileManager,
scpLegacy,
enableDocker,
enableProxmox,
enableTmuxMonitor,
@@ -753,6 +761,7 @@ router.put(
portKnockSequence,
overrideCredentialUsername,
macAddress,
wolBroadcastAddress,
enableSsh,
enableRdp,
enableVnc,
@@ -809,6 +818,7 @@ router.put(
port,
username: effectiveUsername,
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
@@ -822,6 +832,7 @@ router.put(
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
@@ -867,6 +878,7 @@ router.put(
? JSON.stringify(socks5ProxyChain)
: null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -952,10 +964,9 @@ router.put(
sshDataObj.keyType = null;
}
if (rdpPassword !== undefined) sshDataObj.rdpPassword = rdpPassword || null;
if (vncPassword !== undefined) sshDataObj.vncPassword = vncPassword || null;
if (telnetPassword !== undefined)
sshDataObj.telnetPassword = telnetPassword || null;
if (rdpPassword) sshDataObj.rdpPassword = rdpPassword;
if (vncPassword) sshDataObj.vncPassword = vncPassword;
if (telnetPassword) sshDataObj.telnetPassword = telnetPassword;
try {
const accessInfo = await permissionManager.canAccessHost(
@@ -988,6 +999,8 @@ router.put(
.select({
userId: hosts.userId,
credentialId: hosts.credentialId,
rdpCredentialId: hosts.rdpCredentialId,
vncCredentialId: hosts.vncCredentialId,
authType: hosts.authType,
})
.from(hosts)
@@ -1025,11 +1038,26 @@ router.put(
});
}
if (sshDataObj.credentialId !== undefined) {
if (
hostRecord[0].credentialId !== null &&
sshDataObj.credentialId === null
) {
{
const newCredId =
sshDataObj.credentialId !== undefined
? sshDataObj.credentialId
: hostRecord[0].credentialId;
const newRdpCredId =
sshDataObj.rdpCredentialId !== undefined
? sshDataObj.rdpCredentialId
: hostRecord[0].rdpCredentialId;
const newVncCredId =
sshDataObj.vncCredentialId !== undefined
? sshDataObj.vncCredentialId
: hostRecord[0].vncCredentialId;
const hadCredential =
hostRecord[0].credentialId !== null ||
hostRecord[0].rdpCredentialId !== null ||
hostRecord[0].vncCredentialId !== null;
const willHaveCredential =
newCredId !== null || newRdpCredId !== null || newVncCredId !== null;
if (hadCredential && !willHaveCredential) {
await db
.delete(hostAccess)
.where(eq(hostAccess.hostId, Number(hostId)));
@@ -1169,6 +1197,7 @@ router.get(
tunnelConnections: hosts.tunnelConnections,
jumpHosts: hosts.jumpHosts,
enableFileManager: hosts.enableFileManager,
scpLegacy: hosts.scpLegacy,
defaultPath: hosts.defaultPath,
autostartPassword: hosts.autostartPassword,
autostartKey: hosts.autostartKey,
@@ -1203,6 +1232,7 @@ router.get(
ignoreCert: hosts.ignoreCert,
guacamoleConfig: hosts.guacamoleConfig,
macAddress: hosts.macAddress,
wolBroadcastAddress: hosts.wolBroadcastAddress,
dockerConfig: hosts.dockerConfig,
proxmoxConfig: hosts.proxmoxConfig,
enableSsh: hosts.enableSsh,
@@ -1213,11 +1243,13 @@ router.get(
rdpPort: hosts.rdpPort,
vncPort: hosts.vncPort,
telnetPort: hosts.telnetPort,
rdpCredentialId: hosts.rdpCredentialId,
rdpUser: hosts.rdpUser,
rdpPassword: hosts.rdpPassword,
rdpDomain: hosts.rdpDomain,
rdpSecurity: hosts.rdpSecurity,
rdpIgnoreCert: hosts.rdpIgnoreCert,
vncCredentialId: hosts.vncCredentialId,
vncUser: hosts.vncUser,
vncPassword: hosts.vncPassword,
telnetUser: hosts.telnetUser,
@@ -1445,7 +1477,19 @@ router.get(
const host = data[0];
const resolved = (await resolveHostCredentials(host, userId)) || host;
const value = resolved[field];
let value = resolved[field];
if (!value && field === "sudoPassword" && resolved.terminalConfig) {
try {
const tc =
typeof resolved.terminalConfig === "string"
? JSON.parse(resolved.terminalConfig)
: resolved.terminalConfig;
value = tc?.sudoPassword || null;
} catch {
// malformed JSON — leave value null
}
}
if (!value) {
return res.status(404).json({ error: "No password set" });
@@ -1574,7 +1618,8 @@ router.get(
!!resolvedHost.overrideCredentialUsername,
enableTerminal: !!resolvedHost.enableTerminal,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
enableFileManager: resolvedHost.enableFileManager !== false,
scpLegacy: !!resolvedHost.scpLegacy,
enableDocker: !!resolvedHost.enableDocker,
enableProxmox: !!resolvedHost.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
@@ -1722,7 +1767,7 @@ router.get(
!!resolvedHost.overrideCredentialUsername,
enableTerminal: !!resolvedHost.enableTerminal,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
enableFileManager: resolvedHost.enableFileManager !== false,
enableDocker: !!resolvedHost.enableDocker,
enableProxmox: !!resolvedHost.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
@@ -2,7 +2,7 @@ import type { Router } from "express";
import type { LDAPProviderConfig } from "../../../types/index.js";
import { db } from "../db/index.js";
import { ssoProviders, users, roles, userRoles } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
@@ -298,14 +298,7 @@ export function registerLDAPAuthRoutes(router: Router): void {
const isFirst = (countRow?.count || 0) === 0;
if (!isFirst && !autoProvision) {
const regRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_registration'",
)
.get() as { value: string } | undefined;
if (regRow && regRow.value !== "true") {
return res.status(403).json({ error: "Registration is disabled" });
}
return res.status(403).json({ error: "Registration is disabled" });
}
userId = nanoid();
@@ -375,6 +368,39 @@ export function registerLDAPAuthRoutes(router: Router): void {
if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) {
await db.update(users).set({ isAdmin }).where(eq(users.id, userId));
existingUsers[0].isAdmin = isAdmin;
try {
const newRoleName = isAdmin ? "admin" : "user";
const oldRoleName = isAdmin ? "user" : "admin";
const newRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, newRoleName))
.limit(1);
const oldRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, oldRoleName))
.limit(1);
if (oldRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, userId),
eq(userRoles.roleId, oldRole[0].id),
),
);
}
if (newRole.length > 0) {
await db.insert(userRoles).values({
userId,
roleId: newRole[0].id,
grantedBy: userId,
});
}
} catch {
/* non-fatal */
}
}
// Update display name if not dual-auth
+19 -2
View File
@@ -23,7 +23,24 @@ const authenticateJWT = authManager.createAuthMiddleware();
* 200:
* description: List of open tabs ordered by tab_order.
*/
const TAB_TTL_MS = 30 * 60 * 1000;
const DEFAULT_TAB_TTL_MINUTES = 30;
function getTabTtlMs(): number {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
)
.get() as { value: string } | undefined;
if (row) {
const minutes = parseInt(row.value, 10);
if (!isNaN(minutes) && minutes > 0) return minutes * 60_000;
}
} catch {
// DB not available, use default
}
return DEFAULT_TAB_TTL_MINUTES * 60_000;
}
// Legacy tab types that were renamed. Normalize on read so previously saved
// tabs still restore to the correct (renamed) tab type.
@@ -38,7 +55,7 @@ function normalizeTabType(tabType: string): string {
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const cutoff = new Date(Date.now() - TAB_TTL_MS).toISOString();
const cutoff = new Date(Date.now() - getTabTtlMs()).toISOString();
const tabs = db
.select()
.from(userOpenTabs)
+35 -8
View File
@@ -129,7 +129,12 @@ router.post(
return res.status(403).json({ error: "Not host owner" });
}
if (!host[0].credentialId && host[0].authType !== "opkssh") {
if (
!host[0].credentialId &&
!host[0].rdpCredentialId &&
!host[0].vncCredentialId &&
host[0].authType !== "opkssh"
) {
return res.status(400).json({
error:
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
@@ -204,21 +209,25 @@ router.post(
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
if (host[0].credentialId) {
const activeCredentialId =
host[0].credentialId ??
host[0].rdpCredentialId ??
host[0].vncCredentialId;
if (activeCredentialId) {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
existing[0].id,
host[0].credentialId,
activeCredentialId,
targetUserId!,
userId,
);
} else {
await sharedCredManager.createSharedCredentialsForRole(
existing[0].id,
host[0].credentialId,
activeCredentialId,
targetRoleId!,
userId,
);
@@ -252,18 +261,22 @@ router.post(
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (host[0].credentialId) {
const activeCredentialId =
host[0].credentialId ??
host[0].rdpCredentialId ??
host[0].vncCredentialId;
if (activeCredentialId) {
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
result.lastInsertRowid as number,
host[0].credentialId,
activeCredentialId,
targetUserId!,
userId,
);
} else {
await sharedCredManager.createSharedCredentialsForRole(
result.lastInsertRowid as number,
host[0].credentialId,
activeCredentialId,
targetRoleId!,
userId,
);
@@ -487,6 +500,12 @@ router.get(
try {
const now = new Date().toISOString();
const userRoleIds = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleIds.map((r) => r.roleId);
const sharedHosts = await db
.select({
id: hosts.id,
@@ -506,7 +525,15 @@ router.get(
.innerJoin(users, eq(hosts.userId, users.id))
.where(
and(
eq(hostAccess.userId, userId),
or(
eq(hostAccess.userId, userId),
roleIds.length > 0
? sql`${hostAccess.roleId} IN (${sql.join(
roleIds.map((id) => sql`${id}`),
sql`, `,
)})`
: sql`false`,
),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
),
)
+262
View File
@@ -924,6 +924,268 @@ router.post(
},
);
/**
* @openapi
* /snippets/export:
* get:
* summary: Export all snippets and folders as JSON
* description: Returns all snippets and snippet folders for the authenticated user as a JSON export.
* tags:
* - Snippets
* responses:
* 200:
* description: Export object containing snippets and folders arrays.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to export snippets.
*/
router.get(
"/export",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippet export");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const allSnippets = await db
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(asc(snippets.folder), asc(snippets.order));
const allFolders = await db
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
const exportedSnippets = allSnippets.map((s) => ({
name: s.name,
content: s.content,
description: s.description,
folder: s.folder,
order: s.order,
hostFilter: s.hostFilter,
}));
const exportedFolders = allFolders.map((f) => ({
name: f.name,
color: f.color,
icon: f.icon,
}));
authLogger.success(`Snippets exported by user ${userId}`, {
operation: "snippet_export",
userId,
snippetCount: exportedSnippets.length,
folderCount: exportedFolders.length,
});
res.json({ snippets: exportedSnippets, folders: exportedFolders });
} catch (err) {
authLogger.error("Failed to export snippets", err);
res.status(500).json({ error: "Failed to export snippets" });
}
},
);
/**
* @openapi
* /snippets/bulk-import:
* post:
* summary: Bulk import snippets and folders from JSON
* description: Imports snippets and folders. Existing folders are skipped; existing snippets (matched by name+folder) can be skipped or overwritten.
* tags:
* - Snippets
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* snippets:
* type: array
* folders:
* type: array
* overwrite:
* type: boolean
* responses:
* 200:
* description: Import results with counts.
* 400:
* description: Invalid request body.
* 500:
* description: Failed to import snippets.
*/
router.post(
"/bulk-import",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
snippets: snippetsToImport,
folders: foldersToImport,
overwrite,
} = req.body;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!Array.isArray(snippetsToImport) && !Array.isArray(foldersToImport)) {
return res
.status(400)
.json({ error: "snippets or folders array is required" });
}
const results = {
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 0,
foldersImported: 0,
foldersSkipped: 0,
failed: 0,
errors: [] as string[],
};
try {
if (Array.isArray(foldersToImport)) {
for (const folder of foldersToImport) {
if (!isNonEmptyString(folder.name)) {
results.failed++;
results.errors.push(`Folder missing name`);
continue;
}
const existing = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, folder.name.trim()),
),
)
.limit(1);
if (existing.length > 0) {
results.foldersSkipped++;
continue;
}
await db.insert(snippetFolders).values({
userId,
name: folder.name.trim(),
color: folder.color?.trim() || null,
icon: folder.icon?.trim() || null,
});
results.foldersImported++;
}
}
if (Array.isArray(snippetsToImport)) {
for (let i = 0; i < snippetsToImport.length; i++) {
const s = snippetsToImport[i];
if (!isNonEmptyString(s.name) || !isNonEmptyString(s.content)) {
results.failed++;
results.errors.push(
`Snippet ${i + 1}: name and content are required`,
);
continue;
}
const folderVal = s.folder?.trim() || null;
const existing = await db
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
eq(snippets.name, s.name.trim()),
folderVal
? eq(snippets.folder, folderVal)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
)
.limit(1);
if (existing.length > 0) {
if (!overwrite) {
results.snippetsSkipped++;
continue;
}
await db
.update(snippets)
.set({
content: s.content.trim(),
description: s.description?.trim() || null,
folder: folderVal,
order:
typeof s.order === "number" ? s.order : existing[0].order,
hostFilter: s.hostFilter || null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(
eq(snippets.id, existing[0].id),
eq(snippets.userId, userId),
),
);
results.snippetsUpdated++;
continue;
}
const maxOrderResult = await db
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folderVal
? eq(snippets.folder, folderVal)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
await db.insert(snippets).values({
userId,
name: s.name.trim(),
content: s.content.trim(),
description: s.description?.trim() || null,
folder: folderVal,
order: typeof s.order === "number" ? s.order : maxOrder + 1,
hostFilter: s.hostFilter || null,
});
results.snippetsImported++;
}
}
authLogger.success(`Snippets bulk-imported by user ${userId}`, {
operation: "snippet_bulk_import",
userId,
...results,
});
res.json({ success: true, ...results });
} catch (err) {
authLogger.error("Failed to bulk import snippets", err);
res.status(500).json({ error: "Failed to import snippets" });
}
},
);
/**
* @openapi
* /snippets:
@@ -9,6 +9,7 @@ import { eq, asc } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { SSOProviderType } from "../../../types/index.js";
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
const authManager = AuthManager.getInstance();
@@ -117,6 +118,16 @@ export function registerSSOProviderRoutes(router: Router): void {
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
// If no DB providers exist, synthesize one from env vars so SSO login
// remains available when configured purely via environment variables.
if (providers.length === 0) {
const envConfig = getOIDCConfigFromEnv();
if (envConfig) {
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
}
}
res.json(providers);
} catch (err) {
authLogger.error("Failed to list SSO providers", err);
@@ -1,10 +1,13 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { users, roles, userRoles } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { AuthManager } from "../../utils/auth-manager.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
@@ -139,6 +142,50 @@ export function registerUserAdminRoutes(
: eq(users.username, resolvedUsername!),
);
try {
const targetId = targetUser[0].id;
const adminRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "admin"))
.limit(1);
const userRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "user"))
.limit(1);
if (adminRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, adminRole[0].id),
),
);
await db.insert(userRoles).values({
userId: targetId,
roleId: adminRole[0].id,
grantedBy: userId,
});
}
if (userRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, userRole[0].id),
),
);
}
} catch (roleError) {
authLogger.error("Failed to sync admin role on make-admin", roleError, {
operation: "make_admin_role_sync",
userId: targetUser[0].id,
});
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
@@ -277,6 +324,54 @@ export function registerUserAdminRoutes(
: eq(users.username, resolvedUsername!),
);
try {
const targetId = targetUser[0].id;
const adminRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "admin"))
.limit(1);
const userRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "user"))
.limit(1);
if (adminRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, adminRole[0].id),
),
);
}
if (userRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, userRole[0].id),
),
);
await db.insert(userRoles).values({
userId: targetId,
roleId: userRole[0].id,
grantedBy: userId,
});
}
} catch (roleError) {
authLogger.error(
"Failed to sync user role on remove-admin",
roleError,
{
operation: "remove_admin_role_sync",
userId: targetUser[0].id,
},
);
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
@@ -321,4 +416,184 @@ export function registerUserAdminRoutes(
res.status(500).json({ error: "Failed to remove admin status" });
}
});
/**
* @openapi
* /users/admin-create:
* post:
* summary: Admin create user
* description: Allows an admin to create a new user regardless of whether public registration is enabled.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* username:
* type: string
* password:
* type: string
* responses:
* 200:
* description: User created successfully.
* 400:
* description: Username and password are required.
* 403:
* description: Not authorized.
* 409:
* description: Username already exists.
* 500:
* description: Failed to create user.
*/
router.post("/admin-create", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, adminId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
} catch (err) {
authLogger.error("Failed to verify admin status", err);
return res.status(500).json({ error: "Failed to verify admin status" });
}
const { username, password } = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Username and password are required" });
}
try {
const existing = await db
.select()
.from(users)
.where(eq(users.username, username));
if (existing && existing.length > 0) {
return res.status(409).json({ error: "Username already exists" });
}
const password_hash = await bcrypt.hash(password, 10);
const id = nanoid();
db.$client.transaction(() => {
db.$client
.prepare(
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.run(
id,
username,
password_hash,
0,
0,
"",
"",
"",
"",
"",
"",
"",
"openid email profile",
null,
0,
null,
);
})();
try {
const userRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "user"))
.limit(1);
if (userRole.length > 0) {
await db.insert(userRoles).values({
userId: id,
roleId: userRole[0].id,
grantedBy: adminId,
});
}
} catch (roleError) {
authLogger.error(
"Failed to assign default role during admin create",
roleError,
{
operation: "admin_create_user_role",
userId: id,
},
);
}
const authManager = AuthManager.getInstance();
try {
await authManager.registerUser(id, password);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, id));
authLogger.error(
"Failed to setup user encryption during admin create, rolled back",
encryptionError,
{ operation: "admin_create_user_encryption_failed", userId: id },
);
return res.status(500).json({
error: "Failed to setup user security - user creation cancelled",
});
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error(
"Failed to persist admin-created user to disk",
saveError,
{
operation: "admin_create_user_save_failed",
userId: id,
},
);
}
authLogger.success("User created by admin", {
operation: "admin_create_user_success",
adminId,
userId: id,
username,
});
const { ipAddress, userAgent } = getRequestMeta(req);
const adminRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, adminId))
.limit(1);
await logAudit({
userId: adminId,
username: adminRecord[0]?.username ?? adminId,
action: "create_user",
resourceType: "user",
resourceId: id,
resourceName: username,
ipAddress,
userAgent,
success: true,
});
res.json({
message: "User created",
toast: { type: "success", message: `User created: ${username}` },
});
} catch (err) {
authLogger.error("Failed to admin-create user", err);
res.status(500).json({ error: "Failed to create user" });
}
});
}
@@ -61,6 +61,23 @@ describe("isOIDCUserAllowed", () => {
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
});
it("matches *@domain.com wildcard pattern against emails", () => {
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
).toBe(true);
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
).toBe(true);
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
false,
);
});
it("matches glob patterns with multiple wildcards", () => {
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
});
});
describe("getOIDCConfigFromEnv", () => {
+75 -6
View File
@@ -4,6 +4,7 @@ import { db } from "../db/index.js";
import { ssoProviders } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { DataCrypto } from "../../utils/data-crypto.js";
import { Agent } from "undici";
export type OIDCConfig = {
client_id: string;
@@ -18,8 +19,14 @@ export type OIDCConfig = {
allowed_users: string;
admin_group: string;
group_claim?: string;
ca_cert?: string;
};
export function buildFetchOptions(caCert?: string): Record<string, unknown> {
if (!caCert || !caCert.trim()) return {};
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
}
export function getOIDCConfigFromEnv(): OIDCConfig | null {
const client_id = process.env.OIDC_CLIENT_ID;
const client_secret = process.env.OIDC_CLIENT_SECRET;
@@ -107,6 +114,15 @@ export function isOIDCUserAllowed(
];
for (const pattern of patterns) {
if (pattern === "*") return true;
if (pattern.includes("*")) {
const escaped = pattern
.toLowerCase()
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`);
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
continue;
}
for (const value of values) {
if (!value) continue;
if (pattern.toLowerCase().startsWith("@")) {
@@ -123,7 +139,9 @@ export async function verifyOIDCToken(
idToken: string,
issuerUrl: string,
clientId: string,
caCert?: string,
): Promise<Record<string, unknown>> {
const fetchOptions = buildFetchOptions(caCert);
const normalizedIssuerUrl = issuerUrl.endsWith("/")
? issuerUrl.slice(0, -1)
: issuerUrl;
@@ -142,7 +160,7 @@ export async function verifyOIDCToken(
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
if (discoveryResponse.ok) {
const discovery = (await discoveryResponse.json()) as Record<
string,
@@ -160,7 +178,7 @@ export async function verifyOIDCToken(
for (const url of jwksUrls) {
try {
const response = await fetch(url);
const response = await fetch(url, fetchOptions);
if (response.ok) {
const jwksData = (await response.json()) as Record<string, unknown>;
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
@@ -214,6 +232,49 @@ export async function verifyOIDCToken(
return payload;
}
const GOOGLE_DEFAULTS = {
issuer_url: "https://accounts.google.com",
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
};
const GITHUB_DEFAULTS = {
issuer_url: "https://token.actions.githubusercontent.com",
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
userinfo_url: "https://api.github.com/user",
identifier_path: "id",
name_path: "name",
scopes: "read:user user:email",
};
function applyProviderDefaults(
config: OIDCConfig,
providerType: string,
): OIDCConfig {
const defaults =
providerType === "google"
? GOOGLE_DEFAULTS
: providerType === "github"
? GITHUB_DEFAULTS
: null;
if (!defaults) return config;
return {
...config,
issuer_url: config.issuer_url || defaults.issuer_url,
authorization_url: config.authorization_url || defaults.authorization_url,
token_url: config.token_url || defaults.token_url,
userinfo_url: config.userinfo_url || defaults.userinfo_url,
identifier_path: config.identifier_path || defaults.identifier_path,
name_path: config.name_path || defaults.name_path,
scopes: config.scopes || defaults.scopes,
};
}
function decryptConfigSecret(
config: Record<string, unknown>,
): Record<string, unknown> {
@@ -280,10 +341,14 @@ export async function loadProviderConfig(
} else {
parsed = decryptConfigSecret(parsed);
}
const config = parsed as unknown as OIDCConfig;
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
providerType,
);
return {
config,
providerType: row.type as SSOProviderType,
providerType,
providerDbId: row.id,
};
}
@@ -318,9 +383,13 @@ export async function loadProviderConfig(
parsed = {};
}
parsed = decryptConfigSecret(parsed);
const oidcProviderType = oidcRow.type as SSOProviderType;
return {
config: parsed as unknown as OIDCConfig,
providerType: oidcRow.type as SSOProviderType,
config: applyProviderDefaults(
parsed as unknown as OIDCConfig,
oidcProviderType,
),
providerType: oidcProviderType,
providerDbId: oidcRow.id,
};
}
@@ -63,12 +63,19 @@ export function registerUserPasswordResetRoutes(
*/
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") {
const envVal = process.env.ALLOW_PASSWORD_RESET;
const allowed =
envVal !== undefined
? envVal.trim().toLowerCase() === "true"
: (() => {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
)
.get();
return row ? (row as { value: string }).value === "true" : true;
})();
if (!allowed) {
return res
.status(403)
.json({ error: "Password reset is currently disabled" });
@@ -346,8 +353,7 @@ export function registerUserPasswordResetRoutes(
}
const userId = user[0].id;
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
const password_hash = await bcrypt.hash(newPassword, 10);
let userIdFromJwt: string | null = null;
const cookie = req.cookies?.jwt;
@@ -17,7 +17,7 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
fontSize: row?.fontSize ?? null,
accentColor: row?.accentColor ?? null,
language: row?.language ?? null,
storageMode: row?.storageMode ?? "local",
storageMode: row?.storageMode ?? "cloud",
commandAutocomplete: row?.commandAutocomplete ?? null,
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
showHostTags: row?.showHostTags ?? null,
@@ -28,6 +28,8 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
disableUpdateCheck: row?.disableUpdateCheck ?? null,
confirmTabClose: row?.confirmTabClose ?? null,
hiddenRailTabs: row?.hiddenRailTabs ?? null,
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
});
/**
@@ -92,6 +94,12 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
* hiddenRailTabs:
* type: string
* nullable: true
* compactHostView:
* type: boolean
* nullable: true
* statusColorScheme:
* type: string
* nullable: true
*/
router.get("/", authenticateJWT, (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -158,6 +166,10 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
* type: boolean
* hiddenRailTabs:
* type: string
* compactHostView:
* type: boolean
* statusColorScheme:
* type: string
* responses:
* 200:
* description: Preferences updated successfully.
@@ -181,6 +193,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
compactHostView,
statusColorScheme,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -198,6 +212,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
compactHostView?: boolean | null;
statusColorScheme?: string | null;
};
const updates: Partial<typeof userPreferences.$inferInsert> = {
@@ -220,6 +236,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
language,
storageMode,
hiddenRailTabs,
statusColorScheme,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
@@ -236,6 +253,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
compactHostView,
};
for (const [key, value] of Object.entries(boolFields)) {
if (value !== undefined && value !== null && typeof value !== "boolean") {
@@ -263,6 +281,9 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
if (disableUpdateCheck !== undefined)
updates.disableUpdateCheck = disableUpdateCheck;
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
if (statusColorScheme !== undefined)
updates.statusColorScheme = statusColorScheme;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
@@ -15,6 +15,24 @@ function getDefaultGuacUrl(): string {
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
}
export type HostDefaults = {
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
credentialId?: number | null;
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
fontSize?: number;
fontFamily?: string;
theme?: string;
cursorStyle?: string;
cursorBlink?: boolean;
enableSessionLogging?: boolean;
enableCommandHistory?: boolean;
};
export function registerUserSettingsRoutes(
router: Router,
authenticateJWT: RequestHandler,
@@ -544,4 +562,91 @@ export function registerUserSettingsRoutes(
}
},
);
/**
* @openapi
* /users/host-defaults:
* get:
* summary: Get host creation defaults
* description: Returns the global default settings applied when creating a new host.
* tags:
* - Users
* responses:
* 200:
* description: Host defaults object.
* 500:
* description: Failed to get host defaults.
*/
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'host_defaults'")
.get() as { value: string } | undefined;
const defaults: HostDefaults = row ? JSON.parse(row.value) : {};
res.json(defaults);
} catch (err) {
authLogger.error("Failed to get host defaults", err);
res.status(500).json({ error: "Failed to get host defaults" });
}
});
/**
* @openapi
* /users/host-defaults:
* patch:
* summary: Update host creation defaults (admin only)
* description: Sets global default settings applied when a new host is created.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Host defaults updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update host defaults.
*/
router.patch("/host-defaults", 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 defaults: HostDefaults = req.body;
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('host_defaults', ?)",
)
.run(JSON.stringify(defaults));
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "update_host_defaults",
resourceType: "setting",
details: JSON.stringify(defaults),
ipAddress,
userAgent,
success: true,
});
res.json(defaults);
} catch (err) {
authLogger.error("Failed to update host defaults", err);
res.status(500).json({ error: "Failed to update host defaults" });
}
});
}
+102 -17
View File
@@ -5,6 +5,7 @@ import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
import { AuthManager } from "../../utils/auth-manager.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
@@ -28,6 +29,7 @@ type TotpUserRecord = typeof users.$inferSelect;
export async function verifyTotpReauth(
userRecord: TotpUserRecord,
credential: string,
userDataKey?: Buffer | null,
): Promise<boolean> {
if (!userRecord.isOidc && userRecord.passwordHash) {
const passwordMatch = await bcrypt.compare(
@@ -40,22 +42,41 @@ export async function verifyTotpReauth(
}
if (userRecord.totpSecret) {
const totpMatch = speakeasy.totp.verify({
secret: userRecord.totpSecret,
encoding: "base32",
token: credential,
window: 2,
});
if (totpMatch) {
return true;
const totpSecret = userDataKey
? LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userRecord.id,
"totpSecret",
)
: userRecord.totpSecret;
if (totpSecret) {
const totpMatch = speakeasy.totp.verify({
secret: totpSecret,
encoding: "base32",
token: credential,
window: 2,
});
if (totpMatch) {
return true;
}
}
}
const rawBackupCodes =
userDataKey && userRecord.totpBackupCodes
? LazyFieldEncryption.safeGetFieldValue(
userRecord.totpBackupCodes,
userDataKey,
userRecord.id,
"totpBackupCodes",
)
: userRecord.totpBackupCodes;
let backupCodes: unknown = [];
try {
backupCodes = userRecord.totpBackupCodes
? JSON.parse(userRecord.totpBackupCodes)
: [];
backupCodes = rawBackupCodes ? JSON.parse(rawBackupCodes) : [];
} catch {
backupCodes = [];
}
@@ -63,9 +84,18 @@ export async function verifyTotpReauth(
const backupIndex = backupCodes.indexOf(credential);
if (backupIndex !== -1) {
backupCodes.splice(backupIndex, 1);
const updatedJson = JSON.stringify(backupCodes);
const storedValue = userDataKey
? FieldCrypto.encryptField(
updatedJson,
userDataKey,
userRecord.id,
"totpBackupCodes",
)
: updatedJson;
await db
.update(users)
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
.set({ totpBackupCodes: storedValue })
.where(eq(users.id, userRecord.id));
return true;
}
@@ -172,6 +202,21 @@ export function registerUserTotpRoutes(
}
try {
const passwordLoginRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_password_login'",
)
.get() as { value: string } | undefined;
const passwordLoginAllowed = passwordLoginRow
? passwordLoginRow.value === "true"
: true;
if (!passwordLoginAllowed) {
return res.status(409).json({
error:
"Cannot enable 2FA while password login is disabled. Enable password login first.",
});
}
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" });
@@ -187,8 +232,18 @@ export function registerUserTotpRoutes(
return res.status(400).json({ error: "TOTP setup not initiated" });
}
const userDataKey = authManager.getUserDataKey(userId);
const totpSecret = userDataKey
? LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userId,
"totpSecret",
)
: userRecord.totpSecret;
const verified = speakeasy.totp.verify({
secret: userRecord.totpSecret,
secret: totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
@@ -202,11 +257,21 @@ export function registerUserTotpRoutes(
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
const backupCodesJson = JSON.stringify(backupCodes);
const storedBackupCodes = userDataKey
? FieldCrypto.encryptField(
backupCodesJson,
userDataKey,
userId,
"totpBackupCodes",
)
: backupCodesJson;
await db
.update(users)
.set({
totpEnabled: true,
totpBackupCodes: JSON.stringify(backupCodes),
totpBackupCodes: storedBackupCodes,
})
.where(eq(users.id, userId));
@@ -297,7 +362,12 @@ export function registerUserTotpRoutes(
return res.status(400).json({ error: "TOTP is not enabled" });
}
const verified = await verifyTotpReauth(userRecord, credential);
const userDataKey = authManager.getUserDataKey(userId);
const verified = await verifyTotpReauth(
userRecord,
credential,
userDataKey,
);
if (!verified) {
return res
.status(401)
@@ -378,7 +448,12 @@ export function registerUserTotpRoutes(
return res.status(400).json({ error: "TOTP is not enabled" });
}
const verified = await verifyTotpReauth(userRecord, credential);
const userDataKey = authManager.getUserDataKey(userId);
const verified = await verifyTotpReauth(
userRecord,
credential,
userDataKey,
);
if (!verified) {
return res
.status(401)
@@ -389,9 +464,19 @@ export function registerUserTotpRoutes(
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
const backupCodesJson = JSON.stringify(backupCodes);
const storedBackupCodes = userDataKey
? FieldCrypto.encryptField(
backupCodesJson,
userDataKey,
userId,
"totpBackupCodes",
)
: backupCodesJson;
await db
.update(users)
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
.set({ totpBackupCodes: storedBackupCodes })
.where(eq(users.id, userId));
res.json({ backup_codes: backupCodes });
+224 -91
View File
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { users, settings, roles, userRoles } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import type { Request, Response } from "express";
@@ -22,9 +22,11 @@ import {
verifyOIDCToken,
extractOidcGroups,
loadProviderConfig,
buildFetchOptions,
} from "./user-oidc-utils.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
import { registerUserTotpRoutes } from "./user-totp-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
@@ -43,6 +45,45 @@ function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function isRegistrationAllowed(): boolean {
const envVal = process.env.ALLOW_REGISTRATION;
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get() as { value: string } | undefined;
return row ? row.value === "true" : true;
} catch {
return true;
}
}
function isPasswordLoginAllowed(): boolean {
const envVal = process.env.ALLOW_PASSWORD_LOGIN;
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
.get() as { value: string } | undefined;
return row ? row.value === "true" : true;
} catch {
return true;
}
}
function isPasswordResetAllowed(): boolean {
const envVal = process.env.ALLOW_PASSWORD_RESET;
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'")
.get() as { value: string } | undefined;
return row ? row.value === "true" : true;
} catch {
return true;
}
}
function isNativeAppRequest(req: Request): boolean {
return (
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
@@ -85,20 +126,10 @@ const requireAdmin = authManager.createAdminMiddleware();
* description: Failed to create user.
*/
router.post("/create", async (req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get();
if (row && (row as Record<string, unknown>).value !== "true") {
return res
.status(403)
.json({ error: "Registration is currently disabled" });
}
} catch (e) {
authLogger.warn("Failed to check registration status", {
operation: "registration_check",
error: e,
});
if (!isRegistrationAllowed()) {
return res
.status(403)
.json({ error: "Registration is currently disabled" });
}
const { username, password } = req.body;
@@ -135,8 +166,7 @@ router.post("/create", async (req, res) => {
return res.status(409).json({ error: "Username already exists" });
}
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(password, saltRounds);
const password_hash = await bcrypt.hash(password, 10);
const id = nanoid();
const isFirstUser = db.$client.transaction(() => {
@@ -737,6 +767,9 @@ router.get("/oidc/callback", async (req, res) => {
.run(`oidc_provider_${state}`);
}
const caCert = config.ca_cert;
const fetchOptions = buildFetchOptions(caCert);
// GitHub does not issue OIDC id_tokens; handle its token exchange separately
if (callbackProviderType === "github") {
const ghTokenResponse = await fetch(config.token_url, {
@@ -752,6 +785,7 @@ router.get("/oidc/callback", async (req, res) => {
code: code,
redirect_uri: backendCallbackUri,
}),
...fetchOptions,
});
if (!ghTokenResponse.ok) {
@@ -789,6 +823,7 @@ router.get("/oidc/callback", async (req, res) => {
Accept: "application/json",
"User-Agent": "Termix",
},
...fetchOptions,
});
if (!ghUserInfoResponse.ok) {
return res
@@ -859,16 +894,9 @@ router.get("/oidc/callback", async (req, res) => {
"true";
if (!isFirstUser && !ghAutoProvision) {
const regRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_registration'",
)
.get() as { value: string } | undefined;
if (regRow && regRow.value !== "true") {
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "registration_disabled");
return res.redirect(redirectUrl.toString());
}
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "registration_disabled");
return res.redirect(redirectUrl.toString());
}
const ghId = nanoid();
@@ -972,7 +1000,6 @@ router.get("/oidc/callback", async (req, res) => {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Buffer.from(`${encodeURIComponent(config.client_id)}:${encodeURIComponent(config.client_secret)}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
@@ -981,6 +1008,7 @@ router.get("/oidc/callback", async (req, res) => {
code: code,
redirect_uri: backendCallbackUri,
}),
...fetchOptions,
});
if (!tokenResponse.ok) {
@@ -1023,7 +1051,7 @@ router.get("/oidc/callback", async (req, res) => {
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
if (discoveryResponse.ok) {
const discovery = (await discoveryResponse.json()) as Record<
string,
@@ -1058,6 +1086,7 @@ router.get("/oidc/callback", async (req, res) => {
tokenData.id_token as string,
config.issuer_url,
config.client_id,
caCert,
);
} catch {
try {
@@ -1081,6 +1110,7 @@ router.get("/oidc/callback", async (req, res) => {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
...fetchOptions,
});
if (userInfoResponse.ok) {
@@ -1190,33 +1220,17 @@ router.get("/oidc/callback", async (req, res) => {
}
if (!isFirstUser && !oidcAutoProvision) {
try {
const regRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_registration'",
)
.get();
if (regRow && (regRow as Record<string, unknown>).value !== "true") {
authLogger.warn(
"OIDC user attempted to register when registration is disabled",
{
operation: "oidc_registration_disabled",
identifier,
name,
},
);
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "registration_disabled");
return res.redirect(redirectUrl.toString());
}
} catch (e) {
authLogger.warn("Failed to check registration status during OIDC", {
operation: "oidc_registration_check",
error: e,
});
}
authLogger.warn(
"OIDC user attempted to register but auto-provisioning is disabled",
{
operation: "oidc_registration_disabled",
identifier,
name,
},
);
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "registration_disabled");
return res.redirect(redirectUrl.toString());
}
const id = nanoid();
@@ -1367,6 +1381,39 @@ router.get("/oidc/callback", async (req, res) => {
.set({ isAdmin: shouldBeAdmin })
.where(eq(users.id, userRecord.id));
userRecord.isAdmin = shouldBeAdmin;
try {
const newRoleName = shouldBeAdmin ? "admin" : "user";
const oldRoleName = shouldBeAdmin ? "user" : "admin";
const newRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, newRoleName))
.limit(1);
const oldRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, oldRoleName))
.limit(1);
if (oldRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, userRecord.id),
eq(userRoles.roleId, oldRole[0].id),
),
);
}
if (newRole.length > 0) {
await db.insert(userRoles).values({
userId: userRecord.id,
roleId: newRole[0].id,
grantedBy: userRecord.id,
});
}
} catch {
/* non-fatal */
}
authLogger.info("OIDC admin status synced", {
operation: "oidc_admin_group_sync",
userId: userRecord.id,
@@ -1504,21 +1551,10 @@ router.post("/login", async (req, res) => {
});
}
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
.get();
if (row && (row as { value: string }).value !== "true") {
return res
.status(403)
.json({ error: "Password authentication is currently disabled" });
}
} catch (e) {
authLogger.error("Failed to check password login status", {
operation: "login_check",
error: e,
});
return res.status(500).json({ error: "Failed to check login status" });
if (!isPasswordLoginAllowed()) {
return res
.status(403)
.json({ error: "Password authentication is currently disabled" });
}
try {
@@ -1919,12 +1955,7 @@ router.get("/db-health", requireAdmin, async (req, res) => {
*/
router.get("/registration-allowed", async (req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get();
res.json({
allowed: row ? (row as Record<string, unknown>).value === "true" : true,
});
res.json({ allowed: isRegistrationAllowed() });
} catch (err) {
authLogger.error("Failed to get registration allowed", err);
res.status(500).json({ error: "Failed to get registration allowed" });
@@ -2029,6 +2060,107 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /users/oidc-silent-login-default:
* get:
* summary: Get OIDC silent login default setting
* description: Returns whether silent OIDC login is enabled as the default behavior.
* tags:
* - Users
* responses:
* 200:
* description: Silent login default setting.
* 500:
* description: Failed to get setting.
*/
router.get("/oidc-silent-login-default", async (_req, res) => {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'oidc_silent_login_default'",
)
.get();
res.json({
enabled: row ? (row as Record<string, unknown>).value === "true" : false,
});
} catch (err) {
authLogger.error("Failed to get OIDC silent login default", err);
res.status(500).json({ error: "Failed to get OIDC silent login default" });
}
});
/**
* @openapi
* /users/oidc-silent-login-default:
* patch:
* summary: Set OIDC silent login default setting
* description: Enables or disables silent OIDC login as the default behavior on the login page.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 400:
* description: Invalid value.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update setting.
*/
router.patch(
"/oidc-silent-login-default",
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 } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "Invalid value for enabled" });
}
const existing = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'oidc_silent_login_default'",
)
.get();
if (existing) {
db.$client
.prepare(
"UPDATE settings SET value = ? WHERE key = 'oidc_silent_login_default'",
)
.run(enabled ? "true" : "false");
} else {
db.$client
.prepare(
"INSERT INTO settings (key, value) VALUES ('oidc_silent_login_default', ?)",
)
.run(enabled ? "true" : "false");
}
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
res.json({ enabled });
} catch (err) {
authLogger.error("Failed to set OIDC silent login default", err);
res
.status(500)
.json({ error: "Failed to set OIDC silent login default" });
}
},
);
/**
* @openapi
* /users/password-login-allowed:
@@ -2045,12 +2177,7 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
*/
router.get("/password-login-allowed", async (req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
.get();
res.json({
allowed: row ? (row as { value: string }).value === "true" : true,
});
res.json({ allowed: isPasswordLoginAllowed() });
} catch (err) {
authLogger.error("Failed to get password login allowed", err);
res.status(500).json({ error: "Failed to get password login allowed" });
@@ -2095,6 +2222,17 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
if (typeof allowed !== "boolean") {
return res.status(400).json({ error: "Invalid value for allowed" });
}
if (!allowed) {
const totpRow = db.$client
.prepare("SELECT COUNT(*) as count FROM users WHERE totp_enabled = 1")
.get() as { count?: number };
if ((totpRow?.count || 0) > 0) {
return res.status(409).json({
error:
"Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.",
});
}
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
@@ -2125,12 +2263,7 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
*/
router.get("/password-reset-allowed", async (req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'")
.get();
res.json({
allowed: row ? (row as { value: string }).value === "true" : true,
});
res.json({ allowed: isPasswordResetAllowed() });
} catch (err) {
authLogger.error("Failed to get password reset allowed", err);
res.status(500).json({ error: "Failed to get password reset allowed" });
@@ -2347,8 +2480,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
.json({ error: "Failed to update password and re-encrypt data." });
}
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
const password_hash = await bcrypt.hash(newPassword, 10);
await db
.update(users)
.set({ passwordHash: password_hash })
@@ -2518,6 +2650,7 @@ registerUserOidcAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerAcmeSSLRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
+147 -3
View File
@@ -5,8 +5,8 @@ import { AuthManager } from "../utils/auth-manager.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { getDb } from "../database/db/index.js";
import { hosts } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { Client } from "ssh2";
import net from "net";
import type { AuthenticatedRequest } from "../../types/index.js";
@@ -67,9 +67,15 @@ router.use(authManager.createAuthMiddleware());
*/
router.post("/token", async (req, res) => {
try {
const { type, hostname, port, username, password, domain, ...options } =
const { type, hostname, port, username, password, domain, ...rawOptions } =
req.body;
// Strip "auto" sentinel values before forwarding to guacd
const options: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawOptions)) {
if (value !== "auto") options[key] = value;
}
if (!type || !hostname) {
return res
.status(400)
@@ -261,12 +267,138 @@ router.post(
}
}
// Strip "auto" sentinel values — these mean "use guacd default" in the UI
// but guacd doesn't recognise "auto" as a valid parameter value.
for (const key of Object.keys(guacConfig)) {
if (guacConfig[key] === "auto") {
delete guacConfig[key];
}
}
// Extract per-connection guacd proxy settings before passing the rest as connection settings
const perConnectionGuacdHost = guacConfig["guacd-hostname"] as
| string
| undefined;
const perConnectionGuacdPortRaw = guacConfig["guacd-port"];
const perConnectionGuacdPort = perConnectionGuacdPortRaw
? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined
: undefined;
delete guacConfig["guacd-hostname"];
delete guacConfig["guacd-port"];
if (guacConfig.dpi != null) {
const parsed = parseInt(String(guacConfig.dpi), 10);
guacConfig.dpi =
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
const hostRecord = host as Record<string, unknown>;
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
const rdpEffectiveAuthType =
(host.rdpAuthType as string) ||
(host.rdpCredentialId ? "credential" : "direct");
const vncEffectiveAuthType =
(host.vncAuthType as string) ||
(host.vncCredentialId ? "credential" : "direct");
const telnetEffectiveAuthType =
(host.telnetAuthType as string) ||
(hostRecord.telnetCredentialId ? "credential" : "direct");
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
try {
const rdpCreds = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.rdpCredentialId as number),
eq(sshCredentials.userId, host.userId as string),
),
),
"ssh_credentials",
userId,
);
if (rdpCreds.length > 0) {
const cred = rdpCreds[0] as Record<string, unknown>;
if (cred.username) host.rdpUser = cred.username;
if (cred.password) host.rdpPassword = cred.password;
// domain is never sourced from credential
}
} catch (e) {
guacLogger.warn("Failed to resolve RDP credential", {
operation: "guac_rdp_credential_resolve",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
try {
const vncCreds = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.vncCredentialId as number),
eq(sshCredentials.userId, host.userId as string),
),
),
"ssh_credentials",
userId,
);
if (vncCreds.length > 0) {
const cred = vncCreds[0] as Record<string, unknown>;
if (cred.password) host.vncPassword = cred.password;
if (cred.username) host.vncUser = cred.username;
}
} catch (e) {
guacLogger.warn("Failed to resolve VNC credential", {
operation: "guac_vnc_credential_resolve",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
if (
telnetEffectiveAuthType === "credential" &&
hostRecord.telnetCredentialId
) {
try {
const telnetCreds = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(
sshCredentials.id,
hostRecord.telnetCredentialId as number,
),
eq(sshCredentials.userId, host.userId as string),
),
),
"ssh_credentials",
userId,
);
if (telnetCreds.length > 0) {
const cred = telnetCreds[0] as Record<string, unknown>;
if (cred.username) host.telnetUser = cred.username;
if (cred.password) host.telnetPassword = cred.password;
}
} catch (e) {
guacLogger.warn("Failed to resolve Telnet credential", {
operation: "guac_telnet_credential_resolve",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
let token: string;
let hostname = host.ip as string;
let port = host.port as number;
@@ -385,6 +517,15 @@ router.post(
}
}
const guacdOverrides = {
...(perConnectionGuacdHost
? { guacdHost: perConnectionGuacdHost }
: {}),
...(perConnectionGuacdPort
? { guacdPort: perConnectionGuacdPort }
: {}),
};
switch (connectionType) {
case "rdp":
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
@@ -405,6 +546,7 @@ router.post(
? !!host.ignoreCert
: true,
...guacConfig,
...guacdOverrides,
});
break;
case "vnc":
@@ -416,6 +558,7 @@ router.post(
port,
security: "any",
...guacConfig,
...guacdOverrides,
},
);
break;
@@ -423,6 +566,7 @@ router.post(
token = tokenService.createTelnetToken(hostname, username, password, {
port,
...guacConfig,
...guacdOverrides,
});
break;
default:
+28 -8
View File
@@ -3,6 +3,8 @@ import { guacLogger } from "../utils/logger.js";
export interface GuacamoleConnectionSettings {
type: "rdp" | "vnc" | "telnet";
guacdHost?: string;
guacdPort?: number;
settings: {
hostname: string;
port?: number;
@@ -120,18 +122,24 @@ export class GuacamoleTokenService {
hostname: string,
username: string,
password: string,
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "rdp",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
username,
password,
...(username ? { username } : {}),
...(password ? { password } : {}),
port: 3389,
"ignore-cert": true,
...options,
...settingsOptions,
},
},
};
@@ -142,17 +150,23 @@ export class GuacamoleTokenService {
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "vnc",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
...(username ? { username } : {}),
password,
port: 5900,
...options,
...settingsOptions,
},
},
};
@@ -163,17 +177,23 @@ export class GuacamoleTokenService {
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "telnet",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
username,
password,
port: 23,
...options,
...settingsOptions,
},
},
};
+36 -4
View File
@@ -23,6 +23,7 @@ interface HostConfig {
keyPassword?: string;
keyType?: string;
authType?: string;
useWarpgate?: boolean;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
@@ -112,6 +113,7 @@ export class SSHAuthManager {
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostConfig,
): void {
this.context.isKeyboardInteractive = true;
const promptTexts = prompts.map((p) => p.prompt);
@@ -143,7 +145,7 @@ export class SSHAuthManager {
return;
}
this.handlePasswordAuth(prompts, finish, resolvedCredentials);
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
}
private handleWarpgateAuth(
@@ -282,7 +284,22 @@ export class SSHAuthManager {
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostConfig,
): void {
// For Warpgate hosts: auto-answer password prompts silently using stored credentials.
// Warpgate sends a password prompt before its browser-verification round; we must
// not show a UI prompt here -- the WarpgateDialog handles user interaction later.
if (hostConfig?.useWarpgate) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
return;
}
const hasStoredPassword =
resolvedCredentials.password && resolvedCredentials.authType !== "none";
@@ -290,19 +307,34 @@ export class SSHAuthManager {
/password/i.test(p.prompt),
);
if (!hasStoredPassword && passwordPromptIndex !== -1) {
// Find the first prompt we can't auto-answer. This handles DUO/PAM challenges
// that don't say "password" (e.g. "Passcode or option (1-N):").
const firstUnansweredIndex = prompts.findIndex((p) => {
if (/password/i.test(p.prompt) && hasStoredPassword) return false;
return true;
});
if (firstUnansweredIndex !== -1) {
if (this.context.keyboardInteractiveResponded) {
return;
}
this.context.keyboardInteractiveResponded = true;
const promptIndex =
passwordPromptIndex !== -1 && !hasStoredPassword
? passwordPromptIndex
: firstUnansweredIndex;
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
const userInput = (userResponses[0] || "").trim();
const responses = prompts.map((p, index) => {
if (index === passwordPromptIndex) {
if (index === promptIndex) {
return userInput;
}
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
@@ -335,7 +367,7 @@ export class SSHAuthManager {
this.context.ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[passwordPromptIndex].prompt,
prompt: prompts[promptIndex].prompt,
}),
);
return;
+72 -2
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { pickResolvedUsername } from "./credential-username.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
pickResolvedUsername,
expandOidcUsername,
} from "./credential-username.js";
describe("pickResolvedUsername", () => {
it("keeps the host username when one is set, even with a credential username", () => {
@@ -26,3 +29,70 @@ describe("pickResolvedUsername", () => {
expect(pickResolvedUsername(undefined, undefined, false)).toBeUndefined();
});
});
describe("expandOidcUsername", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns the username unchanged when it has no placeholder", async () => {
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
});
it("expands the placeholder with the user's OIDC identifier", async () => {
vi.doMock("../database/db/index.js", () => ({
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: async () => [{ oidcIdentifier: "jdoe" }],
}),
}),
}),
}),
}));
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
const { expandOidcUsername: expand } =
await import("./credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
});
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
vi.doMock("../database/db/index.js", () => ({
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: async () => [{ oidcIdentifier: null }],
}),
}),
}),
}),
}));
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
const { expandOidcUsername: expand } =
await import("./credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
);
});
it("returns the username unchanged when the DB lookup throws", async () => {
vi.doMock("../database/db/index.js", () => ({
getDb: () => {
throw new Error("DB unavailable");
},
}));
const { expandOidcUsername: expand } =
await import("./credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
);
});
});
+34
View File
@@ -21,6 +21,40 @@ export function pickResolvedUsername(
return cred;
}
/**
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
* connecting user's OIDC identifier. Returns the username unchanged if it does
* not contain the placeholder or the user has no OIDC identifier.
*/
export async function expandOidcUsername(
username: string | undefined,
userId: string,
): Promise<string | undefined> {
if (!username || !username.includes("$oidc.preferred_username")) {
return username;
}
try {
const { getDb } = await import("../database/db/index.js");
const { users } = await import("../database/db/schema.js");
const { eq } = await import("drizzle-orm");
const db = getDb();
const rows = await db
.select({ oidcIdentifier: users.oidcIdentifier })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const oidcIdentifier = rows[0]?.oidcIdentifier;
if (!oidcIdentifier) return username;
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
} catch {
return username;
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}
+12 -3
View File
@@ -8,6 +8,7 @@ type DockerSession = {
lastActive: number;
activeOperations: number;
hostId?: number;
isWindows?: boolean;
};
type PendingDockerTotpSession = unknown;
@@ -93,7 +94,10 @@ export function registerDockerContainerRoutes(
try {
const allFlag = all ? "-a " : "";
const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`;
const formatStr = session.isWindows
? `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`
: `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const command = `docker ps ${allFlag}--format ${formatStr}`;
const output = await executeDockerCommand(
session,
@@ -916,7 +920,7 @@ export function registerDockerContainerRoutes(
session.activeOperations++;
try {
let command = `docker logs ${containerId} 2>&1`;
let command = `docker logs ${containerId}`;
if (tail && tail > 0) {
command += ` --tail ${Math.floor(tail)}`;
@@ -934,6 +938,8 @@ export function registerDockerContainerRoutes(
command += ` --until ${until}`;
}
command += " 2>&1";
const logs = await executeDockerCommand(
session,
command,
@@ -1026,7 +1032,10 @@ export function registerDockerContainerRoutes(
session.activeOperations++;
try {
const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`;
const statsFormatStr = session.isWindows
? `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`
: `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const command = `docker stats ${containerId} --no-stream --format ${statsFormatStr}`;
const output = await executeDockerCommand(
session,
+27 -4
View File
@@ -44,6 +44,7 @@ interface SSHSession {
activeOperations: number;
hostId?: number;
userId?: string;
isWindows?: boolean;
}
interface PendingTOTPSession {
@@ -852,9 +853,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
resolvedCredentials.authType === "tailscale" ||
resolvedCredentials.authType === "warpgate"
) {
// Tailscale SSH and "none" auth: no credentials needed
// Tailscale SSH, "none", and Warpgate auth: no static credentials
} else if (resolvedCredentials.authType === "password") {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
@@ -1038,7 +1040,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
),
);
sshSessions[sessionId] = {
const session: SSHSession = {
client,
isConnected: true,
lastActive: Date.now(),
@@ -1047,8 +1049,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
userId,
};
sshSessions[sessionId] = session;
scheduleSessionCleanup(sessionId);
client.exec("ver", (err, stream) => {
if (!err && stream) {
let output = "";
stream.on("data", (d: Buffer) => {
output += d.toString();
});
stream.on("close", () => {
if (output.toLowerCase().includes("windows")) {
session.isWindows = true;
}
});
stream.stderr.on("data", () => {});
}
});
res.json({
success: true,
message: "SSH connection established",
@@ -1313,6 +1331,11 @@ app.post("/docker/ssh/connect", async (req, res) => {
/password/i.test(p.prompt),
);
if (resolvedCredentials.authType === "warpgate") {
finish(prompts.map(() => ""));
return;
}
if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
@@ -2144,7 +2167,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
try {
await executeDockerCommand(
session,
"docker ps >/dev/null 2>&1",
"docker ps",
sessionId,
userId,
session.hostId,
+206 -1
View File
@@ -1,4 +1,5 @@
import type { Express } from "express";
import type { Express, Request, Response } from "express";
import Busboy from "busboy";
import type { AuthenticatedRequest } from "../../types/index.js";
import { fileLogger } from "../utils/logger.js";
import {
@@ -1302,4 +1303,208 @@ export function registerFileContentRoutes(
trySFTP();
});
/**
* @openapi
* /ssh/file_manager/ssh/uploadFileStream:
* post:
* summary: Stream-upload a file via multipart form
* description: Uploads a file to the remote host by streaming multipart form data directly into an SFTP write stream, avoiding full in-memory buffering.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required:
* - sessionId
* - path
* - file
* properties:
* sessionId:
* type: string
* path:
* type: string
* file:
* type: string
* format: binary
* responses:
* 200:
* description: File uploaded successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 500:
* description: Failed to upload file.
*/
app.post(
"/ssh/file_manager/ssh/uploadFileStream",
(req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const contentType = req.headers["content-type"] || "";
if (!contentType.includes("multipart/form-data")) {
return res
.status(400)
.json({ error: "Expected multipart/form-data request" });
}
let sessionId: string | undefined;
let remotePath: string | undefined;
let fileName: string | undefined;
let uploadStartTime: number;
let resolved = false;
const bb = Busboy({ headers: req.headers });
bb.on("field", (name: string, value: string) => {
if (name === "sessionId") sessionId = value;
if (name === "path") remotePath = value;
});
bb.on(
"file",
(
fieldname: string,
fileStream: NodeJS.ReadableStream,
info: { filename: string; encoding: string; mimeType: string },
) => {
fileName = info.filename;
if (!sessionId || !remotePath || !fileName) {
fileStream.resume();
if (!resolved) {
resolved = true;
res
.status(400)
.json({ error: "Missing sessionId or path field" });
}
return;
}
const sshConn = sshSessions[sessionId];
if (!sshConn?.isConnected) {
fileStream.resume();
if (!resolved) {
resolved = true;
res.status(400).json({ error: "SSH connection not established" });
}
return;
}
if (!verifySessionOwnership(sshConn, userId)) {
fileStream.resume();
if (!resolved) {
resolved = true;
res.status(403).json({ error: "Session access denied" });
}
return;
}
sshConn.lastActive = Date.now();
uploadStartTime = Date.now();
const fullPath = remotePath.endsWith("/")
? remotePath + fileName
: remotePath + "/" + fileName;
fileLogger.info("Streaming file upload started", {
operation: "file_upload_stream_start",
sessionId,
userId,
path: fullPath,
});
getSessionSftp(sshConn)
.then((sftp) => {
const writeStream = sftp.createWriteStream(fullPath);
writeStream.on("error", (err) => {
fileLogger.error("SFTP write stream error during upload:", err);
if (!resolved) {
resolved = true;
res
.status(500)
.json({ error: `Upload failed: ${err.message}` });
}
});
writeStream.on("finish", () => {
if (resolved) return;
resolved = true;
fileLogger.success("Streaming file upload completed", {
operation: "file_upload_stream_complete",
sessionId,
userId,
path: fullPath,
duration: Date.now() - uploadStartTime,
});
res.json({
message: "File uploaded successfully",
path: fullPath,
toast: {
type: "success",
message: `File uploaded: ${fullPath}`,
},
});
});
writeStream.on("close", () => {
if (resolved) return;
resolved = true;
fileLogger.success("Streaming file upload completed", {
operation: "file_upload_stream_complete",
sessionId,
userId,
path: fullPath,
duration: Date.now() - uploadStartTime,
});
res.json({
message: "File uploaded successfully",
path: fullPath,
toast: {
type: "success",
message: `File uploaded: ${fullPath}`,
},
});
});
fileStream.on("error", (err) => {
fileLogger.error("File read stream error during upload:", err);
writeStream.destroy();
if (!resolved) {
resolved = true;
res
.status(500)
.json({ error: `Upload stream error: ${err.message}` });
}
});
(fileStream as NodeJS.ReadableStream).pipe(
writeStream as unknown as NodeJS.WritableStream,
);
})
.catch((err: Error) => {
fileStream.resume();
fileLogger.error("SFTP session error during stream upload:", err);
if (!resolved) {
resolved = true;
res.status(500).json({ error: `SFTP error: ${err.message}` });
}
});
},
);
bb.on("error", (err: Error) => {
fileLogger.error("Busboy parse error during stream upload:", err);
if (!resolved) {
resolved = true;
res.status(500).json({ error: `Upload parse error: ${err.message}` });
}
});
req.pipe(bb);
},
);
}
+140 -8
View File
@@ -2,7 +2,11 @@ import type { Express } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { fileLogger } from "../utils/logger.js";
import { getMimeType } from "./file-manager-utils.js";
import { getSessionSftp, type SSHSession } from "./file-manager-session.js";
import {
execChannel,
getSessionSftp,
type SSHSession,
} from "./file-manager-session.js";
type FileDownloadRoutesDeps = {
sshSessions: Record<string, SSHSession>;
@@ -10,6 +14,37 @@ type FileDownloadRoutesDeps = {
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
function escapeSingleQuotes(path: string): string {
return path.replace(/'/g, "'\"'\"'");
}
function downloadViaExec(
session: SSHSession,
filePath: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const escaped = escapeSingleQuotes(filePath);
execChannel(session, `cat '${escaped}'`, (err, stream) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
return reject(
new Error(stderr.trim() || `cat exited with code ${code}`),
);
}
resolve(Buffer.concat(chunks));
});
stream.on("error", reject);
});
});
}
export function registerFileDownloadRoutes(
app: Express,
{
@@ -23,7 +58,7 @@ export function registerFileDownloadRoutes(
* /ssh/file_manager/ssh/downloadFile:
* post:
* summary: Download a file
* description: Downloads a file from the remote host.
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
@@ -88,6 +123,45 @@ export function registerFileDownloadRoutes(
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
if (sshConn.scpLegacy) {
fileLogger.info(
"Downloading file via legacy exec/cat (SCP legacy mode)",
{
operation: "file_download_legacy",
sessionId,
userId,
path: filePath,
},
);
try {
const data = await downloadViaExec(sshConn, filePath);
const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File download completed (legacy mode)", {
operation: "file_download_complete",
sessionId,
userId,
hostId,
path: filePath,
bytes: data.length,
duration: Date.now() - downloadStartTime,
});
return res.json({
content: data.toString("base64"),
fileName,
size: data.length,
mimeType: getMimeType(fileName),
path: filePath,
});
} catch (err) {
fileLogger.error("Legacy exec/cat download failed:", err);
return res.status(500).json({
error: `Failed to download file: ${(err as Error).message}`,
});
}
}
fileLogger.info("Opening SFTP channel", {
operation: "file_sftp_open",
sessionId,
@@ -168,6 +242,33 @@ export function registerFileDownloadRoutes(
});
});
/**
* @openapi
* /ssh/file_manager/ssh/downloadFileStream:
* post:
* summary: Stream-download a file
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* responses:
* 200:
* description: Binary file stream.
* 400:
* description: Missing required parameters.
* 500:
* description: Download failed.
*/
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
const { sessionId, path: filePath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
@@ -188,6 +289,43 @@ export function registerFileDownloadRoutes(
sshConn.lastActive = Date.now();
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
if (sshConn.scpLegacy) {
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
operation: "file_download_stream_legacy",
sessionId,
userId,
path: filePath,
});
const escaped = escapeSingleQuotes(filePath);
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
if (err) {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
}
return;
}
stream.on("error", (streamErr: Error) => {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${streamErr.message}` });
} else {
res.destroy();
}
});
stream.pipe(res);
});
return;
}
try {
const sftp = await getSessionSftp(sshConn);
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
@@ -200,12 +338,6 @@ export function registerFileDownloadRoutes(
return res.status(400).json({ error: "Cannot download directories" });
}
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
res.setHeader("Content-Length", String(stats.size));
const readStream = sftp.createReadStream(filePath);
@@ -6,6 +6,7 @@ import {
execWithSudo,
type SSHSession,
} from "./file-manager-session.js";
import { isWindowsSftpPath } from "./transfer-paths.js";
type FileOperationRoutesDeps = {
sshSessions: Record<string, SSHSession>;
@@ -373,11 +374,23 @@ export function registerFileOperationRoutes(
type: isDirectory ? "directory" : "file",
});
sshConn.lastActive = Date.now();
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
const deleteCommand = isDirectory
? `rm -rf '${escapedPath}'`
: `rm -f '${escapedPath}'`;
const isWindowsPath = isWindowsSftpPath(itemPath);
let deleteCommand: string;
if (isWindowsPath) {
const winPath = itemPath
.replace(/\//g, "\\")
.replace(/^\\([A-Za-z]:\\)/, "$1");
const escapedWinPath = winPath.replace(/"/g, '""');
deleteCommand = isDirectory
? `rd /s /q "${escapedWinPath}"`
: `del /f /q "${escapedWinPath}"`;
} else {
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
deleteCommand = isDirectory
? `rm -rf '${escapedPath}'`
: `rm -f '${escapedPath}'`;
}
const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => {
@@ -465,10 +478,6 @@ export function registerFileOperationRoutes(
},
});
} else {
// The shell didn't echo our SUCCESS sentinel. This happens on
// non-POSIX shells (e.g. Windows PowerShell, where `rm -f` is
// not supported) and the command can exit 0 while doing nothing.
// Surface whatever the shell produced so the failure isn't silent.
const detail =
errorData.trim() ||
outputData.trim() ||
+1
View File
@@ -39,6 +39,7 @@ export interface SSHSession {
transferDedicated?: boolean;
transferId?: string;
browseSessionId?: string;
scpLegacy?: boolean;
}
export interface PendingTOTPSession {
+91 -13
View File
@@ -132,6 +132,7 @@ async function buildDedicatedTransferConnectConfig(
client: SSHClient,
): Promise<Record<string, unknown>> {
const { ip, port, username } = host;
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(host.id);
const config: Record<string, unknown> = {
host: ip?.replace(/^\[|\]$/g, "") || ip,
port,
@@ -149,6 +150,7 @@ async function buildDedicatedTransferConnectConfig(
null,
userId,
false,
preloadedHostData,
),
env: {
TERM: "xterm-256color",
@@ -726,6 +728,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
let resolvedPort = port;
let resolvedUsername = username;
let resolvedJumpHosts = jumpHosts;
let resolvedScpLegacy = false;
let resolvedUseSocks5 = useSocks5;
let resolvedSocks5Host = socks5Host;
let resolvedSocks5Port = socks5Port;
let resolvedSocks5Username = socks5Username;
let resolvedSocks5Password = socks5Password;
let resolvedSocks5ProxyChain = socks5ProxyChain;
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById } = await import("./host-resolver.js");
@@ -743,6 +752,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
};
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
resolvedSocks5Port = resolvedHost.socks5Port;
resolvedSocks5Username = resolvedHost.socks5Username;
resolvedSocks5Password = resolvedHost.socks5Password;
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
}
if (
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
resolvedHost.jumpHosts &&
@@ -790,6 +808,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
};
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
resolvedSocks5Port = resolvedHost.socks5Port;
resolvedSocks5Username = resolvedHost.socks5Username;
resolvedSocks5Password = resolvedHost.socks5Password;
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
}
if (
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
resolvedHost.jumpHosts &&
@@ -822,6 +849,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
}
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(hostId);
const config: Record<string, unknown> = {
host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp,
port: resolvedPort,
@@ -829,10 +857,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
tryKeyboard: true,
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
? hostKeepaliveInterval * 1000
? Math.max(5000, hostKeepaliveInterval * 1000)
: 60000,
keepaliveCountMax:
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
typeof hostKeepaliveCountMax === "number"
? Math.max(1, hostKeepaliveCountMax)
: 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
@@ -843,6 +873,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
null,
userId,
false,
preloadedHostData,
),
env: {
TERM: "xterm-256color",
@@ -1015,7 +1046,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
resolvedCredentials.authType === "tailscale" ||
resolvedCredentials.authType === "warpgate"
) {
connectionLogs.push(
createConnectionLog(
@@ -1109,6 +1141,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
hostId,
username,
sudoPassword: resolvedCredentials.sudoPassword,
scpLegacy: resolvedScpLegacy,
};
scheduleSessionCleanup(sessionId);
res.json({
@@ -1266,6 +1299,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
if (
err.message.includes("Cannot parse privateKey") &&
err.message.includes("no passphrase")
) {
res.json({
status: "passphrase_required",
connectionLogs,
});
} else if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
(err.message.includes("authentication") ||
@@ -1426,12 +1467,18 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none" &&
resolvedCredentials.authType !== "tailscale";
resolvedCredentials.authType !== "tailscale" &&
resolvedCredentials.authType !== "warpgate";
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (resolvedCredentials.authType === "warpgate") {
finish(prompts.map(() => ""));
return;
}
if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
@@ -1512,16 +1559,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
);
const proxyConfig: SOCKS5Config | null =
useSocks5 &&
(socks5Host ||
(socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
resolvedUseSocks5 &&
(resolvedSocks5Host ||
(resolvedSocks5ProxyChain &&
(resolvedSocks5ProxyChain as ProxyNode[]).length > 0))
? {
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain: socks5ProxyChain as ProxyNode[],
useSocks5: resolvedUseSocks5,
socks5Host: resolvedSocks5Host,
socks5Port: resolvedSocks5Port,
socks5Username: resolvedSocks5Username,
socks5Password: resolvedSocks5Password,
socks5ProxyChain: resolvedSocks5ProxyChain as ProxyNode[],
}
: null;
@@ -1571,7 +1619,37 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
});
}
let forwardOutDone = false;
const forwardOutTimeout = setTimeout(() => {
if (!forwardOutDone) {
forwardOutDone = true;
fileLogger.error("Timeout waiting for jump host forwardOut", {
operation: "file_jump_forward_timeout",
sessionId,
hostId,
ip,
port,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
"Timed out waiting for jump host tunnel to target host",
),
);
jumpClient.end();
res.status(500).json({
error: "Jump host tunnel timed out",
connectionLogs,
});
}
}, 30000);
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
if (forwardOutDone) return;
forwardOutDone = true;
clearTimeout(forwardOutTimeout);
if (err) {
fileLogger.error("Failed to forward through jump host", err, {
operation: "file_jump_forward",
+49 -10
View File
@@ -21,6 +21,37 @@ interface VerificationResponse {
}
export class SSHHostKeyVerifier {
/**
* Pre-fetches the host record from the database so the verifier callback
* during SSH key exchange doesn't need to do an async DB query. This keeps
* the key exchange critical path fast, avoiding LoginGraceTime expiry on
* the remote server (especially important for jump-host tunneled connections).
*/
static async preloadHostData(hostId: number | null): Promise<{
hostKeyFingerprint: string | null;
hostKeyType: string | null;
hostKeyAlgorithm: string | null;
hostKeyChangedCount: number | null;
name: string | null;
} | null> {
if (!hostId) return null;
try {
const host = await db.query.hosts.findFirst({
where: eq(hosts.id, hostId),
columns: {
hostKeyFingerprint: true,
hostKeyType: true,
hostKeyAlgorithm: true,
hostKeyChangedCount: true,
name: true,
},
});
return host ?? null;
} catch {
return null;
}
}
static async createHostVerifier(
hostId: number | null,
ip: string,
@@ -28,6 +59,9 @@ export class SSHHostKeyVerifier {
ws: WebSocket | null,
userId: string,
isJumpHost: boolean = false,
preloadedHost?: Awaited<
ReturnType<typeof SSHHostKeyVerifier.preloadHostData>
>,
): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> {
return (hostkey: Buffer, verify: (valid: boolean) => void): void => {
(async () => {
@@ -52,9 +86,10 @@ export class SSHHostKeyVerifier {
return;
}
const host = await db.query.hosts.findFirst({
where: eq(hosts.id, hostId),
});
const host =
preloadedHost !== undefined
? preloadedHost
: await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) });
if (!host) {
sshLogger.warn(
@@ -141,13 +176,6 @@ export class SSHHostKeyVerifier {
}
if (host.hostKeyFingerprint === fingerprint) {
await db
.update(hosts)
.set({
hostKeyLastVerified: new Date().toISOString(),
})
.where(eq(hosts.id, hostId));
sshLogger.info("Host key verified successfully", {
operation: "host_key_verified",
hostId,
@@ -158,7 +186,18 @@ export class SSHHostKeyVerifier {
userId,
});
// Verify first, then update the timestamp asynchronously so the
// DB write doesn't delay the SSH key exchange critical path.
verify(true);
db.update(hosts)
.set({ hostKeyLastVerified: new Date().toISOString() })
.where(eq(hosts.id, hostId))
.catch((err) => {
sshLogger.error("Failed to update hostKeyLastVerified", err, {
operation: "host_key_update_timestamp",
hostId,
});
});
return;
}
+20 -2
View File
@@ -96,6 +96,9 @@ interface SSHHostWithCredentials {
socks5Password?: string;
socks5ProxyChain?: ProxyNode[];
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
rdpPort?: number;
vncPort?: number;
telnetPort?: number;
}
type StatusEntry = {
@@ -348,7 +351,12 @@ class PollingManager {
try {
let pingHost = refreshedHost.ip;
let pingPort = refreshedHost.port;
const ct = refreshedHost.connectionType || "ssh";
let pingPort: number;
if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389;
else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900;
else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23;
else pingPort = refreshedHost.port;
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
const firstJump = await fetchHostById(
refreshedHost.jumpHosts[0].hostId,
@@ -792,6 +800,12 @@ async function resolveHostCredentials(
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: undefined,
connectionType:
(host.connectionType as SSHHostWithCredentials["connectionType"]) ||
"ssh",
rdpPort: (host.rdpPort as number | undefined) ?? undefined,
vncPort: (host.vncPort as number | undefined) ?? undefined,
telnetPort: (host.telnetPort as number | undefined) ?? undefined,
};
if (host.credentialId) {
@@ -1028,7 +1042,11 @@ async function buildSshConfig(
cause: keyError,
});
}
} else if (host.authType === "none" || host.authType === "tailscale") {
} else if (
host.authType === "none" ||
host.authType === "tailscale" ||
host.authType === "warpgate"
) {
// no credentials needed
} else if (host.authType === "opkssh") {
// cert auth setup happens in createSshFactory (needs client instance)
+17 -1
View File
@@ -3,7 +3,10 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { logger } from "../utils/logger.js";
import { pickResolvedUsername } from "./credential-username.js";
import {
pickResolvedUsername,
expandOidcUsername,
} from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
const sshLogger = logger;
@@ -120,6 +123,10 @@ export async function resolveHostById(
: cred.password
? "password"
: "none";
host.username = await expandOidcUsername(
host.username as string | undefined,
userId,
);
return host as unknown as SSHHost;
}
}
@@ -150,6 +157,10 @@ export async function resolveHostById(
: sharedCred.password
? "password"
: "none";
host.username = await expandOidcUsername(
host.username as string | undefined,
userId,
);
return host as unknown as SSHHost;
}
} catch (e) {
@@ -203,6 +214,11 @@ export async function resolveHostById(
}
}
host.username = await expandOidcUsername(
host.username as string | undefined,
userId,
);
return host as unknown as SSHHost;
}
+131 -119
View File
@@ -5,7 +5,7 @@ import ssh2Pkg, {
type PseudoTtyOptions,
} from "ssh2";
const { Client, utils: ssh2Utils } = ssh2Pkg;
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { buildSSHAlgorithms } from "../utils/ssh-algorithms.js";
import axios from "axios";
import { getDb } from "../database/db/index.js";
import { hosts } from "../database/db/schema.js";
@@ -30,6 +30,7 @@ import {
waitForTmuxSession,
} from "./tmux-helper.js";
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
interface ConnectToHostData {
cols: number;
@@ -187,9 +188,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
let isConnecting = false;
let isConnected = false;
let isCleaningUp = false;
let cwdPending = false;
let cwdBuffer = "";
let isShellInitializing = false;
let isDuplicateConnDiscarded = false;
let warpgateAuthPromptSent = false;
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
let isAwaitingAuthCredentials = false;
@@ -237,7 +237,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (currentSessionId) {
const session = sessionManager.getSession(currentSessionId);
if (session?.isConnected) {
sessionManager.detachWs(currentSessionId);
// Only detach if this WS is still the one attached to the session.
// If a refresh reconnected and reattached a new WS before this close
// event fired, we must not clobber that new attachment.
if (session.attachedWs === ws || session.attachedWs === null) {
sessionManager.detachWs(currentSessionId);
}
} else {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
@@ -446,15 +451,80 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
case "get_cwd": {
const activeStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (!activeStream) {
const activeConn =
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
if (!activeConn) {
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
break;
}
cwdPending = true;
cwdBuffer = "";
activeStream.write('\x15a=TERMIX_CWD; echo "$a:$(pwd)"\r');
activeConn.exec("pwd", (err, execStream) => {
if (err) {
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
return;
}
let stdout = "";
execStream.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
execStream.stderr.on("data", () => {});
execStream.on("close", () => {
const cwd = stdout.trim() || "/";
const attachedWs =
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
}
});
});
break;
}
case "open_file_in_editor": {
const { path: requestedPath } = data as { path: string };
const activeConn =
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
if (!activeConn || !requestedPath) {
ws.send(
JSON.stringify({
type: "open_file_in_editor",
path: requestedPath || "/",
}),
);
break;
}
const escapedPath = requestedPath.replace(/'/g, "'\\''");
activeConn.exec(
`realpath '${escapedPath}' 2>/dev/null || echo '${escapedPath}'`,
(err, execStream) => {
if (err) {
ws.send(
JSON.stringify({
type: "open_file_in_editor",
path: requestedPath,
}),
);
return;
}
let stdout = "";
execStream.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
execStream.stderr.on("data", () => {});
execStream.on("close", () => {
const resolvedPath = stdout.trim() || requestedPath;
const attachedWs =
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(
JSON.stringify({
type: "open_file_in_editor",
path: resolvedPath,
}),
);
}
});
},
);
break;
}
@@ -990,7 +1060,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
}
if (!hostConfig.useSocks5 && resolvedHostData.useSocks5) {
if (resolvedHostData.useSocks5) {
hostConfig.useSocks5 = resolvedHostData.useSocks5;
hostConfig.socks5Host = resolvedHostData.socks5Host;
hostConfig.socks5Port = resolvedHostData.socks5Port;
@@ -1134,27 +1204,43 @@ wss.on("connection", async (ws: WebSocket, req) => {
!existingSession.sshStream.destroyed &&
existingSession.sshConn !== sshConn
) {
const reusedSessionId = currentSessionId;
sshLogger.info(
"Reusing existing live session after duplicate connectToHost, closing new SSH conn",
{
operation: "terminal_reuse_existing_session",
sessionId: currentSessionId,
sessionId: reusedSessionId,
tabInstanceId,
userId,
},
);
// Null out currentSessionId before ending the duplicate connection so
// the sshConn "close" handler does not destroy the reused session.
// Set isDuplicateConnDiscarded so the close handler does not send a
// "disconnected" message to the new WS that is now attached to the live session.
// Null out currentSessionId before ending the duplicate connection so
// the sshConn "close" handler does not destroy the reused session.
// Set isDuplicateConnDiscarded so the close handler exits without
// sending a "disconnected" message to the new WS.
currentSessionId = null;
isDuplicateConnDiscarded = true;
clearTimeout(connectionTimeout);
try {
sshConn?.end();
} catch {
/* ignore */
}
sshConn = null;
sshStream = null;
// Point this WS handler's closure at the live session so the input
// handler can forward keystrokes via currentSessionId.
currentSessionId = reusedSessionId;
sshStream = existingSession.sshStream;
sshConn = existingSession.sshConn;
isConnecting = false;
isConnected = true;
sessionManager.attachWs(currentSessionId, userId, ws, tabInstanceId);
sessionManager.attachWs(reusedSessionId, userId, ws, tabInstanceId);
const buffered = sessionManager.getBuffer(existingSession);
if (buffered) {
@@ -1163,20 +1249,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({
type: "sessionCreated",
sessionId: currentSessionId,
sessionId: reusedSessionId,
}),
);
ws.send(
JSON.stringify({
type: "sessionAttached",
sessionId: currentSessionId,
sessionId: reusedSessionId,
}),
);
ws.send(
JSON.stringify({ type: "connected", message: "Session reattached" }),
);
cleanupAuthState(connectionTimeout);
return;
}
@@ -1350,44 +1434,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
const boundSessionId = currentSessionId;
const CWD_SENTINEL = "TERMIX_CWD:";
stream.on("data", (data: Buffer) => {
try {
let utf8String = data.toString("utf-8");
if (cwdPending) {
cwdBuffer += utf8String;
const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL);
if (sentinelIdx !== -1) {
const afterSentinel = cwdBuffer.slice(
sentinelIdx + CWD_SENTINEL.length,
);
const newlineIdx = afterSentinel.search(/[\r\n]/);
if (newlineIdx !== -1) {
const cwd =
afterSentinel.slice(0, newlineIdx).trim() || "/";
cwdPending = false;
// Strip the sentinel line from output sent to terminal
const beforeSentinel = cwdBuffer.slice(0, sentinelIdx);
const afterNewline = afterSentinel.slice(newlineIdx);
utf8String = beforeSentinel + afterNewline;
cwdBuffer = "";
const attachedWs =
sessionManager.getSession(boundSessionId)?.attachedWs ??
ws;
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(
JSON.stringify({ type: "cwd", path: cwd }),
);
}
} else {
return;
}
} else {
return;
}
}
const utf8String = data.toString("utf-8");
if (!utf8String) return;
@@ -1475,7 +1524,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
const runPostShellCommands = (delay: number) => {
setTimeout(() => {
if (initialPath && initialPath.trim() !== "") {
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
let cdCommand: string;
if (isWindowsSftpPath(initialPath)) {
const winPath = sftpPathToLocalPath(initialPath);
const escaped = winPath.replace(/"/g, '""');
cdCommand = `cd "${escaped}"\r`;
} else {
cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
}
stream.write(cdCommand);
}
if (executeCommand && executeCommand.trim() !== "") {
@@ -1543,27 +1599,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}),
);
runPostShellCommands(0);
} else if (detection.sessions.length === 1) {
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
const sessionName = detection.sessions[0].name;
const session = sessionManager.getSession(boundSessionId);
if (session) {
session.tmuxSessionName = sessionName;
}
sshLogger.info("Auto-attached to existing tmux session", {
operation: "tmux_auto_attach",
sessionName,
hostId: id,
});
ws.send(
JSON.stringify({
type: "tmux_session_attached",
sessionName,
}),
);
// Reattaching to existing session -- don't re-run
// initialPath/executeCommand since the session already
// has its own state
} else {
sshLogger.info(
"Multiple tmux sessions found, sending list to frontend",
@@ -1910,6 +1945,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
hostId: id,
});
if (isDuplicateConnDiscarded) {
cleanupAuthState(connectionTimeout);
return;
}
if (isAwaitingAuthCredentials) {
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
@@ -1991,6 +2031,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
resolvedCredentials as unknown as Parameters<
typeof sshAuthManager.handleKeyboardInteractive
>[5],
hostConfig,
);
isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive;
@@ -2008,19 +2049,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval;
const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax;
// Pre-fetch the stored host key before connect so the verifier callback
// runs synchronously during SSH key exchange, avoiding LoginGraceTime
// expiry on slow connections (especially through jump host tunnels).
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
const connectConfig: Record<string, unknown> = {
host: ip,
port,
username,
tryKeyboard:
resolvedCredentials.authType !== "none" &&
resolvedCredentials.authType !== "tailscale",
tryKeyboard: resolvedCredentials.authType !== "tailscale",
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
? hostKeepaliveInterval * 1000
? Math.max(5000, hostKeepaliveInterval * 1000)
: 30000,
keepaliveCountMax:
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
typeof hostKeepaliveCountMax === "number"
? Math.max(1, hostKeepaliveCountMax)
: 5,
readyTimeout: 120000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
@@ -2032,6 +2078,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws,
userId,
false,
preloadedHostData,
),
env: {
TERM: "xterm-256color",
@@ -2045,51 +2092,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
algorithms: buildSSHAlgorithms(
hostConfig.terminalConfig?.allowLegacyAlgorithms !== false,
),
};
if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
) {
// Tailscale SSH and "none" auth: daemon handles authorization, no credentials needed
// Tailscale SSH and "none": no static credentials needed
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(
+77 -1
View File
@@ -1,6 +1,12 @@
import crypto from "crypto";
import { createRequire } from "module";
import type { ConnectConfig, CipherAlgorithm } from "ssh2";
import type {
ConnectConfig,
CipherAlgorithm,
KexAlgorithm,
ServerHostKeyAlgorithm,
MacAlgorithm,
} from "ssh2";
const nativeRequire = createRequire(import.meta.url);
const availableCiphers = new Set(crypto.getCiphers());
@@ -47,6 +53,76 @@ function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] {
});
}
const LEGACY_KEX: KexAlgorithm[] = [
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
];
const LEGACY_SERVER_HOST_KEY: ServerHostKeyAlgorithm[] = ["ssh-rsa", "ssh-dss"];
const LEGACY_HMAC: MacAlgorithm[] = ["hmac-sha1", "hmac-md5"];
const LEGACY_CIPHER = filterCiphers(["3des-cbc"]);
export function buildSSHAlgorithms(
allowLegacy: boolean,
): NonNullable<ConnectConfig["algorithms"]> {
const kex: KexAlgorithm[] = [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
];
const serverHostKey: ServerHostKeyAlgorithm[] = [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
];
const hmac: MacAlgorithm[] = [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
];
const cipher = filterCiphers([
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
]);
if (allowLegacy) {
kex.push(...LEGACY_KEX);
serverHostKey.push(...LEGACY_SERVER_HOST_KEY);
hmac.push(...LEGACY_HMAC);
cipher.push(...LEGACY_CIPHER);
}
return {
kex,
serverHostKey,
cipher,
hmac,
compress: ["none", "zlib@openssh.com", "zlib"],
};
}
export const SSH_ALGORITHMS: NonNullable<ConnectConfig["algorithms"]> = {
kex: [
"curve25519-sha256",
+67 -2
View File
@@ -1,5 +1,18 @@
import { describe, it, expect } from "vitest";
import { isValidMac, buildMagicPacket } from "./wake-on-lan.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("dgram", () => {
const socket = {
once: vi.fn(),
bind: vi.fn(),
setBroadcast: vi.fn(),
send: vi.fn(),
close: vi.fn(),
};
return { default: { createSocket: vi.fn(() => socket) } };
});
import dgram from "dgram";
import { isValidMac, buildMagicPacket, sendWakeOnLan } from "./wake-on-lan.js";
describe("isValidMac", () => {
it("accepts colon-separated MAC addresses", () => {
@@ -49,3 +62,55 @@ describe("buildMagicPacket", () => {
expect(colon.equals(hyphen)).toBe(true);
});
});
describe("sendWakeOnLan", () => {
let mockSocket: ReturnType<typeof dgram.createSocket>;
beforeEach(() => {
vi.clearAllMocks();
mockSocket = (dgram.createSocket as ReturnType<typeof vi.fn>)();
(mockSocket.bind as ReturnType<typeof vi.fn>).mockImplementation(
(cb: () => void) => cb(),
);
(mockSocket.send as ReturnType<typeof vi.fn>).mockImplementation(
(
_buf: unknown,
_off: unknown,
_len: unknown,
_port: unknown,
_addr: unknown,
cb: (err: null) => void,
) => cb(null),
);
});
it("rejects on invalid MAC address", async () => {
await expect(sendWakeOnLan("not-a-mac")).rejects.toThrow(
"Invalid MAC address",
);
});
it("sends to 255.255.255.255 by default", async () => {
await sendWakeOnLan("aa:bb:cc:dd:ee:ff");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
102,
9,
"255.255.255.255",
expect.any(Function),
);
});
it("sends to a custom broadcast address when provided", async () => {
await sendWakeOnLan("aa:bb:cc:dd:ee:ff", "192.168.1.255");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
102,
9,
"192.168.1.255",
expect.any(Function),
);
});
});
+5 -2
View File
@@ -20,7 +20,10 @@ export function isValidMac(mac: string): boolean {
return MAC_REGEX.test(mac);
}
export function sendWakeOnLan(mac: string): Promise<void> {
export function sendWakeOnLan(
mac: string,
broadcastAddress = "255.255.255.255",
): Promise<void> {
return new Promise((resolve, reject) => {
if (!isValidMac(mac)) {
return reject(new Error("Invalid MAC address"));
@@ -36,7 +39,7 @@ export function sendWakeOnLan(mac: string): Promise<void> {
socket.bind(() => {
socket.setBroadcast(true);
socket.send(packet, 0, packet.length, 9, "255.255.255.255", (err) => {
socket.send(packet, 0, packet.length, 9, broadcastAddress, (err) => {
socket.close();
if (err) reject(err);
else resolve();