diff --git a/src/backend/database/routes/c2s-tunnel-presets.ts b/src/backend/database/routes/c2s-tunnel-presets.ts index 9eccc34a..f11d5da6 100644 --- a/src/backend/database/routes/c2s-tunnel-presets.ts +++ b/src/backend/database/routes/c2s-tunnel-presets.ts @@ -46,6 +46,22 @@ function validateConfig(config: unknown): config is TunnelConnection[] { }); } +/** + * @openapi + * /c2s-tunnel-presets: + * get: + * summary: List client tunnel presets + * description: Returns the authenticated user's saved client-to-server tunnel presets. + * tags: + * - Tunnel Presets + * responses: + * 200: + * description: List of tunnel presets. + * 401: + * description: Authentication required. + * 500: + * description: Failed to list presets. + */ router.get( "/", authenticateJWT, @@ -67,6 +83,37 @@ router.get( }, ); +/** + * @openapi + * /c2s-tunnel-presets: + * post: + * summary: Create a client tunnel preset + * description: Saves a named client-to-server tunnel configuration for the authenticated user. + * tags: + * - Tunnel Presets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * config: + * type: array + * items: + * type: object + * responses: + * 200: + * description: Preset created. + * 400: + * description: Invalid name or config. + * 401: + * description: Authentication required. + * 500: + * description: Failed to create preset. + */ router.post( "/", authenticateJWT, @@ -111,6 +158,29 @@ router.post( }, ); +/** + * @openapi + * /c2s-tunnel-presets/{id}: + * put: + * summary: Update a client tunnel preset + * description: Updates the name or config of one of the authenticated user's tunnel presets. + * tags: + * - Tunnel Presets + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: Preset updated. + * 400: + * description: Invalid name or config. + * 404: + * description: Preset not found. + * 500: + * description: Failed to update preset. + */ router.put( "/:id", authenticateJWT, @@ -171,6 +241,27 @@ router.put( }, ); +/** + * @openapi + * /c2s-tunnel-presets/{id}: + * delete: + * summary: Delete a client tunnel preset + * description: Deletes one of the authenticated user's tunnel presets. + * tags: + * - Tunnel Presets + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: Preset deleted. + * 404: + * description: Preset not found. + * 500: + * description: Failed to delete preset. + */ router.delete( "/:id", authenticateJWT, diff --git a/src/backend/database/routes/host-enrollment-auth.ts b/src/backend/database/routes/host-enrollment-auth.ts index f5281c4f..572570d1 100644 --- a/src/backend/database/routes/host-enrollment-auth.ts +++ b/src/backend/database/routes/host-enrollment-auth.ts @@ -1,6 +1,6 @@ import type { NextFunction, Request, Response } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; export function applyHostEnrollmentDefaults( hostData: Record, @@ -34,7 +34,7 @@ export function requireHostEnrollmentAccessForPath( return; } - if (!SimpleDBOps.isUserDataUnlocked(authReq.userId)) { + if (!DataCrypto.canUserAccessData(authReq.userId)) { res.status(423).json({ error: "User data is locked. Sign in before enrolling hosts.", code: "DATA_LOCKED", diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index b9aca14b..9826e71d 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -22,9 +22,7 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -// --------------------------------------------------------------------------- // Helpers -// --------------------------------------------------------------------------- // Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself, // but we validate defensively before using in a shell command. diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index e7ee35c1..ce76047e 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -1035,9 +1035,7 @@ router.get( }, ); -// ============================================================================ // SNIPPET SHARING -// ============================================================================ /** * @openapi diff --git a/src/backend/database/routes/termix-id.ts b/src/backend/database/routes/termix-id.ts index 83b76789..ed8e72db 100644 --- a/src/backend/database/routes/termix-id.ts +++ b/src/backend/database/routes/termix-id.ts @@ -88,10 +88,8 @@ async function getActorUsername(userId: string): Promise { return user?.username ?? userId; } -// --------------------------------------------------------------------------- // Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a // small HTML viewer for browsers, keyed by handle, with optional / filter. -// --------------------------------------------------------------------------- // Never let an intermediary/CDN cache a public resolver feed (a disabled/removed // key could keep being served after revocation), and keep it out of search @@ -276,9 +274,7 @@ router.get("/u/:handle/ca", caResolver); router.get("/u/:handle", resolveHandle); router.get("/u/:handle/:algo", resolveHandle); -// --------------------------------------------------------------------------- // Authenticated management API. -// --------------------------------------------------------------------------- /** * @openapi @@ -1024,9 +1020,7 @@ router.delete( }, ); -// --------------------------------------------------------------------------- // Certificate authority — central revocation (rotate) + expiry (validity). -// --------------------------------------------------------------------------- type CaRow = { id: number; diff --git a/src/backend/database/routes/user-webauthn-routes.ts b/src/backend/database/routes/user-webauthn-routes.ts index 8306903f..d0a9035e 100644 --- a/src/backend/database/routes/user-webauthn-routes.ts +++ b/src/backend/database/routes/user-webauthn-routes.ts @@ -132,6 +132,20 @@ export function registerUserWebAuthnRoutes( router: Router, { authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps, ): void { + /** + * @openapi + * /users/webauthn/credentials: + * get: + * summary: List passkeys + * description: Lists the authenticated user's registered passkeys. + * tags: + * - WebAuthn + * responses: + * 200: + * description: List of passkeys. + * 401: + * description: Authentication required. + */ router.get("/webauthn/credentials", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; if (!userId) { @@ -155,6 +169,22 @@ export function registerUserWebAuthnRoutes( }); }); + /** + * @openapi + * /users/webauthn/register/options: + * post: + * summary: Start passkey registration + * description: Generates WebAuthn registration options for the authenticated user. + * tags: + * - WebAuthn + * responses: + * 200: + * description: Registration options and challenge id. + * 401: + * description: Authentication required. + * 404: + * description: User not found. + */ router.post( "/webauthn/register/options", authenticateJWT, @@ -213,6 +243,22 @@ export function registerUserWebAuthnRoutes( }, ); + /** + * @openapi + * /users/webauthn/register/verify: + * post: + * summary: Finish passkey registration + * description: Verifies the WebAuthn registration response and stores the passkey. + * tags: + * - WebAuthn + * responses: + * 200: + * description: Passkey registered. + * 400: + * description: Registration failed or challenge expired. + * 401: + * description: Authentication required. + */ router.post( "/webauthn/register/verify", authenticateJWT, @@ -282,6 +328,20 @@ export function registerUserWebAuthnRoutes( }, ); + /** + * @openapi + * /users/webauthn/authenticate/options: + * post: + * summary: Start passkey login + * description: Generates WebAuthn authentication options, optionally scoped to a username. + * tags: + * - WebAuthn + * responses: + * 200: + * description: Authentication options and challenge id. + * 404: + * description: No passkeys found for the user. + */ router.post("/webauthn/authenticate/options", async (req, res) => { const origin = getRequestOrigin(req); const rpID = getRpID(origin); @@ -333,6 +393,22 @@ export function registerUserWebAuthnRoutes( res.json({ options, challengeId }); }); + /** + * @openapi + * /users/webauthn/authenticate/verify: + * post: + * summary: Finish passkey login + * description: Verifies the WebAuthn assertion and issues a session token (or a TOTP challenge). + * tags: + * - WebAuthn + * responses: + * 200: + * description: Login succeeded or TOTP verification required. + * 400: + * description: Challenge expired or invalid response. + * 401: + * description: Passkey not recognized or authentication failed. + */ router.post("/webauthn/authenticate/verify", async (req, res) => { const challenge = takeChallenge( authenticationChallenges, @@ -468,6 +544,25 @@ export function registerUserWebAuthnRoutes( } }); + /** + * @openapi + * /users/webauthn/credentials/{credentialId}: + * delete: + * summary: Delete a passkey + * description: Removes one of the authenticated user's passkeys. + * tags: + * - WebAuthn + * parameters: + * - in: path + * name: credentialId + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Passkey deleted. + * 401: + * description: Authentication required. + */ router.delete( "/webauthn/credentials/:credentialId", authenticateJWT, diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 08c99ef5..3f576808 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -32,6 +32,7 @@ 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 { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js"; import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; @@ -2425,6 +2426,12 @@ registerUserTotpRoutes(router, { isNativeAppRequest, }); +registerUserWebAuthnRoutes(router, { + authenticateJWT, + authManager, + isNativeAppRequest, +}); + /** * @openapi * /users/delete-user: diff --git a/src/backend/hosts/tmux/index.ts b/src/backend/hosts/tmux/index.ts index c50bf7bd..6d91ccce 100644 --- a/src/backend/hosts/tmux/index.ts +++ b/src/backend/hosts/tmux/index.ts @@ -54,10 +54,8 @@ interface TmuxSessionOverview extends TmuxSessionSummary { tags: string[]; } -// --------------------------------------------------------------------------- // SSH connection (lean variant of the per-module pattern used by server-stats // and docker; jump hosts and SOCKS5 reuse the shared helpers) -// --------------------------------------------------------------------------- async function buildSshConfig(host: SSHHost): Promise { const base: ConnectConfig = { @@ -241,9 +239,7 @@ async function withHostConnection( return withConnection(getPoolKey(host), connectToHost(host), fn); } -// --------------------------------------------------------------------------- // tmux queries -// --------------------------------------------------------------------------- async function tmuxAvailable(conn: Client): Promise { try { @@ -326,9 +322,7 @@ async function collectPaneMetrics( ); } -// --------------------------------------------------------------------------- // Express app -// --------------------------------------------------------------------------- const app = express(); const authManager = AuthManager.getInstance(); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index b7058045..67f0257d 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -38,58 +38,27 @@ import { port: process.env.PORT || 4090, }); - let version = "unknown"; - - const versionSources = [ - () => process.env.VERSION, - () => { + let version = process.env.VERSION || "unknown"; + if (version === "unknown") { + const candidates = [ + path.join(process.cwd(), "package.json"), + path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../../package.json", + ), + ]; + for (const packageJsonPath of candidates) { try { - const packageJsonPath = path.join(process.cwd(), "package.json"); const packageJson = JSON.parse( readFileSync(packageJsonPath, "utf-8"), ); - return packageJson.version; + if (packageJson.version) { + version = packageJson.version; + break; + } } catch { - return null; + // try the next location } - }, - () => { - try { - const __filename = fileURLToPath(import.meta.url); - const packageJsonPath = path.join( - path.dirname(__filename), - "../../../package.json", - ); - const packageJson = JSON.parse( - readFileSync(packageJsonPath, "utf-8"), - ); - return packageJson.version; - } catch { - return null; - } - }, - () => { - try { - const packageJsonPath = path.join("/app", "package.json"); - const packageJson = JSON.parse( - readFileSync(packageJsonPath, "utf-8"), - ); - return packageJson.version; - } catch { - return null; - } - }, - ]; - - for (const getVersion of versionSources) { - try { - const foundVersion = getVersion(); - if (foundVersion && foundVersion !== "unknown") { - version = foundVersion; - break; - } - } catch { - continue; } } versionLogger.info(`Termix Backend starting - Version: ${version}`, { @@ -152,15 +121,15 @@ import { }, ); - const dbServer = await import("./database/database.js"); - await (dbServer as unknown as { serverReady: Promise }).serverReady; + const { serverReady } = await import("./database/database.js"); + await serverReady; await import("./hosts/terminal/index.js"); await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/metrics/index.js"); await import("./hosts/docker/index.js"); await import("./hosts/docker/console.js"); - await import("./hosts/tmux/index.js"); // --- tmux-monitor --- + await import("./hosts/tmux/index.js"); await import("./hosts/serial.js"); await import("./services/dashboard.js"); await import("./services/homepage.js"); diff --git a/src/backend/tests/database/routes/host-enrollment-auth.test.ts b/src/backend/tests/database/routes/host-enrollment-auth.test.ts index 5687dc62..e73d6e53 100644 --- a/src/backend/tests/database/routes/host-enrollment-auth.test.ts +++ b/src/backend/tests/database/routes/host-enrollment-auth.test.ts @@ -4,9 +4,9 @@ const mocks = vi.hoisted(() => ({ isUserDataUnlocked: vi.fn(), })); -vi.mock("../../../utils/simple-db-ops.js", () => ({ - SimpleDBOps: { - isUserDataUnlocked: mocks.isUserDataUnlocked, +vi.mock("../../../utils/data-crypto.js", () => ({ + DataCrypto: { + canUserAccessData: mocks.isUserDataUnlocked, }, })); diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts deleted file mode 100644 index 1ec75a49..00000000 --- a/src/backend/utils/simple-db-ops.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; -import { DataCrypto } from "./data-crypto.js"; -import type { SQLiteTable } from "drizzle-orm/sqlite-core"; -import type { SQL } from "drizzle-orm"; - -type TableName = - | "users" - | "ssh_data" - | "ssh_credentials" - | "termix_identity_ca" - | "recent_activity" - | "socks5_proxy_presets"; - -class SimpleDBOps { - static async insert>( - table: SQLiteTable, - tableName: TableName, - data: T, - userId: string, - ): Promise { - const userDataKey = DataCrypto.validateUserAccess(userId); - - const tempId = data.id || `temp-${userId}-${Date.now()}`; - const dataWithTempId = { ...data, id: tempId }; - - const encryptedData = DataCrypto.encryptRecord( - tableName, - dataWithTempId, - userId, - userDataKey, - ); - - if (!data.id) { - delete encryptedData.id; - } - - const result = await getDb() - .insert(table) - .values(encryptedData) - .returning(); - - DatabaseSaveTrigger.triggerSave(`insert_${tableName}`); - - const decryptedResult = DataCrypto.decryptRecord( - tableName, - result[0], - userId, - userDataKey, - ); - - return decryptedResult as T; - } - - static async select>( - query: unknown, - tableName: TableName, - userId: string, - ): Promise { - const userDataKey = DataCrypto.getUserDataKey(userId); - if (!userDataKey) { - return []; - } - - const results = await query; - - const decryptedResults = DataCrypto.decryptRecords( - tableName, - results as T[], - userId, - userDataKey, - ); - - return decryptedResults; - } - - static async selectOne>( - query: unknown, - tableName: TableName, - userId: string, - ): Promise { - const userDataKey = DataCrypto.getUserDataKey(userId); - if (!userDataKey) { - return undefined; - } - - const result = await query; - if (!result) return undefined; - - const decryptedResult = DataCrypto.decryptRecord( - tableName, - result as T, - userId, - userDataKey, - ); - - return decryptedResult; - } - - static async update>( - table: SQLiteTable, - tableName: TableName, - where: unknown, - data: Partial, - userId: string, - ): Promise { - const userDataKey = DataCrypto.validateUserAccess(userId); - - const encryptedData = DataCrypto.encryptRecord( - tableName, - data, - userId, - userDataKey, - ); - - const result = await getDb() - .update(table) - .set(encryptedData) - .where(where as SQL | undefined) - .returning(); - - DatabaseSaveTrigger.triggerSave(`update_${tableName}`); - - const decryptedResults = DataCrypto.decryptRecords( - tableName, - result, - userId, - userDataKey, - ); - - return decryptedResults as T[]; - } - - static async delete( - table: SQLiteTable, - tableName: TableName, - where: unknown, - ): Promise { - const result = await getDb() - .delete(table) - .where(where as SQL | undefined) - .returning(); - - DatabaseSaveTrigger.triggerSave(`delete_${tableName}`); - - return result; - } - - static async healthCheck(userId: string): Promise { - return DataCrypto.canUserAccessData(userId); - } - - static isUserDataUnlocked(userId: string): boolean { - return DataCrypto.getUserDataKey(userId) !== null; - } - - static async selectEncrypted(query: unknown): Promise { - const results = await query; - - return results as unknown[]; - } -} - -export { SimpleDBOps, type TableName };