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

+68 -134
View File
@@ -8,112 +8,86 @@
import { getErrorMessage } from "../utils/error-message.js";
import { sshLogger } from "../utils/logger.js";
import {
registerKeyboardInteractiveInterceptor,
registerSshAuthProvider,
} from "../hosts/connect/auth-provider-registry.js";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import { registerSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
import type {
SshAuthProvider,
SshConnectHost,
} from "../hosts/connect/types.js";
const OPKSSH_REQUIRED_MESSAGE =
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.";
const STEPCA_REQUIRED_MESSAGE =
"Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication.";
const VAULT_REQUIRED_MESSAGE =
"Vault SSH signer authentication required. Please open a Terminal connection first.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
function issuedCertificateProvider(
type: "opkssh" | "stepca",
labelKey: string,
): SshAuthProvider {
return {
type,
pluginId: "core",
labelKey,
needsUserInteraction: true,
supportsBackground: false,
interaction: "opkssh",
prepare: async (config, host, env) => {
const { getOPKSSHToken } = await import("../hosts/opkssh-auth.js");
const token = await getOPKSSHToken(env.userId, env.hostId);
if (!token) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: "opkssh",
message: OPKSSH_REQUIRED_MESSAGE,
flag: "requiresOPKSSHAuth",
};
}
try {
const { setupOPKSSHCertAuth } =
await import("../hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, env.client, token, host.username);
} catch (error) {
return {
status: "error",
code: "failed",
message: "OPKSSH authentication failed: " + getErrorMessage(error),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
operation: "opkssh_auth_failed",
hostId: env.hostId,
userId: env.userId,
});
void import("../hosts/opkssh-auth.js")
.then(({ invalidateOPKSSHToken }) =>
invalidateOPKSSHToken(env.userId, env.hostId, "SSH auth failed"),
)
.catch(() => {});
const stepCaProvider: SshAuthProvider = {
type: "stepca",
pluginId: "core",
labelKey: "hosts.filterAuthStepca",
needsUserInteraction: true,
supportsBackground: false,
interaction: "stepca",
prepare: async (config, host, env) => {
const { getStepCaCert } = await import("../hosts/step-ca-auth.js");
const cert = getStepCaCert(env.userId, env.hostId);
if (!cert) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: "opkssh",
message:
"OPKSSH authentication failed or expired. Please authenticate again.",
interaction: "stepca",
message: STEPCA_REQUIRED_MESSAGE,
};
},
startInteraction: async (request) => {
if (type === "stepca") {
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
await startStepCaAuth(
request.userId,
request.hostId,
request.host.username,
request.socket,
request.requestOrigin,
);
return;
}
const { startOPKSSHAuth } = await import("../hosts/opkssh-auth.js");
await startOPKSSHAuth(
request.userId,
request.hostId,
request.host.name || request.host.ip,
request.socket,
request.requestOrigin,
}
try {
await applyCertificateAuth(
config,
env.client,
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
},
cancelInteraction: async (request) => {
if (!request.requestId) return;
if (type === "stepca") {
const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js");
cancelStepCaAuth(request.requestId);
return;
}
const { cancelAuthSession } = await import("../hosts/opkssh-auth.js");
cancelAuthSession(request.requestId);
},
};
}
} catch (error) {
return {
status: "error",
code: "failed",
message: "Step CA authentication failed: " + getErrorMessage(error),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
void import("../hosts/step-ca-auth.js")
.then(({ invalidateStepCaCert }) =>
invalidateStepCaCert(env.userId, env.hostId),
)
.catch(() => {});
return {
status: "interaction-required",
interaction: "stepca",
message:
"Step CA authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
await startStepCaAuth(
request.userId,
request.hostId,
request.host.username,
request.socket,
request.requestOrigin,
);
},
cancelInteraction: async (request) => {
if (!request.requestId) return;
const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js");
cancelStepCaAuth(request.requestId);
},
};
function vaultProfileId(host: SshConnectHost): number | undefined {
return host.vaultProfile?.id ?? undefined;
@@ -149,12 +123,10 @@ const vaultProvider: SshAuthProvider = {
};
}
try {
const { setupOPKSSHCertAuth } =
await import("../hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
await applyCertificateAuth(
config,
env.client,
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
} catch (error) {
@@ -214,52 +186,14 @@ const vaultProvider: SshAuthProvider = {
},
};
const WARPGATE_PATTERN = /warpgate\s+authentication/i;
let sshRegistered = false;
export function registerLegacySshAuthProviders(): void {
if (sshRegistered) return;
sshRegistered = true;
registerSshAuthProvider(
issuedCertificateProvider("opkssh", "hosts.filterAuthOpkssh"),
);
registerSshAuthProvider(
issuedCertificateProvider("stepca", "hosts.filterAuthStepca"),
);
registerSshAuthProvider(stepCaProvider);
registerSshAuthProvider(vaultProvider);
// Warpgate is a flag on a host, not an auth type: it shows up as a
// keyboard-interactive round with a sign-in URL.
registerKeyboardInteractiveInterceptor({
id: "warpgate",
pluginId: "core",
detect: ({ name, instructions, prompts }) => {
const texts = prompts.map((p) => p.prompt);
const isWarpgate =
WARPGATE_PATTERN.test(name) ||
WARPGATE_PATTERN.test(instructions) ||
texts.some((p) => WARPGATE_PATTERN.test(p));
if (!isWarpgate) return null;
const fullText = `${name}\n${instructions}\n${texts.join("\n")}`;
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
if (!urlMatch) return null;
const keyMatch = fullText.match(
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
);
return {
kind: "warpgate",
url: urlMatch[0],
securityKey: keyMatch ? keyMatch[1] : "N/A",
instructions,
};
},
// Warpgate asks for the password before its browser round; answer it
// silently so the only thing the user sees is the Warpgate dialog.
autoAnswerPasswords: (host) => host.useWarpgate === true,
});
}
/** Test helper. */
-49
View File
@@ -1420,7 +1420,6 @@ const migrateSchema = () => {
{ column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" },
{ column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "use_warpgate", sql: "ALTER TABLE ssh_data ADD COLUMN use_warpgate INTEGER NOT NULL DEFAULT 0" },
{ column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
@@ -1479,23 +1478,6 @@ const migrateSchema = () => {
});
}
// Migrate legacy authType="warpgate" hosts to useWarpgate=1 with authType="none"
try {
const result = sqlite
.prepare("UPDATE ssh_data SET use_warpgate = 1, auth_type = 'none' WHERE auth_type = 'warpgate'")
.run();
if (result.changes > 0) {
databaseLogger.info(`Migrated ${result.changes} host(s) from authType='warpgate' to useWarpgate=true`, {
operation: "warpgate_auth_migration",
});
}
} catch (e) {
databaseLogger.warn("Failed to migrate legacy warpgate authType hosts", {
operation: "warpgate_auth_migration",
error: e,
});
}
// Copy unencrypted username/domain into protocol-specific columns for old guac hosts.
// Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
const usernameDomainBackfills = [
@@ -1597,37 +1579,6 @@ const migrateSchema = () => {
});
}
try {
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS opkssh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
ssh_cert TEXT NOT NULL,
private_key TEXT NOT NULL,
email TEXT,
sub TEXT,
issuer TEXT,
audience TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_used TEXT,
UNIQUE(user_id, host_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create opkssh_tokens table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get();
} catch {
-32
View File
@@ -177,7 +177,6 @@ export const hosts = mysqlTable(
// never been manually reordered; falls back to name sort in that case.
sortOrder: int("sort_order"),
authType: text("auth_type").notNull(),
useWarpgate: boolean("use_warpgate").notNull().default(false),
shareSshAuth: boolean("share_ssh_auth")
.notNull()
.default(false),
@@ -661,37 +660,6 @@ export const auditLogs = mysqlTable(
],
);
export const opksshTokens = mysqlTable(
"opkssh_tokens",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert").notNull(),
privateKey: text("private_key").notNull(),
email: text("email"),
sub: text("sub"),
issuer: text("issuer"),
audience: text("audience"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
lastUsed: text("last_used"),
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)],
);
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
// are intended to be shared across users (shared === true makes a profile
// visible to every user on the server). Each user authenticates to Vault via an
-32
View File
@@ -178,7 +178,6 @@ export const hosts = pgTable(
// never been manually reordered; falls back to name sort in that case.
sortOrder: integer("sort_order"),
authType: text("auth_type").notNull(),
useWarpgate: boolean("use_warpgate").notNull().default(false),
shareSshAuth: boolean("share_ssh_auth")
.notNull()
.default(false),
@@ -662,37 +661,6 @@ export const auditLogs = pgTable(
],
);
export const opksshTokens = pgTable(
"opkssh_tokens",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert").notNull(),
privateKey: text("private_key").notNull(),
email: text("email"),
sub: text("sub"),
issuer: text("issuer"),
audience: text("audience"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
lastUsed: text("last_used"),
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)],
);
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
// are intended to be shared across users (shared === true makes a profile
// visible to every user on the server). Each user authenticates to Vault via an
-32
View File
@@ -168,7 +168,6 @@ export const hosts = sqliteTable(
// never been manually reordered; falls back to name sort in that case.
sortOrder: integer("sort_order"),
authType: text("auth_type").notNull(),
useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false),
shareSshAuth: integer("share_ssh_auth", { mode: "boolean" })
.notNull()
.default(false),
@@ -654,37 +653,6 @@ export const auditLogs = sqliteTable(
],
);
export const opksshTokens = sqliteTable(
"opkssh_tokens",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
email: text("email"),
sub: text("sub"),
issuer: text("issuer"),
audience: text("audience"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at").notNull(),
lastUsed: text("last_used"),
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)],
);
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
// are intended to be shared across users (shared === true makes a profile
// visible to every user on the server). Each user authenticates to Vault via an
@@ -15,7 +15,6 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
@@ -227,13 +226,6 @@ export function createCurrentOpenTabRepository(): OpenTabRepository {
);
}
export function createCurrentOpksshTokenRepository(): OpksshTokenRepository {
return new OpksshTokenRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("opkssh_token_repository_write"),
);
}
export function createCurrentPluginRepository(): PluginRepository {
return new PluginRepository(
createCurrentRepositoryContext(),
@@ -1,126 +0,0 @@
import { and, eq } from "drizzle-orm";
import { opksshTokens } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { upsert } from "./returning.js";
export type OpksshTokenRecord = typeof opksshTokens.$inferSelect;
export interface OpksshTokenUpsertInput {
userId: string;
hostId: number;
sshCert: string;
privateKey: string;
email?: string | null;
sub?: string | null;
issuer?: string | null;
audience?: string | null;
expiresAt: string;
createdAt?: string;
}
export class OpksshTokenRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
const createdAt = input.createdAt ?? new Date().toISOString();
await upsert(
this.context,
opksshTokens,
{
userId: input.userId,
hostId: input.hostId,
sshCert: input.sshCert,
privateKey: input.privateKey,
email: input.email,
sub: input.sub,
issuer: input.issuer,
audience: input.audience,
expiresAt: input.expiresAt,
},
{
target: [opksshTokens.userId, opksshTokens.hostId],
set: {
sshCert: input.sshCert,
privateKey: input.privateKey,
email: input.email,
sub: input.sub,
issuer: input.issuer,
audience: input.audience,
expiresAt: input.expiresAt,
createdAt,
},
},
);
await this.afterWrite();
}
async findByUserAndHost(
userId: string,
hostId: number,
): Promise<OpksshTokenRecord | null> {
const rows = await this.context.drizzle
.select()
.from(opksshTokens)
.where(
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
)
.limit(1);
return rows[0] ?? null;
}
async updateLastUsed(
userId: string,
hostId: number,
lastUsed = new Date().toISOString(),
): Promise<boolean> {
const result = await this.context.drizzle
.update(opksshTokens)
.set({ lastUsed })
.where(
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(opksshTokens)
.where(
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(opksshTokens)
.where(eq(opksshTokens.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -10,7 +10,6 @@ import {
createCurrentHostSidebarPreferenceRepository,
createCurrentCredentialSidebarPreferenceRepository,
createCurrentUiPreferenceRepository,
createCurrentOpksshTokenRepository,
createCurrentOpenTabRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
@@ -97,9 +96,9 @@ export async function deleteUserAndRelatedData(
// secret_sources cascade on the user's refUser() foreign key, as the
// homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user.
// every other plugin_settings row for this user. The opkssh plugin's
// cached certificates cascade the same way.
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
@@ -0,0 +1,36 @@
/**
* The 2.8 URLs under /host that something outside Termix still depends on:
* the OPKSSH redirect URI registered with identity providers. It forwards to
* the plugin route that replaced it, so with the plugin off it signs nobody
* in.
*/
import type { Request, Router } from "express";
import { getRequestBasePath } from "../../utils/request-origin.js";
/** A path on this install, keeping the query string. */
function localUrl(req: Request, path: string): string {
const query = req.originalUrl.indexOf("?");
return `${getRequestBasePath(req)}${path}${
query >= 0 ? req.originalUrl.slice(query) : ""
}`;
}
export function registerHostAuthCompatRoutes(router: Router): void {
/**
* @openapi
* /host/opkssh-callback:
* get:
* summary: OPKSSH callback (2.8 URL)
* description: The redirect URI OPKSSH identity providers were set up with before 2.9. Redirects to /plugin-api/opkssh/callback with the same subpath and query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the opkssh plugin's callback.
*/
router.all(["/opkssh-callback", "/opkssh-callback/*rest"], (req, res) => {
const rest = req.path.slice("/opkssh-callback".length);
res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`));
});
}
@@ -462,7 +462,6 @@ export function transformHostResponse(
? JSON.parse(host.terminalConfig as string)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
useWarpgate: !!host.useWarpgate,
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: [],
@@ -1,841 +0,0 @@
import type { Router, Request, Response } from "express";
import { sshLogger } from "../../utils/logger.js";
import {
normalizeSelectOpParam,
renderOpksshErrorPage,
rewriteOPKSSHHtml,
} from "./opkssh-html.js";
export function registerHostOpksshRoutes(router: Router): void {
/**
* @openapi
* /host/opkssh-chooser/{requestId}:
* get:
* summary: Proxy OPKSSH provider chooser page and all related resources
* tags: [SSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* description: Authentication request ID
* responses:
* 200:
* description: Chooser page content
* 404:
* description: Session not found
* 500:
* description: Proxy error
*/
router.use(
"/opkssh-chooser/:requestId",
async (req: Request, res: Response) => {
const requestId = Array.isArray(req.params.requestId)
? req.params.requestId[0]
: req.params.requestId;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestIdTemp =
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
sshLogger.info("OPKSSH chooser proxy request", {
operation: "opkssh_chooser_proxy_request",
requestId,
url: req.url,
originalUrl: req.originalUrl,
fullPath,
pathAfterRequestId: pathAfterRequestIdTemp,
method: req.method,
});
try {
const { getActiveAuthSession, registerOAuthState } =
await import("../../hosts/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
sshLogger.error("Session not found for chooser request", {
operation: "opkssh_chooser_session_not_found",
requestId,
});
res.status(404).send(
renderOpksshErrorPage({
title: "Session Not Found",
heading: "Session Not Found",
message: "This authentication session has expired or is invalid.",
requestId,
}),
);
return;
}
const axios = (await import("axios")).default;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestId =
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
const targetPath = pathAfterRequestId || "/chooser";
if (!session.localPort || session.localPort === 0) {
sshLogger.error("OPKSSH session has no local port", {
operation: "opkssh_chooser_proxy",
requestId,
sessionStatus: session.status,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Authentication Error",
message:
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
requestId,
}),
);
return;
}
// /select on OPKSSH's chooser redirects (possibly via multiple local hops) to the
// external OAuth provider URL. The hops we may see:
// 1. /select -> /select/ (Go ServeMux canonicalization, same chooser port)
// 2. /select/?op=ALIAS -> http://localhost:CALLBACK_PORT/login (OPKSSH's separate callback listener)
// 3. /login on the callback listener -> https://<provider>/authorize?... (external OAuth URL)
if (targetPath.startsWith("/select")) {
const selectaxios = (await import("axios")).default;
const rawQs = targetPath.includes("?")
? targetPath.slice(targetPath.indexOf("?"))
: "";
let qs = rawQs;
let opMappedFrom: string | undefined;
if (rawQs) {
try {
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
const rawOp = params.get("op");
if (rawOp) {
const mappedOp = normalizeSelectOpParam(
rawOp,
session.providers || [],
);
if (mappedOp !== rawOp) {
params.set("op", mappedOp);
qs = `?${params.toString()}`;
opMappedFrom = rawOp;
}
}
} catch {
/* keep rawQs if parsing fails */
}
}
const chooserHost = `127.0.0.1:${session.localPort}`;
const startUrl = `http://${chooserHost}/select/${qs}`;
sshLogger.info("Proxying OPKSSH /select", {
operation: "opkssh_select_proxy",
requestId,
targetUrl: startUrl,
opMappedFrom,
});
const isLocalHostname = (host: string): boolean => {
const bare = host.split(":")[0];
return (
bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]"
);
};
interface UpstreamResponse {
status: number;
location?: string;
contentType: string;
body: string;
targetUrl: string;
elapsedMs: number;
}
const fetchUpstream = async (
url: string,
): Promise<UpstreamResponse> => {
const started = Date.now();
let hostHeader = chooserHost;
try {
hostHeader = new URL(url).host;
} catch {
/* fall back to chooser host */
}
const r = await selectaxios({
method: "GET",
url,
maxRedirects: 0,
validateStatus: () => true,
timeout: 10000,
responseType: "text",
transformResponse: (v) => v,
headers: { host: hostHeader },
});
const locHeader = r.headers["location"];
const location = Array.isArray(locHeader)
? locHeader[0]
: locHeader;
const ctHeader = r.headers["content-type"];
const ctRaw = Array.isArray(ctHeader) ? ctHeader[0] : ctHeader;
const contentType = typeof ctRaw === "string" ? ctRaw : "";
const body =
typeof r.data === "string" ? r.data : String(r.data ?? "");
return {
status: r.status,
location: typeof location === "string" ? location : undefined,
contentType,
body,
targetUrl: url,
elapsedMs: Date.now() - started,
};
};
const logResponse = (response: UpstreamResponse): void => {
sshLogger.info("OPKSSH /select upstream response", {
operation: "opkssh_select_upstream_response",
requestId,
targetUrl: response.targetUrl,
status: response.status,
location: response.location,
contentType: response.contentType,
elapsedMs: response.elapsedMs,
bodyPreview: response.body.slice(0, 256),
});
};
const MAX_HOPS = 4;
try {
let response = await fetchUpstream(startUrl);
logResponse(response);
for (let hop = 0; hop < MAX_HOPS; hop++) {
if (
response.status < 300 ||
response.status >= 400 ||
!response.location
) {
break;
}
const loc = response.location;
// Relative path: resolve against the current upstream.
if (loc.startsWith("/")) {
let currentHost = chooserHost;
try {
currentHost = new URL(response.targetUrl).host;
} catch {
/* keep default */
}
response = await fetchUpstream(`http://${currentHost}${loc}`);
logResponse(response);
continue;
}
// Absolute URL: if it points to a localhost OPKSSH endpoint, capture
// the port. Then redirect the BROWSER to the proxied path so that
// Set-Cookie headers from OPKSSH's /login handler reach the browser
// directly — following them server-side would swallow the cookie.
if (/^https?:\/\//i.test(loc)) {
try {
const parsed = new URL(loc);
if (isLocalHostname(parsed.host)) {
// Capture callback listener port if not yet known.
if (!session.callbackPort) {
const port = parseInt(parsed.port, 10);
if (!Number.isNaN(port)) {
session.callbackPort = port;
sshLogger.info(
"Captured OPKSSH callback listener port from /select redirect",
{
operation: "opkssh_select_callback_port_detected",
requestId,
callbackPort: port,
},
);
}
}
// Redirect browser through the chooser proxy so it can receive
// the state cookie that OPKSSH sets on /login.
const browserPath = `/host/opkssh-chooser/${requestId}${parsed.pathname}${parsed.search}`;
sshLogger.info(
"Redirecting browser to OPKSSH callback listener via proxy",
{
operation: "opkssh_select_browser_redirect_to_login",
requestId,
browserPath,
callbackPort: session.callbackPort,
},
);
res.redirect(302, browserPath);
return;
}
// External OAuth provider URL — done, handled below.
break;
} catch {
break;
}
}
break;
}
const isExternalRedirect =
response.status >= 300 &&
response.status < 400 &&
!!response.location &&
/^https?:\/\//i.test(response.location) &&
(() => {
try {
return !isLocalHostname(
new URL(response.location as string).host,
);
} catch {
return false;
}
})();
if (isExternalRedirect) {
const oauthUrl = response.location as string;
try {
const parsed = new URL(oauthUrl);
const oauthState = parsed.searchParams.get("state");
if (oauthState) registerOAuthState(oauthState, requestId);
} catch {
/* already validated above */
}
sshLogger.info(
"OPKSSH /select redirecting browser to OAuth provider",
{
operation: "opkssh_select_redirect",
requestId,
oauthUrl,
},
);
res.redirect(302, oauthUrl);
return;
}
const bodyPreview = response.body.slice(0, 512);
const detailLines = [
`Upstream: ${response.targetUrl}`,
`Status: ${response.status}`,
response.location ? `Location: ${response.location}` : undefined,
`Content-Type: ${response.contentType || "(none)"}`,
`Elapsed: ${response.elapsedMs}ms`,
"",
bodyPreview
? `Body (first 512 chars):\n${bodyPreview}`
: "Body: (empty)",
].filter(Boolean) as string[];
sshLogger.error(
"OPKSSH /select did not produce an OAuth redirect",
{
operation: "opkssh_select_no_oauth_redirect",
requestId,
status: response.status,
location: response.location,
contentType: response.contentType,
bodyPreview,
},
);
res.status(502).send(
renderOpksshErrorPage({
title: "OPKSSH error",
heading: "Failed to get OAuth redirect",
message:
"OPKSSH did not return an external OAuth provider URL. " +
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
"and the Termix callback path. Check the server log for the OPKSSH response body.",
details: detailLines.join("\n"),
requestId,
}),
);
} catch (err) {
sshLogger.error("Error proxying OPKSSH /select", err, {
operation: "opkssh_select_proxy_error",
requestId,
targetUrl: startUrl,
});
const errMsg = err instanceof Error ? err.message : String(err);
res.status(502).send(
renderOpksshErrorPage({
title: "OPKSSH error",
heading: "Failed to reach OPKSSH service",
message:
"Termix could not connect to the local OPKSSH authentication service. " +
"The OPKSSH process may have exited or is not listening yet.",
details: `Upstream: ${startUrl}\nError: ${errMsg}`,
requestId,
}),
);
}
return;
}
// Paths served by the callback listener, not the chooser.
// The browser is redirected here so it receives Set-Cookie from OPKSSH.
const isCallbackListenerPath =
targetPath === "/login" ||
targetPath.startsWith("/login?") ||
targetPath === "/login-callback" ||
targetPath.startsWith("/login-callback?");
const upstreamPort =
isCallbackListenerPath && session.callbackPort
? session.callbackPort
: session.localPort;
const targetUrl = `http://127.0.0.1:${upstreamPort}${targetPath}`;
sshLogger.info("Proxying to OPKSSH chooser", {
operation: "opkssh_chooser_proxy_request_to_opkssh",
requestId,
targetUrl,
upstreamPort,
targetPath,
});
const response = await axios({
method: req.method,
url: targetUrl,
headers: {
...req.headers,
host: `127.0.0.1:${upstreamPort}`,
},
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
sshLogger.info("OPKSSH chooser response received", {
operation: "opkssh_chooser_proxy_response",
requestId,
statusCode: response.status,
contentType: response.headers["content-type"],
contentLength: response.headers["content-length"],
hasLocation: !!response.headers.location,
});
Object.entries(response.headers).forEach(([key, value]) => {
if (key.toLowerCase() === "transfer-encoding") {
return;
}
if (key.toLowerCase() === "location") {
const location = value as string;
if (location.startsWith("/")) {
res.setHeader(
key,
`/host/opkssh-chooser/${requestId}${location}`,
);
} else {
const localhostMatch = location.match(
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
);
if (localhostMatch) {
const port = parseInt(localhostMatch[1], 10);
const path = localhostMatch[2] || "/";
if (session.callbackPort && port === session.callbackPort) {
res.setHeader(
key,
`/host/opkssh-callback/${requestId}${path}`,
);
} else if (port === session.localPort) {
res.setHeader(
key,
`/host/opkssh-chooser/${requestId}${path}`,
);
} else {
const isCallback =
path.includes("login") || path.includes("callback");
const prefix = isCallback
? "opkssh-callback"
: "opkssh-chooser";
res.setHeader(key, `/host/${prefix}/${requestId}${path}`);
}
} else {
// External redirect (e.g. to OIDC provider) — capture OAuth state for session binding
try {
const redirectUrl = new URL(location);
const oauthState = redirectUrl.searchParams.get("state");
if (oauthState) {
registerOAuthState(oauthState, requestId);
}
} catch {
// Not a valid URL, skip state capture
}
res.setHeader(key, value as string);
}
}
} else if (key.toLowerCase() === "set-cookie") {
// Rewrite cookies from OPKSSH's internal listener so they are scoped
// to the Termix proxy path instead of OPKSSH's internal path.
// The state cookie set by /login must survive to /login-callback.
const cookies = Array.isArray(value) ? value : [value as string];
const rewritten = cookies.map((cookie) => {
return cookie
.replace(/;\s*domain=[^;]*/gi, "")
.replace(/;\s*path=[^;]*/gi, "; Path=/host/opkssh-callback/")
.concat(
cookie.match(/;\s*path=/i)
? ""
: "; Path=/host/opkssh-callback/",
);
});
res.setHeader(key, rewritten);
} else {
res.setHeader(key, value as string);
}
});
// Set a cookie to correlate this browser with the requestId.
// OAuth state capture from Location headers only works for 3xx redirects;
// if OPKSSH redirects via JavaScript, the state is never registered.
// This cookie survives the OIDC round-trip and identifies the session on callback.
res.cookie("opkssh_request_id", requestId, {
path: "/host/",
httpOnly: true,
sameSite: "lax",
maxAge: 5 * 60 * 1000,
});
const contentType = String(response.headers["content-type"] || "");
if (contentType.includes("text/html")) {
const html = rewriteOPKSSHHtml(
response.data.toString("utf-8"),
requestId,
"opkssh-chooser",
);
res.status(response.status).send(html);
} else {
res.status(response.status).send(response.data);
}
} catch (error) {
sshLogger.error("Error proxying OPKSSH chooser", error, {
operation: "opkssh_chooser_proxy_error",
requestId,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Error",
message: "Failed to load authentication page. Please try again.",
requestId,
}),
);
}
},
);
/**
* @openapi
* /host/opkssh-callback:
* get:
* summary: Static OAuth callback from OIDC provider for OPKSSH authentication
* tags: [SSH]
* responses:
* 200:
* description: Callback processed successfully
* 404:
* description: No active authentication session found
* 500:
* description: Authentication failed
*/
router.get("/opkssh-callback", async (req: Request, res: Response) => {
try {
sshLogger.info("OAuth callback received", {
operation: "opkssh_static_callback_received",
host: req.headers.host,
});
const {
getUserIdFromRequest,
getActiveSessionsForUser,
getActiveAuthSession,
getRequestIdByOAuthState,
clearOAuthState,
} = await import("../../hosts/opkssh-auth.js");
const userId = await getUserIdFromRequest({
cookies: req.cookies,
headers: req.headers as Record<string, string | undefined>,
});
sshLogger.info("User ID resolved", {
operation: "opkssh_callback_user_lookup",
userId: userId || "null",
hasCookies: !!req.cookies?.jwt,
cookieKeys: Object.keys(req.cookies || {}),
});
let userSessions: Awaited<ReturnType<typeof getActiveSessionsForUser>> =
[];
if (userId) {
userSessions = getActiveSessionsForUser(userId);
} else {
// No JWT cookie (e.g. OAuth redirect landed in external browser).
// Try to find the correct session via the OAuth state parameter.
const oauthState = req.query.state as string | undefined;
if (oauthState) {
const mappedRequestId = getRequestIdByOAuthState(oauthState);
if (mappedRequestId) {
const mappedSession = getActiveAuthSession(mappedRequestId);
if (mappedSession) {
userSessions = [mappedSession];
clearOAuthState(oauthState);
sshLogger.info("Resolved session via OAuth state parameter", {
operation: "opkssh_callback_state_lookup",
requestId: mappedRequestId,
});
}
}
}
// Fallback: use the opkssh_request_id cookie set by the chooser proxy.
// State capture only works for 3xx redirects; if OPKSSH redirects via
// JavaScript in the HTML, the state is never registered in the map.
if (userSessions.length === 0) {
const cookieRequestId = req.cookies?.opkssh_request_id;
if (cookieRequestId) {
const cookieSession = getActiveAuthSession(cookieRequestId);
if (cookieSession) {
userSessions = [cookieSession];
res.clearCookie("opkssh_request_id", { path: "/host/" });
sshLogger.info("Resolved session via opkssh_request_id cookie", {
operation: "opkssh_callback_cookie_lookup",
requestId: cookieRequestId,
});
}
}
}
if (userSessions.length === 0) {
sshLogger.warn(
"OAuth callback with no JWT, no matching state, and no session cookie",
{
operation: "opkssh_callback_no_session_match",
hasState: !!oauthState,
hasCookie: !!req.cookies?.opkssh_request_id,
},
);
res
.status(401)
.send("Authentication callback failed: unable to identify session");
return;
}
}
sshLogger.info("Active sessions for user", {
operation: "opkssh_callback_session_lookup",
userId,
sessionCount: userSessions.length,
sessions: userSessions.map((s) => ({
requestId: s.requestId,
status: s.status,
hasCallbackPort: !!s.callbackPort,
callbackPort: s.callbackPort,
hasLocalPort: !!s.localPort,
localPort: s.localPort,
})),
});
if (userSessions.length === 0) {
sshLogger.error("No active sessions for callback", {
operation: "opkssh_callback_no_sessions",
userId,
});
res.status(404).send("No active authentication session found");
return;
}
const session = userSessions[userSessions.length - 1];
if (!session.callbackPort) {
sshLogger.error("Session callback port not ready", {
operation: "opkssh_callback_port_not_ready",
userId,
requestId: session.requestId,
sessionStatus: session.status,
hasLocalPort: !!session.localPort,
});
res.status(503).send("OPKSSH callback listener not ready yet");
return;
}
const queryString = req.url.includes("?")
? req.url.substring(req.url.indexOf("?"))
: "";
// OPKSSH's internal callback listener handles `/login-callback` regardless of the
// path used in --remote-redirect-uri. The dynamic route below defaults to that path.
const redirectUrl = `/host/opkssh-callback/${session.requestId}${queryString}`;
sshLogger.info("Redirecting OAuth callback to dynamic route", {
operation: "opkssh_static_callback_redirect",
userId,
requestId: session.requestId,
callbackPort: session.callbackPort,
queryParams: Object.keys(req.query),
redirectUrl,
});
res.redirect(302, redirectUrl);
} catch (error) {
sshLogger.error("Error handling OPKSSH static callback", error, {
operation: "opkssh_static_callback_error",
url: req.url,
originalUrl: req.originalUrl,
});
res.status(500).send("Authentication callback failed");
}
});
/**
* @openapi
* /host/opkssh-callback/{requestId}:
* get:
* summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths)
* tags: [SSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* description: Authentication request ID
* responses:
* 200:
* description: Callback processed successfully
* 404:
* description: Invalid authentication session
* 500:
* description: Authentication failed
*/
router.use(
"/opkssh-callback/:requestId",
async (req: Request, res: Response) => {
const requestId = Array.isArray(req.params.requestId)
? req.params.requestId[0]
: req.params.requestId;
try {
const { getActiveAuthSession } =
await import("../../hosts/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
res.status(404).send(
renderOpksshErrorPage({
title: "Session Not Found",
heading: "Session Not Found",
message:
"Authentication session expired or invalid. Please close this window and try again.",
requestId,
}),
);
return;
}
const axios = (await import("axios")).default;
const fullPath = req.originalUrl || req.url;
const pathAfterRequestId =
fullPath.split(`/host/opkssh-callback/${requestId}`)[1] || "";
// pathAfterRequestId may be "", "?query=...", "/subpath", or "/subpath?query=..."
// OPKSSH's internal listener serves /login-callback, so when no sub-path is present
// (query-only or empty), prepend it.
const targetPath =
pathAfterRequestId === "" || pathAfterRequestId.startsWith("?")
? `/login-callback${pathAfterRequestId}`
: pathAfterRequestId;
if (!session.callbackPort || session.callbackPort === 0) {
sshLogger.error("OPKSSH callback session has no callback port", {
operation: "opkssh_callback_proxy",
requestId,
sessionStatus: session.status,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Callback Error",
message:
"OPKSSH callback listener not ready. Please try authenticating again.",
requestId,
}),
);
return;
}
const targetUrl = `http://127.0.0.1:${session.callbackPort}${targetPath}`;
const response = await axios({
method: req.method,
url: targetUrl,
headers: {
...req.headers,
host: `127.0.0.1:${session.callbackPort}`,
},
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
Object.entries(response.headers).forEach(([key, value]) => {
if (key.toLowerCase() === "transfer-encoding") {
return;
}
if (key.toLowerCase() === "location") {
const location = value as string;
if (location.startsWith("/")) {
res.setHeader(
key,
`/host/opkssh-callback/${requestId}${location}`,
);
} else {
res.setHeader(key, value as string);
}
} else {
res.setHeader(key, value as string);
}
});
const contentType = String(response.headers["content-type"] || "");
if (contentType.includes("text/html")) {
const html = rewriteOPKSSHHtml(
response.data.toString("utf-8"),
requestId,
"opkssh-callback",
);
res.status(response.status).send(html);
} else {
res.status(response.status).send(response.data);
}
} catch (error) {
sshLogger.error("Error handling OPKSSH OAuth callback", error, {
operation: "opkssh_oauth_callback_error",
requestId,
});
res.status(500).send(
renderOpksshErrorPage({
title: "Error",
heading: "Error",
message: "An unexpected error occurred. Please try again.",
requestId,
}),
);
}
},
);
}
@@ -1,5 +1,13 @@
import type { Request, Response, Router } from "express";
import { escapeHtml } from "./opkssh-html.js";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
+2 -151
View File
@@ -18,7 +18,6 @@ import { emitInternalEvent } from "../../hosts/internal-events.js";
import { deleteOwnedHost } from "../../hosts/delete-host.js";
import {
createCurrentCredentialRepository,
createCurrentOpksshTokenRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
@@ -46,7 +45,7 @@ import {
withHostPluginSettings,
} from "./host-plugin-settings.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
@@ -183,7 +182,6 @@ router.post(
password,
authMethod,
authType,
useWarpgate,
shareSshAuth,
credentialId,
vaultProfileId,
@@ -306,7 +304,6 @@ router.post(
port,
username: effectiveUsername,
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId:
@@ -860,7 +857,6 @@ router.put(
password,
authMethod,
authType,
useWarpgate,
shareSshAuth,
credentialId,
vaultProfileId,
@@ -984,7 +980,6 @@ router.put(
port,
username: effectiveUsername,
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId:
@@ -2665,151 +2660,7 @@ registerHostAutostartRoutes(router, {
requireDataAccess,
});
/**
* @openapi
* /host/opkssh/token/{hostId}:
* get:
* summary: Get OPKSSH token status for a host
* tags: [SSH]
* security:
* - bearerAuth: []
* parameters:
* - name: hostId
* in: path
* required: true
* schema:
* type: integer
* description: Host ID
* responses:
* 200:
* description: Token status retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* exists:
* type: boolean
* description: Whether a valid token exists
* expiresAt:
* type: string
* format: date-time
* description: Token expiration timestamp
* email:
* type: string
* description: User email from OIDC identity
* 404:
* description: No valid token found
* 500:
* description: Internal server error
*/
router.get(
"/ssh/opkssh/token/:hostId",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId;
const hostId = parseInt(
Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId,
);
if (!userId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const opksshTokenRepository = createCurrentOpksshTokenRepository();
const tokenData = await opksshTokenRepository.findByUserAndHost(
userId,
hostId,
);
if (!tokenData) {
return res.status(404).json({ exists: false });
}
const expiresAt = new Date(tokenData.expiresAt);
if (expiresAt < new Date()) {
await opksshTokenRepository.deleteByUserAndHost(userId, hostId);
return res.status(404).json({ exists: false });
}
res.json({
exists: true,
expiresAt: tokenData.expiresAt,
email: tokenData.email,
});
} catch (error) {
sshLogger.error("Error retrieving OPKSSH token status", error, {
operation: "opkssh_token_status_error",
userId,
hostId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/opkssh/token/{hostId}:
* delete:
* summary: Delete OPKSSH token for a host
* tags: [SSH]
* security:
* - bearerAuth: []
* parameters:
* - name: hostId
* in: path
* required: true
* schema:
* type: integer
* description: Host ID
* responses:
* 200:
* description: Token deleted successfully
* 500:
* description: Internal server error
*/
router.delete(
"/ssh/opkssh/token/:hostId",
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId;
const hostId = parseInt(
Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId,
);
if (!userId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const { deleteOPKSSHToken } = await import("../../hosts/opkssh-auth.js");
await deleteOPKSSHToken(userId, hostId);
res.json({ success: true });
} catch (error) {
sshLogger.error("Error deleting OPKSSH token", error, {
operation: "opkssh_token_delete_error",
userId,
hostId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
registerHostOpksshRoutes(router);
registerHostAuthCompatRoutes(router);
registerHostStepCaRoutes(router);
registerHostNetworkRoutes(router, {
-280
View File
@@ -1,280 +0,0 @@
import { sshLogger } from "../../utils/logger.js";
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
// `alias` field in config.yml. We need the same mapping so we can normalize any
// `op=` query param we receive (which can be alias, issuer with protocol, or
// issuer without protocol depending on client version) to what OPKSSH expects.
function opksshIssuerToName(issuer: string): string | null {
if (!issuer) return null;
const withScheme =
issuer.startsWith("http://") || issuer.startsWith("https://")
? issuer
: `https://${issuer}`;
if (withScheme.startsWith("https://accounts.google.com")) return "google";
if (withScheme.startsWith("https://login.microsoftonline.com"))
return "azure";
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
if (withScheme.startsWith("https://")) {
const host = withScheme.slice("https://".length).split("/")[0];
return host || null;
}
return null;
}
export function normalizeSelectOpParam(
rawOp: string,
providers: Array<{ alias: string; issuer: string }>,
): string {
if (!rawOp) return rawOp;
const knownNames = new Set(
providers
.map((p) => opksshIssuerToName(p.issuer))
.filter((n): n is string => typeof n === "string" && n.length > 0),
);
if (knownNames.has(rawOp)) return rawOp;
const derivedFromRaw = opksshIssuerToName(rawOp);
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
const matchByAlias = providers.find((p) => p.alias === rawOp);
if (matchByAlias) {
const name = opksshIssuerToName(matchByAlias.issuer);
if (name) return name;
}
return rawOp;
}
type OpksshErrorPageOptions = {
title: string;
heading: string;
message: string;
details?: string;
requestId?: string;
statusCode?: number;
};
export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string {
const title = escapeHtml(opts.title);
const heading = escapeHtml(opts.heading);
const message = escapeHtml(opts.message);
const detailsBlock = opts.details
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
: "";
const requestIdBlock = opts.requestId
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
: "";
return `<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #18181b;
color: #fafafa;
padding: 1rem;
}
.container {
text-align: center;
background: #27272a;
padding: 3rem 2rem;
border-radius: 0.625rem;
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 720px;
width: 100%;
}
h1 {
color: #fafafa;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
p {
color: #9ca3af;
font-size: 0.95rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
p + p { margin-top: 0.5rem; }
.details {
color: #d4d4d8;
text-align: left;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.45;
margin-top: 1.25rem;
padding: 0.875rem 1rem;
background: #0f0f11;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.request-id {
color: #6b7280;
font-size: 0.75rem;
margin-top: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>${heading}</h1>
<p>${message}</p>
${detailsBlock}
${requestIdBlock}
</div>
</body>
</html>`;
}
export function rewriteOPKSSHHtml(
html: string,
requestId: string,
routePrefix: "opkssh-chooser" | "opkssh-callback",
): string {
const basePath = `/host/${routePrefix}/${requestId}`;
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
const attrPatterns = ["action", "href", "src", "formaction"];
for (const attr of attrPatterns) {
html = html.replace(
new RegExp(`${attr}="(/[^"]*)`, "g"),
`${attr}="${basePath}$1`,
);
html = html.replace(
new RegExp(`${attr}='(/[^']*)`, "g"),
`${attr}='${basePath}$1`,
);
}
for (const attr of ["href", "action", "src", "formaction"]) {
html = html.replace(
new RegExp(
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
"g",
),
`${attr}="${basePath}/$1`,
);
}
html = html.replace(
new RegExp(
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
html = html.replace(
new RegExp(
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
"gi",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
const baseTag = `<base href="${basePath}/">`;
if (html.includes("<base")) {
sshLogger.info("Replacing existing base tag", {
operation: "opkssh_html_rewrite_base_tag",
requestId,
basePath,
});
html = html.replace(/<base[^>]*>/i, baseTag);
} else if (html.includes("<head>")) {
sshLogger.info("Inserting base tag into head", {
operation: "opkssh_html_rewrite_base_tag_insert",
requestId,
basePath,
});
html = html.replace(/<head>/i, `<head>${baseTag}`);
} else {
sshLogger.warn("No <head> tag found, wrapping HTML", {
operation: "opkssh_html_rewrite_no_head",
requestId,
htmlLength: html.length,
htmlPreview: html.substring(0, 200),
});
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
}
sshLogger.info("HTML rewrite complete", {
operation: "opkssh_html_rewrite_complete",
requestId,
routePrefix,
hasBaseTag: html.includes("<base href="),
staticAssetCount: (html.match(/\/static\//g) || []).length,
});
return html;
}
@@ -102,6 +102,12 @@ export function listCredentialTypes(): string[] {
export function registerKeyboardInteractiveInterceptor(
interceptor: KeyboardInteractiveInterceptor,
): () => void {
const existing = interceptors.get(interceptor.id);
if (existing && existing.pluginId !== interceptor.pluginId) {
throw new Error(
`Keyboard-interactive handler "${interceptor.id}" is already provided by ${existing.pluginId}`,
);
}
interceptors.set(interceptor.id, interceptor);
return () => {
if (interceptors.get(interceptor.id) === interceptor) {
@@ -104,14 +104,17 @@ async function prepareKey(
const certPublicKey = host.certPublicKey;
if (certPublicKey && certPublicKey.trim()) {
try {
const { setupCACertAuth } = await import("../opkssh-cert-auth.js");
await setupCACertAuth(
const { applyCertificateAuth } =
await import("@termix/plugin-sdk/ssh-certs");
await applyCertificateAuth(
config,
env.client,
config.privateKey as Buffer,
certPublicKey,
{
privateKey: config.privateKey as Buffer,
certificate: certPublicKey,
passphrase,
},
host.username,
passphrase,
);
env.log("info", "Using SSH key authentication with CA certificate");
return { status: "ready" };
@@ -154,13 +154,8 @@ export function createPromptKeyboardInteractiveHandler(
switch (decision.kind) {
case "auto":
return decision.responses;
case "warpgate": {
await channel.ask({
kind: "warpgate",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
});
case "browser": {
await channel.ask(decision);
return prompts.map(() => "");
}
case "totp": {
+23 -15
View File
@@ -35,7 +35,11 @@ export interface SshConnectHost {
keyPassword?: string | null;
keyType?: string | null;
certPublicKey?: string | null;
useWarpgate?: boolean | null;
/**
* Host-scope plugin settings by plugin id, filled in by the resolver so
* synchronous hooks (keyboard-interactive handlers) can read them.
*/
pluginSettings?: Record<string, Record<string, unknown>> | null;
forceKeyboardInteractive?: boolean | null;
vaultProfile?: { id?: number | null } | null;
terminalConfig?: Record<string, unknown> | null;
@@ -234,21 +238,30 @@ export interface KeyboardInteractivePrompt {
echo?: boolean;
}
/**
* A round finished in a browser: open the URL, compare the code, continue.
* `id` names the handler that claimed it; transports name their messages
* after it.
*/
export interface BrowserSignInRound {
kind: "browser";
id: string;
label: string;
url: string | null;
code: string;
instructions: string;
}
/** What to do with one keyboard-interactive round. */
export type KeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
}
| BrowserSignInRound
| { kind: "totp"; promptIndex: number }
| { kind: "input"; promptIndex: number; isPush: boolean };
/**
* Interceptors run before the built-in classifier. A provider that owns a
* prompt style (Warpgate today) returns a decision; everyone else returns null.
* Interceptors run before the built-in classifier. A plugin that owns a
* prompt style returns a decision; everyone else returns null.
*/
export interface KeyboardInteractiveInterceptor {
id: string;
@@ -281,9 +294,4 @@ export interface SshPromptChannel {
export type SshPromptRequest =
| { kind: "totp"; prompt: string; retry: boolean }
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
};
| BrowserSignInRound;
+3 -3
View File
@@ -1159,14 +1159,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
return;
}
if (decision.kind === "warpgate") {
if (decision.kind === "browser") {
if (responseSent) return;
responseSent = true;
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Warpgate authentication required",
`${decision.label} sign-in required`,
{ url: decision.url },
),
);
@@ -1175,7 +1175,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
requires_warpgate: true,
sessionId,
url: decision.url,
securityKey: decision.securityKey,
securityKey: decision.code,
connectionLogs,
});
return;
+15
View File
@@ -252,6 +252,21 @@ export async function resolveHostById(
}
}
// Keyboard-interactive handlers run synchronously mid-handshake and can
// only read their host settings from here.
try {
const { loadHostPluginSettings } =
await import("../database/routes/host-plugin-settings.js");
const settings = (await loadHostPluginSettings([hostId])).get(hostId);
if (settings) host.pluginSettings = settings;
} catch (e) {
sshLogger.warn("Failed to load plugin settings for host", {
operation: "host_resolver_plugin_settings",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
return host as unknown as SSHHost;
}
-937
View File
@@ -1,937 +0,0 @@
import { getErrorMessage } from "../utils/error-message.js";
import { spawn, ChildProcess } from "child_process";
import { randomUUID } from "crypto";
import { WebSocket } from "ws";
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
import { sshLogger } from "../utils/logger.js";
import { createCurrentOpksshTokenRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { promises as fs } from "fs";
import path from "path";
import axios from "axios";
import { load as loadYaml } from "js-yaml";
const AUTH_TIMEOUT = 60 * 1000;
export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback";
interface OPKSSHAuthSession {
requestId: string;
userId: string;
hostId: number;
hostname: string;
process: ChildProcess;
localPort: number;
callbackPort: number;
remoteRedirectUri: string;
providers: Array<{ alias: string; issuer: string }>;
status:
"starting" | "waiting_for_auth" | "authenticating" | "completed" | "error";
ws: WebSocket;
stdoutBuffer: string;
privateKeyBuffer: string;
sshCertBuffer: string;
identity: {
email?: string;
sub?: string;
issuer?: string;
audience?: string;
};
createdAt: Date;
approvalTimeout: NodeJS.Timeout;
cleanup: () => Promise<void>;
}
const activeAuthSessions = new Map<string, OPKSSHAuthSession>();
const oauthStateToRequestId = new Map<string, string>();
const cleanupInProgress = new Set<string>();
function getOPKConfigPath(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, ".opk", "config.yml");
}
async function ensureOPKConfigDir(): Promise<void> {
const configPath = getOPKConfigPath();
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
}
async function createTemplateConfig(): Promise<void> {
const configPath = getOPKConfigPath();
const template = `
# OPKSSH Configuration
# OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md
# Termix Documentation: https://docs.termix.site/features/authentication/opkssh
`;
try {
await ensureOPKConfigDir();
await fs.writeFile(configPath, template, "utf8");
sshLogger.info(`Created template OPKSSH config at ${configPath}`);
} catch (error) {
sshLogger.warn("Failed to create template OPKSSH config", error);
}
}
interface ProviderRedirectInfo {
alias: string;
issuer: string;
redirectUris: string[];
}
async function checkOPKConfigExists(): Promise<{
exists: boolean;
error?: string;
configPath?: string;
providers?: ProviderRedirectInfo[];
}> {
const configPath = getOPKConfigPath();
const isDocker =
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
const dockerHint = isDocker
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
: "";
try {
const content = await fs.readFile(configPath, "utf8");
if (!content.includes("providers:")) {
return {
exists: false,
configPath,
error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}\n\n.`,
};
}
const lines = content.split("\n");
const hasUncommentedProvider = lines.some((line) => {
const trimmed = line.trim();
return (
trimmed.startsWith("- alias:") ||
(trimmed.startsWith("issuer:") && !line.trimStart().startsWith("#"))
);
});
if (!hasUncommentedProvider) {
return {
exists: false,
configPath,
error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
};
}
let providers: ProviderRedirectInfo[] = [];
try {
const parsed = loadYaml(content) as {
providers?: Array<{
alias?: string;
issuer?: string;
redirect_uris?: string[];
}>;
};
if (parsed?.providers && Array.isArray(parsed.providers)) {
providers = parsed.providers
.filter(
(
p,
): p is {
alias: string;
issuer: string;
redirect_uris?: string[];
} => typeof p.alias === "string" && typeof p.issuer === "string",
)
.map((p) => ({
alias: p.alias,
issuer: p.issuer.replace(/^https?:\/\//, ""),
redirectUris: Array.isArray(p.redirect_uris)
? p.redirect_uris.filter(
(u): u is string => typeof u === "string",
)
: [],
}));
}
} catch (e) {
sshLogger.warn("Failed to parse OPKSSH config for providers", {
operation: "opkssh_config_parse_providers_error",
error: e,
});
}
return { exists: true, configPath, providers };
} catch {
await createTemplateConfig();
return {
exists: false,
configPath,
error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
};
}
}
// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener
// that OPKSSH binds on the host running the binary. The openpubkey library enforces these
// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime.
// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri
// (derived from request origin); users do NOT put that URL in this config field.
function validateRedirectUrisAreLocalhost(
providers: ProviderRedirectInfo[],
): { ok: true } | { ok: false; message: string } {
const isLocalHost = (host: string): boolean => {
const bare = host.replace(/^\[|\]$/g, "");
return (
bare === "localhost" ||
bare === "127.0.0.1" ||
bare === "::1" ||
bare === "0:0:0:0:0:0:0:1" ||
bare.startsWith("localhost:") ||
bare.startsWith("127.0.0.1:")
);
};
const issues: string[] = [];
for (const p of providers) {
const uris = p.redirectUris || [];
if (uris.length === 0) continue;
const nonLocal = uris.filter((u) => {
try {
return !isLocalHost(new URL(u).hostname);
} catch {
return true;
}
});
if (nonLocal.length > 0) {
issues.push(
`Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
);
}
}
if (issues.length > 0) {
return {
ok: false,
message:
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
`${issues.join("\n")}\n\n` +
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
`Docs: https://docs.termix.site/features/authentication/opkssh`,
};
}
return { ok: true };
}
export async function startOPKSSHAuth(
userId: string,
hostId: number,
hostname: string,
ws: WebSocket,
requestOrigin: string,
): Promise<string> {
try {
await ensureOPKConfigDir();
const configDir = path.dirname(getOPKConfigPath());
await fs.access(configDir, fs.constants.R_OK | fs.constants.W_OK);
} catch (error) {
sshLogger.error("OPKSSH directory not accessible", error);
const isDocker =
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
const dockerHint = isDocker
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
: "";
ws.send(
JSON.stringify({
type: "opkssh_error",
error: `OPKSSH directory initialization failed: ${error.message}${dockerHint}`,
}),
);
return "";
}
const configCheck = await checkOPKConfigExists();
if (!configCheck.exists) {
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId: "",
error: configCheck.error,
instructions: configCheck.error,
}),
);
return "";
}
const redirectValidation = validateRedirectUrisAreLocalhost(
configCheck.providers || [],
);
if (redirectValidation.ok === false) {
sshLogger.warn("OPKSSH config redirect_uris validation failed", {
operation: "opkssh_config_redirect_uris_not_localhost",
configPath: configCheck.configPath,
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId: "",
error: redirectValidation.message,
instructions: redirectValidation.message,
}),
);
return "";
}
const requestId = randomUUID();
const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`;
sshLogger.info("Starting OPKSSH auth session", {
operation: "opkssh_start_auth_remote_redirect_uri",
requestId,
userId,
hostId,
requestOrigin,
remoteRedirectUri,
providerAliases: (configCheck.providers || []).map((p) => p.alias),
});
const session: Partial<OPKSSHAuthSession> = {
requestId,
userId,
hostId,
hostname,
localPort: 0,
callbackPort: 0,
remoteRedirectUri,
providers: configCheck.providers || [],
status: "starting",
ws,
stdoutBuffer: "",
privateKeyBuffer: "",
sshCertBuffer: "",
identity: {},
createdAt: new Date(),
};
try {
const binaryPath = OPKSSHBinaryManager.getBinaryPath();
const configPath = getOPKConfigPath();
const args = [
"login",
"--print-key",
"--disable-browser-open",
`--config-path=${configPath}`,
`--remote-redirect-uri=${remoteRedirectUri}`,
];
const opksshProcess = spawn(binaryPath, args, {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
},
});
session.process = opksshProcess;
const cleanup = async () => {
await cleanupAuthSession(requestId);
};
session.cleanup = cleanup;
const timeout = setTimeout(async () => {
sshLogger.warn(`OPKSSH auth timeout for session ${requestId}`);
ws.send(
JSON.stringify({
type: "opkssh_timeout",
requestId,
}),
);
await cleanup();
}, AUTH_TIMEOUT);
session.approvalTimeout = timeout;
ws.on("close", () => {
cleanup();
});
activeAuthSessions.set(requestId, session as OPKSSHAuthSession);
opksshProcess.stdout?.on("data", (data) => {
const output = data.toString();
handleOPKSSHOutput(requestId, output);
});
opksshProcess.stderr?.on("data", async (data) => {
const stderr = data.toString();
if (
stderr.includes("Opening browser to") ||
stderr.includes("Open your browser to:")
) {
handleOPKSSHOutput(requestId, stderr);
}
if (stderr.includes("listening on")) {
handleOPKSSHOutput(requestId, stderr);
}
const lowerStderr = stderr.toLowerCase();
// OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime
// with the distinctive message "redirectURI must be localhost". Surface that
// directly with actionable guidance.
if (lowerStderr.includes("redirecturi must be localhost")) {
sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", {
operation: "opkssh_stderr_redirect_uris_not_localhost",
requestId,
remoteRedirectUri,
stderrSnippet: stderr.slice(0, 500),
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` +
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
return;
}
// Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.)
const genericRedirectIndicators = [
"redirect_uri",
"redirect uri",
"invalid redirect",
"no matching redirect",
"allowed redirect",
"mismatching redirection",
];
const hasGenericRedirectError = genericRedirectIndicators.some((s) =>
lowerStderr.includes(s),
);
if (hasGenericRedirectError) {
sshLogger.warn("OPKSSH stderr reported redirect_uri error", {
operation: "opkssh_stderr_redirect_uri_error",
requestId,
remoteRedirectUri,
stderrSnippet: stderr.slice(0, 500),
});
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
`Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
`(e.g. in Google Cloud Console → OAuth client). ` +
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
return;
}
if (
stderr.includes("provider not found") ||
stderr.includes("config error") ||
stderr.includes("invalid config") ||
stderr.includes("config not found")
) {
ws.send(
JSON.stringify({
type: "opkssh_config_error",
requestId,
error:
"OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.",
instructions:
"See documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md",
}),
);
cleanup();
}
if (
stderr.includes("level=error") ||
stderr.includes("Error:") ||
stderr.includes("failed")
) {
const isXdgOpenError = stderr.includes('exec: "xdg-open"');
if (!isXdgOpenError) {
if (
stderr.includes("bind: address already in use") ||
stderr.includes("error logging in") ||
stderr.includes("failed to start")
) {
await cleanup();
}
}
}
});
opksshProcess.on("error", (error) => {
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `OPKSSH process error: ${error.message}`,
}),
);
cleanup();
});
opksshProcess.on("exit", (code) => {
if (code !== 0 && session.status !== "completed") {
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `OPKSSH process exited with code ${code}`,
}),
);
}
cleanup();
});
return requestId;
} catch (error) {
sshLogger.error(`Failed to start OPKSSH auth session`, error);
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: `Failed to start OPKSSH authentication: ${getErrorMessage(error)}`,
}),
);
return "";
}
}
function handleOPKSSHOutput(requestId: string, output: string): void {
const session = activeAuthSessions.get(requestId);
if (!session) {
return;
}
session.stdoutBuffer += output;
const chooserUrlMatch = session.stdoutBuffer.match(
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/,
);
if (chooserUrlMatch && session.status === "starting") {
const actualPort = parseInt(chooserUrlMatch[1], 10);
const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`;
session.localPort = actualPort;
// No port rewrite any more: the terminal socket used to live on 30002,
// separate from the HTTP API, so a redirect URI derived from it pointed at
// the wrong server in direct dev mode. The terminal rides 30001 now.
const baseUrl = session.remoteRedirectUri.replace(
/\/host\/opkssh-callback$/,
"",
);
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
session.status = "waiting_for_auth";
session.ws.send(
JSON.stringify({
type: "opkssh_status",
requestId,
stage: "chooser",
url: proxiedChooserUrl,
providers: session.providers,
localUrl: localChooserUrl,
message: "Please authenticate in your browser",
}),
);
}
const callbackPortMatch = session.stdoutBuffer.match(
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//,
);
if (callbackPortMatch && !session.callbackPort) {
session.callbackPort = parseInt(callbackPortMatch[1], 10);
}
if (output.includes("BEGIN OPENSSH PRIVATE KEY")) {
session.status = "authenticating";
session.ws.send(
JSON.stringify({
type: "opkssh_status",
requestId,
stage: "authenticating",
message: "Processing authentication...",
}),
);
}
const privateKeyMatch = session.stdoutBuffer.match(
/(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/,
);
if (privateKeyMatch) {
session.privateKeyBuffer = privateKeyMatch[1].trim();
}
const certMatch = session.stdoutBuffer.match(
/(ecdsa-sha2-nistp256-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-rsa-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-ed25519-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/,
);
if (certMatch) {
session.sshCertBuffer = certMatch[1].trim();
}
const identityMatch = session.stdoutBuffer.match(
/Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/,
);
if (identityMatch) {
session.identity = {
email: identityMatch[1],
sub: identityMatch[2],
issuer: identityMatch[3],
audience: identityMatch[4],
};
}
if (session.privateKeyBuffer && session.sshCertBuffer) {
if (!session.privateKeyBuffer.includes("BEGIN OPENSSH PRIVATE KEY")) {
sshLogger.error(`Invalid private key extracted [${requestId}]`, {
bufferPrefix: session.privateKeyBuffer.substring(0, 50),
});
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to extract valid private key from OPKSSH output",
}),
);
return;
}
if (!session.sshCertBuffer.match(/-cert-v01@openssh\.com/)) {
sshLogger.error(`Invalid SSH certificate extracted [${requestId}]`, {
bufferPrefix: session.sshCertBuffer.substring(0, 50),
});
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to extract valid SSH certificate from OPKSSH output",
}),
);
return;
}
storeOPKSSHToken(session);
}
}
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
try {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
const userDataKey = DataCrypto.getUserDataKey(session.userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
const encryptedCert = FieldCrypto.encryptField(
session.sshCertBuffer,
userDataKey,
tokenId,
"ssh_cert",
);
const encryptedKey = FieldCrypto.encryptField(
session.privateKeyBuffer,
userDataKey,
tokenId,
"private_key",
);
await createCurrentOpksshTokenRepository().upsert({
userId: session.userId,
hostId: session.hostId,
sshCert: encryptedCert,
privateKey: encryptedKey,
email: session.identity.email,
sub: session.identity.sub,
issuer: session.identity.issuer,
audience: session.identity.audience,
expiresAt: expiresAt.toISOString(),
});
session.status = "completed";
session.ws.send(
JSON.stringify({
type: "opkssh_completed",
requestId: session.requestId,
expiresAt: expiresAt.toISOString(),
}),
);
await session.cleanup();
} catch (error) {
sshLogger.error(
`Failed to store OPKSSH token for session ${session.requestId}`,
error,
);
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: session.requestId,
error: "Failed to store authentication token",
}),
);
await session.cleanup();
}
}
export async function getOPKSSHToken(
userId: string,
hostId: number,
): Promise<{ sshCert: string; privateKey: string } | null> {
try {
const repository = createCurrentOpksshTokenRepository();
const tokenData = await repository.findByUserAndHost(userId, hostId);
if (!tokenData) {
return null;
}
const expiresAt = new Date(tokenData.expiresAt);
if (expiresAt < new Date()) {
await repository.deleteByUserAndHost(userId, hostId);
return null;
}
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const tokenId = `opkssh-${userId}-${hostId}`;
const decryptedCert = FieldCrypto.decryptField(
tokenData.sshCert,
userDataKey,
tokenId,
"ssh_cert",
);
const decryptedKey = FieldCrypto.decryptField(
tokenData.privateKey,
userDataKey,
tokenId,
"private_key",
);
await repository.updateLastUsed(userId, hostId);
return {
sshCert: decryptedCert,
privateKey: decryptedKey,
};
} catch (error) {
sshLogger.error(`Failed to retrieve OPKSSH token`, error);
return null;
}
}
export async function deleteOPKSSHToken(
userId: string,
hostId: number,
): Promise<void> {
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
userId,
hostId,
);
}
export async function invalidateOPKSSHToken(
userId: string,
hostId: number,
reason: string,
): Promise<void> {
try {
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
userId,
hostId,
);
} catch (error) {
sshLogger.error(`Failed to invalidate OPKSSH token`, {
userId,
hostId,
reason,
error,
});
}
}
export async function handleOAuthCallback(
requestId: string,
queryString: string,
): Promise<{ success: boolean; message?: string }> {
const session = activeAuthSessions.get(requestId);
if (!session) {
return { success: false, message: "Invalid authentication session" };
}
try {
const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`;
await axios.get(callbackUrl, {
timeout: 10000,
validateStatus: () => true,
});
return { success: true };
} catch {
session.ws.send(
JSON.stringify({
type: "opkssh_error",
requestId,
error: "Failed to complete authentication",
}),
);
await session.cleanup();
return { success: false, message: "Authentication failed" };
}
}
async function cleanupAuthSession(requestId: string): Promise<void> {
if (cleanupInProgress.has(requestId)) {
return;
}
cleanupInProgress.add(requestId);
try {
const session = activeAuthSessions.get(requestId);
if (!session) {
cleanupInProgress.delete(requestId);
return;
}
if (session.approvalTimeout) {
clearTimeout(session.approvalTimeout);
}
if (session.process) {
try {
session.process.kill("SIGTERM");
await new Promise<void>((resolve) => {
const killTimeout = setTimeout(() => {
if (session.process && !session.process.killed) {
session.process.kill("SIGKILL");
}
resolve();
}, 3000);
session.process.once("exit", () => {
clearTimeout(killTimeout);
resolve();
});
});
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (killError) {
sshLogger.warn(
`Failed to kill OPKSSH process for session ${requestId}`,
killError,
);
}
}
// Clean up any OAuth state mappings for this session
for (const [state, reqId] of oauthStateToRequestId.entries()) {
if (reqId === requestId) {
oauthStateToRequestId.delete(state);
}
}
activeAuthSessions.delete(requestId);
} finally {
cleanupInProgress.delete(requestId);
}
}
export function cancelAuthSession(requestId: string): void {
const session = activeAuthSessions.get(requestId);
if (session) {
session.cleanup();
}
}
export function getActiveAuthSession(
requestId: string,
): OPKSSHAuthSession | undefined {
return activeAuthSessions.get(requestId);
}
export function getActiveSessionsForUser(userId: string): OPKSSHAuthSession[] {
const sessions: OPKSSHAuthSession[] = [];
for (const session of activeAuthSessions.values()) {
if (session.userId === userId) {
sessions.push(session);
}
}
return sessions;
}
export function getActiveSessionsAll(): OPKSSHAuthSession[] {
return Array.from(activeAuthSessions.values());
}
export function registerOAuthState(state: string, requestId: string): void {
oauthStateToRequestId.set(state, requestId);
}
export function getRequestIdByOAuthState(state: string): string | undefined {
return oauthStateToRequestId.get(state);
}
export function clearOAuthState(state: string): void {
oauthStateToRequestId.delete(state);
}
export async function getUserIdFromRequest(req: {
cookies?: Record<string, string>;
headers: Record<string, string | undefined>;
}): Promise<string | null> {
try {
const { AuthManager } = await import("../utils/auth-manager.js");
const authManager = AuthManager.getInstance();
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return null;
}
const decoded = await authManager.verifyJWTToken(token);
if (!decoded?.userId || decoded.pendingTOTP) return null;
return decoded.userId;
} catch {
return null;
}
}
-369
View File
@@ -1,369 +0,0 @@
import { getErrorMessage } from "../utils/error-message.js";
// SSH certificate authentication workarounds for ssh2.
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
// the certificate onto the parsed key, wraps ECDSA signing to convert
// DER → SSH wire format, and patches Protocol.authPK to use the base
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
//
// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase)
// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported)
import type {
AnyAuthMethod,
AuthHandlerMiddleware,
AuthenticationType,
Client,
ConnectConfig,
PublicKeyAuthMethod,
} from "ssh2";
interface OPKSSHToken {
privateKey: string;
sshCert: string;
}
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 OPKSSHProtocol {
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 OPKSSHClient = Client & {
_protocol?: OPKSSHProtocol;
};
type OPKSSHNextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
// ── Internal implementation ──────────────────────────────────────────────────
// 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 OPKSSHNextAuthHandler;
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 OPKSSHClient;
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 cert algo — matches server verification)
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;
};
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Set up OPKSSH certificate authentication on an ssh2 Client.
* The OPKSSH private key is assumed to be unencrypted (no passphrase).
*/
export async function setupOPKSSHCertAuth(
config: ConnectConfig,
client: Client,
token: OPKSSHToken,
username: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const parsed = parseKey(Buffer.from(token.privateKey));
if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key");
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, token.sshCert);
}
/**
* Set up CA-signed certificate authentication on an ssh2 Client.
* Supports passphrase-protected private keys.
* The cert content is the full text of the -cert.pub file
* (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...").
*/
export async function setupCACertAuth(
config: ConnectConfig,
client: Client,
privateKey: Buffer | string,
certPublicKey: string,
username: string,
passphrase?: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const keyBuf = Buffer.isBuffer(privateKey)
? privateKey
: Buffer.from(privateKey, "utf8");
const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf);
if (parsed instanceof Error || !parsed) {
const errMsg = getErrorMessage(parsed, "unknown error");
throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`);
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, certPublicKey);
}
+48 -44
View File
@@ -2,12 +2,7 @@ import { randomBytes } from "crypto";
import type { WebSocket } from "ws";
import { sshLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import {
createCurrentOpksshTokenRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { readStepCaPrivateAllowlist } from "../utils/step-ca-egress.js";
import {
stepCaRuntime,
@@ -31,9 +26,8 @@ import {
/**
* Step CA (smallstep) SSH user certificates through its OIDC provisioner.
*
* Mirrors the OPKSSH flow and reuses its storage, WS messages and connect
* path; the only difference is how the certificate is obtained: no binary,
* just the CA's HTTP API plus one OIDC redirect back to Termix.
* The CA's HTTP API plus one OIDC redirect back to Termix. Issued
* certificates are kept in memory until they expire.
*/
export const STEP_CA_CALLBACK_PATH = "/host/step-ca-callback";
@@ -87,6 +81,37 @@ interface StepCaAuthSession {
const sessions = new Map<string, StepCaAuthSession>();
interface IssuedCertificate {
sshCert: string;
privateKey: string;
expiresAt: number;
}
const issued = new Map<string, IssuedCertificate>();
function certKey(userId: string, hostId: number): string {
return `${userId}:${hostId}`;
}
/** The cached certificate for this user and host, while it is valid. */
export function getStepCaCert(
userId: string,
hostId: number,
): { sshCert: string; privateKey: string } | null {
const key = certKey(userId, hostId);
const cert = issued.get(key);
if (!cert) return null;
if (cert.expiresAt <= Date.now()) {
issued.delete(key);
return null;
}
return { sshCert: cert.sshCert, privateKey: cert.privateKey };
}
export function invalidateStepCaCert(userId: string, hostId: number): void {
issued.delete(certKey(userId, hostId));
}
function send(ws: WebSocket, message: object): void {
try {
ws.send(JSON.stringify(message));
@@ -112,7 +137,7 @@ export async function startStepCaAuth(
const settings = await readStepCaSettings();
if (!settings) {
send(ws, {
type: "opkssh_config_error",
type: "stepca_config_error",
requestId: "",
error:
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner under Admin Settings.",
@@ -162,7 +187,7 @@ export async function startStepCaAuth(
timeout: setTimeout(() => {
const current = sessions.get(state);
if (!current || current.completed || current.processing) return;
send(ws, { type: "opkssh_timeout", requestId: state });
send(ws, { type: "stepca_timeout", requestId: state });
endSession(current);
}, AUTH_TIMEOUT_MS),
};
@@ -184,7 +209,7 @@ export async function startStepCaAuth(
});
send(ws, {
type: "opkssh_status",
type: "stepca_status",
requestId: state,
stage: "chooser",
label: "Step CA",
@@ -205,7 +230,7 @@ export async function startStepCaAuth(
hostId,
});
send(ws, {
type: "opkssh_error",
type: "stepca_error",
requestId: state,
error: `Step CA: ${getErrorMessage(error)}`,
});
@@ -221,9 +246,8 @@ export function cancelStepCaAuth(requestId: string): boolean {
/**
* Finishes the flow once the identity provider redirects back: exchanges
* the code, has the CA sign the key, stores the certificate the way OPKSSH
* does (same table, same encryption, same token id) and tells the terminal
* to reconnect.
* the code, has the CA sign the key, caches the certificate and tells the
* terminal to reconnect.
*/
export async function completeStepCaAuth(
query: StepCaCallbackQuery,
@@ -262,7 +286,7 @@ async function finishStepCaAuth(
if (query.error || !query.code) {
const message = query.error_description || query.error || "Sign-in failed";
send(session.ws, {
type: "opkssh_error",
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
@@ -272,7 +296,7 @@ async function finishStepCaAuth(
try {
send(session.ws, {
type: "opkssh_status",
type: "stepca_status",
requestId: session.state,
stage: "authenticating",
});
@@ -320,35 +344,15 @@ async function finishStepCaAuth(
}
const expiresAt = certificateInfo.validBefore;
const userDataKey = DataCrypto.getUserDataKey(session.userId);
if (!userDataKey) throw new Error("User data key not found");
// Same token id as OPKSSH: getOPKSSHToken decrypts with it.
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
await createCurrentOpksshTokenRepository().upsert({
userId: session.userId,
hostId: session.hostId,
sshCert: FieldCrypto.encryptField(
certificate,
userDataKey,
tokenId,
"ssh_cert",
),
privateKey: FieldCrypto.encryptField(
session.keyPair.privateKeyPem,
userDataKey,
tokenId,
"private_key",
),
email,
sub: typeof claims.sub === "string" ? claims.sub : undefined,
issuer: typeof claims.iss === "string" ? claims.iss : undefined,
audience: typeof claims.aud === "string" ? claims.aud : undefined,
expiresAt: expiresAt.toISOString(),
issued.set(certKey(session.userId, session.hostId), {
sshCert: certificate,
privateKey: session.keyPair.privateKeyPem,
expiresAt: expiresAt.getTime(),
});
session.completed = true;
send(session.ws, {
type: "opkssh_completed",
type: "stepca_completed",
requestId: session.state,
expiresAt: expiresAt.toISOString(),
});
@@ -362,7 +366,7 @@ async function finishStepCaAuth(
});
const message = getErrorMessage(error);
send(session.ws, {
type: "opkssh_error",
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
+2 -2
View File
@@ -1,6 +1,6 @@
// HashiCorp Vault SSH signer authentication.
//
// Flow (mirrors the OPKSSH subsystem, but Vault-driven over HTTP):
// Flow (Vault-driven over HTTP):
// 1. Generate an ephemeral SSH keypair (never persisted long-term).
// 2. The user authenticates to Vault via an interactive OIDC flow
// (auth/<mount>/oidc/auth_url -> browser -> auth/<mount>/oidc/callback),
@@ -8,7 +8,7 @@
// 3. Vault's SSH secrets engine signs the ephemeral public key
// (<sshMount>/sign/<role>) -> short-lived OpenSSH certificate.
// 4. The ephemeral private key + certificate are cached per-user (encrypted)
// until the certificate expires, then used to connect via setupOPKSSHCertAuth.
// until the certificate expires, then used to connect via applyCertificateAuth.
//
// No Vault tokens, AppRole secrets, or long-lived private keys are ever stored.
// Vault connection SETTINGS live in shareable vault_profiles rows.
+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}$`);
}
/**
+4 -20
View File
@@ -268,26 +268,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await provisionLocalDesktopUserIfNeeded();
}
import("./utils/opkssh-binary-manager.js").then(
({ OPKSSHBinaryManager }) => {
OPKSSHBinaryManager.ensureBinary().catch((error) => {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
systemLogger.warn(
"Failed to initialize OPKSSH binary - OPKSSH authentication will not be available",
{
operation: "opkssh_binary_init_failed",
error: getErrorMessage(error),
stack: error instanceof Error ? error.stack : undefined,
platform: process.platform,
arch: process.arch,
dataDir,
},
);
});
},
);
const { serverReady } = await import("./database/database.js");
await serverReady;
@@ -393,6 +373,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/wake-on-lan-settings-migration.js");
await runWakeOnLanSettingsMigration();
const { runWarpgateSettingsMigration } =
await import("./utils/crypto-migration/warpgate-settings-migration.js");
await runWarpgateSettingsMigration();
const { runSecretSourcesTokenMigration } =
await import("./utils/crypto-migration/secret-sources-token-migration.js");
await runSecretSourcesTokenMigration();
@@ -1,105 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { OpksshTokenRepository } from "../../../database/repositories/opkssh-token-repository.js";
describe("OpksshTokenRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<OpksshTokenRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
INSERT INTO opkssh_tokens (
user_id, host_id, ssh_cert, private_key, email, expires_at
)
VALUES
('user-1', 1, 'cert-1', 'key-1', 'alice@example.com', '2099-01-01T00:00:00.000Z'),
('user-1', 2, 'cert-2', 'key-2', 'alice2@example.com', '2099-01-01T00:00:00.000Z'),
('user-2', 3, 'cert-3', 'key-3', 'bob@example.com', '2099-01-01T00:00:00.000Z');
`);
return new OpksshTokenRepository(context, onWrite);
}
it("finds and upserts a token by user and host", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const existing = await repo.findByUserAndHost("user-1", 1);
expect(existing?.sshCert).toBe("cert-1");
await repo.upsert({
userId: "user-1",
hostId: 1,
sshCert: "new-cert",
privateKey: "new-key",
email: "new@example.com",
sub: "sub",
issuer: "issuer",
audience: "aud",
expiresAt: "2099-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
const updated = await repo.findByUserAndHost("user-1", 1);
expect(updated).toMatchObject({
sshCert: "new-cert",
privateKey: "new-key",
email: "new@example.com",
sub: "sub",
issuer: "issuer",
audience: "aud",
expiresAt: "2099-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
expect(writeCount).toBe(1);
});
it("updates last-used and deletes one token", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"),
).toBe(true);
expect(await repo.updateLastUsed("missing", 1)).toBe(false);
expect((await repo.findByUserAndHost("user-1", 1))?.lastUsed).toBe(
"2026-01-02T00:00:00.000Z",
);
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(true);
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(false);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect(writeCount).toBe(2);
});
it("deletes tokens by user id", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByUserId("user-1")).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect(await repo.findByUserAndHost("user-2", 3)).not.toBeNull();
expect(writeCount).toBe(1);
});
});
@@ -0,0 +1,51 @@
/**
* The OPKSSH redirect URI identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { registerHostAuthCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
let server: http.Server;
let base: string;
beforeEach(async () => {
const router = express.Router();
registerHostAuthCompatRoutes(router);
const app = express();
app.use("/host", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("the old OPKSSH callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const response = await fetch(
`${base}/host/opkssh-callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/opkssh/callback?code=abc&state=xyz",
);
});
it("keeps a subpath", async () => {
const response = await fetch(`${base}/host/opkssh-callback/req-1/done`, {
redirect: "manual",
});
expect(response.headers.get("location")).toBe(
"/plugin-api/opkssh/callback/req-1/done",
);
});
});
@@ -18,10 +18,9 @@ vi.mock("ssh2", async (importOriginal) => {
const mocks = vi.hoisted(() => ({
verifier: () => {},
createHostVerifier: vi.fn(),
getOPKSSHToken: vi.fn(),
invalidateOPKSSHToken: vi.fn(),
setupOPKSSHCertAuth: vi.fn(),
setupCACertAuth: vi.fn(),
getStepCaCert: vi.fn(),
invalidateStepCaCert: vi.fn(),
applyCertificateAuth: vi.fn(),
getVaultCert: vi.fn(),
deleteVaultCert: vi.fn(),
applyAgentAuth: vi.fn(),
@@ -41,13 +40,12 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
createHostVerifier: mocks.createHostVerifier,
},
}));
vi.mock("../../../hosts/opkssh-auth.js", () => ({
getOPKSSHToken: mocks.getOPKSSHToken,
invalidateOPKSSHToken: mocks.invalidateOPKSSHToken,
vi.mock("../../../hosts/step-ca-auth.js", () => ({
getStepCaCert: mocks.getStepCaCert,
invalidateStepCaCert: mocks.invalidateStepCaCert,
}));
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
setupOPKSSHCertAuth: mocks.setupOPKSSHCertAuth,
setupCACertAuth: mocks.setupCACertAuth,
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
applyCertificateAuth: mocks.applyCertificateAuth,
}));
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
getVaultCert: mocks.getVaultCert,
@@ -135,12 +133,9 @@ beforeEach(() => {
config.agent = "agent-object";
return { socketPath: "/tmp/agent.sock" };
});
mocks.setupOPKSSHCertAuth.mockImplementation(async (config) => {
mocks.applyCertificateAuth.mockImplementation(async (config) => {
config.authHandler = "cert-handler";
});
mocks.setupCACertAuth.mockImplementation(async (config) => {
config.authHandler = "ca-handler";
});
});
describe("buildConnectConfig per auth type", () => {
@@ -186,7 +181,7 @@ describe("buildConnectConfig per auth type", () => {
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
expect(config.passphrase).toBe("secret");
expect(config.password).toBe("fallback");
expect(mocks.setupCACertAuth).not.toHaveBeenCalled();
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
});
it("key with a CA certificate grafts the certificate", async () => {
@@ -198,12 +193,15 @@ describe("buildConnectConfig per auth type", () => {
certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA",
}),
);
expect(mocks.setupCACertAuth).toHaveBeenCalledOnce();
expect(config.authHandler).toBe("ca-handler");
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
expect(mocks.applyCertificateAuth.mock.calls[0][2]).toMatchObject({
certificate: "ssh-ed25519-cert-v01@openssh.com AAAA",
});
expect(config.authHandler).toBe("cert-handler");
});
it("key with a failing CA certificate falls back to key only", async () => {
mocks.setupCACertAuth.mockRejectedValueOnce(new Error("bad cert"));
mocks.applyCertificateAuth.mockRejectedValueOnce(new Error("bad cert"));
const { outcome } = await build(
host({ authType: "key", key: plainKey, certPublicKey: "junk" }),
);
@@ -275,27 +273,28 @@ describe("buildConnectConfig per auth type", () => {
expect(config.tryKeyboard).toBe(true);
});
it("opkssh without a cached token asks for a sign-in", async () => {
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
const { outcome } = await build(host({ authType: "opkssh" }));
// OPKSSH is a plugin-registered SSH auth provider now: see
// plugins/opkssh/tests/backend/provider.test.ts.
it("stepca without a cached certificate asks for a sign-in", async () => {
mocks.getStepCaCert.mockReturnValueOnce(null);
const { outcome } = await build(host({ authType: "stepca" }));
expect(outcome).toMatchObject({
status: "interaction-required",
interaction: "opkssh",
interaction: "stepca",
});
expect(mocks.getOPKSSHToken).toHaveBeenCalledWith("user-1", 7);
expect(mocks.getStepCaCert).toHaveBeenCalledWith("user-1", 7);
});
it("opkssh and stepca use the cached certificate", async () => {
for (const authType of ["opkssh", "stepca"]) {
mocks.getOPKSSHToken.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
const { config, outcome } = await build(host({ authType }));
expect(outcome.status).toBe("ready");
expect(config.authHandler).toBe("cert-handler");
}
expect(mocks.setupOPKSSHCertAuth).toHaveBeenCalledTimes(2);
it("stepca uses the cached certificate", async () => {
mocks.getStepCaCert.mockReturnValueOnce({
privateKey: "k",
sshCert: "c",
});
const { config, outcome } = await build(host({ authType: "stepca" }));
expect(outcome.status).toBe("ready");
expect(config.authHandler).toBe("cert-handler");
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
});
it("vault needs a profile, then a cached certificate", async () => {
@@ -466,18 +465,18 @@ describe("connectHost", () => {
});
it("throws the auth outcome instead of connecting", async () => {
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
const attempt = connectHost(host({ authType: "opkssh" }), {
mocks.getStepCaCert.mockReturnValue(null);
const attempt = connectHost(host({ authType: "stepca" }), {
userId: "user-1",
purpose: "tmux",
});
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
await expect(
connectHost(host({ authType: "opkssh" }), {
connectHost(host({ authType: "stepca" }), {
userId: "user-1",
purpose: "tmux",
}).catch((error) => error.code),
).resolves.toBe("opkssh-required");
).resolves.toBe("stepca-required");
});
it("goes through the jump chain and forwards to the target", async () => {
@@ -629,22 +628,18 @@ describe("connectHost", () => {
it("lets the provider react to an auth failure", async () => {
FakeSshClient.nextBehaviour = ["auth-fail"];
mocks.getOPKSSHToken.mockResolvedValueOnce({
mocks.getStepCaCert.mockReturnValueOnce({
privateKey: "k",
sshCert: "c",
});
await expect(
connectHost(host({ authType: "opkssh" }), {
connectHost(host({ authType: "stepca" }), {
userId: "user-1",
purpose: "fleet",
}),
).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() =>
expect(mocks.invalidateOPKSSHToken).toHaveBeenCalledWith(
"user-1",
7,
"SSH auth failed",
),
expect(mocks.invalidateStepCaCert).toHaveBeenCalledWith("user-1", 7),
);
});
+15 -19
View File
@@ -3,7 +3,6 @@ import type { WebSocket } from "ws";
const state = vi.hoisted(() => ({
sent: [] as Array<Record<string, unknown>>,
upsert: vi.fn(async () => undefined),
parseInfo: {
publicKeyLine: "ssh-ed25519 TESTKEY",
principals: ["alice"],
@@ -19,15 +18,6 @@ const state = vi.hoisted(() => ({
vi.mock("../../utils/logger.js", () => ({
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 1) },
}));
vi.mock("../../utils/field-crypto.js", () => ({
FieldCrypto: {
encryptField: (value: string, _key: Buffer, _id: string, field: string) =>
`${field}:${value}`,
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) =>
@@ -37,7 +27,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
step_ca_provisioner: "oidc",
})[key],
}),
createCurrentOpksshTokenRepository: () => ({ upsert: state.upsert }),
}));
vi.mock("../../utils/step-ca-egress.js", () => ({
readStepCaPrivateAllowlist: async () => [],
@@ -92,8 +81,13 @@ vi.mock("../../hosts/step-ca-runtime.js", () => ({
},
}));
const { cancelStepCaAuth, completeStepCaAuth, startStepCaAuth } =
await import("../../hosts/step-ca-auth.js");
const {
cancelStepCaAuth,
completeStepCaAuth,
getStepCaCert,
invalidateStepCaCert,
startStepCaAuth,
} = await import("../../hosts/step-ca-auth.js");
function jwt(payload: object): string {
return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`;
@@ -124,7 +118,7 @@ async function start(): Promise<{ requestId: string; nonce: string }> {
beforeEach(() => {
state.sent.length = 0;
state.upsert.mockClear();
invalidateStepCaCert("user-1", 7);
state.runtimeComplete.mockClear();
state.command = null;
state.remoteResult = null;
@@ -147,9 +141,11 @@ describe("Step CA authentication", () => {
ok: true,
message: "Signed in. You can close this window.",
});
expect(state.upsert).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", hostId: 7 }),
);
expect(getStepCaCert("user-1", 7)).toEqual({
sshCert: "CERT",
privateKey: "PRIVATE",
});
expect(state.sent.at(-1)).toMatchObject({ type: "stepca_completed" });
});
it("rejects a token without the requested nonce", async () => {
@@ -159,7 +155,7 @@ describe("Step CA authentication", () => {
const result = await completeStepCaAuth({ state: requestId, code: "code" });
expect(result.ok).toBe(false);
expect(result.message).toMatch(/does not match/);
expect(state.upsert).not.toHaveBeenCalled();
expect(getStepCaCert("user-1", 7)).toBeNull();
});
it("routes a callback through Redis to the instance holding the WebSocket", async () => {
@@ -170,7 +166,7 @@ describe("Step CA authentication", () => {
await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), {
timeout: 2000,
});
expect(state.upsert).toHaveBeenCalled();
expect(getStepCaCert("user-1", 7)).not.toBeNull();
});
it("returns a result produced by another instance", async () => {
@@ -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");
+18
View File
@@ -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(),
@@ -1,97 +0,0 @@
import crypto from "crypto";
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPKSSHBinaryManager } from "../../utils/opkssh-binary-manager.js";
// Mirrors the manager's own platform/arch mapping so the test's fake release
// asset matches whatever OS and architecture actually run the suite.
const OS_MAP: Record<string, string> = {
win32: "windows",
linux: "linux",
darwin: "osx",
};
const ARCH_MAP: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const mappedOs = OS_MAP[process.platform] ?? process.platform;
const mappedArch = ARCH_MAP[process.arch] ?? process.arch;
const binaryName =
process.platform === "win32"
? `opkssh-${mappedOs}-${mappedArch}.exe`
: `opkssh-${mappedOs}-${mappedArch}`;
let dataDir: string;
beforeEach(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-opkssh-"));
process.env.DATA_DIR = dataDir;
process.env.OPKSSH_VERSION = "v-test";
});
afterEach(async () => {
vi.unstubAllGlobals();
delete process.env.DATA_DIR;
delete process.env.OPKSSH_VERSION;
delete process.env.OPKSSH_SHA256;
await fs.rm(dataDir, { recursive: true, force: true });
});
function mockRelease(binary: Buffer): void {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
tag_name: "v-test",
assets: [
{ name: binaryName, browser_download_url: "https://asset.test" },
],
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(new Response(binary, { status: 200 })),
);
}
describe("OPKSSHBinaryManager download integrity", () => {
it("installs a release only after its configured checksum matches", async () => {
const binary = Buffer.from("verified-opkssh-binary");
process.env.OPKSSH_SHA256 = crypto
.createHash("sha256")
.update(binary)
.digest("hex");
mockRelease(binary);
await OPKSSHBinaryManager.downloadBinary();
const installDir = path.join(dataDir, "opkssh");
expect(await fs.readFile(path.join(installDir, binaryName))).toEqual(
binary,
);
expect(
await fs.readFile(path.join(installDir, "version.txt"), "utf8"),
).toBe("v-test");
expect(
await fs.readFile(path.join(installDir, "checksum.txt"), "utf8"),
).toBe(process.env.OPKSSH_SHA256);
});
it("rejects a mismatched release without replacing the installed binary", async () => {
const installDir = path.join(dataDir, "opkssh");
const binaryPath = path.join(installDir, binaryName);
await fs.mkdir(installDir, { recursive: true });
await fs.writeFile(binaryPath, "known-good");
process.env.OPKSSH_SHA256 = "0".repeat(64);
mockRelease(Buffer.from("tampered"));
await expect(OPKSSHBinaryManager.downloadBinary()).rejects.toThrow(
"checksum mismatch",
);
expect(await fs.readFile(binaryPath, "utf8")).toBe("known-good");
});
});
@@ -0,0 +1,96 @@
/**
* The OPKSSH config move. A 2.8 install's config must reach the opkssh
* plugin's folder, the old file must stay for a downgrade, and the install
* must keep the redirect URI its identity providers know. Running it twice
* must not overwrite anything.
*/
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 state = vi.hoisted(() => ({
settings: new Map<string, string | null>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "opkssh" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, _id: string | null, key: string) =>
state.settings.has(key) ? { key, value: state.settings.get(key) } : null,
set: async (
_p: string,
_s: string,
_id: string | null,
key: string,
value: string | null,
) => {
state.settings.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js";
let dataDir: string;
const legacy = () => path.join(dataDir, ".opk", "config.yml");
const target = () => path.join(dataDir, "plugins", "opkssh", "config.yml");
beforeEach(async () => {
state.settings = new Map();
state.pluginInstalled = true;
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-config-"));
});
afterEach(async () => {
await fs.rm(dataDir, { recursive: true, force: true });
});
describe("runOpksshConfigMigration", () => {
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
await fs.mkdir(path.dirname(legacy()), { recursive: true });
await fs.writeFile(legacy(), "providers:\n - alias: google\n");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: true,
legacyCallback: true,
});
expect(await fs.readFile(target(), "utf8")).toContain("alias: google");
expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google");
expect(state.settings.get("legacyCallback")).toBe("true");
await fs.writeFile(target(), "providers:\n - alias: edited\n");
state.settings.set("legacyCallback", "false");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(await fs.readFile(target(), "utf8")).toContain("alias: edited");
expect(state.settings.get("legacyCallback")).toBe("false");
});
it("does nothing on an install that never set up OPKSSH", async () => {
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(state.settings.size).toBe(0);
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
await fs.mkdir(path.dirname(legacy()), { recursive: true });
await fs.writeFile(legacy(), "providers: []\n");
await runOpksshConfigMigration(dataDir);
await expect(fs.access(target())).rejects.toThrow();
});
});
@@ -0,0 +1,161 @@
/**
* The Warpgate host settings migration.
*
* Every host with use_warpgate on, whatever shape the engine returned it in,
* and every pre-2.7 host still on auth type "warpgate", must come through
* into the warpgate plugin's useWarpgate host setting. Running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
statements: [] as string[],
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "warpgate" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows.push({ scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks?: unknown[] }) => {
const text = JSON.stringify(query);
if (text.includes("use_warpgate") && state.columnGone) {
throw new Error("no such column: use_warpgate");
}
return state.hostRows.filter(
(row) => !text.includes("WHERE") || row.auth_type === "warpgate",
);
},
runStatement: async (query: unknown) => {
const text = JSON.stringify(query);
state.statements.push(text);
const id = (query as { queryChunks: unknown[] }).queryChunks.find(
(chunk) => typeof chunk === "number",
);
const row = state.hostRows.find((candidate) => candidate.id === id);
if (row) row.auth_type = "none";
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
runWarpgateSettingsMigration,
usesWarpgate,
} from "../../utils/crypto-migration/warpgate-settings-migration.js";
const enabled = (hostId: number) =>
state.pluginRows.some(
(row) =>
row.scopeId === String(hostId) &&
row.key === "useWarpgate" &&
row.value === "true",
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.statements = [];
state.pluginInstalled = true;
state.columnGone = false;
});
describe("usesWarpgate", () => {
it("reads the flag in every shape the engines return", () => {
for (const flag of [true, 1, "1", "true"]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(true);
}
for (const flag of [false, 0, "0", null]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(false);
}
expect(usesWarpgate({ id: 1, auth_type: "warpgate" })).toBe(true);
});
});
describe("runWarpgateSettingsMigration", () => {
it("moves every flagged host and fixes the old auth type, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, use_warpgate: 1, auth_type: "password" },
{ id: 2, use_warpgate: 0, auth_type: "key" },
{ id: 3, use_warpgate: true, auth_type: "none" },
{ id: 4, use_warpgate: 0, auth_type: "warpgate" },
];
const first = await runWarpgateSettingsMigration();
expect(first).toEqual({
hostsMoved: 3,
hostsSkipped: 0,
authTypesFixed: 1,
});
expect(enabled(1)).toBe(true);
expect(enabled(2)).toBe(false);
expect(enabled(3)).toBe(true);
expect(enabled(4)).toBe(true);
expect(state.hostRows[3].auth_type).toBe("none");
const rows = state.pluginRows.length;
const second = await runWarpgateSettingsMigration();
// Host 4 is on auth type "none" now, so only the flagged two are seen.
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
authTypesFixed: 0,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("still fixes old auth types on an install without the column", async () => {
state.columnGone = true;
state.hostRows = [{ id: 5, auth_type: "warpgate" }];
await runWarpgateSettingsMigration();
expect(enabled(5)).toBe(true);
expect(state.hostRows[0].auth_type).toBe("none");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, use_warpgate: 1, auth_type: "password" }];
await runWarpgateSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,95 @@
/**
* Moves a 2.8 OPKSSH setup into the opkssh plugin.
*
* The config file moves from DATA_DIR/.opk/config.yml to the plugin's own
* folder (DATA_DIR/plugins/opkssh/config.yml). The old file is copied, not
* moved, so a downgrade still finds it. An install that had a config also
* keeps sending the pre-2.9 redirect URI (/host/opkssh-callback), because
* that is the one registered with its identity providers: the plugin's
* legacyCallback admin setting is turned on.
*
* Idempotent: an existing target file or setting is never overwritten.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../../database/repositories/factory.js";
const PLUGIN_ID = "opkssh";
export interface OpksshConfigMigrationResult {
copied: boolean;
legacyCallback: boolean;
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
export async function runOpksshConfigMigration(
dataDir: string = process.env.DATA_DIR ||
path.join(process.cwd(), "db", "data"),
): Promise<OpksshConfigMigrationResult> {
const result: OpksshConfigMigrationResult = {
copied: false,
legacyCallback: false,
};
const legacyConfig = path.join(dataDir, ".opk", "config.yml");
if (!(await exists(legacyConfig))) return result;
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
try {
const target = path.join(dataDir, "plugins", PLUGIN_ID, "config.yml");
if (!(await exists(target))) {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(legacyConfig, target);
result.copied = true;
}
const settings = createCurrentPluginSettingsRepository();
const current = await settings.get(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
);
if (!current) {
await settings.set(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
JSON.stringify(true),
);
result.legacyCallback = true;
}
} catch (error) {
databaseLogger.warn("OPKSSH config migration failed", {
operation: "opkssh_config_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.copied) {
databaseLogger.info("Copied the OPKSSH config into the opkssh plugin", {
operation: "opkssh_config_migration",
});
}
return result;
}
@@ -6,7 +6,9 @@
*/
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
await runOpksshConfigMigration();
}
@@ -0,0 +1,131 @@
/**
* Moves each host's Warpgate flag out of ssh_data and into the warpgate
* plugin's useWarpgate host setting. Hosts from before 2.7 that still carry
* auth_type "warpgate" get the setting too, and auth type "none", which is
* what that auth type meant.
*
* The column stays in the database, unused: core's drizzle migrations run
* before this does, so dropping it there would lose the data first.
*
* Idempotent: a host that already has the setting is skipped, so this is
* safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../../database/repositories/factory.js";
import { runStatement, selectRows } from "./raw-rows.js";
const PLUGIN_ID = "warpgate";
const SETTING_KEY = "useWarpgate";
interface LegacyHostRow {
id: number;
use_warpgate?: number | boolean | string | null;
auth_type: string | null;
}
export function usesWarpgate(row: LegacyHostRow): boolean {
const flag = row.use_warpgate;
return (
flag === true ||
flag === 1 ||
flag === "1" ||
flag === "true" ||
row.auth_type === "warpgate"
);
}
export interface WarpgateSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
authTypesFixed: number;
}
async function legacyRows(): Promise<LegacyHostRow[]> {
try {
// Raw SQL: schema.ts no longer declares use_warpgate.
return await selectRows<LegacyHostRow>(
sql`SELECT id, use_warpgate, auth_type FROM ssh_data`,
);
} catch {
// A fresh install never had the column.
try {
return await selectRows<LegacyHostRow>(
sql`SELECT id, auth_type FROM ssh_data WHERE auth_type = 'warpgate'`,
);
} catch {
return [];
}
}
}
export async function runWarpgateSettingsMigration(): Promise<WarpgateSettingsMigrationResult> {
const result: WarpgateSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
authTypesFixed: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
for (const row of await legacyRows()) {
if (!usesWarpgate(row)) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
);
if (existing) {
result.hostsSkipped++;
} else {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
JSON.stringify(true),
);
result.hostsMoved++;
}
if (row.auth_type === "warpgate") {
await runStatement(
sql`UPDATE ssh_data SET auth_type = 'none' WHERE id = ${row.id}`,
);
result.authTypesFixed++;
}
} catch (error) {
databaseLogger.warn(
"Warpgate host settings migration failed for a host",
{
operation: "warpgate_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved the Warpgate flag for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "warpgate_settings_migration" },
);
}
return result;
}
-1
View File
@@ -39,7 +39,6 @@ class FieldCrypto {
// Channel configs hold ntfy tokens, webhook auth headers and Discord
// webhook URLs, which are credentials like any other.
notification_channels: new Set(["config"]),
opkssh_tokens: new Set(["sshCert", "privateKey"]),
termix_identity_ca: new Set(["privateKey"]),
vault_tokens: new Set(["sshCert", "privateKey"]),
};
-353
View File
@@ -1,353 +0,0 @@
import { getErrorMessage } from "./error-message.js";
import { createWriteStream, promises as fs } from "fs";
import crypto from "crypto";
import path from "path";
import { pipeline } from "stream/promises";
import { systemLogger } from "./logger.js";
const OPKSSH_REPO = "openpubkey/opkssh";
const DEFAULT_OPKSSH_VERSION = "v0.16.0";
const DEFAULT_CHECKSUMS: Record<string, string> = {
"opkssh-linux-amd64":
"c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf",
"opkssh-linux-arm64":
"9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44",
"opkssh-osx-amd64":
"e1ccddb4a73c7dd24e0677e9c933462b954dde9a151fcd96c8ee7ba83bc3f146",
"opkssh-osx-arm64":
"be279812cc4d44a28f8cb6eef4b13515fae16b6d00ae61e21630e0c061b02cbd",
"opkssh-windows-amd64.exe":
"db8991ceaac7ac224b704510ca6fba2114998291d4283de4bc2d3b8efa66ad07",
"opkssh-windows-arm64.exe":
"c35352dc2d12ef3775b280aa85dc1e97ff90cd8ad4beb62c3e2357fdf80ba5af",
};
function getBinaryDir(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, "opkssh");
}
function getVersionFile(): string {
return path.join(getBinaryDir(), "version.txt");
}
function getChecksumFile(): string {
return path.join(getBinaryDir(), "checksum.txt");
}
function getTrustedRelease(binaryName: string): {
version: string;
checksum: string;
} {
const version = process.env.OPKSSH_VERSION || DEFAULT_OPKSSH_VERSION;
const configuredChecksum = process.env.OPKSSH_SHA256?.trim().toLowerCase();
const checksum =
version === DEFAULT_OPKSSH_VERSION
? DEFAULT_CHECKSUMS[binaryName]
: configuredChecksum;
if (!checksum || !/^[0-9a-f]{64}$/.test(checksum)) {
throw new Error(
`OPKSSH ${version} has no trusted SHA-256 checksum for ${binaryName}`,
);
}
return { version, checksum };
}
async function sha256File(filePath: string): Promise<string> {
const contents = await fs.readFile(filePath);
return crypto.createHash("sha256").update(contents).digest("hex");
}
async function verifyChecksum(
filePath: string,
expectedChecksum: string,
): Promise<void> {
const actualChecksum = await sha256File(filePath);
if (actualChecksum !== expectedChecksum) {
throw new Error(`OPKSSH checksum mismatch for ${path.basename(filePath)}`);
}
}
async function replaceBinary(
temporaryPath: string,
binaryPath: string,
): Promise<void> {
const backupPath = `${binaryPath}.previous`;
let backedUp = false;
try {
await fs.rename(binaryPath, backupPath);
backedUp = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
try {
await fs.rename(temporaryPath, binaryPath);
if (backedUp) await fs.rm(backupPath, { force: true });
} catch (error) {
if (backedUp) await fs.rename(backupPath, binaryPath);
throw error;
}
}
function getBundledDir(): string {
return (
process.env.OPKSSH_BUNDLED_DIR || path.join(process.cwd(), "opkssh-bundled")
);
}
interface GitHubAsset {
name: string;
browser_download_url: string;
}
interface GitHubRelease {
tag_name: string;
assets: GitHubAsset[];
}
export class OPKSSHBinaryManager {
private static binaryPath: string | null = null;
static async ensureBinary(): Promise<string> {
if (this.binaryPath) {
return this.binaryPath;
}
const binaryName = this.getBinaryName();
const expectedPath = path.join(getBinaryDir(), binaryName);
try {
await fs.access(expectedPath);
const storedChecksum = (await fs.readFile(getChecksumFile(), "utf8"))
.trim()
.toLowerCase();
await verifyChecksum(expectedPath, storedChecksum);
const needsUpdate = await this.checkForUpdate();
if (needsUpdate) {
systemLogger.info("Newer OPKSSH version available, updating...", {
operation: "opkssh_binary_update_start",
});
await this.downloadBinary();
}
this.binaryPath = expectedPath;
return expectedPath;
} catch {
const usedBundled = await this.useBundledBinary(expectedPath);
if (usedBundled) {
this.binaryPath = expectedPath;
return expectedPath;
}
systemLogger.info("OPKSSH binary not found, downloading...", {
operation: "opkssh_binary_download_start",
});
await this.downloadBinary();
this.binaryPath = expectedPath;
return expectedPath;
}
}
private static async useBundledBinary(
expectedPath: string,
): Promise<boolean> {
const binaryName = this.getBinaryName();
const bundledPath = path.join(getBundledDir(), binaryName);
const { version, checksum } = getTrustedRelease(binaryName);
try {
await fs.access(bundledPath);
await verifyChecksum(bundledPath, checksum);
await fs.mkdir(getBinaryDir(), { recursive: true });
await fs.copyFile(bundledPath, expectedPath);
await fs.chmod(expectedPath, 0o755);
await fs.writeFile(getChecksumFile(), checksum, "utf8");
await fs.writeFile(getVersionFile(), version, "utf8");
systemLogger.info("Using bundled OPKSSH binary", {
operation: "opkssh_binary_bundled_used",
path: expectedPath,
});
return true;
} catch {
return false;
}
}
static async downloadBinary(): Promise<void> {
try {
await fs.mkdir(getBinaryDir(), { recursive: true });
const release = await this.getLatestRelease();
const asset = this.findMatchingAsset(release.assets);
if (!asset) {
throw new Error(
`No matching OPKSSH binary found for platform ${process.platform} ${process.arch}`,
);
}
const binaryName = this.getBinaryName();
const binaryPath = path.join(getBinaryDir(), binaryName);
const temporaryPath = `${binaryPath}.download-${crypto.randomUUID()}`;
const { checksum } = getTrustedRelease(binaryName);
const response = await fetch(asset.browser_download_url);
if (!response.ok) {
throw new Error(`Failed to download: ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is null");
}
try {
const fileStream = createWriteStream(temporaryPath, { mode: 0o600 });
await pipeline(
response.body as unknown as NodeJS.ReadableStream,
fileStream,
);
await verifyChecksum(temporaryPath, checksum);
await fs.chmod(temporaryPath, 0o755);
await replaceBinary(temporaryPath, binaryPath);
} catch (error) {
await fs.rm(temporaryPath, { force: true });
throw error;
}
await fs.writeFile(getVersionFile(), release.tag_name, "utf8");
await fs.writeFile(getChecksumFile(), checksum, "utf8");
systemLogger.info(
`OPKSSH binary downloaded successfully to ${binaryPath}`,
{
operation: "opkssh_binary_download_complete",
path: binaryPath,
version: release.tag_name,
},
);
} catch (error) {
systemLogger.error("Failed to download OPKSSH binary", error, {
operation: "opkssh_binary_download_error",
});
throw error;
}
}
static getBinaryPath(): string {
if (!this.binaryPath) {
throw new Error(
"OPKSSH binary not initialized. Call ensureBinary() first.",
);
}
return this.binaryPath;
}
private static async checkForUpdate(): Promise<boolean> {
try {
let localVersion: string | null = null;
try {
localVersion = await fs.readFile(getVersionFile(), "utf8");
localVersion = localVersion.trim();
} catch {
return true;
}
const latestVersion = getTrustedRelease(this.getBinaryName()).version;
if (localVersion !== latestVersion) {
return true;
}
return false;
} catch (error) {
systemLogger.warn("Failed to check for OPKSSH updates", {
operation: "opkssh_update_check_failed",
error: getErrorMessage(error),
});
return false;
}
}
private static async getLatestRelease(): Promise<GitHubRelease> {
const { version } = getTrustedRelease(this.getBinaryName());
const url = `https://api.github.com/repos/${OPKSSH_REPO}/releases/tags/${encodeURIComponent(version)}`;
const response = await fetch(url, {
headers: {
"User-Agent": "Termix",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch release info: ${response.statusText}`);
}
return (await response.json()) as GitHubRelease;
}
private static findMatchingAsset(assets: GitHubAsset[]): GitHubAsset | null {
const platform = process.platform;
const arch = process.arch;
const osMap: Record<string, string> = {
win32: "windows",
linux: "linux",
darwin: "osx",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const mappedOs = osMap[platform];
const mappedArch = archMap[arch];
if (!mappedOs || !mappedArch) {
return null;
}
const patterns = [
`opkssh-${mappedOs}-${mappedArch}.exe`,
`opkssh-${mappedOs}-${mappedArch}`,
`opkssh_${mappedOs}_${mappedArch}.exe`,
`opkssh_${mappedOs}_${mappedArch}`,
];
for (const pattern of patterns) {
const asset = assets.find(
(a) => a.name.toLowerCase() === pattern.toLowerCase(),
);
if (asset) {
return asset;
}
}
return null;
}
private static getBinaryName(): string {
const platform = process.platform;
const arch = process.arch;
const osMap: Record<string, string> = {
win32: "windows",
linux: "linux",
darwin: "osx",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const mappedOs = osMap[platform] || platform;
const mappedArch = archMap[arch] || arch;
const extension = platform === "win32" ? ".exe" : "";
return `opkssh-${mappedOs}-${mappedArch}${extension}`;
}
}