mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -281,7 +281,9 @@ export type PluginMiddleware = (
|
||||
export interface PluginRouterOptions {
|
||||
/**
|
||||
* Paths served without authentication, relative to the plugin's mount point
|
||||
* and matched exactly ("/callback", not "/plugin-api/<id>/callback").
|
||||
* and matched exactly ("/callback", not "/plugin-api/<id>/callback"). A
|
||||
* ":name" segment matches one segment, and a trailing "/*" matches any
|
||||
* number of further segments, for a proxied page ("/chooser/:id/*").
|
||||
*
|
||||
* For the handful of routes an unauthenticated third party has to reach: an
|
||||
* OIDC callback, an inbound webhook. Every entry is audited when the router
|
||||
@@ -775,26 +777,69 @@ export interface PluginKeyboardInteractivePrompt {
|
||||
echo?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A round the user finishes in a browser: open a URL, confirm a code there,
|
||||
* then continue. Transports show one generic dialog for it and name their
|
||||
* messages after `id` (the terminal sends "<id>_auth_required" and waits for
|
||||
* "<id>_auth_continue").
|
||||
*/
|
||||
export interface PluginBrowserSignInRound {
|
||||
kind: "browser";
|
||||
/** The keyboard-interactive handler that claimed the round. */
|
||||
id: string;
|
||||
/** Product name for the dialog title. */
|
||||
label: string;
|
||||
url: string | null;
|
||||
/** A code to compare in the browser, or "N/A". */
|
||||
code: string;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export type PluginKeyboardInteractiveDecision =
|
||||
| { kind: "auto"; responses: string[] }
|
||||
| {
|
||||
kind: "warpgate";
|
||||
url: string | null;
|
||||
securityKey: string;
|
||||
instructions: string;
|
||||
}
|
||||
| PluginBrowserSignInRound
|
||||
| { kind: "totp"; promptIndex: number }
|
||||
| { kind: "input"; promptIndex: number; isPush: boolean };
|
||||
|
||||
export type PluginSshPromptRequest =
|
||||
| { kind: "totp"; prompt: string; retry: boolean }
|
||||
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
|
||||
| {
|
||||
kind: "warpgate";
|
||||
url: string | null;
|
||||
securityKey: string;
|
||||
| PluginBrowserSignInRound;
|
||||
|
||||
/** What a keyboard-interactive handler's detect may return. */
|
||||
export type PluginKeyboardInteractiveDetection =
|
||||
| Exclude<PluginKeyboardInteractiveDecision, PluginBrowserSignInRound>
|
||||
| Omit<PluginBrowserSignInRound, "id" | "label">;
|
||||
|
||||
/**
|
||||
* Claims keyboard-interactive rounds on any host, whatever its auth type,
|
||||
* for a server that speaks its own prompt style (a bastion asking for a
|
||||
* browser sign-in). Handlers run before core's TOTP and push detection.
|
||||
*
|
||||
* `settings` is this plugin's host-scope settings for the host, with manifest
|
||||
* defaults filled in. Both hooks are synchronous; they run inside ssh2's
|
||||
* keyboard-interactive callback.
|
||||
*/
|
||||
export interface PluginKeyboardInteractiveHandler {
|
||||
/** Listed in contributes.auth.keyboardInteractive. */
|
||||
id: string;
|
||||
/** Product name shown when a browser round is claimed. */
|
||||
label: string;
|
||||
detect: (
|
||||
round: {
|
||||
name: string;
|
||||
instructions: string;
|
||||
};
|
||||
prompts: PluginKeyboardInteractivePrompt[];
|
||||
},
|
||||
host: PluginSshHost,
|
||||
settings: Record<string, unknown>,
|
||||
) => PluginKeyboardInteractiveDetection | null;
|
||||
/** Answer password prompts with the stored password instead of asking. */
|
||||
autoAnswerPasswords?: (
|
||||
host: PluginSshHost,
|
||||
settings: Record<string, unknown>,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
/** Answers keyboard-interactive prompts. Resolve null to give up. */
|
||||
export interface PluginSshPromptChannel {
|
||||
@@ -1213,6 +1258,10 @@ export interface PluginSecondFactor {
|
||||
*/
|
||||
export interface PluginAuth {
|
||||
registerSshAuthProvider: (provider: PluginSshAuthProvider) => void;
|
||||
/** A host-independent keyboard-interactive handler. */
|
||||
registerKeyboardInteractiveHandler: (
|
||||
handler: PluginKeyboardInteractiveHandler,
|
||||
) => void;
|
||||
registerLoginMethod: (method: PluginLoginMethod) => void;
|
||||
registerSecondFactor: (factor: PluginSecondFactor) => void;
|
||||
/**
|
||||
@@ -1459,7 +1508,8 @@ export type PluginFetch = (
|
||||
* hardware (a serial port, a USB device) is the first caller. Core cannot
|
||||
* mediate that access the way it mediates ctx.ssh or ctx.db, so the
|
||||
* capability here is a declared, reviewable, audited statement of intent
|
||||
* rather than a technical gate, matching process:spawn.
|
||||
* rather than a technical gate. Running programs has its own member,
|
||||
* ctx.process.
|
||||
*/
|
||||
export interface PluginCapabilities {
|
||||
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
|
||||
@@ -1468,6 +1518,60 @@ export interface PluginCapabilities {
|
||||
require: (capability: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginProcessOptions {
|
||||
/** Added to the server's environment. */
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
/** Kills the program with SIGKILL after this long. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** A running program started with ctx.process.run. */
|
||||
export interface PluginProcessHandle {
|
||||
readonly pid: number | undefined;
|
||||
/** Output as UTF-8 text, in the chunks it arrives in. */
|
||||
onStdout: (listener: (chunk: string) => void) => void;
|
||||
onStderr: (listener: (chunk: string) => void) => void;
|
||||
/** Settles when the program ends, or rejects when it cannot start. */
|
||||
readonly exited: Promise<{ code: number | null; signal: string | null }>;
|
||||
kill: (signal?: "SIGTERM" | "SIGKILL") => void;
|
||||
}
|
||||
|
||||
/** A program a plugin downloads and runs, pinned to one checksum. */
|
||||
export interface PluginBinarySpec {
|
||||
/** File name under the plugin's data dir, e.g. "opkssh-linux-amd64". */
|
||||
name: string;
|
||||
version: string;
|
||||
/** https URL of the release asset. Redirects are followed. */
|
||||
url: string;
|
||||
/** Hex SHA-256 the file must have. */
|
||||
sha256: string;
|
||||
/**
|
||||
* Paths checked before downloading, such as a copy baked into the Docker
|
||||
* image. One whose checksum matches is used in place.
|
||||
*/
|
||||
prebuilt?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Programs on the Termix server. Needs process:spawn. Every program still
|
||||
* running is killed on deactivate.
|
||||
*/
|
||||
export interface PluginProcess {
|
||||
/** Starts `file` with `args`, no shell. Audited. */
|
||||
run: (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options?: PluginProcessOptions,
|
||||
) => Promise<PluginProcessHandle>;
|
||||
/**
|
||||
* Returns the path of a verified executable: a matching prebuilt copy, the
|
||||
* copy downloaded earlier, or a fresh download (which also needs
|
||||
* network:outbound). Throws when the checksum does not match.
|
||||
*/
|
||||
ensureBinary: (spec: PluginBinarySpec) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timers owned by the plugin. Every timer is cleared on deactivate, a run that
|
||||
* throws is logged instead of crashing the server, and a repeating job never
|
||||
@@ -1527,6 +1631,8 @@ export interface PluginContext {
|
||||
readonly notify: PluginNotify;
|
||||
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
||||
readonly fetch: PluginFetch;
|
||||
/** Programs on the Termix server. Needs process:spawn. */
|
||||
readonly process: PluginProcess;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `userId` as the acting user, for background work that has
|
||||
|
||||
@@ -259,6 +259,8 @@ export interface PluginAuthContribution {
|
||||
secondFactors?: string[];
|
||||
/** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */
|
||||
secretSchemes?: string[];
|
||||
/** Keyboard-interactive handler ids, e.g. "warpgate". */
|
||||
keyboardInteractive?: string[];
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
@@ -792,7 +794,13 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
|
||||
}
|
||||
rejectUnknown(
|
||||
value,
|
||||
["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"],
|
||||
[
|
||||
"sshAuthTypes",
|
||||
"loginMethods",
|
||||
"secondFactors",
|
||||
"secretSchemes",
|
||||
"keyboardInteractive",
|
||||
],
|
||||
"contributes.auth",
|
||||
errors,
|
||||
);
|
||||
@@ -801,6 +809,7 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
|
||||
"loginMethods",
|
||||
"secondFactors",
|
||||
"secretSchemes",
|
||||
"keyboardInteractive",
|
||||
]) {
|
||||
const list = value[key];
|
||||
if (list === undefined) continue;
|
||||
@@ -1351,7 +1360,8 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
(auth?.sshAuthTypes?.length ?? 0) +
|
||||
(auth?.loginMethods?.length ?? 0) +
|
||||
(auth?.secondFactors?.length ?? 0) +
|
||||
(auth?.secretSchemes?.length ?? 0) >
|
||||
(auth?.secretSchemes?.length ?? 0) +
|
||||
(auth?.keyboardInteractive?.length ?? 0) >
|
||||
0;
|
||||
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
|
||||
errors.push(
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* OpenSSH certificate auth for ssh2, which has no native support for it.
|
||||
*
|
||||
* Grafts the certificate onto the parsed key, converts ECDSA signatures from
|
||||
* DER to SSH wire format, and patches Protocol.authPK so the signature
|
||||
* wrapper carries the base algorithm (OpenSSH's sshkey_check_sigtype wants
|
||||
* that). Pure: no core state, it only touches the config and client it gets.
|
||||
*
|
||||
* Used by core key auth with a CA-signed certificate and by every plugin that
|
||||
* issues short-lived certificates (opkssh, step-ca, vault).
|
||||
*/
|
||||
|
||||
import type {
|
||||
AnyAuthMethod,
|
||||
AuthHandlerMiddleware,
|
||||
AuthenticationType,
|
||||
Client,
|
||||
ConnectConfig,
|
||||
PublicKeyAuthMethod,
|
||||
} from "ssh2";
|
||||
|
||||
type SignCallback = (
|
||||
data: Buffer,
|
||||
callback: (signature: Buffer) => void,
|
||||
) => void;
|
||||
|
||||
interface ParsedPrivateKey {
|
||||
type: string;
|
||||
sign: (data: Buffer, algo?: string) => Buffer | Error;
|
||||
getPublicSSH: () => Buffer;
|
||||
[key: symbol]: unknown;
|
||||
}
|
||||
|
||||
interface PatchedProtocol {
|
||||
authPK: (
|
||||
user: string,
|
||||
pubKey: ParsedPrivateKey,
|
||||
keyAlgo: string | undefined,
|
||||
cbSign?: SignCallback,
|
||||
) => unknown;
|
||||
_kex: {
|
||||
sessionID: Buffer;
|
||||
};
|
||||
_packetRW: {
|
||||
write: {
|
||||
alloc: (payloadLength: number) => Buffer;
|
||||
allocStart: number;
|
||||
finalize: (packet: Buffer) => Buffer;
|
||||
};
|
||||
};
|
||||
_authsQueue: string[];
|
||||
_debug?: (message: string) => void;
|
||||
_cipher: {
|
||||
encrypt: (packet: Buffer) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type PatchedClient = Client & {
|
||||
_protocol?: PatchedProtocol;
|
||||
};
|
||||
|
||||
type NextAuthHandler = (
|
||||
authInfo: AuthenticationType | AnyAuthMethod | false,
|
||||
) => void;
|
||||
|
||||
// Grafts an OpenSSH certificate onto an already-parsed private key object and
|
||||
// patches the ssh2 client so that certificate-based publickey auth succeeds.
|
||||
|
||||
async function applyCertToConnection(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
privKey: ParsedPrivateKey,
|
||||
certStr: string,
|
||||
): Promise<void> {
|
||||
// Extract cert type and blob from the stored certificate
|
||||
const certParts = certStr.trim().split(/\s+/);
|
||||
if (certParts.length < 2) {
|
||||
throw new Error(
|
||||
"Invalid certificate format: expected '<type> <base64>' string",
|
||||
);
|
||||
}
|
||||
const certType = certParts[0];
|
||||
const certBlob = Buffer.from(certParts[1], "base64");
|
||||
|
||||
// Graft cert type and blob onto the parsed private key
|
||||
privKey.type = certType;
|
||||
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
|
||||
(s) => String(s) === "Symbol(Public key SSH)",
|
||||
);
|
||||
if (!pubSSHSym) {
|
||||
throw new Error(
|
||||
"Cannot find public SSH symbol on parsed key; ssh2 internals may have changed",
|
||||
);
|
||||
}
|
||||
privKey[pubSSHSym] = certBlob;
|
||||
|
||||
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
|
||||
if (privKey.type.startsWith("ecdsa-")) {
|
||||
const origSign = privKey.sign.bind(privKey);
|
||||
privKey.sign = (data: Buffer, algo?: string) => {
|
||||
const sigAlgo = algo?.includes("-cert-")
|
||||
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
|
||||
: algo;
|
||||
const sig = origSign(data, sigAlgo);
|
||||
if (sig instanceof Error || sig[0] !== 0x30) return sig;
|
||||
// Convert DER-encoded ECDSA signature to SSH wire format
|
||||
try {
|
||||
let pos = 2;
|
||||
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
|
||||
pos++;
|
||||
const rLen = sig[pos++];
|
||||
const r = sig.subarray(pos, pos + rLen);
|
||||
pos += rLen + 1;
|
||||
const sLen = sig[pos++];
|
||||
const s = sig.subarray(pos, pos + sLen);
|
||||
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
|
||||
out.writeUInt32BE(r.length, 0);
|
||||
r.copy(out, 4);
|
||||
out.writeUInt32BE(s.length, 4 + r.length);
|
||||
s.copy(out, 4 + r.length + 4);
|
||||
return out;
|
||||
} catch {
|
||||
return sig;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Set up authHandler to bypass ssh2's cert type rejection
|
||||
let certAuthAttempted = false;
|
||||
const authHandler: AuthHandlerMiddleware = (
|
||||
methodsLeft: string[],
|
||||
_partialSuccess: boolean,
|
||||
callback,
|
||||
) => {
|
||||
const next = callback as NextAuthHandler;
|
||||
if (
|
||||
!certAuthAttempted &&
|
||||
(!methodsLeft || methodsLeft.includes("publickey"))
|
||||
) {
|
||||
certAuthAttempted = true;
|
||||
next({
|
||||
type: "publickey",
|
||||
username: (config as Record<string, unknown>).username as string,
|
||||
key: privKey as unknown as PublicKeyAuthMethod["key"],
|
||||
});
|
||||
} else {
|
||||
next(false);
|
||||
}
|
||||
};
|
||||
config.authHandler = authHandler;
|
||||
|
||||
// Monkey-patch Protocol.authPK after connect() to fix the signature
|
||||
// wrapper algorithm for cert types.
|
||||
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
|
||||
const origConnect = client.connect.bind(client);
|
||||
const patchedClient = client as PatchedClient;
|
||||
patchedClient.connect = (cfg: ConnectConfig) => {
|
||||
const connectedClient = origConnect(cfg);
|
||||
const proto = patchedClient._protocol;
|
||||
if (!proto) return connectedClient;
|
||||
const origAuthPK = proto.authPK.bind(proto);
|
||||
proto.authPK = (
|
||||
user: string,
|
||||
pubKey: ParsedPrivateKey,
|
||||
keyAlgo: string | undefined,
|
||||
cbSign?: SignCallback,
|
||||
) => {
|
||||
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
|
||||
if (!isCertAuth) {
|
||||
return origAuthPK(user, pubKey, keyAlgo, cbSign);
|
||||
}
|
||||
|
||||
// Signed auth with cert type: rebuild packet with base algo in
|
||||
// the signature wrapper. keyAlgo may be undefined for ECDSA.
|
||||
const certAlgo = keyAlgo || pubKey.type;
|
||||
const pubSSH = pubKey.getPublicSSH();
|
||||
const sessionID = proto._kex.sessionID;
|
||||
const sesLen = sessionID.length;
|
||||
const userLen = Buffer.byteLength(user);
|
||||
const certAlgoLen = Buffer.byteLength(certAlgo);
|
||||
const baseAlgoLen = Buffer.byteLength(baseAlgo);
|
||||
const pubKeyLen = pubSSH.length;
|
||||
|
||||
// Build data to sign (uses the cert algo, which the server verifies against)
|
||||
const sigDataLen =
|
||||
4 +
|
||||
sesLen +
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen;
|
||||
const sigData = Buffer.allocUnsafe(sigDataLen);
|
||||
let sp = 0;
|
||||
sigData.writeUInt32BE(sesLen, sp);
|
||||
sp += 4;
|
||||
sessionID.copy(sigData, sp);
|
||||
sp += sesLen;
|
||||
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
sigData.writeUInt32BE(userLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(user, sp, userLen, "utf8");
|
||||
sp += userLen;
|
||||
sigData.writeUInt32BE(14, sp);
|
||||
sp += 4;
|
||||
sigData.write("ssh-connection", sp, 14, "utf8");
|
||||
sp += 14;
|
||||
sigData.writeUInt32BE(9, sp);
|
||||
sp += 4;
|
||||
sigData.write("publickey", sp, 9, "utf8");
|
||||
sp += 9;
|
||||
sigData[sp++] = 1; // TRUE
|
||||
sigData.writeUInt32BE(certAlgoLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
|
||||
sp += certAlgoLen;
|
||||
sigData.writeUInt32BE(pubKeyLen, sp);
|
||||
sp += 4;
|
||||
pubSSH.copy(sigData, sp);
|
||||
|
||||
cbSign(sigData, (signature: Buffer) => {
|
||||
const sigLen = signature.length;
|
||||
const payloadLen =
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen +
|
||||
4 +
|
||||
4 +
|
||||
baseAlgoLen +
|
||||
4 +
|
||||
sigLen;
|
||||
const packet = proto._packetRW.write.alloc(payloadLen);
|
||||
let pp = proto._packetRW.write.allocStart;
|
||||
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
packet.writeUInt32BE(userLen, ++pp);
|
||||
pp += 4;
|
||||
packet.write(user, pp, userLen, "utf8");
|
||||
pp += userLen;
|
||||
packet.writeUInt32BE(14, pp);
|
||||
pp += 4;
|
||||
packet.write("ssh-connection", pp, 14, "utf8");
|
||||
pp += 14;
|
||||
packet.writeUInt32BE(9, pp);
|
||||
pp += 4;
|
||||
packet.write("publickey", pp, 9, "utf8");
|
||||
pp += 9;
|
||||
packet[pp++] = 1; // TRUE
|
||||
// Header: cert type
|
||||
packet.writeUInt32BE(certAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(certAlgo, pp, certAlgoLen, "utf8");
|
||||
pp += certAlgoLen;
|
||||
// Public key blob
|
||||
packet.writeUInt32BE(pubKeyLen, pp);
|
||||
pp += 4;
|
||||
pubSSH.copy(packet, pp);
|
||||
pp += pubKeyLen;
|
||||
// Signature wrapper: base algo (NOT cert type)
|
||||
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
|
||||
pp += 4;
|
||||
packet.writeUInt32BE(baseAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
|
||||
pp += baseAlgoLen;
|
||||
packet.writeUInt32BE(sigLen, pp);
|
||||
pp += 4;
|
||||
signature.copy(packet, pp);
|
||||
|
||||
proto._authsQueue.push("publickey");
|
||||
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
|
||||
const finalized = proto._packetRW.write.finalize(packet);
|
||||
proto._cipher.encrypt(finalized);
|
||||
});
|
||||
};
|
||||
return connectedClient;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CertificateCredentials {
|
||||
/** OpenSSH or PEM private key. */
|
||||
privateKey: Buffer | string;
|
||||
/** The certificate line, e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...". */
|
||||
certificate: string;
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up certificate publickey auth on an ssh2 client. Call before
|
||||
* `client.connect(config)`; it sets `config.authHandler` and
|
||||
* `config.username`.
|
||||
*/
|
||||
export async function applyCertificateAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
credentials: CertificateCredentials,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const {
|
||||
utils: { parseKey },
|
||||
} = esmRequire("ssh2");
|
||||
|
||||
(config as Record<string, unknown>).username = username;
|
||||
|
||||
const keyBuf = Buffer.isBuffer(credentials.privateKey)
|
||||
? credentials.privateKey
|
||||
: Buffer.from(credentials.privateKey, "utf8");
|
||||
const parsed = credentials.passphrase
|
||||
? parseKey(keyBuf, credentials.passphrase)
|
||||
: parseKey(keyBuf);
|
||||
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
const reason = parsed instanceof Error ? parsed.message : "unknown error";
|
||||
throw new Error(
|
||||
`Failed to parse private key for certificate auth: ${reason}`,
|
||||
);
|
||||
}
|
||||
const privKey = (
|
||||
Array.isArray(parsed) ? parsed[0] : parsed
|
||||
) as ParsedPrivateKey;
|
||||
|
||||
await applyCertToConnection(config, client, privKey, credentials.certificate);
|
||||
}
|
||||
@@ -68,6 +68,10 @@ import type {
|
||||
PluginNotification,
|
||||
PluginNotificationChannel,
|
||||
PluginFetchInit,
|
||||
PluginBinarySpec,
|
||||
PluginKeyboardInteractiveHandler,
|
||||
PluginProcessHandle,
|
||||
PluginProcessOptions,
|
||||
} from "./backend.js";
|
||||
|
||||
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
|
||||
@@ -127,6 +131,11 @@ export interface FakeContextOptions {
|
||||
* reaches the network by accident.
|
||||
*/
|
||||
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||
/**
|
||||
* Answers ctx.process. Without run every ctx.process.run rejects; without
|
||||
* ensureBinary it answers "/tmp/<pluginId>/bin/<name>".
|
||||
*/
|
||||
process?: FakeProcessOptions;
|
||||
/**
|
||||
* Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted
|
||||
* proxy login on, password login off), with this message and a 409.
|
||||
@@ -140,8 +149,55 @@ export interface FakeContextOptions {
|
||||
loginAttemptLimit?: number;
|
||||
}
|
||||
|
||||
export interface FakeProcessOptions {
|
||||
run?: (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options?: PluginProcessOptions,
|
||||
) => PluginProcessHandle | Promise<PluginProcessHandle>;
|
||||
ensureBinary?: (spec: PluginBinarySpec) => Promise<string>;
|
||||
}
|
||||
|
||||
/** A ctx.process handle a test drives by hand. */
|
||||
export interface FakeProcess extends PluginProcessHandle {
|
||||
emitStdout: (chunk: string) => void;
|
||||
emitStderr: (chunk: string) => void;
|
||||
exit: (code: number | null, signal?: string | null) => void;
|
||||
killed: string[];
|
||||
}
|
||||
|
||||
export function createFakeProcess(pid = 4242): FakeProcess {
|
||||
const stdout: Array<(chunk: string) => void> = [];
|
||||
const stderr: Array<(chunk: string) => void> = [];
|
||||
const killed: string[] = [];
|
||||
let resolveExit: (value: {
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
}) => void = () => {};
|
||||
const exited = new Promise<{ code: number | null; signal: string | null }>(
|
||||
(resolve) => {
|
||||
resolveExit = resolve;
|
||||
},
|
||||
);
|
||||
return {
|
||||
pid,
|
||||
onStdout: (listener) => void stdout.push(listener),
|
||||
onStderr: (listener) => void stderr.push(listener),
|
||||
exited,
|
||||
kill: (signal = "SIGTERM") => {
|
||||
killed.push(signal);
|
||||
resolveExit({ code: null, signal });
|
||||
},
|
||||
emitStdout: (chunk) => stdout.forEach((listener) => listener(chunk)),
|
||||
emitStderr: (chunk) => stderr.forEach((listener) => listener(chunk)),
|
||||
exit: (code, signal = null) => resolveExit({ code, signal }),
|
||||
killed,
|
||||
};
|
||||
}
|
||||
|
||||
export interface FakeAuthRegistrations {
|
||||
sshAuthProviders: PluginSshAuthProvider[];
|
||||
keyboardInteractiveHandlers: PluginKeyboardInteractiveHandler[];
|
||||
loginMethods: PluginLoginMethod[];
|
||||
secondFactors: PluginSecondFactor[];
|
||||
/** "<userId>:<factorId>" for every recorded enrolment. */
|
||||
@@ -227,6 +283,14 @@ export interface FakePluginContext {
|
||||
secretStore: Map<string, string>;
|
||||
/** Every ctx.fetch call, in order. */
|
||||
fetches: Array<{ url: string; init?: PluginFetchInit }>;
|
||||
/** Every ctx.process.run call, in order. */
|
||||
processRuns: Array<{
|
||||
file: string;
|
||||
args: readonly string[];
|
||||
options?: PluginProcessOptions;
|
||||
}>;
|
||||
/** Every ctx.process.ensureBinary call, in order. */
|
||||
binaries: PluginBinarySpec[];
|
||||
/** Every ctx.audit.record entry, in order. */
|
||||
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
||||
/** Every ctx.hosts.recordActivity call, in order. */
|
||||
@@ -330,6 +394,8 @@ export function createFakeContext(
|
||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
const fetches: FakePluginContext["fetches"] = [];
|
||||
const processRuns: FakePluginContext["processRuns"] = [];
|
||||
const binaries: FakePluginContext["binaries"] = [];
|
||||
const secretStore = new Map<string, string>();
|
||||
const audits: FakePluginContext["audits"] = [];
|
||||
const activities: FakePluginContext["activities"] = [];
|
||||
@@ -371,6 +437,7 @@ export function createFakeContext(
|
||||
const registryProviders = new Map<string, unknown>();
|
||||
const auth: FakeAuthRegistrations = {
|
||||
sshAuthProviders: [],
|
||||
keyboardInteractiveHandlers: [],
|
||||
loginMethods: [],
|
||||
secondFactors: [],
|
||||
enrollments: new Set(),
|
||||
@@ -811,13 +878,20 @@ export function createFakeContext(
|
||||
requiresSecret: (authType) =>
|
||||
["password", "key", "credential", "agent"].includes(authType),
|
||||
supportsBackground: (authType) =>
|
||||
!["none", "opkssh", "stepca"].includes(authType),
|
||||
!["none", "stepca"].includes(authType) &&
|
||||
!auth.sshAuthProviders.some(
|
||||
(provider) =>
|
||||
provider.type === authType && provider.supportsBackground === false,
|
||||
),
|
||||
},
|
||||
|
||||
auth: {
|
||||
registerSshAuthProvider: (provider) => {
|
||||
auth.sshAuthProviders.push(provider);
|
||||
},
|
||||
registerKeyboardInteractiveHandler: (handler) => {
|
||||
auth.keyboardInteractiveHandlers.push(handler);
|
||||
},
|
||||
registerLoginMethod: (method) => {
|
||||
auth.loginMethods.push(method);
|
||||
},
|
||||
@@ -927,6 +1001,23 @@ export function createFakeContext(
|
||||
return options.fetch(url, init);
|
||||
},
|
||||
|
||||
process: {
|
||||
run: async (file, args, runOptions) => {
|
||||
processRuns.push({ file, args, options: runOptions });
|
||||
if (!options.process?.run) {
|
||||
throw new Error("ctx.process.run is not stubbed");
|
||||
}
|
||||
return options.process.run(file, args, runOptions);
|
||||
},
|
||||
ensureBinary: async (spec) => {
|
||||
binaries.push(spec);
|
||||
if (options.process?.ensureBinary) {
|
||||
return options.process.ensureBinary(spec);
|
||||
}
|
||||
return `/tmp/${pluginId}/bin/${spec.name}`;
|
||||
},
|
||||
},
|
||||
|
||||
asUser: async (userId, fn) => {
|
||||
const previous = actor;
|
||||
actor = userId;
|
||||
@@ -960,6 +1051,8 @@ export function createFakeContext(
|
||||
credentialReads,
|
||||
notifications,
|
||||
fetches,
|
||||
processRuns,
|
||||
binaries,
|
||||
secretStore,
|
||||
audits,
|
||||
activities,
|
||||
@@ -1024,6 +1117,8 @@ export interface MockContextOptions {
|
||||
*/
|
||||
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||
/** See FakeContextOptions. */
|
||||
process?: FakeProcessOptions;
|
||||
/** See FakeContextOptions. */
|
||||
refuseEnrollment?: string;
|
||||
/** See FakeContextOptions. */
|
||||
baseUrl?: string;
|
||||
@@ -1046,8 +1141,8 @@ export interface MockPluginContext extends FakePluginContext {
|
||||
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
|
||||
* topic outside the plugin's own namespace, settings:read-core on
|
||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
||||
* notify:send on ctx.notify, network:outbound on ctx.fetch, and
|
||||
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on
|
||||
* ctx.process, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
export function createMockCtx(
|
||||
@@ -1073,6 +1168,7 @@ export function createMockCtx(
|
||||
hostStatuses: options.hostStatuses,
|
||||
notificationChannels: options.notificationChannels,
|
||||
fetch: options.fetch,
|
||||
process: options.process,
|
||||
refuseEnrollment: options.refuseEnrollment,
|
||||
baseUrl: options.baseUrl,
|
||||
linkedUsers: options.linkedUsers,
|
||||
@@ -1309,6 +1405,10 @@ export function createMockCtx(
|
||||
require("auth:provide");
|
||||
ctx.auth.registerSshAuthProvider(provider);
|
||||
},
|
||||
registerKeyboardInteractiveHandler: (handler) => {
|
||||
require("auth:provide");
|
||||
ctx.auth.registerKeyboardInteractiveHandler(handler);
|
||||
},
|
||||
registerLoginMethod: (method) => {
|
||||
require("auth:provide");
|
||||
ctx.auth.registerLoginMethod(method);
|
||||
@@ -1388,6 +1488,17 @@ export function createMockCtx(
|
||||
return ctx.fetch(url, init);
|
||||
},
|
||||
|
||||
process: {
|
||||
run: async (file, args, runOptions) => {
|
||||
require("process:spawn");
|
||||
return ctx.process.run(file, args, runOptions);
|
||||
},
|
||||
ensureBinary: async (spec) => {
|
||||
require("process:spawn");
|
||||
return ctx.process.ensureBinary(spec);
|
||||
},
|
||||
},
|
||||
|
||||
secrets: {
|
||||
...ctx.secrets,
|
||||
get: async (key) => {
|
||||
|
||||
Reference in new issue
Block a user