Files
Termix/src/backend/sync/host.ts
T
a89ccc6b07 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>
2026-10-20 16:03:29 -05:00

545 lines
18 KiB
TypeScript

/**
* What the sync provider plugin gets from core through ctx.sync.host: the
* replica store, the change feed, the linked server registry, sessions and
* a few helpers. Core stays neutral; the plugin decides what to do with it.
*/
import fs from "fs";
import os from "os";
import path from "path";
import * as tar from "tar";
import { and, eq, isNotNull } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { AuthManager } from "../utils/auth-manager.js";
import { getLocalVersion } from "../utils/app-version.js";
import { syncLogger } from "../utils/logger.js";
import { sendCoreAlert } from "../notify/core-notify.js";
import { getPluginRuntime } from "../plugins/index.js";
import {
getEntity,
listEntities,
onEntitiesChanged,
type RegisteredSyncEntity,
} from "../plugins/sync-registry.js";
import {
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentPluginPermissionGrantRepository,
createCurrentPluginRepository,
createCurrentRepositoryContext,
createCurrentSessionRepository,
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { resolveBrandingSettings } from "../database/routes/branding-settings.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import {
createResolvers,
deleteStoredRow,
listStoredRows,
loadWireRow,
loadWireRows,
regenerateSyncIds,
writeWireRow,
type Resolvers,
} from "./store.js";
import {
clearRecordErrors,
deleteConflict,
deleteRecordsForUser,
getConflict,
listChanges,
listConflicts,
listRecordErrors,
listRecords,
putRecord,
recordKey,
saveConflict,
setRecordError,
} from "./records.js";
import { orderSelfReferences } from "./wire.js";
import { onLocalWrite } from "./server/write-listeners.js";
import {
createLink,
deleteLink,
getLink,
listLinks,
updateLink,
} from "./client/link-store.js";
import {
createAccount,
getActiveUserId,
listAccounts,
removeAccount,
setActiveUserId,
} from "./accounts.js";
import {
normalizeServerUrl,
RemoteError,
remoteFetch,
remoteJson,
} from "./client/http.js";
import { applyElectronProxyConfig } from "./client/electron-proxy.js";
import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js";
import { defaultDeviceName } from "./client/device.js";
function entityOf(type: string): RegisteredSyncEntity {
const entity = getEntity(type);
if (!entity) throw new Error(`Unknown sync entity "${type}"`);
return entity;
}
function info(entity: RegisteredSyncEntity) {
return {
type: entity.type,
owner: entity.owner,
order: entity.order,
readOnly: !!entity.readOnly,
covers: entity.covers,
};
}
/** Deletes every row that syncs, keeping local-only ones. */
async function wipeSyncedData(userId: string): Promise<void> {
const entities = [...listEntities()].reverse();
for (const entity of entities) {
if (entity.readOnly) continue;
try {
if (entity.load) {
for (const row of await entity.load(userId)) {
await entity.erase?.(userId, String(row.syncId));
}
continue;
}
if (!entity.table || entity.singleton) continue;
for (const row of await listStoredRows(entity, userId)) {
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
if (typeof row.syncId !== "string") continue;
await deleteStoredRow(entity, userId, row.syncId);
}
} catch (error) {
syncLogger.warn("Could not clear synced data", {
operation: "sync_wipe",
entityType: entity.type,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/** Removes read-only copies of what other users shared with the account. */
async function removeSharedCopies(userId: string): Promise<void> {
const db = createCurrentRepositoryContext().drizzle;
const sharedHosts = await db
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
const sharedCredentials = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
isNotNull(sshCredentials.sharedSource),
),
);
const { deleteOwnedCredential } =
await import("../hosts/delete-credential.js");
for (const credential of sharedCredentials) {
await deleteOwnedCredential(userId, credential.id);
}
}
async function ensureCoreEntities(): Promise<void> {
const { registerCoreSyncEntities } = await import("./entities.js");
registerCoreSyncEntities();
}
async function counts(userId: string): Promise<Record<string, number>> {
await ensureCoreEntities();
const result: Record<string, number> = {};
for (const entity of listEntities()) {
if (entity.readOnly) continue;
try {
if (entity.load) {
result[entity.type] = (await entity.load(userId)).length;
continue;
}
if (!entity.table) continue;
result[entity.type] = (await listStoredRows(entity, userId)).filter(
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
).length;
} catch {
result[entity.type] = 0;
}
}
return result;
}
async function listPluginsForDesktop() {
const records = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record,
]),
);
const grants = createCurrentPluginPermissionGrantRepository();
const plugins = [];
for (const plugin of getPluginRuntime().loader.list()) {
plugins.push({
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
// A bundled plugin updated from a signed .tmxplug can be mirrored.
artifact: !!plugin.artifact,
enabled: records.get(plugin.id)?.state === "enabled",
active: plugin.state === "active",
desktop: resolvePluginDesktopMode(plugin.manifest),
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
granted: (await grants.listByPlugin(plugin.id)).map(
(grant) => grant.capability,
),
});
}
const { customRegistryKeys } = await import("../plugins/registries.js");
return { plugins, registryKeys: await customRegistryKeys() };
}
async function sendPackage(
pluginId: string,
target: unknown,
): Promise<boolean> {
const res = target as Response;
const plugin = getPluginRuntime()
.loader.list()
.find((candidate) => candidate.id === pluginId);
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false;
const artifact = plugin.artifact ?? "";
res.setHeader("Content-Type", "application/gzip");
if (fs.existsSync(artifact)) {
const sig = `${artifact}.sig`;
if (fs.existsSync(sig)) {
res.setHeader(
"X-Termix-Signature",
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"),
);
}
fs.createReadStream(artifact).pipe(res);
return true;
}
const temp = path.join(
os.tmpdir(),
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
);
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
const stream = fs.createReadStream(temp);
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
stream.pipe(res);
return true;
}
export function createSyncHost(): PluginSyncHost {
const authManager = AuthManager.getInstance();
return {
embedded: process.env.ELECTRON_EMBEDDED === "true",
info: {
appName: async () => {
try {
return (
await resolveBrandingSettings(createCurrentSettingsRepository())
).appName;
} catch {
return "Termix";
}
},
version: () => getLocalVersion(),
},
entities: {
list: () => listEntities().map(info),
get: (type) => {
const entity = getEntity(type);
return entity ? info(entity) : undefined;
},
ensureCore: () => ensureCoreEntities(),
onChanged: (listener) => onEntitiesChanged(listener),
onLocalWrite: (listener) => onLocalWrite(listener),
},
replica: {
resolvers: (userId, onMissing) => createResolvers(userId, onMissing),
loadRows: async (type, userId, resolvers) => {
const result = await loadWireRows(
entityOf(type),
userId,
resolvers as Resolvers,
);
return { rows: result.rows, failed: result.failed };
},
loadRow: (type, userId, syncId, resolvers) =>
loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers),
writeRow: async (type, userId, wire, resolvers) => {
await writeWireRow(
entityOf(type),
userId,
wire,
resolvers as Resolvers,
);
},
deleteRow: async (type, userId, syncId) => {
await deleteStoredRow(entityOf(type), userId, syncId);
},
orderSelfReferences: (type, rows) =>
orderSelfReferences(entityOf(type), rows as SyncRow[]),
counts: (userId) => counts(userId),
wipe: (userId) => wipeSyncedData(userId),
regenerateSyncIds: async (userId) => {
for (const entity of listEntities()) {
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
}
},
removeSharedCopies: (userId) => removeSharedCopies(userId),
records: {
list: (userId) => listRecords(userId) as never,
put: (userId, type, syncId, values) =>
putRecord(userId, type, syncId, values),
setError: (userId, type, syncId, reason, hash) =>
setRecordError(userId, type, syncId, reason, hash),
clearErrors: (userId) => clearRecordErrors(userId),
listErrors: (userId) => listRecordErrors(userId) as never,
deleteForUser: (userId) => deleteRecordsForUser(userId),
key: (type, syncId) => recordKey(type, syncId),
},
conflicts: {
save: (userId, type, syncId, row, revision) =>
saveConflict(userId, type, syncId, row, revision),
list: (userId) => listConflicts(userId) as never,
get: (userId, id) => getConflict(userId, id) as never,
delete: (userId, id) => deleteConflict(userId, id),
},
},
feed: {
withUserLock: async (userId, run) => {
const { withUserLock } = await import("./server/feed.js");
return withUserLock(userId, run);
},
changes: async (userId, cursor, limit, types) => {
const { withUserLock, reconcileUser } =
await import("./server/feed.js");
const page = await withUserLock(userId, async () => {
const { wire } = await reconcileUser(userId);
const records = await listChanges(userId, cursor, limit + 1, types);
return { wire, records };
});
const hasMore = page.records.length > limit;
const records = [];
for (const record of page.records.slice(0, limit)) {
if (!getEntity(record.entityType)) continue;
const row = record.deleted
? null
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
null);
// A live record with no row failed to serialize; it comes again once
// it changes, rather than arriving as a delete.
if (!record.deleted && !row) continue;
records.push({
seq: record.seq,
entityType: record.entityType,
syncId: record.syncId,
revision: record.revision,
deleted: !!record.deleted,
row,
});
}
const slice = page.records.slice(0, limit);
return {
records,
hasMore,
nextCursor: slice.length ? slice[slice.length - 1].seq : cursor,
entityTypes: listEntities().map((entity) => ({
type: entity.type,
owner: entity.owner,
order: entity.order,
readOnly: !!entity.readOnly,
})),
} as never;
},
parsePushOps: async (body) => {
const { parsePushOps } = await import("./server/push.js");
return parsePushOps(body) as never;
},
applyPush: async (userId, ops) => {
const { withUserLock } = await import("./server/feed.js");
const { applyPush } = await import("./server/push.js");
return withUserLock(userId, () =>
applyPush(userId, ops as never),
) as never;
},
listen: async (userId, res) => {
const { addListener } = await import("./server/feed.js");
return addListener(userId, res as Response);
},
},
links: {
list: () => listLinks() as never,
get: (userId) => getLink(userId) as never,
create: (link) => createLink(link) as never,
update: (patch, userId) => updateLink(patch, userId) as never,
remove: (userId) => deleteLink(userId),
},
accounts: {
list: () => listAccounts(),
create: () => createAccount(),
setActive: (userId) => setActiveUserId(userId),
activeId: () => getActiveUserId(),
remove: (userId) => removeAccount(userId),
},
http: {
json: (target, pathname, request) =>
remoteJson(target, pathname, request),
fetch: ((target, pathname, request) =>
remoteFetch(target, pathname, request)) as never,
normalizeServerUrl,
isRemoteError: ((error: unknown) =>
error instanceof RemoteError) as never,
},
sessions: {
issueDesktop: (userId, options) =>
authManager.generateJWTToken(userId, {
expiresIn: `${options.days}d`,
deviceType: "desktop",
deviceInfo: options.deviceInfo,
}),
revoke: async (sessionId) => {
await authManager.revokeSession(sessionId);
},
find: async (sessionId) => {
const session =
await createCurrentSessionRepository().findById(sessionId);
return session
? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo }
: null;
},
findUser: async (userId) => {
const user = await createCurrentUserRepository().findById(userId);
return user ? { id: user.id, username: user.username } : null;
},
verify: async (token) => {
const payload = await authManager.verifyJWTToken(token);
return payload
? { userId: payload.userId, sessionId: payload.sessionId }
: null;
},
setCookie: (req, res, value, options) => {
(res as Response).cookie(
options?.name ?? "jwt",
value,
authManager.getSecureCookieOptions(
req as Request,
(options?.days ?? 30) * 24 * 60 * 60 * 1000,
),
);
},
clearCookie: (req, res, name) => {
(res as Response).clearCookie(
name ?? "jwt",
authManager.getClearCookieOptions(req as Request),
);
},
middleware: () => authManager.createAuthMiddleware(),
desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => {
if (process.env.ELECTRON_EMBEDDED !== "true") {
res.status(404).json({ error: "Not found" });
return;
}
next();
},
},
hosts: {
findForSync: async (userId, syncId) => {
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncId(
syncId,
);
if (hostId === null) return null;
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess) return null;
const row = await createCurrentHostRepository().findById(hostId);
return { id: hostId, name: row?.name ?? row?.ip ?? null };
},
statusBySyncId: async (userId) => {
const { hostStatus } =
await import("../plugins/host-status-provider.js");
const { PermissionManager } =
await import("../utils/permission-manager.js");
const statuses = await hostStatus.statusesFor(userId, null);
const entries = [...statuses.entries()];
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
userId,
entries.map(([id]) => id),
);
const hostRepository = createCurrentHostRepository();
const result: Record<string, unknown> = {};
for (const [id, entry] of entries) {
if (!allowed.has(id)) continue;
const row = await hostRepository.findById(id);
if (row?.syncId) result[row.syncId] = entry;
}
return result;
},
},
plugins: {
listForDesktop: listPluginsForDesktop as never,
sendPackage,
mirror: (link) => mirrorPlugins(link as never),
lastRemote: () => lastRemotePlugins() as never,
localNames: () => {
const names = new Map<string, string>();
for (const plugin of getPluginRuntime().loader.list()) {
names.set(plugin.id, plugin.manifest.name);
}
return names;
},
},
desktop: {
applyProxy: (link) => applyElectronProxyConfig(link as never),
deviceName: defaultDeviceName,
},
alerts: {
send: (alert) =>
sendCoreAlert({
title: alert.title,
body: alert.body,
severity: alert.severity,
category: alert.category,
dedupeKey: alert.dedupeKey,
audience: "admins",
link: alert.link,
} as never),
},
};
}