* 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:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

+90
View File
@@ -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",