mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -0,0 +1,197 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({ granted: new Set<string>() }));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { createPluginProcess } = await import("../../plugins/ctx-process.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const DECLARED = ["process:spawn", "network:outbound"];
|
||||
|
||||
function manifest(capabilities: string[] = DECLARED) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function sha256(data: string): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let audits: Array<{ action: string; success: boolean }>;
|
||||
|
||||
function create(
|
||||
options: { capabilities?: string[]; fetch?: typeof fetch } = {},
|
||||
) {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const process = createPluginProcess({
|
||||
manifest: manifest(options.capabilities),
|
||||
bag,
|
||||
audit: async (action, _details, outcome) => {
|
||||
audits.push({ action, success: outcome.success });
|
||||
},
|
||||
binDir: () => path.join(dir, "bin"),
|
||||
fetch: options.fetch,
|
||||
});
|
||||
return { process, bag };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
h.granted = new Set(DECLARED);
|
||||
audits = [];
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "ctx-process-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("ctx.process.run", () => {
|
||||
it("runs a program and streams its output", async () => {
|
||||
const { process: proc } = create();
|
||||
const child = await proc.run(globalThis.process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write('hello'); process.stderr.write('oops')",
|
||||
]);
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.onStdout((chunk) => (out += chunk));
|
||||
child.onStderr((chunk) => (err += chunk));
|
||||
await expect(child.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
expect(out).toBe("hello");
|
||||
expect(err).toBe("oops");
|
||||
expect(audits).toContainEqual({ action: "process_run", success: true });
|
||||
});
|
||||
|
||||
it("refuses without process:spawn, and audits the refusal", async () => {
|
||||
h.granted = new Set();
|
||||
const { process: proc } = create();
|
||||
await expect(
|
||||
proc.run(globalThis.process.execPath, ["-e", ""]),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audits).toContainEqual({ action: "process_run", success: false });
|
||||
});
|
||||
|
||||
it("kills what is still running on deactivate", async () => {
|
||||
const { process: proc, bag } = create();
|
||||
const child = await proc.run(globalThis.process.execPath, [
|
||||
"-e",
|
||||
"setInterval(() => {}, 1000)",
|
||||
]);
|
||||
await bag.disposeAll();
|
||||
const exit = await child.exited;
|
||||
expect(exit.code === null || exit.signal !== null).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.process.ensureBinary", () => {
|
||||
const contents = "#!/bin/sh\necho hi\n";
|
||||
|
||||
it("uses a prebuilt copy whose checksum matches, without downloading", async () => {
|
||||
const prebuilt = path.join(dir, "prebuilt-tool");
|
||||
await fs.writeFile(prebuilt, contents);
|
||||
const fetch = vi.fn();
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
prebuilt: [path.join(dir, "missing"), prebuilt],
|
||||
}),
|
||||
).resolves.toBe(prebuilt);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads once, verifies it and reuses the copy", async () => {
|
||||
const fetch = vi.fn(async () => new Response(contents));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
const spec = {
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
};
|
||||
const first = await proc.ensureBinary(spec);
|
||||
expect(first).toBe(path.join(dir, "bin", "tool"));
|
||||
expect(await fs.readFile(first, "utf8")).toBe(contents);
|
||||
await proc.ensureBinary(spec);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses a download whose checksum does not match", async () => {
|
||||
const fetch = vi.fn(async () => new Response("tampered"));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
prebuilt: [],
|
||||
}),
|
||||
).rejects.toThrow(/Checksum mismatch/);
|
||||
await expect(fs.access(path.join(dir, "bin", "tool"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("needs network:outbound only to download", async () => {
|
||||
h.granted = new Set(["process:spawn"]);
|
||||
const { process: proc } = create({
|
||||
fetch: vi.fn(async () => new Response(contents)) as never,
|
||||
});
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("refuses plain http and odd file names", async () => {
|
||||
const { process: proc } = create({
|
||||
fetch: vi.fn(async () => new Response(contents)) as never,
|
||||
});
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "http://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toThrow(/https/);
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "../tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toThrow(/Invalid binary name/);
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,8 @@ const { createPluginAuth, createPluginSsh } =
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { getSshAuthProvider } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { classifyKeyboardInteractive } =
|
||||
await import("../../hosts/connect/keyboard-interactive.js");
|
||||
const { getLoginMethod, getSecondFactor } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
@@ -341,6 +343,101 @@ describe("ctx.auth", () => {
|
||||
expect(getSshAuthProvider("corp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers a keyboard-interactive handler that sees only its own host settings", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
auth.registerKeyboardInteractiveHandler({
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
detect: (round, _host, settings) => {
|
||||
seen.push(settings);
|
||||
return /gate/i.test(round.name)
|
||||
? {
|
||||
kind: "browser",
|
||||
url: "https://gate",
|
||||
code: "AB12",
|
||||
instructions: "",
|
||||
}
|
||||
: null;
|
||||
},
|
||||
autoAnswerPasswords: (_host, settings) => settings.on === true,
|
||||
});
|
||||
|
||||
const target = {
|
||||
id: 1,
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
password: "pw",
|
||||
pluginSettings: { fixture: { on: true }, other: { secret: "x" } },
|
||||
};
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{ name: "Gate sign-in", instructions: "", prompts: [] },
|
||||
target,
|
||||
),
|
||||
).toEqual({
|
||||
kind: "browser",
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
url: "https://gate",
|
||||
code: "AB12",
|
||||
instructions: "",
|
||||
});
|
||||
expect(seen[0]).toEqual({ on: true });
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{
|
||||
name: "",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Password:", echo: false }],
|
||||
},
|
||||
target,
|
||||
),
|
||||
).toEqual({ kind: "auto", responses: ["pw"] });
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{ name: "Gate sign-in", instructions: "", prompts: [] },
|
||||
target,
|
||||
).kind,
|
||||
).not.toBe("browser");
|
||||
});
|
||||
|
||||
it("refuses a keyboard-interactive handler that is not declared", () => {
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
auth.registerKeyboardInteractiveHandler({
|
||||
id: "other",
|
||||
label: "Other",
|
||||
detect: () => null,
|
||||
}),
|
||||
).toThrow(/contributes\.auth\.keyboardInteractive/);
|
||||
|
||||
const undeclared = createPluginAuth({
|
||||
manifest: manifest([], { keyboardInteractive: ["gate"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
undeclared.registerKeyboardInteractiveHandler({
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
detect: () => null,
|
||||
}),
|
||||
).toThrow(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("registers login methods and second factors under the plugin, and records enrolment", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
|
||||
@@ -234,6 +234,24 @@ describe("ctx.http authentication", () => {
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("opens everything below a trailing /* and nothing beside it", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/chooser/:id/*"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.use("/chooser", (_req, res) => res.json({ ok: true }));
|
||||
router.get("/chooserx/a", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
const root = `${base}/plugin-api/sample-plugin`;
|
||||
|
||||
expect((await fetch(`${root}/chooser/abc`)).status).toBe(200);
|
||||
expect((await fetch(`${root}/chooser/abc/select/x`)).status).toBe(200);
|
||||
expect((await fetch(`${root}/chooser`)).status).toBe(401);
|
||||
expect((await fetch(`${root}/chooserx/a`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it("gives the plugin the user core authenticated, not one the caller named", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
|
||||
Reference in new issue
Block a user