mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user