mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +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,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");
|
||||
}
|
||||
Reference in new issue
Block a user