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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user