mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
v2.2.1 (#754)
* fix: rdp, desktop app, and mobile app login issues and guacd issues * fix: general fixes * fix: mobile/desktop login isssues * fix: mobile/desktop login isssues * fix: mobile/desktop login isssues * chore: format
This commit is contained in:
@@ -77,6 +77,172 @@ function isValidPort(port: unknown): port is number {
|
||||
return typeof port === "number" && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asPort(value: unknown): number | undefined {
|
||||
const port =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value, 10)
|
||||
: NaN;
|
||||
|
||||
return isValidPort(port) ? port : undefined;
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): number | undefined {
|
||||
const number =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value, 10)
|
||||
: NaN;
|
||||
|
||||
return Number.isInteger(number) ? number : undefined;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "off"].includes(normalized)) return false;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeImportTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((tag) => asString(tag))
|
||||
.filter((tag): tag is string => !!tag);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
type NormalizedImportedHost = Record<string, unknown> & {
|
||||
connectionType: string;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType?: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
pin?: unknown;
|
||||
enableTerminal?: unknown;
|
||||
enableTunnel?: unknown;
|
||||
enableFileManager?: unknown;
|
||||
enableDocker?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
showTunnelInSidebar?: unknown;
|
||||
showDockerInSidebar?: unknown;
|
||||
showServerStatsInSidebar?: unknown;
|
||||
defaultPath?: unknown;
|
||||
sudoPassword?: unknown;
|
||||
tunnelConnections?: unknown;
|
||||
jumpHosts?: unknown;
|
||||
quickActions?: unknown;
|
||||
statsConfig?: unknown;
|
||||
dockerConfig?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
forceKeyboardInteractive?: unknown;
|
||||
notes?: unknown;
|
||||
useSocks5?: unknown;
|
||||
socks5Host?: unknown;
|
||||
socks5Port?: unknown;
|
||||
socks5Username?: unknown;
|
||||
socks5Password?: unknown;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: unknown;
|
||||
overrideCredentialUsername?: unknown;
|
||||
domain?: unknown;
|
||||
security?: unknown;
|
||||
ignoreCert?: unknown;
|
||||
guacamoleConfig?: unknown;
|
||||
enableSsh: boolean;
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
};
|
||||
|
||||
function normalizeImportedHost(
|
||||
hostData: Record<string, unknown>,
|
||||
): NormalizedImportedHost {
|
||||
const connectionType =
|
||||
asString(hostData.connectionType) ||
|
||||
(asBoolean(hostData.enableRdp)
|
||||
? "rdp"
|
||||
: asBoolean(hostData.enableVnc)
|
||||
? "vnc"
|
||||
: asBoolean(hostData.enableTelnet)
|
||||
? "telnet"
|
||||
: "ssh");
|
||||
|
||||
const port =
|
||||
asPort(hostData.port) ||
|
||||
(connectionType === "rdp"
|
||||
? asPort(hostData.rdpPort) || 3389
|
||||
: connectionType === "vnc"
|
||||
? asPort(hostData.vncPort) || 5900
|
||||
: connectionType === "telnet"
|
||||
? asPort(hostData.telnetPort) || 23
|
||||
: asPort(hostData.sshPort) || 22);
|
||||
|
||||
return {
|
||||
...hostData,
|
||||
connectionType,
|
||||
name: asString(hostData.name) || asString(hostData.label),
|
||||
ip:
|
||||
asString(hostData.ip) ||
|
||||
asString(hostData.address) ||
|
||||
asString(hostData.host) ||
|
||||
asString(hostData.hostname),
|
||||
port,
|
||||
username: asString(hostData.username) || asString(hostData.user),
|
||||
folder: asString(hostData.folder) || asString(hostData.group),
|
||||
tags: normalizeImportTags(hostData.tags),
|
||||
credentialId: asInteger(hostData.credentialId),
|
||||
authType:
|
||||
asString(hostData.authType) ||
|
||||
asString(hostData.authMethod) ||
|
||||
(hostData.credentialId ? "credential" : hostData.key ? "key" : undefined),
|
||||
enableSsh:
|
||||
hostData.enableSsh === undefined
|
||||
? connectionType === "ssh"
|
||||
: asBoolean(hostData.enableSsh),
|
||||
enableRdp:
|
||||
hostData.enableRdp === undefined
|
||||
? connectionType === "rdp"
|
||||
: asBoolean(hostData.enableRdp),
|
||||
enableVnc:
|
||||
hostData.enableVnc === undefined
|
||||
? connectionType === "vnc"
|
||||
: asBoolean(hostData.enableVnc),
|
||||
enableTelnet:
|
||||
hostData.enableTelnet === undefined
|
||||
? connectionType === "telnet"
|
||||
: asBoolean(hostData.enableTelnet),
|
||||
};
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
@@ -1351,19 +1517,26 @@ router.get(
|
||||
const ownHosts = rawData.filter((row) => row.userId === userId);
|
||||
const sharedHosts = rawData.filter((row) => row.userId !== userId);
|
||||
|
||||
let decryptedOwnHosts: Record<string, unknown>[] = [];
|
||||
try {
|
||||
decryptedOwnHosts = await SimpleDBOps.select(
|
||||
Promise.resolve(ownHosts),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
} catch (decryptError) {
|
||||
sshLogger.error("Failed to decrypt own hosts", decryptError, {
|
||||
operation: "host_fetch_own_decrypt_failed",
|
||||
userId,
|
||||
});
|
||||
decryptedOwnHosts = [];
|
||||
const decryptedOwnHosts: Record<string, unknown>[] = [];
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (userDataKey) {
|
||||
for (const host of ownHosts) {
|
||||
try {
|
||||
decryptedOwnHosts.push(
|
||||
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey),
|
||||
);
|
||||
} catch (decryptError) {
|
||||
sshLogger.warn("Skipping host with invalid encrypted fields", {
|
||||
operation: "host_fetch_own_decrypt_failed",
|
||||
userId,
|
||||
hostId: host.id,
|
||||
error:
|
||||
decryptError instanceof Error
|
||||
? decryptError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedSharedHosts = sharedHosts;
|
||||
@@ -3374,7 +3547,7 @@ router.post(
|
||||
}
|
||||
|
||||
for (let i = 0; i < hostsToImport.length; i++) {
|
||||
const hostData = hostsToImport[i];
|
||||
const hostData = normalizeImportedHost(hostsToImport[i]);
|
||||
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { snippets, snippetFolders } from "../db/schema.js";
|
||||
import { eq, and, desc, asc, sql } from "drizzle-orm";
|
||||
import {
|
||||
snippets,
|
||||
snippetFolders,
|
||||
snippetAccess,
|
||||
users,
|
||||
userRoles,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, asc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
@@ -15,6 +21,92 @@ function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
async function getUserRoleIds(userId: string): Promise<number[]> {
|
||||
const rows = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
|
||||
return rows.map((row) => row.roleId);
|
||||
}
|
||||
|
||||
function roleIdFilter(roleIds: number[]) {
|
||||
if (roleIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sql`${snippetAccess.roleId} IN (${sql.join(
|
||||
roleIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`;
|
||||
}
|
||||
|
||||
function activeSnippetAccessFilter(userId: string, roleIds: number[]) {
|
||||
const roleFilter = roleIdFilter(roleIds);
|
||||
const targetFilter = roleFilter
|
||||
? or(eq(snippetAccess.userId, userId), roleFilter)
|
||||
: eq(snippetAccess.userId, userId);
|
||||
|
||||
return and(
|
||||
targetFilter,
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, new Date().toISOString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sortSnippets<
|
||||
T extends { folder: string | null; order: number; updatedAt: string },
|
||||
>(a: T, b: T) {
|
||||
const aFolder = a.folder || "";
|
||||
const bFolder = b.folder || "";
|
||||
|
||||
if (!aFolder && bFolder) return -1;
|
||||
if (aFolder && !bFolder) return 1;
|
||||
if (aFolder !== bFolder) return aFolder.localeCompare(bFolder);
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
}
|
||||
|
||||
async function getAccessibleSnippet(snippetId: number, userId: string) {
|
||||
const owned = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (owned.length > 0) {
|
||||
return owned[0];
|
||||
}
|
||||
|
||||
const roleIds = await getUserRoleIds(userId);
|
||||
const shared = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.snippetId, snippetId),
|
||||
activeSnippetAccessFilter(userId, roleIds),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return shared[0] ?? null;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
@@ -618,22 +710,12 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const snippetResult = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.id, parseInt(snippetId)),
|
||||
eq(snippets.userId, userId),
|
||||
),
|
||||
);
|
||||
const snippet = await getAccessibleSnippet(parseInt(snippetId), userId);
|
||||
|
||||
if (snippetResult.length === 0) {
|
||||
if (!snippet) {
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
const snippet = snippetResult[0];
|
||||
|
||||
const { Client } = await import("ssh2");
|
||||
const { hosts, sshCredentials } = await import("../db/schema.js");
|
||||
|
||||
@@ -869,7 +951,7 @@ router.get(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
const ownedSnippets = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
@@ -880,6 +962,43 @@ router.get(
|
||||
desc(snippets.updatedAt),
|
||||
);
|
||||
|
||||
const roleIds = await getUserRoleIds(userId);
|
||||
const sharedSnippets = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(activeSnippetAccessFilter(userId, roleIds));
|
||||
|
||||
const visibleSnippets = new Map<number, Record<string, unknown>>();
|
||||
for (const snippet of ownedSnippets) {
|
||||
visibleSnippets.set(snippet.id, { ...snippet, isShared: false });
|
||||
}
|
||||
for (const snippet of sharedSnippets) {
|
||||
if (visibleSnippets.has(snippet.id)) continue;
|
||||
visibleSnippets.set(snippet.id, { ...snippet, isShared: true });
|
||||
}
|
||||
|
||||
const result = Array.from(visibleSnippets.values()).sort((a, b) =>
|
||||
sortSnippets(
|
||||
a as { folder: string | null; order: number; updatedAt: string },
|
||||
b as { folder: string | null; order: number; updatedAt: string },
|
||||
),
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch snippets", err);
|
||||
@@ -930,16 +1049,13 @@ router.get(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
||||
const result = await getAccessibleSnippet(snippetId, userId);
|
||||
|
||||
if (result.length === 0) {
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
res.json(result[0]);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch snippet", err);
|
||||
res.status(500).json({
|
||||
|
||||
@@ -224,6 +224,13 @@ function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function isNativeAppRequest(req: Request): boolean {
|
||||
return (
|
||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||
req.get("X-Electron-App") === "true"
|
||||
);
|
||||
}
|
||||
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
@@ -1575,6 +1582,7 @@ router.post("/login", async (req, res) => {
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
};
|
||||
|
||||
const timeoutRow = db.$client
|
||||
@@ -1683,6 +1691,33 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me/token:
|
||||
* get:
|
||||
* summary: Get current session token
|
||||
* description: Returns the JWT for the currently authenticated session. Intended for mobile WebView clients that cannot read HTTP-only cookies.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Current session token.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
*/
|
||||
router.get("/me/token", authenticateJWT, (req: Request, res: Response) => {
|
||||
const token = (req as Request & { cookies: Record<string, string> }).cookies
|
||||
?.jwt;
|
||||
res.json({ token: token || null });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/setup-required:
|
||||
@@ -3393,6 +3428,7 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
userId: userRecord.id,
|
||||
is_oidc: !!userRecord.isOidc,
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
};
|
||||
|
||||
const timeoutRow = db.$client
|
||||
|
||||
Reference in New Issue
Block a user