mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 21:33:41 +00:00
refactor(backend): final cleanup pass
- re-register WebAuthn passkey routes (registration was dropped in the #1054 merge, breaking passkey login) and document all six endpoints - delete utils/simple-db-ops.ts (last caller migrated to DataCrypto) - starter: use the typed serverReady export, collapse the four-way version lookup to env then package.json candidates - add OpenAPI JSDoc to c2s-tunnel-presets endpoints - strip block-divider comment banners
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1035,9 +1035,7 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPET SHARING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
|
||||
@@ -88,10 +88,8 @@ async function getActorUsername(userId: string): Promise<string> {
|
||||
return user?.username ?? userId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a
|
||||
// small HTML viewer for browsers, keyed by handle, with optional /<ALGO> 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<ConnectConfig> {
|
||||
const base: ConnectConfig = {
|
||||
@@ -241,9 +239,7 @@ async function withHostConnection<T>(
|
||||
return withConnection(getPoolKey(host), connectToHost(host), fn);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tmux queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
||||
try {
|
||||
@@ -326,9 +322,7 @@ async function collectPaneMetrics(
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Express app
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
+18
-49
@@ -38,58 +38,27 @@ import {
|
||||
port: process.env.PORT || 4090,
|
||||
});
|
||||
|
||||
let version = "unknown";
|
||||
|
||||
const versionSources = [
|
||||
() => process.env.VERSION,
|
||||
() => {
|
||||
try {
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(packageJsonPath, "utf-8"),
|
||||
);
|
||||
return packageJson.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const packageJsonPath = path.join(
|
||||
path.dirname(__filename),
|
||||
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",
|
||||
);
|
||||
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) {
|
||||
for (const packageJsonPath of candidates) {
|
||||
try {
|
||||
const foundVersion = getVersion();
|
||||
if (foundVersion && foundVersion !== "unknown") {
|
||||
version = foundVersion;
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(packageJsonPath, "utf-8"),
|
||||
);
|
||||
if (packageJson.version) {
|
||||
version = packageJson.version;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
// try the next location
|
||||
}
|
||||
}
|
||||
}
|
||||
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<void> }).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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<T extends Record<string, unknown>>(
|
||||
table: SQLiteTable,
|
||||
tableName: TableName,
|
||||
data: T,
|
||||
userId: string,
|
||||
): Promise<T> {
|
||||
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<T extends Record<string, unknown>>(
|
||||
query: unknown,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
|
||||
const decryptedResults = DataCrypto.decryptRecords<T>(
|
||||
tableName,
|
||||
results as T[],
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
static async selectOne<T extends Record<string, unknown>>(
|
||||
query: unknown,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T | undefined> {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = await query;
|
||||
if (!result) return undefined;
|
||||
|
||||
const decryptedResult = DataCrypto.decryptRecord<T>(
|
||||
tableName,
|
||||
result as T,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResult;
|
||||
}
|
||||
|
||||
static async update<T extends Record<string, unknown>>(
|
||||
table: SQLiteTable,
|
||||
tableName: TableName,
|
||||
where: unknown,
|
||||
data: Partial<T>,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
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<unknown[]> {
|
||||
const result = await getDb()
|
||||
.delete(table)
|
||||
.where(where as SQL | undefined)
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async healthCheck(userId: string): Promise<boolean> {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
}
|
||||
|
||||
static isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
static async selectEncrypted(query: unknown): Promise<unknown[]> {
|
||||
const results = await query;
|
||||
|
||||
return results as unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
export { SimpleDBOps, type TableName };
|
||||
Reference in New Issue
Block a user