mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -16,6 +16,7 @@ import { nanoid } from "nanoid";
|
||||
import { inArray, isNotNull } from "drizzle-orm";
|
||||
import type { ExternalSessionRef } from "../auth/types.js";
|
||||
import { listSecondFactors } from "../auth/registry.js";
|
||||
import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sessions } from "../database/db/schema.js";
|
||||
@@ -50,6 +51,8 @@ interface JWTPayload {
|
||||
* from the token, so a refreshed token stays marked.
|
||||
*/
|
||||
pluginSession?: boolean;
|
||||
/** sessions.restriction, read from the row on verify. */
|
||||
restriction?: string | null;
|
||||
dataKeyWrap?: WrappedDataKey;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
@@ -69,6 +72,7 @@ interface AuthenticatedRequest extends Request {
|
||||
pendingTOTP?: boolean;
|
||||
dataKey?: Buffer;
|
||||
actingAdminUserId?: string;
|
||||
sessionRestriction?: string | null;
|
||||
}
|
||||
|
||||
interface RequestWithHeaders extends Request {
|
||||
@@ -129,6 +133,28 @@ export function allowsPluginSession(path: string): boolean {
|
||||
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
||||
}
|
||||
|
||||
// What a session that must set up a second factor first may call: who am I,
|
||||
// sign out, the login and factor routes, plugin bundles, and the plugins that
|
||||
// provide a second factor.
|
||||
const SETUP_SESSION_ALLOWLIST = [
|
||||
/^\/users\/(me|logout)$/,
|
||||
/^\/users\/auth\//,
|
||||
/^\/user-preferences\/look(\/|$)/,
|
||||
/^\/plugins(\/|$)/,
|
||||
/^\/plugin-assets\//,
|
||||
];
|
||||
|
||||
/** Whether a session restricted to second factor setup may call this path. */
|
||||
export function allowsSetupSession(path: string): boolean {
|
||||
if (SETUP_SESSION_ALLOWLIST.some((pattern) => pattern.test(path))) {
|
||||
return true;
|
||||
}
|
||||
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
|
||||
return (
|
||||
!!plugin && listSecondFactors().some((factor) => factor.pluginId === plugin)
|
||||
);
|
||||
}
|
||||
|
||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||
|
||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||
@@ -336,6 +362,11 @@ class AuthManager {
|
||||
deviceType?: DeviceType;
|
||||
deviceInfo?: string;
|
||||
externalSession?: ExternalSessionRef | null;
|
||||
/** Caps the lifetime, from the login policy. */
|
||||
maxAgeSeconds?: number | null;
|
||||
/** Stored on the session, see SECOND_FACTOR_SETUP. */
|
||||
restriction?: string | null;
|
||||
idleTimeoutSeconds?: number | null;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
@@ -355,6 +386,12 @@ class AuthManager {
|
||||
} else if (!expiresIn) {
|
||||
expiresIn = defaultExpiry;
|
||||
}
|
||||
if (
|
||||
options.maxAgeSeconds &&
|
||||
options.maxAgeSeconds * 1000 < this.parseExpiresIn(expiresIn)
|
||||
) {
|
||||
expiresIn = `${Math.floor(options.maxAgeSeconds)}s`;
|
||||
}
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
if (options.pendingTOTP) {
|
||||
@@ -385,6 +422,8 @@ class AuthManager {
|
||||
createdAt,
|
||||
expiresAt,
|
||||
lastActiveAt: createdAt,
|
||||
restriction: options.restriction ?? null,
|
||||
idleTimeout: options.idleTimeoutSeconds ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create session", error, {
|
||||
@@ -433,6 +472,24 @@ class AuthManager {
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* A one minute socket ticket for an API key. The key has no login session,
|
||||
* so a two minute session a plugin could have minted carries the ticket. It
|
||||
* can open plugin sockets and call nothing else.
|
||||
*/
|
||||
async issueApiKeySocketTicket(
|
||||
userId: string,
|
||||
keyId: string,
|
||||
): Promise<string> {
|
||||
const { token } = await this.issuePluginSession(userId, {
|
||||
pluginId: "api-key",
|
||||
purpose: `socket ${keyId.slice(0, 8)}`,
|
||||
ttlSeconds: 120,
|
||||
});
|
||||
const sessionId = (jwt.decode(token) as JWTPayload).sessionId!;
|
||||
return this.issueSocketTicket(userId, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session a plugin mints for a user, to call core's API as them. It is
|
||||
* an ordinary session row, so it verifies, expires and shows up like a
|
||||
@@ -500,6 +557,19 @@ class AuthManager {
|
||||
}
|
||||
payload.pluginSession =
|
||||
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
||||
payload.restriction = sessionRecord.restriction ?? null;
|
||||
|
||||
if (this.idleExpired(sessionRecord)) {
|
||||
databaseLogger.info("Session ended after idle timeout", {
|
||||
operation: "session_idle_timeout",
|
||||
sessionId: payload.sessionId,
|
||||
userId: payload.userId,
|
||||
});
|
||||
await createCurrentSessionRepository()
|
||||
.revoke(payload.sessionId)
|
||||
.catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.migrateDataKeyFromPayload(payload);
|
||||
} catch (dbError) {
|
||||
@@ -531,6 +601,15 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
private idleExpired(session: {
|
||||
idleTimeout: number | null;
|
||||
lastActiveAt: string;
|
||||
}): boolean {
|
||||
if (!session.idleTimeout) return false;
|
||||
const idleMs = Date.now() - new Date(session.lastActiveAt).getTime();
|
||||
return idleMs > session.idleTimeout * 1000;
|
||||
}
|
||||
|
||||
async refreshSessionToken(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
@@ -921,6 +1000,7 @@ class AuthManager {
|
||||
authReq.userId = payload.userId;
|
||||
authReq.sessionId = payload.sessionId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
authReq.sessionRestriction = payload.restriction ?? null;
|
||||
|
||||
if (authReq.headers[ADMIN_TARGET_USER_HEADER]) {
|
||||
const handled = await this.applyAdminImpersonation(
|
||||
@@ -1022,6 +1102,16 @@ class AuthManager {
|
||||
payload: JWTPayload,
|
||||
): boolean {
|
||||
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||
if (
|
||||
payload.restriction === SECOND_FACTOR_SETUP &&
|
||||
!allowsSetupSession(path)
|
||||
) {
|
||||
res.status(403).json({
|
||||
error: "Set up a second factor before using Termix",
|
||||
code: "SECOND_FACTOR_SETUP_REQUIRED",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
||||
res.status(403).json({
|
||||
error: "A session a plugin issued cannot use this route",
|
||||
|
||||
Reference in new issue
Block a user