* 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

-70
View File
@@ -1,70 +0,0 @@
import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js";
/** Column order for CSV export; also the header row. */
const COLUMNS = [
"id",
"timestamp",
"username",
"userId",
"action",
"resourceType",
"resourceId",
"resourceName",
"success",
"ipAddress",
"userAgent",
"errorMessage",
"details",
] as const;
/**
* RFC 4180 field escaping.
*
* The leading-character guard is not part of RFC 4180: a field starting with
* `=`, `+`, `-` or `@` is treated as a formula by spreadsheet software, so an
* audit entry containing an attacker-chosen resource name could execute on
* open. Prefixing with a single quote neutralises that while keeping the value
* readable.
*/
export function escapeCsvField(value: unknown): string {
if (value === null || value === undefined) return "";
let text = typeof value === "boolean" ? String(value) : String(value);
if (/^[=+\-@\t\r]/.test(text)) {
text = `'${text}`;
}
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
export function toCsv(rows: AuditLogRecord[]): string {
const lines = [COLUMNS.join(",")];
for (const row of rows) {
lines.push(
COLUMNS.map((column) =>
escapeCsvField((row as Record<string, unknown>)[column]),
).join(","),
);
}
// Trailing newline so the file ends cleanly when appended to or concatenated.
return `${lines.join("\n")}\n`;
}
/**
* Newline-delimited JSON: one entry per line, which is what log shippers and
* SIEM bulk endpoints expect, and which streams without holding the whole set.
*/
export function toNdjson(rows: AuditLogRecord[]): string {
return (
rows.map((row) => JSON.stringify(row)).join("\n") +
(rows.length ? "\n" : "")
);
}
export function exportFilename(format: "csv" | "ndjson", now: Date): string {
const stamp = now.toISOString().slice(0, 19).replace(/[:T]/g, "-");
return `termix-audit-${stamp}.${format === "csv" ? "csv" : "ndjson"}`;
}
-152
View File
@@ -1,152 +0,0 @@
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
import { getCurrentSettingValue } from "../database/repositories/factory.js";
import { databaseLogger } from "./logger.js";
import type { AuditLogParams } from "./audit-logger.js";
export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL";
export const AUDIT_FORWARD_TOKEN_ENV = "AUDIT_LOG_FORWARD_TOKEN";
export const AUDIT_FORWARD_URL_SETTING = "audit_log_forward_url";
export const AUDIT_FORWARD_TOKEN_SETTING = "audit_log_forward_token";
/**
* Settings-table read that tolerates running before the database is up (unit
* tests, early startup): no readable setting behaves like an unset one.
*/
function readForwardSetting(key: string): string | null {
try {
return getCurrentSettingValue(key)?.trim() || null;
} catch {
return null;
}
}
/**
* How many consecutive failures before the forwarder stops complaining on every
* entry. It keeps trying, this only throttles the log noise, and it reports
* again once delivery recovers.
*/
const QUIET_AFTER_FAILURES = 5;
let consecutiveFailures = 0;
let quietened = false;
export interface AuditForwardTarget {
url: string;
token?: string;
}
export function auditForwardTarget(
env: NodeJS.ProcessEnv = process.env,
): AuditForwardTarget | null {
// An admin-entered collector wins over the deployment env var, so the UI
// can configure forwarding - and its token - without a restart.
const settingUrl = readForwardSetting(AUDIT_FORWARD_URL_SETTING);
const url = settingUrl ?? env[AUDIT_FORWARD_URL_ENV]?.trim();
if (!url) return null;
const token = settingUrl
? readForwardSetting(AUDIT_FORWARD_TOKEN_SETTING)
: env[AUDIT_FORWARD_TOKEN_ENV]?.trim();
return token ? { url, token } : { url };
}
/** The wire shape: one JSON object per entry, matching the export's NDJSON. */
export function forwardPayload(
entry: AuditLogParams,
now: Date,
): Record<string, unknown> {
return {
timestamp: now.toISOString(),
userId: entry.userId,
username: entry.username,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId ?? null,
resourceName: entry.resourceName ?? null,
success: entry.success,
ipAddress: entry.ipAddress ?? null,
userAgent: entry.userAgent ?? null,
errorMessage: entry.errorMessage ?? null,
details: entry.details ?? null,
};
}
/** Exposed for tests; forwarding state is process-wide otherwise. */
export function resetAuditForwarderState(): void {
consecutiveFailures = 0;
quietened = false;
}
/**
* Ships one entry to the configured collector.
*
* Never throws and never blocks the audited operation: a SIEM being unreachable
* must not stop Termix from recording locally, which stays the source of truth.
* Delivery goes through safeOutboundFetch so a misconfigured URL cannot be used
* to probe the internal network.
*/
export async function forwardAuditEntry(
entry: AuditLogParams,
now: Date = new Date(),
env: NodeJS.ProcessEnv = process.env,
): Promise<boolean> {
const target = auditForwardTarget(env);
if (!target) return false;
try {
const response = await safeOutboundFetch(target.url, {
method: "POST",
headers: {
"Content-Type": "application/x-ndjson",
...(target.token ? { Authorization: `Bearer ${target.token}` } : {}),
},
body: `${JSON.stringify(forwardPayload(entry, now))}\n`,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
noteFailure(`collector returned ${response.status}`, entry.action);
return false;
}
noteSuccess();
return true;
} catch (error) {
noteFailure(
error instanceof Error ? error.message : String(error),
entry.action,
);
return false;
}
}
function noteFailure(reason: string, action: string): void {
consecutiveFailures++;
if (quietened) return;
databaseLogger.warn("Failed to forward audit entry", {
operation: "audit_forward_failed",
action,
reason,
consecutiveFailures,
});
if (consecutiveFailures >= QUIET_AFTER_FAILURES) {
quietened = true;
databaseLogger.warn(
`Audit forwarding has failed ${consecutiveFailures} times; suppressing further messages until it recovers`,
{ operation: "audit_forward_suppressed" },
);
}
}
function noteSuccess(): void {
if (quietened) {
databaseLogger.info("Audit forwarding recovered", {
operation: "audit_forward_recovered",
afterFailures: consecutiveFailures,
});
}
consecutiveFailures = 0;
quietened = false;
}
+32
View File
@@ -0,0 +1,32 @@
import type { AuditLogParams } from "./audit-logger.js";
export interface AuditHookEntry extends AuditLogParams {
timestamp: Date;
}
type AuditHook = (entry: AuditHookEntry) => void;
const hooks = new Set<AuditHook>();
/** Registers a listener for accepted audit lines. Returns the remover. */
export function onAuditEntry(hook: AuditHook): () => void {
hooks.add(hook);
return () => {
hooks.delete(hook);
};
}
/** Never throws and never waits: a hook cannot delay the audited action. */
export function emitAuditEntry(params: AuditLogParams): void {
if (hooks.size === 0) return;
const entry: AuditHookEntry = { ...params, timestamp: new Date() };
for (const hook of hooks) {
queueMicrotask(() => {
try {
hook(entry);
} catch {
// a plugin hook must not break auditing
}
});
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import type { Request } from "express";
import { forwardAuditEntry } from "./audit-forwarder.js";
import { emitAuditEntry } from "./audit-hooks.js";
import {
createCurrentAuditLogRepository,
createCurrentUserRepository,
@@ -78,9 +78,9 @@ function isCoalescedRoutine(params: AuditLogParams): boolean {
export async function logAudit(params: AuditLogParams): Promise<void> {
if (isCoalescedRoutine(params)) return;
// Local storage is the source of truth and runs first; forwarding is a copy
// and must never delay or fail the audited operation.
void forwardAuditEntry(params).catch(() => {});
// Hooks (plugins copying the log elsewhere) never delay or fail the
// audited operation.
emitAuditEntry(params);
try {
await createCurrentAuditLogRepository().create({
+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",
+20
View File
@@ -245,10 +245,30 @@ function getUnsupportedPrivateKeyError(privateKeyData: string): string {
return "Unsupported private key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.";
}
const SECURITY_KEY_ERROR =
"This is a security key (FIDO2) key file. It only holds a handle, so the key itself must sign on your device. Load it with ssh-add and use SSH agent authentication in the desktop app.";
/** An OpenSSH private key file for a FIDO2 security key (sk-ssh-ed25519, sk-ecdsa). */
export function isSecurityKeyFile(privateKeyData: string): boolean {
const match = privateKeyData.match(
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/,
);
if (!match) return false;
try {
const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test(
body.toString("latin1"),
);
} catch {
return false;
}
}
export function preparePrivateKeyForSSH2(
privateKeyData: string,
passphrase?: string,
): Buffer {
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
let cleanKey = normalizePrivateKeyText(privateKeyData);
if (
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&