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

@@ -3,12 +3,13 @@
*
* Core's classifier (ctx.ssh.classifyKeyboardInteractive) decides what a
* round is; this keeps the terminal's message protocol (totp_required,
* totp_retry, password_required, warpgate_auth_required) and its timeouts
* exactly as the client expects.
* totp_retry, password_required, "<handler>_auth_required" for a browser
* sign-in round) and its timeouts exactly as the client expects.
*/
import type { WebSocket } from "ws";
import type {
PluginBrowserSignInRound,
PluginKeyboardInteractivePrompt,
PluginSsh,
PluginSshHost,
@@ -40,7 +41,6 @@ interface ResolvedCredentials {
}
interface HostFlags {
useWarpgate?: boolean;
[key: string]: unknown;
}
@@ -54,15 +54,16 @@ interface AuthContext {
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
/** The handler whose browser round is waiting for "<id>_auth_continue". */
browserSignInId: string | null;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
browserSignInTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
const TOTP_TIMEOUT_MS = 180000;
const PUSH_TIMEOUT_MS = 300000;
const WARPGATE_TIMEOUT_MS = 300000;
const BROWSER_SIGN_IN_TIMEOUT_MS = 300000;
export class SSHAuthManager {
public context: AuthContext;
@@ -101,8 +102,8 @@ export class SSHAuthManager {
case "auto":
finish(decision.responses);
return;
case "warpgate":
this.handleWarpgate(decision, finish);
case "browser":
this.handleBrowserSignIn(decision, finish);
return;
case "totp":
this.handleTotp(
@@ -124,40 +125,48 @@ export class SSHAuthManager {
}
}
private handleWarpgate(
decision: { url: string | null; securityKey: string; instructions: string },
/**
* A round finished in a browser. The message is named after the handler
* that claimed it (Termix-Mobile listens for these names), and the client
* answers with "<id>_auth_continue".
*/
private handleBrowserSignIn(
round: PluginBrowserSignInRound,
finish: (responses: string[]) => void,
): void {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.browserSignInId = round.id;
this.sendLog("auth", "info", `${round.label} sign-in required`);
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
type: `${round.id}_auth_required`,
kind: "browser",
label: round.label,
url: round.url,
securityKey: round.code,
instructions: round.instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
this.context.browserSignInTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
this.context.log.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
this.context.browserSignInId = null;
this.context.log.warn("Browser sign-in timeout", {
operation: "browser_sign_in_timeout",
handler: round.id,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
message: `${round.label} sign-in timed out. Please reconnect.`,
}),
);
}
}, WARPGATE_TIMEOUT_MS);
}, BROWSER_SIGN_IN_TIMEOUT_MS);
}
private handleTotp(
@@ -289,13 +298,13 @@ export class SSHAuthManager {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
if (this.context.browserSignInTimeout) {
clearTimeout(this.context.browserSignInTimeout);
this.context.browserSignInTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.browserSignInId = null;
this.context.keyboardInteractiveResponded = false;
}
}
@@ -64,7 +64,6 @@ interface ConnectToHostData {
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
useWarpgate?: boolean;
jumpHosts?: Array<{ hostId: number }>;
useSocks5?: boolean;
socks5Host?: string;
@@ -341,8 +340,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
let isCleaningUp = false;
let isShellInitializing = false;
let isDuplicateConnDiscarded = false;
let warpgateAuthPromptSent = false;
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
// The keyboard-interactive handler whose browser sign-in is pending.
let browserSignInId: string | null = null;
let browserSignInTimeout: NodeJS.Timeout | null = null;
let isAwaitingAuthCredentials = false;
let wsAlive = true;
@@ -418,7 +418,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null;
totpPromptSent = false;
warpgateAuthPromptSent = false;
browserSignInId = null;
}
ws.on(
@@ -907,19 +907,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
break;
}
case "warpgate_auth_continue": {
if (keyboardInteractiveFinish) {
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
}
keyboardInteractiveFinish([""]);
keyboardInteractiveFinish = null;
warpgateAuthPromptSent = false;
}
break;
}
case "reconnect_with_credentials": {
const credentialsData = data as {
cols: number;
@@ -1004,178 +991,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
break;
}
case "opkssh_start_auth": {
const opksshData = data as { hostId: number };
try {
await startAuthInteraction("opkssh", opksshData.hostId, data);
} catch (error) {
sshLogger.error("Failed to start OPKSSH auth", error, {
operation: "opkssh_start_auth_error",
userId,
hostId: opksshData.hostId,
});
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: "",
error:
error instanceof PluginSshInteractionError
? error.message
: "Failed to start OPKSSH authentication",
}),
);
}
break;
}
case "opkssh_cancel": {
const cancelData = data as { requestId: string };
try {
await ctx.ssh.cancelInteraction("opkssh", {
requestId: String(cancelData?.requestId ?? ""),
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel OPKSSH auth", error, {
operation: "opkssh_cancel_error",
userId,
});
}
break;
}
case "opkssh_browser_opened": {
break;
}
case "opkssh_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error(
"Failed to reconnect after OPKSSH auth",
error,
{
operation: "opkssh_reconnect_error",
userId,
hostId: completedData.hostId,
},
);
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
break;
}
case "vault_start_auth": {
const vaultData = data as { hostId: number };
try {
await startAuthInteraction("vault", vaultData.hostId, data);
} catch (error) {
sshLogger.error("Failed to start Vault auth", error, {
operation: "vault_start_auth_error",
userId,
hostId: vaultData.hostId,
});
ws.send(
JSON.stringify({
type: "vault_error",
hostId: vaultData.hostId,
error: getErrorMessage(
error,
"Failed to start Vault authentication",
),
}),
);
}
break;
}
case "vault_cancel": {
const cancelData = data as { hostId: number };
try {
await ctx.ssh.cancelInteraction("vault", {
hostId: cancelData.hostId,
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel Vault auth", error, {
operation: "vault_cancel_error",
userId,
});
}
break;
}
case "vault_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after Vault auth", error, {
operation: "vault_reconnect_error",
userId,
hostId: completedData.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
break;
}
case "collab_subscribe": {
const { roomId } = (data ?? {}) as { roomId?: string };
if (typeof roomId !== "string" || !roomId) break;
@@ -1296,6 +1111,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
}
default:
if (await handleSignInMessage(type, data)) break;
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
userId,
@@ -1340,6 +1156,122 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
});
}
/**
* Messages a sign-in's UI sends: "<interaction>_start_auth", "_cancel"
* and "_auth_completed" for a provider's browser step, and
* "<handler>_auth_continue" for a pending keyboard-interactive browser
* round. False for anything else.
*/
async function handleSignInMessage(
type: string,
data: unknown,
): Promise<boolean> {
const match =
/^([a-z0-9-]+)_(start_auth|cancel|auth_completed|auth_continue)$/.exec(
type,
);
if (!match) return false;
const [, name, action] = match;
const payload = (data ?? {}) as Record<string, unknown>;
const hostId = Number(payload.hostId);
if (action === "start_auth") {
try {
await startAuthInteraction(name, hostId, payload);
} catch (error) {
sshLogger.error("Failed to start sign-in", error, {
operation: "sign_in_start_error",
interaction: name,
userId,
hostId,
});
ws.send(
JSON.stringify({
type: `${name}_error`,
requestId: "",
hostId,
error:
error instanceof PluginSshInteractionError
? error.message
: getErrorMessage(error, "Failed to start authentication"),
}),
);
}
return true;
}
if (action === "cancel") {
try {
await ctx.ssh.cancelInteraction(name, {
requestId:
typeof payload.requestId === "string"
? payload.requestId
: undefined,
hostId: Number.isInteger(hostId) ? hostId : undefined,
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel sign-in", error, {
operation: "sign_in_cancel_error",
interaction: name,
userId,
});
}
return true;
}
if (action === "auth_continue") {
if (name === browserSignInId && keyboardInteractiveFinish) {
if (browserSignInTimeout) {
clearTimeout(browserSignInTimeout);
browserSignInTimeout = null;
}
keyboardInteractiveFinish([""]);
keyboardInteractiveFinish = null;
browserSignInId = null;
}
return true;
}
const completed = payload as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completed.cols || 80,
rows: completed.rows || 24,
hostConfig:
completed.hostConfig ||
({
id: completed.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after sign-in", error, {
operation: "sign_in_reconnect_error",
interaction: name,
userId,
hostId: completed.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
return true;
}
async function handleConnectToHost(data: ConnectToHostData) {
const { hostConfig, initialPath, executeCommand, tmuxAttachSession } =
data;
@@ -2360,7 +2292,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
port,
username,
authType: resolvedCredentials.authType,
warpgateAuthPromptSent,
browserSignInId,
isKeyboardInteractive,
hasKeyboardInteractiveFinish: !!keyboardInteractiveFinish,
keyboardInteractiveResponded,
@@ -2680,9 +2612,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveResponded,
keyboardInteractiveFinish,
totpPromptSent,
warpgateAuthPromptSent,
browserSignInId,
totpTimeout,
warpgateAuthTimeout,
browserSignInTimeout,
totpAttempts: 0,
});
@@ -2709,7 +2641,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
resolvedCredentials as unknown as Parameters<
typeof sshAuthManager.handleKeyboardInteractive
>[5],
hostConfig,
// The resolved host carries the plugin host settings that
// keyboard-interactive handlers read.
connectTarget ?? hostConfig,
);
isKeyboardInteractive =
@@ -2719,10 +2653,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveFinish =
sshAuthManager.context.keyboardInteractiveFinish;
totpPromptSent = sshAuthManager.context.totpPromptSent;
warpgateAuthPromptSent =
sshAuthManager.context.warpgateAuthPromptSent;
browserSignInId = sshAuthManager.context.browserSignInId;
totpTimeout = sshAuthManager.context.totpTimeout;
warpgateAuthTimeout = sshAuthManager.context.warpgateAuthTimeout;
browserSignInTimeout = sshAuthManager.context.browserSignInTimeout;
},
),
);
@@ -2759,7 +2692,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyType: resolvedCredentials.keyType,
certPublicKey: resolvedCredentials.certPublicKey,
forceKeyboardInteractive: hostConfig.forceKeyboardInteractive,
useWarpgate: hostConfig.useWarpgate,
terminalConfig: hostConfig.terminalConfig,
jumpHosts: hostConfig.jumpHosts,
useSocks5: hostConfig.useSocks5,
@@ -2946,9 +2878,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
totpTimeout = null;
}
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
if (browserSignInTimeout) {
clearTimeout(browserSignInTimeout);
browserSignInTimeout = null;
}
sshStream = null;