mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* 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>
316 lines
9.6 KiB
TypeScript
316 lines
9.6 KiB
TypeScript
/**
|
|
* The desktop's links to servers, one row each in sync_link and at most one
|
|
* per local user, since each link has its own replica user. Secrets (the
|
|
* session token, proxy headers, basic auth) are stored encrypted with the
|
|
* system key. Without a user, a call means the active account's link.
|
|
*/
|
|
|
|
import { createSecureContext } from "node:tls";
|
|
import { eq } from "drizzle-orm";
|
|
import { getActiveUserId } from "../accounts.js";
|
|
import { syncLink } from "../../database/db/schema.js";
|
|
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
|
import {
|
|
decryptSystemSecret,
|
|
encryptSystemSecret,
|
|
} from "../../utils/system-secret-crypto.js";
|
|
|
|
export type LinkStatus =
|
|
"idle" | "syncing" | "offline" | "error" | "signed_out";
|
|
|
|
export interface LinkAccount {
|
|
remoteUserId?: string;
|
|
username?: string;
|
|
isAdmin?: boolean;
|
|
roles?: string[];
|
|
permissions?: string[];
|
|
}
|
|
|
|
export interface ProxyHeader {
|
|
name: string;
|
|
value: string;
|
|
}
|
|
|
|
export interface BasicAuth {
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
/** A PFX/P12 client certificate for servers that require mutual TLS. */
|
|
export interface ClientCert {
|
|
/** Base64 of the PFX/P12 file. */
|
|
pfx: string;
|
|
passphrase: string;
|
|
name: string | null;
|
|
}
|
|
|
|
export interface SyncLink {
|
|
id: number;
|
|
userId: string;
|
|
serverUrl: string;
|
|
serverName: string | null;
|
|
serverVersion: string | null;
|
|
sessionToken: string | null;
|
|
customHeaders: ProxyHeader[];
|
|
basicAuth: BasicAuth | null;
|
|
allowInvalidCertificate: boolean;
|
|
clientCert: ClientCert | null;
|
|
remoteUserId: string | null;
|
|
remoteUsername: string | null;
|
|
account: LinkAccount | null;
|
|
/** Entity types switched off on this device. */
|
|
disabledTypes: string[];
|
|
/** Extra connection options for this server, kept open for new ones. */
|
|
options: Record<string, unknown>;
|
|
/** Entity types this device has fully pulled at least once. */
|
|
knownTypes: string[];
|
|
cursor: number;
|
|
status: LinkStatus;
|
|
lastError: string | null;
|
|
linkedAt: string;
|
|
lastSyncAt: string | null;
|
|
}
|
|
|
|
type Row = typeof syncLink.$inferSelect;
|
|
|
|
function db() {
|
|
return createCurrentRepositoryContext().drizzle;
|
|
}
|
|
|
|
function parseJson<T>(value: string | null, fallback: T): T {
|
|
if (!value) return fallback;
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function decryptJson<T>(value: string | null, fallback: T): Promise<T> {
|
|
if (!value) return fallback;
|
|
try {
|
|
return JSON.parse(await decryptSystemSecret(value)) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function fromRow(row: Row): Promise<SyncLink> {
|
|
let sessionToken: string | null = null;
|
|
if (row.sessionToken) {
|
|
try {
|
|
sessionToken = await decryptSystemSecret(row.sessionToken);
|
|
} catch {
|
|
sessionToken = null;
|
|
}
|
|
}
|
|
const scope = parseJson<{
|
|
disabled?: string[];
|
|
options?: Record<string, unknown>;
|
|
clientCert?: string;
|
|
}>(row.scope, {});
|
|
return {
|
|
id: row.id,
|
|
userId: row.userId,
|
|
serverUrl: row.serverUrl,
|
|
serverName: row.serverName,
|
|
serverVersion: row.serverVersion,
|
|
sessionToken,
|
|
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
|
|
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
|
|
allowInvalidCertificate: !!row.allowInvalidCertificate,
|
|
clientCert: await decryptJson<ClientCert | null>(
|
|
scope.clientCert ?? null,
|
|
null,
|
|
),
|
|
remoteUserId: row.remoteUserId,
|
|
remoteUsername: row.remoteUsername,
|
|
account: parseJson<LinkAccount | null>(row.account, null),
|
|
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
|
|
options:
|
|
scope.options && typeof scope.options === "object" ? scope.options : {},
|
|
knownTypes: parseJson<string[]>(row.knownTypes, []),
|
|
cursor: Number(row.cursor) || 0,
|
|
status: (row.status as LinkStatus) || "idle",
|
|
lastError: row.lastError,
|
|
linkedAt: row.linkedAt,
|
|
lastSyncAt: row.lastSyncAt,
|
|
};
|
|
}
|
|
|
|
export async function listLinks(): Promise<SyncLink[]> {
|
|
const rows = await db().select().from(syncLink);
|
|
return Promise.all(rows.map(fromRow));
|
|
}
|
|
|
|
/** The link of one local user, or of the active account when none is given. */
|
|
export async function getLink(userId?: string): Promise<SyncLink | null> {
|
|
const owner = userId ?? (await getActiveUserId());
|
|
if (!owner) return null;
|
|
const [row] = await db()
|
|
.select()
|
|
.from(syncLink)
|
|
.where(eq(syncLink.userId, owner))
|
|
.limit(1);
|
|
return row ? fromRow(row) : null;
|
|
}
|
|
|
|
export interface NewLink {
|
|
userId: string;
|
|
serverUrl: string;
|
|
serverName?: string | null;
|
|
serverVersion?: string | null;
|
|
sessionToken: string;
|
|
customHeaders?: ProxyHeader[];
|
|
basicAuth?: BasicAuth | null;
|
|
allowInvalidCertificate?: boolean;
|
|
clientCert?: ClientCert | null;
|
|
remoteUserId?: string | null;
|
|
remoteUsername?: string | null;
|
|
}
|
|
|
|
/** Throws when the PFX is unreadable or the passphrase is wrong. */
|
|
export function assertClientCert(cert: ClientCert): void {
|
|
try {
|
|
createSecureContext({
|
|
pfx: Buffer.from(cert.pfx, "base64"),
|
|
passphrase: cert.passphrase,
|
|
});
|
|
} catch {
|
|
throw new Error("The client certificate or its passphrase is not valid");
|
|
}
|
|
}
|
|
|
|
async function sealScope(
|
|
disabled: string[],
|
|
options: Record<string, unknown>,
|
|
clientCert: ClientCert | null,
|
|
): Promise<string> {
|
|
return JSON.stringify({
|
|
disabled,
|
|
options,
|
|
...(clientCert
|
|
? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
export async function createLink(link: NewLink): Promise<SyncLink> {
|
|
if (link.clientCert) assertClientCert(link.clientCert);
|
|
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
|
|
await db()
|
|
.insert(syncLink)
|
|
.values({
|
|
userId: link.userId,
|
|
serverUrl: link.serverUrl,
|
|
serverName: link.serverName ?? null,
|
|
serverVersion: link.serverVersion ?? null,
|
|
sessionToken: await encryptSystemSecret(link.sessionToken),
|
|
customHeaders: link.customHeaders?.length
|
|
? await encryptSystemSecret(JSON.stringify(link.customHeaders))
|
|
: null,
|
|
basicAuth: link.basicAuth
|
|
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
|
|
: null,
|
|
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
|
scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null,
|
|
remoteUserId: link.remoteUserId ?? null,
|
|
remoteUsername: link.remoteUsername ?? null,
|
|
cursor: 0,
|
|
status: "idle",
|
|
knownTypes: "[]",
|
|
linkedAt: new Date().toISOString(),
|
|
});
|
|
await DatabaseSaveTrigger.forceSave("sync_link");
|
|
return (await getLink(link.userId))!;
|
|
}
|
|
|
|
export interface LinkPatch {
|
|
serverName?: string | null;
|
|
serverVersion?: string | null;
|
|
sessionToken?: string | null;
|
|
customHeaders?: ProxyHeader[];
|
|
basicAuth?: BasicAuth | null;
|
|
allowInvalidCertificate?: boolean;
|
|
clientCert?: ClientCert | null;
|
|
remoteUserId?: string | null;
|
|
remoteUsername?: string | null;
|
|
account?: LinkAccount | null;
|
|
disabledTypes?: string[];
|
|
options?: Record<string, unknown>;
|
|
knownTypes?: string[];
|
|
cursor?: number;
|
|
status?: LinkStatus;
|
|
lastError?: string | null;
|
|
lastSyncAt?: string | null;
|
|
}
|
|
|
|
export async function updateLink(
|
|
patch: LinkPatch,
|
|
userId?: string,
|
|
): Promise<SyncLink | null> {
|
|
const current = await getLink(userId);
|
|
if (!current) return null;
|
|
if (patch.clientCert) assertClientCert(patch.clientCert);
|
|
const set: Partial<Row> = {};
|
|
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
|
if (patch.serverVersion !== undefined)
|
|
set.serverVersion = patch.serverVersion;
|
|
if (patch.sessionToken !== undefined) {
|
|
set.sessionToken = patch.sessionToken
|
|
? await encryptSystemSecret(patch.sessionToken)
|
|
: null;
|
|
}
|
|
if (patch.customHeaders !== undefined) {
|
|
set.customHeaders = patch.customHeaders.length
|
|
? await encryptSystemSecret(JSON.stringify(patch.customHeaders))
|
|
: null;
|
|
}
|
|
if (patch.basicAuth !== undefined) {
|
|
set.basicAuth = patch.basicAuth
|
|
? await encryptSystemSecret(JSON.stringify(patch.basicAuth))
|
|
: null;
|
|
}
|
|
if (patch.allowInvalidCertificate !== undefined) {
|
|
set.allowInvalidCertificate = patch.allowInvalidCertificate;
|
|
}
|
|
if (patch.remoteUserId !== undefined) set.remoteUserId = patch.remoteUserId;
|
|
if (patch.remoteUsername !== undefined) {
|
|
set.remoteUsername = patch.remoteUsername;
|
|
}
|
|
if (patch.account !== undefined) {
|
|
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
|
}
|
|
if (
|
|
patch.disabledTypes !== undefined ||
|
|
patch.options !== undefined ||
|
|
patch.clientCert !== undefined
|
|
) {
|
|
set.scope = await sealScope(
|
|
patch.disabledTypes ?? current.disabledTypes,
|
|
patch.options ?? current.options,
|
|
patch.clientCert === undefined ? current.clientCert : patch.clientCert,
|
|
);
|
|
}
|
|
if (patch.knownTypes !== undefined) {
|
|
set.knownTypes = JSON.stringify(patch.knownTypes);
|
|
}
|
|
if (patch.cursor !== undefined) set.cursor = patch.cursor;
|
|
if (patch.status !== undefined) set.status = patch.status;
|
|
if (patch.lastError !== undefined) set.lastError = patch.lastError;
|
|
if (patch.lastSyncAt !== undefined) set.lastSyncAt = patch.lastSyncAt;
|
|
if (Object.keys(set).length > 0) {
|
|
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
|
|
DatabaseSaveTrigger.triggerSave("sync_link");
|
|
}
|
|
return getLink(current.userId);
|
|
}
|
|
|
|
export async function deleteLink(userId?: string): Promise<void> {
|
|
const owner = userId ?? (await getActiveUserId());
|
|
if (!owner) return;
|
|
await db().delete(syncLink).where(eq(syncLink.userId, owner));
|
|
await DatabaseSaveTrigger.forceSave("sync_link");
|
|
}
|