mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function resultPage(ok: boolean, message: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]),
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user