mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user