* 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

+105
View File
@@ -0,0 +1,105 @@
/**
* Recent activity is a provider: core owns the callers (the dashboard routes,
* ctx.hosts.recordActivity, the cleanup when a user or host goes away) and one
* provider owns the storage. A plugin registers with
* ctx.hosts.activity.provide. With none, nothing is recorded and the list is
* empty. Core checks that the user may reach the host before it records.
*/
export interface ActivityEntry {
id: number;
userId: string;
type: string;
hostId: number;
hostName: string | null;
timestamp: string;
}
export type RecordActivityResult =
| { status: "logged"; id: number }
| { status: "rate_limited" }
| { status: "invalid_type" }
| { status: "denied" }
| { status: "unavailable" };
export interface ActivityProvider {
record(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult>;
list(userId: string, limit: number): Promise<ActivityEntry[]>;
deleteByUserId(userId: string): Promise<void>;
deleteByHostIds(hostIds: number[]): Promise<void>;
}
let plugged: { pluginId: string; provider: ActivityProvider } | null = null;
export function provideActivity(
pluginId: string,
provider: ActivityProvider,
): () => void {
if (plugged) {
throw new Error(
`Recent activity is already provided by plugin "${plugged.pluginId}"`,
);
}
const entry = { pluginId, provider };
plugged = entry;
return () => {
if (plugged === entry) plugged = null;
};
}
function active(): ActivityProvider | null {
return plugged?.provider ?? null;
}
async function canReachHost(userId: string, hostId: number): Promise<boolean> {
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
} = await import("../database/repositories/factory.js");
if (
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
)
) {
return true;
}
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const shared =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
return shared.some((access) => access.hostId === hostId);
}
/** What the rest of core calls. */
export const recentActivity = {
async record(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const provider = active();
if (!provider) return { status: "unavailable" };
if (!(await canReachHost(userId, entry.hostId))) {
return { status: "denied" };
}
return provider.record(userId, entry);
},
async list(userId: string, limit: number): Promise<ActivityEntry[]> {
return (await active()?.list(userId, limit)) ?? [];
},
async deleteByUserId(userId: string): Promise<void> {
await active()?.deleteByUserId(userId);
},
async deleteByHostId(hostId: number): Promise<void> {
await active()?.deleteByHostIds([hostId]);
},
async deleteByHostIds(hostIds: number[]): Promise<void> {
await active()?.deleteByHostIds(hostIds);
},
};
+36 -3
View File
@@ -1,4 +1,5 @@
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
// CSS files its themes declare.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
/** Changes whenever a theme's CSS does, for its ?v= link. */
themeVersion: string | null;
}
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
export const THEME_ASSET_PREFIX = "_themes/";
/** The declared CSS file of each of the plugin's themes, by theme id. */
function themeFiles(plugin: AssetPlugin): Map<string, string> {
const files = new Map<string, string>();
const manifest = plugin.manifest as PluginManifest;
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.resolve(plugin.dir, theme.css);
if (isInside(plugin.dir, file)) files.set(theme.id, file);
}
return files;
}
function frontendEntry(
@@ -72,9 +89,16 @@ export function describePluginFrontend(
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
};
if (!plugin) return none;
const themes = [...themeFiles(plugin).values()].filter(fileExists);
const themeVersion =
themes.length > 0
? contentVersion(themes.sort(), plugin.manifest.version)
: null;
const locales: string[] = [];
const localeFiles: string[] = [];
const dir = localesDir(plugin);
@@ -96,7 +120,8 @@ export function describePluginFrontend(
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
if (!entry || !fileExists(entry.file))
return { ...none, locales, themeVersion };
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
@@ -111,6 +136,7 @@ export function describePluginFrontend(
css,
assetVersion,
locales: locales.sort(),
themeVersion,
};
}
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
let root: string | null;
let rest: string;
if (clean.startsWith("locales/")) {
if (clean.startsWith(THEME_ASSET_PREFIX)) {
// Only a declared theme file, which the loader checked on load.
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
const file = themeFiles(plugin).get(themeId);
if (!file || !clean.endsWith(".css")) return null;
root = path.dirname(file);
rest = path.basename(file);
} else if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
+21 -1
View File
@@ -1,5 +1,5 @@
/**
* The onboarding defaults of the bundled plugins, from
* The onboarding defaults and the default look of the bundled plugins, from
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
* docker/bundled-plugins.json). Core never names plugins, so which ones a
* fresh install keeps lives in that file and not here.
@@ -27,6 +27,7 @@ interface BundledIndex {
consent?: boolean;
enabledByEnv?: string[];
};
defaultLook?: boolean;
}
>;
}
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
return cached;
}
/**
* Bundled plugins whose layout and theme a fresh install shows. Core reads
* the default look from here so it never has to name a plugin.
*/
export function defaultLookPluginIds(): string[] {
const index = readBundledIndex();
if (!index) return [];
return Object.entries(index.plugins)
.filter(([, entry]) => entry?.defaultLook === true)
.map(([id]) => id)
.sort();
}
export function isDefaultLookPlugin(pluginId: string): boolean {
return defaultLookPluginIds().includes(pluginId);
}
export function resetBundledIndexCache(): void {
cached = undefined;
}
@@ -96,6 +114,8 @@ export function initialPluginState(
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
}
+143
View File
@@ -0,0 +1,143 @@
/**
* ctx.audit.subscribe, list and exportBatches: reading the audit trail,
* behind audit:read. Writing lines (record) stays in ctx.ts.
*/
import type {
PluginAudit,
PluginAuditEntry,
PluginAuditFilters,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
import { onAuditEntry } from "../utils/audit-hooks.js";
import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const MAX_PAGE = 200;
const MAX_BATCH = 1000;
function toEntry(row: AuditLogRecord): PluginAuditEntry {
return {
id: row.id,
timestamp: new Date(
row.timestamp as unknown as string | number,
).toISOString(),
userId: row.userId ?? null,
username: row.username,
action: row.action,
resourceType: row.resourceType,
resourceId: row.resourceId ?? null,
resourceName: row.resourceName ?? null,
details: row.details ?? null,
ipAddress: row.ipAddress ?? null,
userAgent: row.userAgent ?? null,
success: Boolean(row.success),
errorMessage: row.errorMessage ?? null,
};
}
export function createPluginAuditReader(
deps: Deps,
): Pick<PluginAudit, "subscribe" | "list" | "exportBatches"> {
const { manifest, bag, audit } = deps;
const require = (action: string) =>
assertCapability(manifest.id, "audit:read", manifest.capabilities, action);
return {
subscribe: (handler) => {
if (!manifest.capabilities.includes("audit:read")) {
throw capabilityRefused(manifest.id, "audit:read", "audit_subscribe");
}
let remove: (() => void) | null = null;
let forgetBag: (() => void) | null = null;
// Granted state is checked once, then every line is delivered until
// unsubscribed; a revoked grant stops the next subscribe.
void require("audit_subscribe")
.then(async () => {
await audit("audit_subscribe", "listened to the audit log", {
success: true,
});
remove = onAuditEntry((entry) => {
handler({
timestamp: entry.timestamp.toISOString(),
userId: entry.userId,
username: entry.username,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId ?? null,
resourceName: entry.resourceName ?? null,
details: entry.details ?? null,
ipAddress: entry.ipAddress ?? null,
userAgent: entry.userAgent ?? null,
success: entry.success,
errorMessage: entry.errorMessage ?? null,
});
});
})
.catch(() => {});
forgetBag = bag.add(() => remove?.(), "audit subscription");
return () => {
remove?.();
remove = null;
forgetBag?.();
};
},
list: async (options) => {
await require("audit_list");
const { createCurrentAuditLogRepository } =
await import("../database/repositories/factory.js");
const limit = Math.min(MAX_PAGE, Math.max(1, options?.limit ?? 50));
const page = await createCurrentAuditLogRepository().listPage({
filters: options?.filters ?? {},
limit,
offset: Math.max(0, options?.offset ?? 0),
});
await audit("audit_list", "read audit log entries", { success: true });
return { entries: page.logs.map(toEntry), total: page.total };
},
exportBatches: async function* (
filters?: PluginAuditFilters,
batchSize?: number,
) {
await require("audit_export");
const { createCurrentAuditLogRepository } =
await import("../database/repositories/factory.js");
const limit = Math.min(MAX_BATCH, Math.max(1, batchSize ?? 500));
let offset = 0;
let exported = 0;
try {
for (;;) {
const rows = await createCurrentAuditLogRepository().listForExport({
filters: filters ?? {},
limit,
offset,
});
if (rows.length === 0) break;
exported += rows.length;
offset += rows.length;
yield rows.map(toEntry);
if (rows.length < limit) break;
}
} finally {
await audit("audit_export", `exported ${exported} audit log entries`, {
success: true,
});
}
},
};
}
+99
View File
@@ -150,6 +150,37 @@ export function createPluginCredentials({
}
},
list: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const list = rows.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
authType: String(row.authType ?? ""),
folder: row.folder ?? null,
}));
await audit("credentials_list", `${list.length} credentials`, {
success: true,
});
return list;
} catch (error) {
await audit("credentials_list", "saved credentials", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
createSshKey: async (input) => {
const details = `SSH key "${input?.name ?? ""}"`;
try {
@@ -194,6 +225,74 @@ export function createPluginCredentials({
}
},
publicKey: async (credentialId) => {
const details = `public key of credential ${credentialId}`;
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
if (!Number.isInteger(credentialId) || credentialId <= 0) {
throw new Error("A credential id is required");
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
userId,
);
const row = credential as unknown as Record<string, unknown> | null;
if (!row || row.authType !== "key") {
await audit("credentials_public_key", details, { success: true });
return null;
}
let publicKey =
typeof row.publicKey === "string" && row.publicKey.trim()
? row.publicKey.trim()
: null;
if (!publicKey) {
const stored = str(row.privateKey) || str(row.key);
if (stored) {
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(
userId,
stored,
typeof row.keyPassword === "string" ? row.keyPassword : null,
);
publicKey = derivePublicKey(resolved.key, resolved.passphrase);
}
}
await audit("credentials_public_key", details, { success: true });
return publicKey;
} catch (error) {
await audit("credentials_public_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
resolveKeyRefs: async (key, passphrase) => {
const details = "resolved key references";
try {
await assertCapability(pluginId, "credentials:read", declared);
const userId = actingUser();
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(userId, key, passphrase);
await audit("credentials_resolve_key", details, { success: true });
return resolved;
} catch (error) {
await audit("credentials_resolve_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw capabilityRefused(pluginId, "auth:provide");
+260
View File
@@ -0,0 +1,260 @@
/**
* ctx.desktop.local: the user's own machine, behind desktop:local.
*
* Only works on the desktop app's embedded backend. On a server every call
* throws, and available() is false. Path rules live in desktop-local/local-fs.
*/
import type {
PluginDesktopLocal,
PluginLocalPty,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
import * as localFs from "./desktop-local/local-fs.js";
import { remoteJson, requestHeaders } from "../sync/client/http.js";
import { spawnLocalPty } from "./desktop-local/local-pty.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
function isEmbedded(): boolean {
return process.env.ELECTRON_EMBEDDED === "true";
}
export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
const { manifest, bag, audit } = deps;
const audited = new Set<string>();
const guard = async (action: string, details: string, every = false) => {
await assertCapability(
manifest.id,
"desktop:local",
manifest.capabilities,
action,
);
if (!isEmbedded()) {
throw new Error(
`Plugin ${manifest.id} used the local machine outside the desktop app`,
);
}
if (every || !audited.has(action)) {
audited.add(action);
await audit(action, details, { success: true });
}
};
const mainRequest = async (channel: string, path: string) => {
const { requestFromElectronMain } =
await import("../utils/electron-ipc-bridge.js");
return requestFromElectronMain<{ success: boolean; error?: string }>(
channel,
{ path },
);
};
return {
available: () =>
isEmbedded() && manifest.capabilities.includes("desktop:local"),
pluginFetch: async (origin, subPath, init) => {
await guard("desktop_local_fetch", "called its own routes");
if (
typeof subPath !== "string" ||
!subPath.startsWith("/") ||
subPath.startsWith("//") ||
subPath.includes("\\") ||
/(^|\/)\.\.?(\/|\?|$)/.test(subPath)
) {
throw new Error("Invalid plugin route path");
}
const route = `/plugin-api/${manifest.id}${subPath}`;
const headers: Record<string, string> = { ...init?.headers };
for (const name of Object.keys(headers)) {
if (/^(authorization|cookie|host)$/i.test(name)) delete headers[name];
}
if (origin === "remote") {
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link) throw new Error("This device is not linked to a server");
const { remoteStreamFetch } = await import("../sync/client/http.js");
return (await remoteStreamFetch(link, route, {
method: init?.method,
headers,
body: init?.body,
signal: init?.signal,
})) as never;
}
if (origin !== "local") throw new Error("Unknown origin");
const { getActor } = await import("./actor.js");
const actor = getActor();
if (!actor) throw new Error("No acting user");
const { AuthManager } = await import("../utils/auth-manager.js");
const session = await AuthManager.getInstance().issuePluginSession(
actor,
{
pluginId: manifest.id,
purpose: "desktop local transfer",
ttlSeconds: 900,
},
);
const port = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
const { fetch: undiciFetch } = await import("undici");
return (await undiciFetch(`http://127.0.0.1:${port}${route}`, {
method: init?.method ?? "GET",
headers: {
...headers,
"X-Electron-App": "true",
Authorization: `Bearer ${session.token}`,
},
body: init?.body as never,
duplex: "half",
signal: init?.signal,
} as never)) as never;
},
info: async () => {
await guard("desktop_local_info", "read local machine info");
const home = await localFs.homeInfo();
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
return {
...home,
userDataDir: process.env.ELECTRON_USER_DATA ?? "",
backendPort: Number(process.env.EMBEDDED_BACKEND_PORT) || 30001,
link: link
? {
serverUrl: link.serverUrl,
token: link.sessionToken,
headers: requestHeaders(link),
allowInvalidCertificate: !!link.allowInvalidCertificate,
}
: null,
};
},
linkJson: async (pathname) => {
await guard("desktop_local_link", "called the linked server");
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link)
throw new Error("This device is not linked to a Termix server");
if (!pathname.startsWith("/")) throw new Error("Invalid path");
return remoteJson(
{
serverUrl: link.serverUrl,
sessionToken: link.sessionToken,
customHeaders: link.customHeaders,
basicAuth: link.basicAuth,
allowInvalidCertificate: link.allowInvalidCertificate,
clientCert: link.clientCert,
},
pathname,
);
},
fs: {
list: async (dirPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.listDirectory(dirPath);
},
mkdir: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.makeDirectory(parentPath, name);
},
createFile: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.createEmptyFile(parentPath, name);
},
rename: async (path, newName) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.renameEntry(path, newName);
},
ensureDir: async (dirPath, rootPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.ensureDirectory(dirPath, rootPath);
},
exists: async (paths) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.existingPaths(paths);
},
walk: async (rootPaths) => {
await guard("desktop_local_fs", "used the local file system");
if (!Array.isArray(rootPaths) || rootPaths.length === 0) {
throw new Error("No local paths provided");
}
return localFs.walkPaths(rootPaths);
},
trash: async (paths) => {
await guard(
"desktop_local_trash",
`moved ${Array.isArray(paths) ? paths.length : 0} local items to the trash`,
true,
);
if (!Array.isArray(paths) || paths.length === 0) {
throw new Error("No local paths provided");
}
const failed: Array<{ path: string; error: string }> = [];
for (const candidate of paths) {
const target = localFs.normalizeLocalPath(candidate);
try {
const result = await mainRequest("shell-trash-item", target);
if (!result?.success) throw new Error(result?.error ?? "Failed");
} catch (error) {
failed.push({
path: target,
error: error instanceof Error ? error.message : String(error),
});
}
}
return { trashed: paths.length - failed.length, failed };
},
reveal: async (path) => {
await guard("desktop_local_fs", "used the local file system");
await mainRequest("shell-show-item", localFs.normalizeLocalPath(path));
},
open: async (path) => {
await guard("desktop_local_open", "opened a local path", true);
const result = await mainRequest(
"shell-open-path",
localFs.normalizeLocalPath(path),
);
if (!result?.success) throw new Error(result?.error ?? "Failed");
},
openRead: async (path) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openRead(path);
},
openDownload: async (options) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openDownload(options);
},
},
pty: {
spawn: async (options) => {
await guard("desktop_local_pty", "started a local shell", true);
const child: PluginLocalPty = spawnLocalPty(options);
// A shell never outlives the plugin that started it.
bag.add(() => {
try {
child.kill();
} catch {
// already gone
}
});
return child;
},
},
};
}
+192
View File
@@ -0,0 +1,192 @@
/**
* ctx.hosts.importMany, importSshConfig, exportMany and registerImportNormalizer.
*
* The import and export logic is the same code the host routes run
* (hosts/import/), so a plugin gets the same validation, defaults, protocol
* logins and plugin settings. What this file adds is the capability checks,
* the core permission checks, one batched save and the audit lines.
*/
import type {
PluginHostExport,
PluginHostImportNormalizer,
PluginHostImportOptions,
PluginHostImportResult,
PluginHosts,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import * as registry from "./registry.js";
import type { DisposableBag } from "./disposables.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
import { PermissionManager } from "../utils/permission-manager.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag?: DisposableBag;
audit: AuditFn;
}
type ImportApi = Pick<
PluginHosts,
"importMany" | "importSshConfig" | "exportMany" | "registerImportNormalizer"
>;
function actingUser(): string {
const actor = getActor();
if (!actor) throw new Error("This call needs an acting user");
return actor;
}
async function requirePermissions(userId: string, names: string[]) {
const permissions = PermissionManager.getInstance();
for (const name of names) {
if (!(await permissions.hasPermission(userId, name))) {
throw new Error(`The acting user lacks the ${name} permission`);
}
}
}
function toResult(
results: Awaited<
ReturnType<typeof import("../hosts/import/host-import.js").runBulkImport>
>,
): PluginHostImportResult {
return {
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
createdHostIds: results.createdHostIds,
...(results.rolledBack ? { rolledBack: true } : {}),
};
}
export function createPluginHostImport({
manifest,
bag,
audit,
}: Deps): ImportApi {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const runImport = async (
action: string,
run: (userId: string) => Promise<PluginHostImportResult>,
needsCredentials: boolean,
): Promise<PluginHostImportResult> => {
try {
await assertCapability(pluginId, "hosts:write", declared, action);
if (needsCredentials) {
await assertCapability(pluginId, "credentials:write", declared, action);
}
const userId = actingUser();
await requirePermissions(userId, ["hosts.create", "hosts.edit"]);
const result = await DatabaseSaveTrigger.batched(() => run(userId))();
await audit(
action,
`${result.success} created, ${result.updated} updated, ${result.skipped} skipped, ${result.failed} failed`,
{ success: true },
);
return result;
} catch (error) {
await audit(action, "host import", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
return {
importMany: (hosts, options?: PluginHostImportOptions) =>
runImport(
"hosts_import",
async (userId) => {
const { runBulkImport } =
await import("../hosts/import/host-import.js");
return toResult(
await runBulkImport(userId, {
hosts,
overwrite: options?.overwrite,
credentials: options?.credentials,
atomic: options?.atomic,
}),
);
},
Array.isArray(options?.credentials) && options.credentials.length > 0,
),
importSshConfig: (content, options) =>
runImport(
"hosts_import_ssh_config",
async (userId) => {
const { runSshConfigImport } =
await import("../hosts/import/host-import.js");
return toResult(
await runSshConfigImport(userId, {
content,
overwrite: options?.overwrite,
atomic: options?.atomic,
}),
);
},
false,
),
exportMany: async (options): Promise<PluginHostExport> => {
const share = options?.share === true;
try {
await assertCapability(
pluginId,
"hosts:export",
declared,
"hosts_export",
);
const userId = actingUser();
const { exportAllHosts } =
await import("../hosts/import/host-export.js");
const exported = await exportAllHosts(userId, share, options?.hostIds);
await audit(
"export_hosts",
`exported ${exported.hosts.length} hosts${share ? " without secrets" : " with secrets"}`,
{ success: true },
);
return exported;
} catch (error) {
await audit("export_hosts", share ? "share export" : "full export", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
registerImportNormalizer: (normalizer: PluginHostImportNormalizer) => {
if (!declared.includes("hosts:write")) {
throw capabilityRefused(pluginId, "hosts:write");
}
// Same key the import path has always read, so the older
// ctx.registry.provide form keeps working.
const key = `${pluginId}.hostImportNormalizer`;
registry.provide(key, normalizer);
const remove = () => {
registry.revoke(key, normalizer);
};
const drop = bag?.add(remove, "host import normalizer");
return () => {
drop?.();
remove();
};
},
};
}
+384 -61
View File
@@ -20,18 +20,27 @@ import type {
PluginShareableUser,
PluginShareableRole,
PluginHostJumpHost,
PluginHostShareGrant,
} from "@termix-ssh/plugin-sdk/backend";
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { hostSessionStatus } from "../hosts/host-session-status.js";
import { getActor } from "./actor.js";
import { hostStatusService } from "../hosts/status/host-status-service.js";
import { hostStatus, provideHostStatus } from "./host-status-provider.js";
import { provideActivity, recentActivity } from "./activity-provider.js";
import { loadProbeTargets } from "./ctx-probe.js";
import { createPluginHostImport } from "./ctx-host-import.js";
import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js";
import {
assertHostsUnlocked,
assertManualCreateAllowed,
provideHostLocks,
} from "../hosts/locks.js";
type AuditFn = (
action: string,
@@ -171,7 +180,7 @@ export function toRecord(
connectionOrigin: origin === "local" || origin === "remote" ? origin : null,
sshOptions: hostTerminalExport(host).sshOptions,
pluginSettings,
status: Number.isInteger(id) ? hostStatusService.get(id) : null,
status: Number.isInteger(id) ? hostStatus.get(id) : null,
localOnly: asFlag(host.localOnly, false),
createdAt: (host.createdAt as string | null) ?? null,
updatedAt: (host.updatedAt as string | null) ?? null,
@@ -204,6 +213,8 @@ void inputKeysComplete;
export function toHostWrite(input: PluginHostUpdateInput): {
row: Record<string, unknown>;
settings: Record<string, Record<string, unknown>>;
protocolAuth: Record<string, unknown> | null;
pluginEnable: Record<string, boolean>;
} {
const unknown = Object.keys(input ?? {}).filter(
(key) => !INPUT_KEYS.has(key),
@@ -265,6 +276,32 @@ export function toHostWrite(input: PluginHostUpdateInput): {
? null
: JSON.stringify(parseSshOptions(input.sshOptions)),
);
for (const key of [
"password",
"key",
"keyPassword",
"keyType",
"sudoPassword",
] as const) {
const value = input[key];
if (value !== undefined && value !== null && typeof value !== "string") {
throw new PluginHostInputError(`${key} must be a string`);
}
set(key, value === undefined ? undefined : value || null);
}
let protocolAuth: Record<string, unknown> | null = null;
if (input.protocolAuth !== undefined && input.protocolAuth !== null) {
if (
typeof input.protocolAuth !== "object" ||
Array.isArray(input.protocolAuth)
) {
throw new PluginHostInputError(
"protocolAuth must map protocol ids to their logins",
);
}
protocolAuth = input.protocolAuth as Record<string, unknown>;
}
const settings: Record<string, Record<string, unknown>> = {};
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
@@ -285,7 +322,136 @@ export function toHostWrite(input: PluginHostUpdateInput): {
settings[pluginId] = values;
}
}
return { row, settings };
const pluginEnable: Record<string, boolean> = {};
if (input.pluginEnable !== undefined && input.pluginEnable !== null) {
if (
typeof input.pluginEnable !== "object" ||
Array.isArray(input.pluginEnable)
) {
throw new PluginHostInputError(
"pluginEnable must map plugin ids to true or false",
);
}
for (const [pluginId, enabled] of Object.entries(input.pluginEnable)) {
if (typeof enabled !== "boolean") {
throw new PluginHostInputError(
`pluginEnable.${pluginId} must be true or false`,
);
}
pluginEnable[pluginId] = enabled;
}
}
return { row, settings, protocolAuth, pluginEnable };
}
/** Folds pluginEnable into each plugin's settings under its enableKey. */
async function foldPluginEnable(
settings: Record<string, Record<string, unknown>>,
pluginEnable: Record<string, boolean>,
): Promise<Record<string, Record<string, unknown>>> {
const ids = Object.keys(pluginEnable);
if (ids.length === 0) return settings;
const { hostSettingsPlugins } =
await import("../database/routes/host-plugin-settings.js");
const running = hostSettingsPlugins();
const out = { ...settings };
for (const pluginId of ids) {
const manifest = running.find((m) => m.id === pluginId);
if (!manifest) {
// checkHostPluginSettingsInput skips and logs a plugin that is not running.
out[pluginId] = { ...out[pluginId] };
continue;
}
const enableKey = manifest.contributes?.settings?.host?.enableKey;
if (!enableKey) {
throw new PluginHostInputError(`${pluginId} has no host switch`);
}
out[pluginId] = { ...out[pluginId], [enableKey]: pluginEnable[pluginId] };
}
return out;
}
/** Writes a host's protocol logins; a credential the owner can't use is refused. */
async function writeHostProtocolAuth(
ownerId: string,
hostId: number,
protocolAuth: Record<string, unknown> | null,
): Promise<void> {
if (!protocolAuth) return;
const { readProtocolAuthPayload, writeProtocolAuth } =
await import("../hosts/protocol-auth/protocol-auth.js");
const patch = readProtocolAuthPayload({ protocolAuth });
if (!patch) return;
const { findUsableCredential } =
await import("../hosts/usable-credential.js");
for (const [protocol, login] of patch) {
const credentialId = Number(login?.credentialId);
if (
Number.isInteger(credentialId) &&
credentialId > 0 &&
!(await findUsableCredential(credentialId, ownerId))
) {
throw new PluginHostInputError(
`protocolAuth.${protocol}: credential ${credentialId} not found`,
);
}
}
await writeProtocolAuth(ownerId, hostId, patch, { isOwner: true });
}
const SHARE_LEVELS: readonly string[] = ["connect", "view", "edit", "manage"];
/** Upserts one grant and snapshots the host's secrets for it. */
async function grantHostShare(
hostId: number,
ownerId: string,
grantedBy: string,
target: PluginShareTarget,
permissionLevel: PluginHostShareLevel,
expiresAt: string | null,
): Promise<number> {
const { createCurrentRbacAccessRepository } =
await import("../database/repositories/factory.js");
const grant = await createCurrentRbacAccessRepository().upsertHostAccess({
hostId,
grantedBy,
permissionLevel,
expiresAt,
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: { targetType: "role" as const, targetRoleId: target.id as number }),
});
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
grant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
grant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch {
// A snapshot failure never blocks the grant: the recipient can
// still be prompted, or the owner can retry sharing later.
}
return grant.id;
}
function hoursFromNow(durationHours: number | undefined): string | null {
return durationHours && durationHours > 0
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
: null;
}
function actingUser(): string {
@@ -389,6 +555,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
};
return {
...createPluginHostImport({ manifest, bag, audit }),
list: async () => {
await requireRead();
const userId = actingUser();
@@ -471,6 +639,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
try {
await requireWrite();
assertManualCreateAllowed(pluginId);
} catch (error) {
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
success: false,
@@ -479,8 +648,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
throw error;
}
const userId = actingUser();
const { row, settings } = toHostWrite(host);
const writes = await checkSettings(settings);
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host);
const writes = await checkSettings(
await foldPluginEnable(settings, pluginEnable),
);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
@@ -499,6 +670,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
userId,
columns,
);
await writeHostProtocolAuth(userId, created.id, protocolAuth);
await applyDefaultsAfterHostWrite(created.id);
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
@@ -515,6 +687,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
): Promise<PluginHostRecord | null> => {
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) {
await audit("hosts_update", `host ${hostId}`, {
success: false,
@@ -523,8 +696,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
throw error;
}
const userId = actingUser();
const { row, settings } = toHostWrite(patch);
const writes = await checkSettings(settings);
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch);
const writes = await checkSettings(
await foldPluginEnable(settings, pluginEnable),
);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
@@ -555,6 +730,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
columns,
);
if (updated) {
await writeHostProtocolAuth(userId, hostId, protocolAuth);
await writeSettings(hostId, writes);
await markSettingsOwn(hostId, writes);
await applyDefaultsAfterHostWrite(hostId, {
@@ -579,6 +755,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
delete: async (hostId: number): Promise<boolean> => {
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
const userId = actingUser();
const { PermissionManager } =
await import("../utils/permission-manager.js");
@@ -626,6 +803,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
): Promise<PluginHostShareResult> => {
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) {
await audit("hosts_share", `host ${hostId}`, {
success: false,
@@ -655,10 +833,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
return result;
}
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const ownerId =
(await createCurrentHostResolutionRepository().findHostOwnerId(
hostId,
@@ -672,56 +848,95 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
return { hostId, shared: false, reason: "owner" };
}
const expiresAt =
durationHours && durationHours > 0
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
: null;
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
const expiresAt = hoursFromNow(durationHours);
for (const target of targets) {
const grant = await rbacAccessRepository.upsertHostAccess({
await grantHostShare(
hostId,
grantedBy: userId,
ownerId,
userId,
target,
permissionLevel,
expiresAt,
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
grant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
grant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch {
// A snapshot failure never blocks the grant: the recipient can
// still be prompted, or the owner can retry sharing later.
}
);
}
await audit("hosts_share", `host ${hostId}`, { success: true });
return { hostId, shared: true };
},
setShares: async (
hostId: number,
grants: PluginHostShareGrant[],
): Promise<PluginHostShareResult> => {
const fail = async (reason: string): Promise<PluginHostShareResult> => {
await audit("hosts_set_shares", `host ${hostId}`, {
success: false,
errorMessage: reason,
});
return { hostId, shared: false, reason };
};
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) {
await fail(error instanceof Error ? error.message : String(error));
throw error;
}
if (!Array.isArray(grants)) {
throw new PluginHostInputError("grants must be a list");
}
for (const grant of grants) {
const type = grant?.target?.type;
if (
(type !== "user" && type !== "role") ||
!SHARE_LEVELS.includes(grant.permissionLevel)
) {
throw new PluginHostInputError(
"Each grant needs a user or role target and a share level",
);
}
}
const userId = actingUser();
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
if (ownerId !== userId) return fail("forbidden");
if (
grants.some((g) => g.target.type === "user" && g.target.id === userId)
) {
return fail("owner");
}
const kept = new Set<number>();
for (const grant of grants) {
kept.add(
await grantHostShare(
hostId,
ownerId,
userId,
grant.target,
grant.permissionLevel,
hoursFromNow(grant.durationHours),
),
);
}
const repository = createCurrentRbacAccessRepository();
for (const existing of await repository.listHostAccess(hostId)) {
if (!kept.has(existing.id)) {
await repository.revokeHostAccess(existing.id, hostId);
}
}
const { pruneHostGrants } = await import("../database/routes/rbac.js");
await pruneHostGrants(hostId);
await audit("hosts_set_shares", `host ${hostId}`, { success: true });
return { hostId, shared: true };
},
listUsers: async (): Promise<PluginShareableUser[]> => {
try {
await requireWrite();
@@ -787,38 +1002,146 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await requireRead();
const userId = actingUser();
if (!(await actorMayReach(hostId))) return;
const { recordRecentActivity } =
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
await recentActivity.record(userId, { type, hostId, hostName });
},
probeTargets: async (filter) => {
await requireRead();
const actor = getActor();
if (!actor) {
throw new Error("ctx.hosts.probeTargets needs an acting user");
}
const ids = filter?.hostIds?.filter((id) => Number.isInteger(id));
return loadProbeTargets(actor, ids);
},
sessionActive: (hostId) => {
requireReadSync();
return hostSessionStatus.hasActiveSession(hostId);
},
locks: {
provide: (provider) => {
if (!declared.includes("hosts:lock")) {
throw capabilityRefused(pluginId, "hosts:lock");
}
const remove = provideHostLocks(
pluginId,
manifest.name || pluginId,
provider,
);
void audit("host_locks_provide", `${pluginId} manages hosts`, {
success: true,
});
const drop = bag?.add(remove, "host lock provider");
return () => {
drop?.();
remove();
};
},
},
folders: {
ensure: async (name, style) => {
await requireWrite();
const userId = actingUser();
const { createCurrentHostFolderRepository } =
await import("../database/repositories/factory.js");
await createCurrentHostFolderRepository().upsertMetadata(
userId,
name,
style?.color ?? null,
style?.icon ?? null,
);
},
removeEmpty: async (name) => {
await requireWrite();
const userId = actingUser();
const { createCurrentHostFolderRepository } =
await import("../database/repositories/factory.js");
return createCurrentHostFolderRepository().deleteEmptyFolderRecords(
userId,
name,
);
},
},
activity: {
provide: (provider) => {
if (!declared.includes("hosts:activity-provide")) {
throw capabilityRefused(pluginId, "hosts:activity-provide");
}
const remove = provideActivity(
pluginId,
provider as Parameters<typeof provideActivity>[1],
);
void audit(
"host_activity_provide",
`${pluginId} stores recent activity`,
{
success: true,
},
);
const drop = bag?.add(remove, "activity provider");
return () => {
drop?.();
remove();
};
},
},
// Not audited: pollers call these on every sample.
status: {
provide: (provider) => {
requireReadSync();
if (!declared.includes("hosts:status-provide")) {
throw capabilityRefused(pluginId, "hosts:status-provide");
}
const remove = provideHostStatus(pluginId, {
start: () => provider.start(),
stop: () => provider.stop(),
get: (hostId) => provider.get(hostId),
statusesFor: (userId, requested) =>
provider.statusesFor(userId, requested ? [...requested] : null),
refresh: (userId) => provider.refresh(userId),
check: (hostId) => provider.check(hostId),
reportLogin: (hostId, outcome) =>
provider.reportLogin(hostId, outcome),
registerPort: (type, resolve) => provider.registerPort(type, resolve),
getIntervalSeconds: () => provider.getIntervalSeconds(),
setIntervalSeconds: (seconds) => provider.setIntervalSeconds(seconds),
});
void audit("host_status_provide", `${pluginId} provides host status`, {
success: true,
});
const drop = bag?.add(remove, "host status provider");
return () => {
drop?.();
remove();
};
},
get: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.get(hostId);
return hostStatus.get(hostId);
},
check: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.check(hostId);
return hostStatus.check(hostId);
},
// Synchronous for the same reason as trackSession.
reportLogin: (hostId, outcome) => {
requireReadSync();
void actorMayReach(hostId)
.then((allowed) => {
if (allowed) hostStatusService.reportLogin(hostId, outcome);
if (allowed) hostStatus.reportLogin(hostId, outcome);
})
.catch(() => {});
},
registerPort: (connectionType, resolve) => {
requireReadSync();
const unregister = hostStatusService.registerPort(
connectionType,
resolve,
);
const unregister = hostStatus.registerPort(connectionType, resolve);
const drop = bag?.add(unregister, `status port for ${connectionType}`);
return () => {
drop?.();
+172
View File
@@ -0,0 +1,172 @@
/**
* Reachability for status providers: ctx.network.tcpProbe, ctx.ssh.probe and
* ctx.hosts.probeTargets. Nothing here ever logs in to the probed host.
*/
import type {
PluginHostProbeTarget,
PluginNetwork,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
import { tcpPing } from "./probe/tcp-ping.js";
import { sharedJumpProbes } from "./probe/jump-probe-pool.js";
import type { HostStatusTargetRow } from "../database/repositories/host-resolution-repository.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
const MIN_TIMEOUT_MS = 500;
const MAX_TIMEOUT_MS = 15_000;
const DEFAULT_TIMEOUT_MS = 5000;
function clampTimeout(value?: number): number {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_TIMEOUT_MS;
return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, n));
}
function validPort(port: number): boolean {
return Number.isInteger(port) && port > 0 && port <= 65535;
}
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter(
(hop): hop is { hostId: number } =>
!!hop && Number.isInteger((hop as { hostId?: unknown }).hostId),
)
: [];
} catch {
return [];
}
}
function toTarget(row: HostStatusTargetRow): PluginHostProbeTarget {
return {
id: row.id,
ownerUserId: row.userId,
ip: row.ip,
port: row.port,
connectionType: row.connectionType || "ssh",
jumpHosts: parseJumpHosts(row.jumpHosts),
statusCheckEnabled: row.statusCheckEnabled !== false,
statusCheckInterval: row.statusCheckInterval ?? null,
};
}
export function createPluginNetwork(deps: {
manifest: PluginManifest;
audit: AuditFn;
}): PluginNetwork {
const { manifest, audit } = deps;
let audited = false;
return {
tcpProbe: async (host, port, options) => {
await assertCapability(
manifest.id,
"network:probe",
manifest.capabilities,
"network_tcp_probe",
);
if (typeof host !== "string" || !host.trim() || host.length > 255) {
throw new Error("A host name or address is required");
}
if (!validPort(port))
throw new Error("A port from 1 to 65535 is required");
if (!audited) {
audited = true;
await audit("network_tcp_probe", "checked if TCP ports answer", {
success: true,
});
}
return tcpPing(host.trim(), port, clampTimeout(options?.timeoutMs));
},
};
}
/** The hosts the actor owns or was shared, narrowed to ones they may connect to. */
export async function loadProbeTargets(
actor: string,
hostIds?: number[],
): Promise<PluginHostProbeTarget[]> {
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
} = await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const hosts = createCurrentHostResolutionRepository();
const own = await hosts.listStatusTargets({
userId: actor,
hostIds,
});
const ownIds = new Set(own.map((row) => row.id));
const roleIds = await createCurrentRoleRepository().listUserRoleIds(actor);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
actor,
roleIds,
);
let sharedIds = [...new Set(entries.map((entry) => entry.hostId))].filter(
(id) => !ownIds.has(id) && (!hostIds || hostIds.includes(id)),
);
if (sharedIds.length > 0) {
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
actor,
sharedIds,
);
sharedIds = sharedIds.filter((id) => allowed.has(id));
}
const shared =
sharedIds.length > 0
? await hosts.listStatusTargets({ hostIds: sharedIds })
: [];
return [...own, ...shared].map(toTarget);
}
export async function probeHost(
hostId: number,
options?: { port?: number; timeoutMs?: number },
): Promise<boolean> {
const actor = getActor();
if (actor) {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
actor,
hostId,
"connect",
);
if (!access.hasAccess) return false;
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const [row] = await createCurrentHostResolutionRepository().listStatusTargets(
{ hostIds: [hostId] },
);
if (!row) return false;
const target = toTarget(row);
const port = options?.port ?? target.port;
if (!validPort(port)) throw new Error("A port from 1 to 65535 is required");
const timeout = clampTimeout(options?.timeoutMs);
return target.jumpHosts.length > 0
? sharedJumpProbes.ping(
target.jumpHosts,
target.ownerUserId,
target.ip,
port,
timeout,
)
: tcpPing(target.ip, port, timeout);
}
+83 -5
View File
@@ -26,6 +26,8 @@ import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import { hostStatus } from "./host-status-provider.js";
import { probeHost } from "./ctx-probe.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -36,9 +38,12 @@ import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interacti
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import {
getLoginMethod,
listSecondFactors,
registerLoginMethod,
registerSecondFactor,
} from "../auth/registry.js";
import { provideAuthPolicy } from "../auth/policy.js";
import { provideSshTransport } from "../hosts/connect/transport-providers.js";
import type { VerifiedIdentity } from "../auth/types.js";
import type {
MutableConnectConfig,
@@ -224,11 +229,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const reportLogin = (host: number | PluginSshHost) => {
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return;
void import("../hosts/status/host-status-service.js")
.then(({ hostStatusService }) =>
hostStatusService.reportLogin(hostId, { ok: true }),
)
.catch(() => {});
hostStatus.reportLogin(hostId, { ok: true });
};
/** Checks, connects and audits one new connection. */
@@ -296,6 +297,31 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
}`;
return {
transports: {
provide: (provider) => {
if (!declared.includes("ssh:transport")) {
throw capabilityRefused(pluginId, "ssh:transport");
}
const remove = provideSshTransport(pluginId, provider);
void audit(
"ssh_transport_provide",
`${pluginId} carries host connections`,
{
success: true,
},
);
const drop = bag.add(remove, "ssh transport");
return () => {
drop();
remove();
};
},
},
probe: async (hostId, probeOptions) => {
await assertCapability(pluginId, "ssh:connect", declared);
if (!Number.isInteger(hostId) || hostId <= 0) return false;
return probeHost(hostId, probeOptions);
},
connect: async (host, options) => {
const connection = await connectOnce(host, options);
return {
@@ -306,6 +332,33 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
},
exec: async (host, command, execOptions) => {
const connection = await connectOnce(host, {
...execOptions,
purpose: execOptions?.purpose ?? "exec",
timeoutMs: undefined,
});
const details = `${describeHost(host)}: command run`;
try {
const { runExec } = await import("./ctx-ssh-exec.js");
const result = await runExec(
connection.client as never,
command,
execOptions,
);
await audit("ssh_exec", details, { success: true });
return result;
} catch (error) {
await audit("ssh_exec", details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
connection.dispose();
}
},
withConnection: async (host, options, fn) => {
await checkSsh(true);
const { resolveConnectHost } =
@@ -826,6 +879,8 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
getActor() === userId ? getActorSessionId() : undefined,
);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
// The session that set it up may have been a setup only one.
await createCurrentSessionRepository().clearRestrictionForUser(userId);
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
success: true,
});
@@ -904,5 +959,28 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
},
issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options),
policy: {
provide: (provider) => {
if (!declared.includes("auth:policy")) {
throw capabilityRefused(pluginId, "auth:policy");
}
const remove = provideAuthPolicy(pluginId, provider);
void audit("auth_policy_provide", `${pluginId} sets login rules`, {
success: true,
});
const drop = bag.add(remove, "login policy");
return () => {
drop();
remove();
};
},
},
listSecondFactors: () =>
listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
})),
};
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Runs one command on an open ssh2 client for ctx.ssh.exec, with a time limit
* and caps on how much output is kept.
*/
import type { PluginSshExecOptions } from "@termix-ssh/plugin-sdk/backend";
export const EXEC_DEFAULT_TIMEOUT_MS = 30_000;
export const EXEC_MAX_TIMEOUT_MS = 10 * 60_000;
export const EXEC_DEFAULT_MAX_OUTPUT = 256 * 1024;
export const EXEC_HARD_MAX_OUTPUT = 4 * 1024 * 1024;
export const EXEC_MAX_COMMAND_LENGTH = 64 * 1024;
interface ExecStream {
on: (event: string, listener: (...args: never[]) => void) => unknown;
stderr: { on: (event: string, listener: (chunk: Buffer) => void) => unknown };
write: (data: string) => unknown;
end: (data?: string) => unknown;
close: () => void;
}
export interface ExecClient {
exec: (
command: string,
callback: (error: Error | undefined, stream: ExecStream) => void,
) => unknown;
}
export interface ExecResult {
stdout: string;
stderr: string;
/** Null when the command was killed by a signal or hit the time limit. */
code: number | null;
truncated: boolean;
timedOut: boolean;
}
export function runExec(
client: ExecClient,
command: string,
options: PluginSshExecOptions = {},
): Promise<ExecResult> {
if (typeof command !== "string" || command.trim() === "") {
return Promise.reject(new Error("A command is required"));
}
if (command.length > EXEC_MAX_COMMAND_LENGTH) {
return Promise.reject(new Error("The command is too long"));
}
const timeoutMs = Math.min(
EXEC_MAX_TIMEOUT_MS,
Math.max(1000, options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS),
);
const maxOutput = Math.min(
EXEC_HARD_MAX_OUTPUT,
Math.max(1024, options.maxOutputBytes ?? EXEC_DEFAULT_MAX_OUTPUT),
);
return new Promise<ExecResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
let truncated = false;
let stdout = "";
let stderr = "";
let timer: NodeJS.Timeout | null = null;
const finish = (code: number | null) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve({ stdout, stderr, code, truncated, timedOut });
};
const append = (current: string, chunk: Buffer): string => {
const room = maxOutput - Buffer.byteLength(current);
if (room <= 0) {
truncated = true;
return current;
}
if (chunk.length > room) {
truncated = true;
return current + chunk.subarray(0, room).toString("utf8");
}
return current + chunk.toString("utf8");
};
client.exec(command, (error, stream) => {
if (error) {
if (!settled) {
settled = true;
reject(error);
}
return;
}
timer = setTimeout(() => {
timedOut = true;
try {
stream.close();
} catch {
// already closed
}
finish(null);
}, timeoutMs);
stream.on("data", ((chunk: Buffer) => {
stdout = append(stdout, chunk);
}) as never);
stream.stderr.on("data", (chunk: Buffer) => {
stderr = append(stderr, chunk);
});
stream.on("close", ((code: number | null | undefined) => {
finish(typeof code === "number" ? code : null);
}) as never);
stream.on("error", ((streamError: Error) => {
if (!settled) {
settled = true;
if (timer) clearTimeout(timer);
reject(streamError);
}
}) as never);
if (options.stdin !== undefined) stream.end(options.stdin);
});
});
}
+49 -6
View File
@@ -22,6 +22,9 @@
import { pluginLogger } from "../utils/logger.js";
import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js";
import { pluginEvents } from "./events.js";
import { createPluginDesktopLocal } from "./ctx-desktop-local.js";
import { createPluginAuditReader } from "./ctx-audit.js";
import { createPluginNetwork } from "./ctx-probe.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
@@ -51,6 +54,12 @@ import {
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
import { provideSync } from "../sync/provider.js";
import { createSyncHost } from "../sync/host.js";
import type {
PluginSync,
PluginSyncHost,
} from "@termix-ssh/plugin-sdk/backend";
import {
needsExplicitPersist,
resolveDatabaseDialect,
@@ -186,6 +195,34 @@ export function createPluginContext(
handle: PluginHandle,
): PluginContext {
const pluginId = manifest.id;
let syncHost: PluginSyncHost | undefined;
const syncApi: PluginSync = {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
provide: (provider) => {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
const remove = provideSync(pluginId, provider);
const drop = handle.bag.add(remove, "sync provider");
return () => {
drop();
remove();
};
},
} as PluginSync;
// Not enumerable, so tools that walk the context do not trip the check.
Object.defineProperty(syncApi, "host", {
enumerable: false,
get() {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
return (syncHost ??= createSyncHost());
},
});
const auditCall = (
action: string,
details: string,
@@ -517,12 +554,7 @@ export function createPluginContext(
},
},
sync: {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
},
sync: syncApi,
kv: {
get: (key) => kvGet(key),
@@ -950,6 +982,7 @@ export function createPluginContext(
bag: handle.bag,
}),
fetch: createPluginFetch({ manifest, audit: auditCall }),
network: createPluginNetwork({ manifest, audit: auditCall }),
process: createPluginProcess({
manifest,
bag: handle.bag,
@@ -964,6 +997,11 @@ export function createPluginContext(
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
launchExternalClient: (request) => desktopLaunchExternalClient(request),
available: () => isElectronIpcAvailable(),
local: createPluginDesktopLocal({
manifest,
bag: handle.bag,
audit: auditCall,
}),
},
credentials: createPluginCredentials({
@@ -995,6 +1033,11 @@ export function createPluginContext(
// Auditing must never break the caller.
}
},
...createPluginAuditReader({
manifest,
bag: handle.bag,
audit: auditCall,
}),
},
/**
@@ -0,0 +1,572 @@
/**
* Local filesystem helpers for plugins on the desktop app's embedded backend.
* Ported from the old Electron local-files module. The path rules are the
* security part: a download can never leave the folder the user picked, links
* inside it are refused, and a download never replaces a file by accident.
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
PluginLocalDirEntry,
PluginLocalDownload,
PluginLocalWalkResult,
} from "@termix-ssh/plugin-sdk/backend";
export class LocalFileError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = "LocalFileError";
this.code = code;
}
}
type PathImpl = typeof path;
export function normalizeLocalPath(candidate: unknown): string {
if (typeof candidate !== "string" || !path.isAbsolute(candidate)) {
throw new Error("A local absolute path is required");
}
return path.normalize(candidate);
}
// A single path segment: no separators, not "." / "..", no NULs.
export function requireEntryName(name: unknown, what: string): string {
const safeName = String(name || "").trim();
if (
!safeName ||
safeName === "." ||
safeName === ".." ||
/[/\\\0]/.test(safeName)
) {
throw new Error(`Invalid ${what}`);
}
return safeName;
}
// pathImpl is injectable so the Windows rules can be tested on any OS.
export function assertWithinRoot(
rootPath: string,
candidate: string,
pathImpl: PathImpl = path,
): string {
if (typeof rootPath !== "string" || !rootPath.trim()) {
throw new LocalFileError("EINVAL", "A download folder is required");
}
const root = pathImpl.normalize(pathImpl.resolve(rootPath));
const target = pathImpl.normalize(pathImpl.resolve(candidate));
const rel = pathImpl.relative(root, target);
const escapes =
rel === "" ||
rel === ".." ||
rel.startsWith(`..${pathImpl.sep}`) ||
pathImpl.isAbsolute(rel) ||
rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === "");
if (escapes) {
throw new LocalFileError(
"EINVAL",
`"${pathImpl.basename(candidate)}" would be written outside the selected folder`,
);
}
return target;
}
// Resolve a deliberately selected root once, but never follow links supplied
// as descendants of a downloaded tree.
export async function prepareDownloadPath(
rootPath: string,
candidate: string,
directory = false,
): Promise<{ root: string; path: string }> {
const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate));
const selected = path.resolve(rootPath);
const realRoot = await fsp.realpath(selected);
if (!(await fsp.stat(realRoot)).isDirectory()) {
throw new LocalFileError("EINVAL", "The download root is not a directory");
}
const parts = path.relative(selected, target).split(path.sep);
let current = realRoot;
for (let i = 0; i < parts.length; i++) {
current = path.join(current, parts[i]);
const needsDirectory = directory || i < parts.length - 1;
if (needsDirectory) {
await fsp.mkdir(current).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
}
const stat = await fsp
.lstat(current)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT" && !needsDirectory) return null;
throw error;
});
if (stat?.isSymbolicLink()) {
throw new LocalFileError(
"EINVAL",
"Downloads cannot follow links inside the selected folder",
);
}
if (needsDirectory && !stat?.isDirectory()) {
throw new LocalFileError(
"ENOTDIR",
"A download parent is not a directory",
);
}
}
return { root: realRoot, path: current };
}
export async function pathExists(target: string): Promise<boolean> {
try {
await fsp.lstat(target);
return true;
} catch {
return false;
}
}
async function describeEntry(
dirPath: string,
dirent: fs.Dirent,
): Promise<PluginLocalDirEntry> {
const entryPath = path.join(dirPath, dirent.name);
let type: PluginLocalDirEntry["type"] = "file";
let size = 0;
let modifiedTimestamp: number | undefined;
let linkTarget: string | undefined;
try {
if (dirent.isSymbolicLink()) {
type = "link";
try {
linkTarget = await fsp.readlink(entryPath);
} catch {
// dangling or unreadable link
}
try {
const target = await fsp.stat(entryPath);
if (target.isDirectory()) type = "directory";
size = target.size;
modifiedTimestamp = target.mtimeMs;
} catch {
const own = await fsp.lstat(entryPath);
modifiedTimestamp = own.mtimeMs;
}
} else if (dirent.isDirectory()) {
type = "directory";
modifiedTimestamp = (await fsp.stat(entryPath)).mtimeMs;
} else {
const stat = await fsp.stat(entryPath);
size = stat.size;
modifiedTimestamp = stat.mtimeMs;
}
} catch {
// Unreadable entry: still list it so the user sees it exists.
}
return {
name: dirent.name,
path: entryPath,
type,
size,
modifiedTimestamp,
linkTarget,
hidden: dirent.name.startsWith("."),
};
}
export async function listDirectory(dirPath: string): Promise<{
path: string;
parent: string | null;
entries: PluginLocalDirEntry[];
}> {
const resolved = normalizeLocalPath(dirPath);
const dirents = await fsp.readdir(resolved, { withFileTypes: true });
const entries = await Promise.all(
dirents.map((dirent) => describeEntry(resolved, dirent)),
);
const parent = path.dirname(resolved);
return {
path: resolved,
parent: parent === resolved ? null : parent,
entries,
};
}
// Expands dropped local paths into a flat file list with paths relative to the
// drop root, plus empty directories.
export async function walkPaths(
rootPaths: string[],
): Promise<PluginLocalWalkResult> {
const files: PluginLocalWalkResult["files"] = [];
const emptyDirs: string[] = [];
let totalBytes = 0;
async function walkDir(absDir: string, relDir: string): Promise<void> {
const dirents = await fsp.readdir(absDir, { withFileTypes: true });
if (dirents.length === 0) {
emptyDirs.push(relDir);
return;
}
for (const dirent of dirents) {
const abs = path.join(absDir, dirent.name);
const rel = `${relDir}/${dirent.name}`;
if (dirent.isDirectory()) {
await walkDir(abs, rel);
} else if (dirent.isFile()) {
const stat = await fsp.stat(abs);
files.push({ localPath: abs, relativePath: rel, size: stat.size });
totalBytes += stat.size;
} else if (dirent.isSymbolicLink()) {
try {
const stat = await fsp.stat(abs);
if (stat.isFile()) {
files.push({ localPath: abs, relativePath: rel, size: stat.size });
totalBytes += stat.size;
}
} catch {
// dangling link: skip
}
}
}
}
for (const rootPath of rootPaths) {
const abs = normalizeLocalPath(rootPath);
const stat = await fsp.stat(abs);
const name = path.basename(abs);
if (stat.isDirectory()) {
await walkDir(abs, name);
} else if (stat.isFile()) {
files.push({ localPath: abs, relativePath: name, size: stat.size });
totalBytes += stat.size;
}
}
return { files, emptyDirs, totalBytes };
}
export async function homeInfo(): Promise<{
home: string;
separator: string;
platform: NodeJS.Platform;
}> {
return {
home: os.homedir(),
separator: path.sep,
platform: process.platform,
};
}
export async function makeDirectory(
parentPath: string,
name: string,
): Promise<string> {
const target = path.join(
normalizeLocalPath(parentPath),
requireEntryName(name, "folder name"),
);
await fsp.mkdir(target);
return target;
}
export async function createEmptyFile(
parentPath: string,
name: string,
): Promise<string> {
const target = path.join(
normalizeLocalPath(parentPath),
requireEntryName(name, "file name"),
);
// "wx" fails if the file already exists rather than truncating it.
await fsp.writeFile(target, "", { flag: "wx" });
return target;
}
// Exclusive rename: a destination that appeared meanwhile is never replaced.
export async function renameLocalPath(
source: string,
target: string,
): Promise<void> {
if (source === target) return;
try {
await fsp.lstat(target);
throw new LocalFileError(
"EEXIST",
"A file or folder with that name already exists",
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if ((await fsp.lstat(source)).isDirectory()) {
await fsp.rename(source, target);
return;
}
await fsp.link(source, target);
await fsp.unlink(source);
}
export async function renameEntry(
oldPath: string,
newName: string,
): Promise<string> {
const source = normalizeLocalPath(oldPath);
const safeName = requireEntryName(newName, "name");
const target = path.join(path.dirname(source), safeName);
if (target === source) return source;
if (await pathExists(target)) {
throw new LocalFileError("EEXIST", `"${safeName}" already exists`);
}
await renameLocalPath(source, target);
return target;
}
export async function ensureDirectory(
dirPath: string,
rootPath?: string,
): Promise<string> {
let target = normalizeLocalPath(dirPath);
if (rootPath !== undefined) {
target = (await prepareDownloadPath(rootPath, target, true)).path;
} else {
await fsp.mkdir(target, { recursive: true });
}
return target;
}
export async function existingPaths(paths: string[]): Promise<string[]> {
if (!Array.isArray(paths)) throw new Error("Expected a list of paths");
const existing: string[] = [];
for (const candidate of paths) {
const target = normalizeLocalPath(candidate);
if (await pathExists(target)) existing.push(target);
}
return existing;
}
export async function openRead(
localPath: string,
): Promise<{ stream: fs.ReadStream; size: number }> {
const absPath = normalizeLocalPath(localPath);
const stat = await fsp.stat(absPath);
if (!stat.isFile()) throw new Error("Only regular files can be uploaded");
return {
stream: fs.createReadStream(absPath, { highWaterMark: 1024 * 1024 }),
size: stat.size,
};
}
// Destinations with a download in flight, so two transfers never race onto
// the same file.
const activeDestinations = new Set<string>();
function transferToken(transferId: string): string {
return (
String(transferId)
.replace(/[^A-Za-z0-9_-]/g, "")
.slice(0, 48) || "transfer"
);
}
export interface PublishFs {
rename: (from: string, to: string) => Promise<void>;
link: (from: string, to: string) => Promise<void>;
copyFileExcl: (from: string, to: string) => Promise<void>;
rm: (target: string) => Promise<void>;
lstat: (target: string) => Promise<fs.Stats>;
}
export const defaultPublishFs: PublishFs = Object.freeze({
rename: (from, to) => fsp.rename(from, to),
link: (from, to) => fsp.link(from, to),
copyFileExcl: (from, to) =>
fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL),
rm: (target) => fsp.rm(target, { force: true }),
lstat: (target) => fsp.lstat(target),
});
// link() is atomic and fails with EEXIST if the name is taken; filesystems
// without hard links fall back to an exclusive copy.
async function publishExclusive(
io: PublishFs,
sourcePath: string,
absDest: string,
): Promise<void> {
const exists = () =>
new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`);
try {
await io.link(sourcePath, absDest);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "EEXIST") throw exists();
try {
await io.copyFileExcl(sourcePath, absDest);
} catch (copyError) {
if ((copyError as NodeJS.ErrnoException)?.code === "EEXIST") {
throw exists();
}
throw copyError;
}
}
await io.rm(sourcePath);
}
// rename() onto an existing file is not portable (it commonly fails on
// Windows), so move the old file aside, publish exclusively, then drop it.
async function replaceExisting(
io: PublishFs,
partialPath: string,
absDest: string,
transferId: string,
): Promise<void> {
let current: fs.Stats;
try {
current = await io.lstat(absDest);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
await publishExclusive(io, partialPath, absDest);
return;
}
throw error;
}
if (current.isDirectory()) {
throw new LocalFileError(
"EISDIR",
`"${path.basename(absDest)}" is a folder and cannot be replaced by a file`,
);
}
const asidePath = `${absDest}.${transferToken(transferId)}.termix-replaced`;
try {
await io.rename(absDest, asidePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "EPERM" || code === "EBUSY" || code === "EACCES") {
throw new LocalFileError(
"EBUSY",
`"${path.basename(absDest)}" is in use and could not be replaced`,
);
}
throw error;
}
try {
await publishExclusive(io, partialPath, absDest);
} catch (error) {
await io.rm(absDest).catch(() => {});
await io.rename(asidePath, absDest).catch(() => {});
throw error;
}
try {
await io.rm(asidePath);
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
await io.rm(asidePath).catch(() => {});
}
}
export async function publishDownload(
partialPath: string,
absDest: string,
overwrite: boolean,
transferId: string,
io: PublishFs = defaultPublishFs,
): Promise<void> {
if (overwrite) {
await replaceExisting(io, partialPath, absDest, transferId);
return;
}
await publishExclusive(io, partialPath, absDest);
}
// Bytes go to a transfer-unique temp sibling first and are published under the
// real name only on success, so a failed transfer never leaves a truncated file.
export async function openDownload(
options: {
rootPath: string;
destPath: string;
transferId: string;
overwrite?: boolean;
},
io: PublishFs = defaultPublishFs,
): Promise<PluginLocalDownload> {
const { rootPath, destPath, transferId } = options;
if (!transferId || !destPath) throw new Error("Missing download parameters");
const destination = await prepareDownloadPath(rootPath, destPath);
const absDest = destination.path;
if (activeDestinations.has(absDest)) {
throw new LocalFileError(
"EBUSY",
`"${path.basename(absDest)}" is already being downloaded`,
);
}
activeDestinations.add(absDest);
const partialPath = `${path.join(destination.root, path.basename(absDest))}.${transferToken(
transferId,
)}.termix-part`;
let released = false;
const release = () => {
if (released) return;
released = true;
activeDestinations.delete(absDest);
};
try {
if (options.overwrite !== true && (await pathExists(absDest))) {
throw new LocalFileError(
"EEXIST",
`"${path.basename(absDest)}" already exists`,
);
}
// "wx": the temp name is ours alone; refuse to reuse a stale one.
const stream = fs.createWriteStream(partialPath, { flags: "wx" });
// A write error must not crash the backend. publish() reports it.
let streamError: Error | null = null;
stream.on("error", (error) => {
streamError = error;
});
return {
path: absDest,
stream,
publish: async (publishOptions) => {
try {
if (streamError) throw streamError;
await new Promise<void>((resolve, reject) => {
if (stream.writableFinished) return resolve();
stream.once("error", reject);
stream.end(() => resolve());
});
if (streamError) throw streamError;
await prepareDownloadPath(destination.root, absDest);
await publishDownload(
partialPath,
absDest,
(publishOptions?.overwrite ?? options.overwrite) === true,
transferId,
io,
);
return absDest;
} catch (error) {
await fsp.rm(partialPath, { force: true }).catch(() => {});
throw error;
} finally {
release();
}
},
abort: async () => {
stream.destroy();
await fsp.rm(partialPath, { force: true }).catch(() => {});
release();
},
};
} catch (error) {
release();
throw error;
}
}
@@ -0,0 +1,167 @@
/**
* Local shells for plugins on the desktop app's embedded backend. Ported from
* the old Electron local terminal handlers.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import type { PluginLocalPty } from "@termix-ssh/plugin-sdk/backend";
export function resolveLocalShell(
platform: NodeJS.Platform,
requestedShell?: string,
env: NodeJS.ProcessEnv = process.env,
exists: (file: string) => boolean = fs.existsSync,
): { file: string; args: string[] } {
if (platform === "win32") {
if (requestedShell === "wsl") return { file: "wsl.exe", args: [] };
return {
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
args: ["-NoLogo"],
};
}
// A SHELL left pointing at an uninstalled shell makes the spawn fail.
const fallbacks =
platform === "darwin"
? ["/bin/zsh", "/bin/bash", "/bin/sh"]
: ["/bin/bash", "/bin/sh"];
const usable = (file?: string): file is string =>
!!file && (!path.isAbsolute(file) || exists(file));
const file =
[env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find(usable) ||
fallbacks[0];
return { file, args: ["-l"] };
}
/**
* node-pty starts every shell through its spawn-helper binary, and a packaged
* copy that lost its execute bit fails with "posix_spawnp failed". Returns how
* many helpers were fixed.
*/
export function repairSpawnHelpers(ptyDir: string, fsImpl = fs): number {
// A universal macOS build ships app-arm64.asar and app-x64.asar.
const dir = ptyDir.replace(
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
"$1.asar.unpacked",
);
const candidates = [
path.join(dir, "build", "Release", "spawn-helper"),
path.join(
dir,
"prebuilds",
`${process.platform}-${process.arch}`,
"spawn-helper",
),
];
let fixed = 0;
for (const helper of candidates) {
try {
const mode = fsImpl.statSync(helper).mode;
if ((mode & 0o111) === 0) {
fsImpl.chmodSync(helper, mode | 0o755);
fixed += 1;
}
} catch {
// Missing or read-only; nothing to fix here.
}
}
return fixed;
}
interface NodePty {
spawn: (
file: string,
args: string[],
options: Record<string, unknown>,
) => {
pid: number;
write: (data: string) => void;
resize: (cols: number, rows: number) => void;
kill: () => void;
onData: (cb: (data: string) => void) => { dispose: () => void };
onExit: (cb: (e: { exitCode: number }) => void) => { dispose: () => void };
};
}
const requireFromHere = createRequire(import.meta.url);
function loadNodePty(): NodePty {
try {
return requireFromHere("node-pty") as NodePty;
} catch {
throw new Error("The local terminal is not available in this build");
}
}
export const MAX_PTY_COLS = 500;
export const MAX_PTY_ROWS = 300;
export function spawnLocalPty(options?: {
cols?: number;
rows?: number;
shell?: string;
}): PluginLocalPty {
const pty = loadNodePty();
const cols = Math.min(MAX_PTY_COLS, Math.max(2, Number(options?.cols) || 80));
const rows = Math.min(MAX_PTY_ROWS, Math.max(1, Number(options?.rows) || 24));
const shellConfig = resolveLocalShell(process.platform, options?.shell);
const start = () =>
pty.spawn(shellConfig.file, shellConfig.args, {
name: "xterm-256color",
cols,
rows,
cwd: os.homedir(),
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
});
let child: ReturnType<NodePty["spawn"]>;
try {
child = start();
} catch (error) {
const ptyDir = path.dirname(
requireFromHere.resolve("node-pty/package.json"),
);
if (
!String((error as Error)?.message).includes("posix_spawnp") ||
repairSpawnHelpers(ptyDir) === 0
) {
throw error;
}
child = start();
}
return {
pid: child.pid,
shell: shellConfig.file,
write: (data) => {
if (typeof data === "string" && data.length <= 64 * 1024) {
child.write(data);
}
},
resize: (c, r) => {
if (
Number.isInteger(c) &&
Number.isInteger(r) &&
c >= 2 &&
c <= MAX_PTY_COLS &&
r >= 1 &&
r <= MAX_PTY_ROWS
) {
child.resize(c, r);
}
},
kill: () => child.kill(),
onData: (handler) => {
const sub = child.onData(handler);
return () => sub.dispose();
},
onExit: (handler) => {
const sub = child.onExit(({ exitCode }) => handler(exitCode));
return () => sub.dispose();
},
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const TOPICS = {
hostSessionStatus: "host.session.status",
/** A generic named internal event (host_deleted, user_login, ...). */
internalEvent: "internal.event",
/** A host's status dot changed (hosts/status/host-status-service.ts). */
/** A host's status dot changed (the host status provider). */
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
+149
View File
@@ -0,0 +1,149 @@
/**
* Host status is a provider: core owns the interface and the callers (the
* status routes, the sync feed, ctx.hosts.status), and one provider fills it.
* A plugin registers with ctx.hosts.status.provide. With none, every status
* is unknown.
*/
export type ProviderHostStatus = "online" | "reachable" | "offline";
export interface HostStatusEntry {
status: ProviderHostStatus;
lastChecked: string;
}
export type HostPortResolver = (
hostId: number,
) => number | undefined | Promise<number | undefined>;
export interface HostStatusProvider {
start(): void;
stop(): void;
get(hostId: number): HostStatusEntry | null;
/** All known statuses after bringing polling in line with the request. */
statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>>;
refresh(userId: string): Promise<void>;
check(hostId: number): Promise<HostStatusEntry | null>;
reportLogin(hostId: number, outcome: { ok: boolean }): void;
registerPort(connectionType: string, resolve: HostPortResolver): () => void;
getIntervalSeconds(): Promise<number>;
setIntervalSeconds(seconds: number): Promise<void>;
}
let builtin: HostStatusProvider | null = null;
let plugged: { pluginId: string; provider: HostStatusProvider } | null = null;
let running = false;
const ports = new Map<symbol, { type: string; resolve: HostPortResolver }>();
const portDrops = new Map<symbol, () => void>();
function active(): HostStatusProvider | null {
return plugged?.provider ?? builtin;
}
function attachPorts(provider: HostStatusProvider): void {
for (const [key, { type, resolve }] of ports) {
portDrops.set(key, provider.registerPort(type, resolve));
}
}
function detachPorts(): void {
for (const drop of portDrops.values()) drop();
portDrops.clear();
}
function swap(next: () => void): void {
const before = active();
if (running) before?.stop();
detachPorts();
next();
const after = active();
if (after) attachPorts(after);
if (running) after?.start();
}
/** A fallback provider. Core registers none; tests use it. */
export function registerBuiltinHostStatusProvider(
provider: HostStatusProvider | null,
): void {
swap(() => {
builtin = provider;
});
}
/** Returns the remover. A second provider is refused. */
export function provideHostStatus(
pluginId: string,
provider: HostStatusProvider,
): () => void {
if (plugged) {
throw new Error(
`Host status is already provided by plugin "${plugged.pluginId}"`,
);
}
const entry = { pluginId, provider };
swap(() => {
plugged = entry;
});
return () => {
if (plugged !== entry) return;
swap(() => {
plugged = null;
});
};
}
export function hostStatusProviderId(): string | null {
return plugged?.pluginId ?? null;
}
/** What the rest of core calls. */
export const hostStatus = {
start(): void {
running = true;
active()?.start();
},
stop(): void {
running = false;
active()?.stop();
},
get(hostId: number): HostStatusEntry | null {
return active()?.get(hostId) ?? null;
},
async statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>> {
return (await active()?.statusesFor(userId, requestedHostIds)) ?? new Map();
},
async refresh(userId: string): Promise<void> {
await active()?.refresh(userId);
},
async check(hostId: number): Promise<HostStatusEntry | null> {
return (await active()?.check(hostId)) ?? null;
},
reportLogin(hostId: number, outcome: { ok: boolean }): void {
active()?.reportLogin(hostId, outcome);
},
/** Kept across a provider change, so a plugin's port stays registered. */
registerPort(connectionType: string, resolve: HostPortResolver): () => void {
const key = Symbol(connectionType);
ports.set(key, { type: connectionType, resolve });
const provider = active();
if (provider)
portDrops.set(key, provider.registerPort(connectionType, resolve));
return () => {
ports.delete(key);
portDrops.get(key)?.();
portDrops.delete(key);
};
},
async getIntervalSeconds(): Promise<number> {
return (await active()?.getIntervalSeconds()) ?? 30;
},
async setIntervalSeconds(seconds: number): Promise<void> {
await active()?.setIntervalSeconds(seconds);
},
};
+106 -3
View File
@@ -18,7 +18,11 @@ import path from "node:path";
import semver from "semver";
import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import {
isTermixCompatible,
parseManifest,
validateThemeCss,
} from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
@@ -85,6 +89,19 @@ export interface LoadedPlugin {
signedTier?: KeyTier;
}
/**
* A plugin folder that did not load. Kept so the layout repair view can say
* why a chosen layout or theme is missing instead of only "not installed".
*/
export interface SkippedPlugin {
/** Manifest id when it could be read, else the folder or file name. */
id: string;
version: string | null;
layouts: string[];
themes: string[];
error: string;
}
export interface PluginLoaderOptions {
errorThreshold?: number;
errorWindowMs?: number;
@@ -92,8 +109,70 @@ export interface PluginLoaderOptions {
onFailed?: (plugin: LoadedPlugin) => void;
}
/**
* Every theme file a manifest declares must sit inside the plugin and hold
* only what a theme may hold. Refusing the plugin keeps a bad file from ever
* being served.
*/
function validatePluginThemes(dir: string, manifest: PluginManifest): void {
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.join(dir, theme.css);
if (!fs.existsSync(file) || !isRealPathInside(dir, file)) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`,
);
}
const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id);
if (errors.length > 0) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`,
);
}
}
}
/** What can still be said about a plugin folder that failed to load. */
function describeSkipped(
dir: string,
name: string,
isArtifact: boolean,
error: unknown,
): SkippedPlugin {
const skipped: SkippedPlugin = {
id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name,
version: null,
layouts: [],
themes: [],
error: error instanceof Error ? error.message : String(error),
};
if (isArtifact) return skipped;
try {
const raw = JSON.parse(
fs.readFileSync(getPluginManifestPath(dir), "utf8"),
) as {
id?: unknown;
version?: unknown;
contributes?: { layouts?: unknown; themes?: unknown };
};
const ids = (list: unknown) =>
Array.isArray(list)
? list
.map((entry) => (entry as { id?: unknown })?.id)
.filter((id): id is string => typeof id === "string")
: [];
if (typeof raw.id === "string") skipped.id = raw.id;
if (typeof raw.version === "string") skipped.version = raw.version;
skipped.layouts = ids(raw.contributes?.layouts);
skipped.themes = ids(raw.contributes?.themes);
} catch {
// An unreadable manifest leaves only the folder name.
}
return skipped;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
private skippedPlugins: SkippedPlugin[] = [];
/** Version of each bundled plugin as shipped, the floor for any update. */
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
@@ -113,6 +192,11 @@ export class PluginLoader {
return [...this.plugins.values()];
}
/** Plugin folders the last full load skipped, with the reason. */
skipped(): SkippedPlugin[] {
return [...this.skippedPlugins];
}
get(pluginId: string): LoadedPlugin | undefined {
return this.plugins.get(pluginId);
}
@@ -210,6 +294,8 @@ export class PluginLoader {
);
}
validatePluginThemes(dir, manifest);
if (this.plugins.has(manifest.id)) {
throw new Error(`another plugin already uses the id "${manifest.id}"`);
}
@@ -258,6 +344,7 @@ export class PluginLoader {
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
this.skippedPlugins = [];
await removeStaleStaging();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
@@ -322,6 +409,9 @@ export class PluginLoader {
}
loaded.push(plugin);
} catch (error) {
this.skippedPlugins.push(
describeSkipped(dir, entry.name, isArtifact, error),
);
pluginLogger.error(
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
@@ -566,6 +656,15 @@ export class PluginLoader {
if (plugin.state === "active" || plugin.state === "activating") return;
if (
plugin.manifest.runtime === "desktop" &&
process.env.ELECTRON_EMBEDDED !== "true"
) {
plugin.state = "blocked";
plugin.lastError = "only runs in the desktop app";
return;
}
plugin.state = "activating";
plugin.lastError = null;
@@ -629,8 +728,12 @@ export class PluginLoader {
bundled:
plugin.source === "bundled" || plugin.signedTier === "official",
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
// A new table, or a plugin that keeps its state in settings, may be
// where core data is waiting to move. Every move is idempotent.
if (
applied.length > 0 ||
plugin.manifest.contributes?.settings?.admin?.length
) {
const { runPluginDataMoves } =
await import("../upgrade/plugin-data-moves.js");
await runPluginDataMoves();
+76
View File
@@ -0,0 +1,76 @@
/**
* Layouts and themes on the server side: which look a fresh install shows,
* and which installed looks could not load. Core names no plugin here; the
* default look comes from the bundled index's defaultLook flag.
*/
import { defaultLookPluginIds } from "./bundled-index.js";
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
export interface DefaultLook {
layout: string | null;
theme: string | null;
}
interface LookLoader {
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
list(): Array<Pick<LoadedPlugin, "manifest">>;
skipped(): SkippedPlugin[];
}
/**
* The first default-look plugin's default layout (else its first one) and
* that layout's theme. A default-look plugin with themes only fills in the
* theme. Null where none is installed, and the browser uses its built-in.
*/
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
const look: DefaultLook = { layout: null, theme: null };
for (const id of defaultLookPluginIds()) {
const contributes = loader.get(id)?.manifest.contributes;
const layouts = contributes?.layouts ?? [];
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
if (layout && !look.layout) {
look.layout = layout.id;
look.theme ??= layout.defaultTheme ?? null;
}
if (!look.theme && contributes?.themes?.length) {
look.theme = contributes.themes[0].id;
}
}
return look;
}
/** Plugins with a layout or theme that were found but did not load. */
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
return loader
.skipped()
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
}
/**
* Layout ids some installed plugin brings, whether it runs, is turned off or
* failed to load. A choice outside this set was uninstalled, so the next
* choice down applies instead of the repair view.
*/
export function installedLayoutIds(loader: LookLoader): Set<string> {
const ids = new Set<string>();
for (const plugin of loader.list()) {
for (const layout of plugin.manifest.contributes?.layouts ?? []) {
ids.add(layout.id);
}
}
for (const plugin of loader.skipped()) {
for (const id of plugin.layouts) ids.add(id);
}
return ids;
}
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
export function safeLayoutForced(): boolean {
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
return value === "1" || value === "true";
}
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
export const LAYOUT_SETTING = "ui.layout";
export const THEME_SETTING = "ui.theme";
+109 -28
View File
@@ -7,7 +7,9 @@
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
* image and can still be uninstalled: their files are deleted and the id is
* recorded in the settings table, so the shipped copy is ignored from then
* on and a reinstall downloads the plugin like any other.
* on and a reinstall downloads the plugin like any other. The one exception
* is a default-look plugin (bundled-index.ts): the app is drawn with it, so
* it can be disabled or swapped but never uninstalled.
*/
import crypto from "node:crypto";
@@ -15,6 +17,7 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import {
activatePlugin,
deactivatePlugin,
@@ -31,6 +34,7 @@ import {
} from "./paths.js";
import {
OFFICIAL_REGISTRY_ID,
canRunRelease,
downloadRelease,
fetchRegistryIndex,
channelReleases,
@@ -39,6 +43,7 @@ import {
getRegistryStatus,
isApiCompatible,
latestOnChannel,
neededTermix,
officialSource,
parsePluginChannel,
type PluginChannel,
@@ -254,10 +259,13 @@ export interface RegistryListing {
videoId?: string;
features: string[];
docs?: string;
contributes: string[];
versions: Array<{
version: string;
prerelease: boolean;
compatible: boolean;
/** The Termix range this version needs, when this build is too old. */
needsTermix: string | null;
capabilities: string[];
publishedAt?: string;
releaseNotesUrl?: string;
@@ -393,7 +401,7 @@ export async function listRegistry(
const channel = channelOf(record);
const latest = latestOnChannel(plugin, channel);
const latestBeta =
plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null;
plugin.prereleases.find((entry) => canRunRelease(entry)) ?? null;
const installedVersion =
live?.manifest.version ?? record?.version ?? null;
const updateAvailable = Boolean(
@@ -416,10 +424,12 @@ export async function listRegistry(
videoId: plugin.videoId,
features: plugin.features,
docs: plugin.docs,
contributes: plugin.contributes,
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
compatible: isApiCompatible(entry.api),
compatible: canRunRelease(entry),
needsTermix: neededTermix(entry),
capabilities: entry.capabilities,
publishedAt: entry.publishedAt,
releaseNotesUrl: entry.releaseNotesUrl,
@@ -899,9 +909,12 @@ async function requireRelease(
"NOT_IN_REGISTRY",
);
}
if (!isApiCompatible(found.release.api)) {
if (!canRunRelease(found.release)) {
const termix = neededTermix(found.release);
throw new PluginManageError(
`${pluginId} ${found.release.version} does not run on this version of Termix`,
termix
? `${pluginId} ${found.release.version} needs Termix ${termix}`
: `${pluginId} ${found.release.version} does not run on this version of Termix`,
409,
"INCOMPATIBLE",
);
@@ -1154,6 +1167,13 @@ export async function uninstallPluginUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const shipped = loader.bundledIds().has(pluginId);
if (shipped && isDefaultLookPlugin(pluginId)) {
throw new PluginManageError(
"The default look cannot be uninstalled",
409,
"DEFAULT_LOOK",
);
}
const dependents = dependentChain(pluginId, await enabledIds());
if (dependents.length > 0) {
@@ -1492,6 +1512,14 @@ function validateUpload(buffer: Buffer): PluginManifest {
"INCOMPATIBLE",
);
}
const termix = neededTermix({ engine: manifest.engine });
if (termix) {
throw new PluginManageError(
`${manifest.id} ${manifest.version} needs Termix ${termix}`,
409,
"INCOMPATIBLE",
);
}
return manifest;
}
@@ -1634,41 +1662,94 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again. Running dependents stop first
* and start again after. `prepare` runs once the plugin is stopped, for work
* that needs its files out of use.
*/
async function reloadBundledUnlocked(
pluginId: string,
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
): Promise<{ id: string; version: string; state: string }> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled" && !prepare) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
409,
"NOT_BUNDLED",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
if (prepare) await prepare(previous);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner. Running dependents stop first and start again after.
* dev runner.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(() => reloadBundledUnlocked(pluginId));
}
/**
* Puts a bundled plugin back to the copy that shipped with this install,
* dropping any update downloaded over it. The shipped version is pinned so
* auto update does not bring a broken release straight back; unpin it in the
* Plugins tab to follow updates again.
*/
export function restoreBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
"This plugin has no bundled copy",
409,
"NOT_BUNDLED",
"NO_BUNDLED_COPY",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
await removeArtifactFiles(pluginId, previous?.artifact);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
});
await repository.update(pluginId, {
autoUpdate: false,
pinnedVersion: result.version,
});
await applyPin(pluginId, result.version);
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
operation: "plugin_restore_bundled",
});
return result;
});
}
+1
View File
@@ -21,6 +21,7 @@ export {
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
validateThemeCss,
} from "@termix-ssh/plugin-sdk/manifest";
export type {
@@ -0,0 +1,106 @@
import type { Client } from "ssh2";
import { tcpPingThroughJumpHostResult } from "./tcp-ping.js";
type OpenChain = (
jumpHosts: Array<{ hostId: number }>,
userId: string,
) => Promise<Client | null>;
interface Entry {
client: Promise<Client | null>;
idle?: NodeJS.Timeout;
}
/**
* Status probes for hosts behind jump hosts share one chain per hop list.
* Opening a chain logs in to every hop, so doing it for every host on every
* interval made an import of jump-hosted hosts open dozens of SSH sessions
* each minute. A chain closes after it sits idle.
*/
export class JumpProbePool {
private readonly entries = new Map<string, Entry>();
constructor(
private readonly open: OpenChain,
private readonly idleMs = 120_000,
) {}
async ping(
jumpHosts: Array<{ hostId: number }>,
userId: string,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
const key = `${userId}:${jumpHosts.map((hop) => hop.hostId).join(">")}`;
let entry = this.entries.get(key);
if (!entry) {
entry = { client: this.connect(key, jumpHosts, userId) };
this.entries.set(key, entry);
}
const client = await entry.client;
if (!client) {
this.drop(key, entry);
return false;
}
this.touch(key, entry, client);
const result = await tcpPingThroughJumpHostResult(
client,
host,
port,
timeoutMs,
true,
);
if (result === "timeout") {
// The hop never answered, so the next ping opens a fresh chain.
this.drop(key, entry);
client.end();
}
return result === "ok";
}
closeAll(): void {
for (const [key, entry] of [...this.entries]) {
this.drop(key, entry);
void entry.client.then((client) => client?.end()).catch(() => {});
}
}
private async connect(
key: string,
jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise<Client | null> {
const client = await this.open(jumpHosts, userId).catch(() => null);
if (client) {
const forget = () => {
const current = this.entries.get(key);
if (current) this.drop(key, current);
};
client.once("close", forget);
client.once("error", forget);
}
return client;
}
private touch(key: string, entry: Entry, client: Client): void {
if (entry.idle) clearTimeout(entry.idle);
entry.idle = setTimeout(() => {
this.drop(key, entry);
client.end();
}, this.idleMs);
entry.idle.unref?.();
}
private drop(key: string, entry: Entry): void {
if (entry.idle) clearTimeout(entry.idle);
if (this.entries.get(key) === entry) this.entries.delete(key);
}
}
/** One pool for core's checks and for plugins probing through ctx. */
export const sharedJumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
const { createJumpHostChain } =
await import("../../hosts/jump-host-chain.js");
return createJumpHostChain(jumpHosts, userId);
});
+117
View File
@@ -0,0 +1,117 @@
import net from "net";
import type { Client } from "ssh2";
/**
* Opens a TCP connection and closes it again. SSH probes exchange banners,
* but still close before authentication and may trigger aggressive Fail2Ban rules.
*/
export function tcpPing(
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
resolve(result);
};
const cleanup = () => {
try {
socket.destroy();
} catch {
// expected
}
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => {
const dataTimeout = setTimeout(() => {
cleanup();
finish(true);
}, 2000);
socket.once("data", (data) => {
clearTimeout(dataTimeout);
if (data.toString("utf8").startsWith("SSH-")) {
try {
socket.end("SSH-2.0-TermixHealthCheck\r\n");
} catch {
// expected
}
setTimeout(cleanup, 200);
} else {
cleanup();
}
finish(true);
});
});
socket.once("timeout", () => {
cleanup();
finish(false);
});
socket.once("error", () => {
cleanup();
finish(false);
});
socket.connect(port, host);
});
}
/**
* "refused" means the last hop answered that the target is down. "timeout"
* means the hop never answered, so the chain itself may be dead.
*/
export type JumpPingResult = "ok" | "refused" | "timeout";
export function tcpPingThroughJumpHostResult(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
keepOpen = false,
): Promise<JumpPingResult> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: JumpPingResult) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (!keepOpen) jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish("timeout"), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream ? "ok" : "refused");
});
});
}
/** The same check from the far end of a jump host chain. Ends the chain. */
export async function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
keepOpen = false,
): Promise<boolean> {
return (
(await tcpPingThroughJumpHostResult(
jumpClient,
host,
port,
timeoutMs,
keepOpen,
)) === "ok"
);
}
+60 -9
View File
@@ -15,11 +15,14 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import {
isApiCompatible as satisfiesPluginApi,
isTermixCompatible,
pluginDocsUrl,
pluginFeatures,
SUPPORTED_PLUGIN_API_VERSION,
youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { pluginLogger } from "../utils/logger.js";
import {
@@ -59,7 +62,10 @@ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
export interface RegistryVersion {
version: string;
/** The API major, all that builds before engine ranges read. */
api: string;
/** The manifest's engine ranges, when the index lists them. */
engine?: { api: string; termix: string };
url: string;
sha256: string;
signature: string;
@@ -92,6 +98,8 @@ export interface RegistryPlugin {
features: string[];
/** https docs link from the index. */
docs?: string;
/** The manifest's contributes keys, like layouts, themes or tabs. */
contributes: string[];
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
@@ -149,6 +157,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
return null;
}
if (!/^[a-f0-9]{64}$/i.test(sha256)) return null;
const engine = entry.engine as Record<string, unknown> | undefined;
const engineApi = asString(engine?.api);
const engineTermix = asString(engine?.termix);
try {
if (new URL(url).protocol !== "https:") return null;
} catch {
@@ -157,6 +168,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
return {
version,
api: asString(entry.api) ?? "",
...(engineApi && engineTermix
? { engine: { api: engineApi, termix: engineTermix } }
: {}),
url,
sha256: sha256.toLowerCase(),
signature,
@@ -191,11 +205,15 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
const versions = parseList(entry.versions).filter(
(v) => !semver.prerelease(v.version),
);
const prereleases = parseList(entry.prereleases)
// gated holds releases that need a newer Termix than builds which skip
// the engine.termix check, so only builds that check it read the list.
const gated = parseList(entry.gated);
const versions = [...parseList(entry.versions), ...gated]
.filter((v) => !semver.prerelease(v.version))
.sort((a, b) => semver.rcompare(a.version, b.version));
const prereleases = [...parseList(entry.prereleases), ...gated]
.filter((v) => semver.prerelease(v.version))
.sort((a, b) => semver.rcompare(a.version, b.version))
.map((v) => ({ ...v, prerelease: true }));
if (versions.length === 0 && prereleases.length === 0) continue;
plugins.push({
@@ -209,6 +227,14 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
docs: pluginDocsUrl(entry.docs) ?? undefined,
contributes: Array.isArray(entry.contributes)
? entry.contributes
.filter(
(kind): kind is string =>
typeof kind === "string" && /^[A-Za-z]+$/.test(kind),
)
.slice(0, 50)
: [],
versions,
prereleases,
});
@@ -229,6 +255,29 @@ export function isApiCompatible(api: string): boolean {
return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION;
}
/**
* The Termix range a release needs when this build is too old for it, or
* null when this build satisfies it.
*/
export function neededTermix(
release: Pick<RegistryVersion, "engine">,
): string | null {
const range = release.engine?.termix;
if (!range) return null;
return isTermixCompatible(range, getLocalVersion()) ? null : range;
}
/** Whether this build can run a release: its API and its engine ranges. */
export function canRunRelease(
release: Pick<RegistryVersion, "api" | "engine">,
): boolean {
if (!isApiCompatible(release.api)) return false;
if (!release.engine) return true;
return (
satisfiesPluginApi(release.engine.api) && neededTermix(release) === null
);
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
let current = url;
@@ -427,9 +476,8 @@ export function latestOnChannel(
channel: PluginChannel,
): RegistryVersion | null {
return (
channelReleases(plugin, channel).find((entry) =>
isApiCompatible(entry.api),
) ?? null
channelReleases(plugin, channel).find((entry) => canRunRelease(entry)) ??
null
);
}
@@ -486,9 +534,12 @@ export async function downloadRelease(
release: RegistryVersion,
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
): Promise<{ file: string; signatureFile: string }> {
if (!isApiCompatible(release.api)) {
if (!canRunRelease(release)) {
const termix = neededTermix(release);
throw new Error(
`${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
termix
? `${pluginId} ${release.version} needs Termix ${termix}, this is ${getLocalVersion()}`
: `${pluginId} ${release.version} needs plugin API ${release.engine?.api ?? release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
);
}
+12
View File
@@ -41,6 +41,18 @@ export function revoke(key: string, value?: unknown): boolean {
return providers.delete(key);
}
/**
* The provider under "<some plugin id>.<name>", for a well-known name core
* reads without knowing which plugin supplies it. First match wins.
*/
export function consumeByName<T>(name: string): T | undefined {
const suffix = `.${name}`;
for (const [key, value] of providers) {
if (key.endsWith(suffix)) return value as T;
}
return undefined;
}
export function has(key: string): boolean {
return providers.has(key);
}
+4
View File
@@ -44,6 +44,10 @@ export type { PluginSettingsScope };
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"notification_private_endpoint_allowlist",
// Read once by the branding provider to keep a pre-plugin install's branding.
"branding_app_name",
"branding_tagline",
"branding_logo",
];
type ChangeListener = (value: unknown) => void;
+2 -4
View File
@@ -27,7 +27,7 @@ export interface UserOwnedTable {
otherUserColumns: string[];
/** refHost columns. */
hostColumns: string[];
/** Columns that travel, as SQL names. The primary key and secrets do not. */
/** Columns that travel, as SQL names. Auto ids and secrets do not. */
columns: string[];
/** The owner column is the primary key: one row per user. */
userIsKey: boolean;
@@ -57,9 +57,7 @@ export function listUserOwnedTables(): UserOwnedTable[] {
.filter(
([, column]) =>
column === user[1] ||
(column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText"),
(column.type !== "id" && column.type !== "encryptedText"),
)
.map(([property, column]) => columnName(property, column)),
userIsKey: !!user[1].primaryKey,
+32 -1
View File
@@ -54,6 +54,8 @@ interface Route {
rawHandler?: RawUpgradeHandler;
/** Sockets handed to rawHandler, destroyed when the route goes away. */
rawSockets?: Set<Duplex>;
/** Core's own socket: served under /plugin-ws/core/, no plugin grant. */
core?: boolean;
}
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
@@ -163,7 +165,10 @@ async function handlePluginUpgrade(
// checked per upgrade rather than at registration, and a revoke takes effect
// on the next connection without a restart.
const { hasCapability } = await import("./permissions.js");
if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
if (
!route.core &&
!(await hasCapability(route.pluginId, "network:serve", route.declared))
) {
reject(socket, 403, "Forbidden");
return true;
}
@@ -303,6 +308,32 @@ export function registerPluginWsRoute(
return () => disposeRoute(routeKey, pluginId, normalized);
}
/**
* A socket core itself serves, at /plugin-ws/core/<path>, so it rides the same
* proxy rules and token checks as plugin sockets. Authenticated like them.
*/
export function registerCoreWsRoute(
path: string,
handler: PluginWebSocketHandler,
): () => void {
const routeKey = key("core", path);
const normalized = normalizePath(path);
if (routes.has(routeKey)) return () => {};
routes.set(routeKey, {
pluginId: "core",
path: normalized,
options: {},
declared: [],
handler,
core: true,
});
servers.set(
routeKey,
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
);
return () => disposeRoute(routeKey, "core", normalized);
}
export function registerPluginWsUpgrade(
pluginId: string,
path: string,