* 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

+96 -14
View File
@@ -1,9 +1,13 @@
/**
* The desktop's link to a server, one row in sync_link. Secrets (the session
* token, proxy headers, basic auth) are stored encrypted with the system key.
* 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";
@@ -33,6 +37,14 @@ export interface BasicAuth {
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;
@@ -43,11 +55,14 @@ export interface SyncLink {
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;
@@ -90,7 +105,11 @@ async function fromRow(row: Row): Promise<SyncLink> {
sessionToken = null;
}
}
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
const scope = parseJson<{
disabled?: string[];
options?: Record<string, unknown>;
clientCert?: string;
}>(row.scope, {});
return {
id: row.id,
userId: row.userId,
@@ -101,10 +120,16 @@ async function fromRow(row: Row): Promise<SyncLink> {
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",
@@ -114,8 +139,20 @@ async function fromRow(row: Row): Promise<SyncLink> {
};
}
export async function getLink(): Promise<SyncLink | null> {
const [row] = await db().select().from(syncLink).limit(1);
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;
}
@@ -128,12 +165,40 @@ export interface NewLink {
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> {
await db().delete(syncLink);
if (link.clientCert) assertClientCert(link.clientCert);
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
await db()
.insert(syncLink)
.values({
@@ -149,6 +214,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
? 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,
@@ -157,7 +223,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
linkedAt: new Date().toISOString(),
});
await DatabaseSaveTrigger.forceSave("sync_link");
return (await getLink())!;
return (await getLink(link.userId))!;
}
export interface LinkPatch {
@@ -167,10 +233,12 @@ export interface LinkPatch {
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;
@@ -178,9 +246,13 @@ export interface LinkPatch {
lastSyncAt?: string | null;
}
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
const current = await getLink();
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)
@@ -210,8 +282,16 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
if (patch.account !== undefined) {
set.account = patch.account ? JSON.stringify(patch.account) : null;
}
if (patch.disabledTypes !== undefined) {
set.scope = JSON.stringify({ disabled: patch.disabledTypes });
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);
@@ -224,10 +304,12 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
DatabaseSaveTrigger.triggerSave("sync_link");
}
return getLink();
return getLink(current.userId);
}
export async function deleteLink(): Promise<void> {
await db().delete(syncLink);
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");
}