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
@@ -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"}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
Reference in new issue
Block a user