feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
/**
* ctx.process: programs on the Termix server, behind process:spawn.
*
* run starts a program without a shell and kills it on deactivate.
* ensureBinary gives a plugin a verified executable pinned to one SHA-256:
* a prebuilt copy (baked into the Docker image), the copy it downloaded
* before, or a fresh download.
*/
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type {
PluginBinarySpec,
PluginProcess,
PluginProcessHandle,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
/** Where downloaded binaries go. Defaults to <DATA_DIR>/plugins/<id>/bin. */
binDir?: () => string;
/** Test seam for the download. */
fetch?: typeof fetch;
}
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
async function sha256Of(file: string): Promise<string | null> {
try {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest("hex");
} catch {
return null;
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function createPluginProcess(deps: Deps): PluginProcess {
const pluginId = deps.manifest.id;
const declared = deps.manifest.capabilities;
const binDir =
deps.binDir ??
(() =>
path.join(
process.env.DATA_DIR ?? "./db/data",
"plugins",
pluginId,
"bin",
));
const doFetch = deps.fetch ?? fetch;
const audited = async <T>(
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> => {
try {
await assertCapability(pluginId, "process:spawn", declared);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: errorMessage(error),
});
throw error;
}
};
const run: PluginProcess["run"] = (file, args, options = {}) =>
audited("process_run", `ran ${path.basename(file)}`, async () => {
const child = spawn(file, [...args], {
cwd: options.cwd,
env: { ...process.env, ...(options.env ?? {}) },
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
let running = true;
const kill = (signal: "SIGTERM" | "SIGKILL" = "SIGTERM") => {
if (!running) return;
try {
child.kill(signal);
} catch {
// already gone
}
};
const untrack = deps.bag.add(
() => kill("SIGKILL"),
`process ${path.basename(file)}`,
);
const timer =
options.timeoutMs && options.timeoutMs > 0
? setTimeout(() => kill("SIGKILL"), options.timeoutMs)
: null;
timer?.unref();
const exited = new Promise<{
code: number | null;
signal: string | null;
}>((resolve, reject) => {
child.once("error", (error) => {
running = false;
if (timer) clearTimeout(timer);
untrack();
reject(error);
});
child.once("close", (code, signal) => {
running = false;
if (timer) clearTimeout(timer);
untrack();
resolve({ code, signal });
});
});
// A caller that never awaits exited must not see an unhandled rejection.
exited.catch(() => {});
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
const handle: PluginProcessHandle = {
pid: child.pid,
onStdout: (listener) => {
child.stdout?.on("data", listener);
},
onStderr: (listener) => {
child.stderr?.on("data", listener);
},
exited,
kill,
};
return handle;
});
const download = async (
spec: PluginBinarySpec,
target: string,
): Promise<void> => {
await assertCapability(pluginId, "network:outbound", declared);
const url = new URL(spec.url);
if (url.protocol !== "https:") {
throw new Error("Binary downloads must use https");
}
const response = await doFetch(url, {
redirect: "follow",
headers: { "User-Agent": "Termix" },
});
if (!response.ok || !response.body) {
throw new Error(`Download failed with HTTP ${response.status}`);
}
const data = Buffer.from(await response.arrayBuffer());
const actual = createHash("sha256").update(data).digest("hex");
if (actual !== spec.sha256.toLowerCase()) {
throw new Error(
`Checksum mismatch for ${spec.name}: expected ${spec.sha256}, got ${actual}`,
);
}
const temp = `${target}.download-${randomUUID()}`;
await fs.writeFile(temp, data, { mode: 0o755 });
await fs.rename(temp, target);
};
const ensureBinary: PluginProcess["ensureBinary"] = (spec) =>
audited(
"process_ensure_binary",
`${spec.name} ${spec.version}`,
async () => {
if (!SAFE_NAME.test(spec.name)) {
throw new Error(`Invalid binary name "${spec.name}"`);
}
const expected = spec.sha256.toLowerCase();
if (!SHA256_PATTERN.test(expected)) {
throw new Error(`Invalid SHA-256 for ${spec.name}`);
}
for (const candidate of spec.prebuilt ?? []) {
if ((await sha256Of(candidate)) === expected) return candidate;
}
const dir = binDir();
await fs.mkdir(dir, { recursive: true });
const target = path.join(dir, spec.name);
if ((await sha256Of(target)) === expected) return target;
await download(spec, target);
return target;
},
);
return { run, ensureBinary };
}
+38
View File
@@ -522,6 +522,44 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
record("auth_register_ssh", provider.type);
},
registerKeyboardInteractiveHandler: (handler) => {
requireDeclared(
contributes.keyboardInteractive,
handler.id,
"keyboardInteractive",
);
// Synchronous hooks inside ssh2's callback, so only the declaration is
// checked, like ctx.http.router. Deactivate removes the handler.
const settingsFor = (host: SshConnectHost): Record<string, unknown> =>
host.pluginSettings?.[pluginId] ?? {};
ensureCoreSshAuthProviders();
const dispose = registerKeyboardInteractiveInterceptor({
id: handler.id,
pluginId,
detect: (round, host) => {
const detected = handler.detect(
round,
host as PluginSshHost,
settingsFor(host),
);
if (!detected) return null;
if (detected.kind === "browser") {
return { ...detected, id: handler.id, label: handler.label };
}
return detected;
},
autoAnswerPasswords: handler.autoAnswerPasswords
? (host) =>
handler.autoAnswerPasswords!(
host as PluginSshHost,
settingsFor(host),
) === true
: undefined,
});
bag.add(dispose, `keyboard-interactive handler "${handler.id}"`);
record("auth_register_keyboard_interactive", handler.id);
},
registerLoginMethod: (method) => {
requireDeclared(contributes.loginMethods, method.id, "loginMethods");
{
+6
View File
@@ -56,6 +56,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -821,6 +822,11 @@ export function createPluginContext(
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
notify: createPluginNotify({ manifest, audit: auditCall }),
fetch: createPluginFetch({ manifest, audit: auditCall }),
process: createPluginProcess({
manifest,
bag: handle.bag,
audit: auditCall,
}),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
desktop: {
+6 -2
View File
@@ -88,16 +88,20 @@ function normalizePublicPath(path: string): string {
* to be public are usually keyed by a token ("/webhook/:token"). A parameter
* matches exactly one segment and never a slash, so ":token" cannot swallow
* the rest of the path and turn one declared route into a prefix hole.
*
* A trailing "/*" is the one deliberate prefix: it matches the path itself and
* anything below it, for a proxied page that loads its own subpaths.
*/
function compilePublicPath(path: string): RegExp {
const escaped = path
const prefix = path.endsWith("/*");
const escaped = (prefix ? path.slice(0, -2) : path)
.split("/")
.map((segment) => {
if (segment.startsWith(":")) return "[^/]+";
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
})
.join("/");
return new RegExp(`^${escaped}$`);
return new RegExp(prefix ? `^${escaped}(?:/.*)?$` : `^${escaped}$`);
}
/**