mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,6 @@ export type Host = {
|
||||
| "tailscale"
|
||||
| "agent"
|
||||
| "vault";
|
||||
useWarpgate?: boolean;
|
||||
shareSshAuth?: boolean;
|
||||
password?: string;
|
||||
key?: string;
|
||||
@@ -293,7 +292,6 @@ export interface HostData {
|
||||
| "tailscale"
|
||||
| "agent"
|
||||
| "vault";
|
||||
useWarpgate?: boolean;
|
||||
shareSshAuth?: boolean;
|
||||
password?: string;
|
||||
key?: File | string | null;
|
||||
|
||||
@@ -33,7 +33,6 @@ export type Host = {
|
||||
| "tailscale"
|
||||
| "vault"
|
||||
| "agent";
|
||||
useWarpgate?: boolean;
|
||||
shareSshAuth?: boolean;
|
||||
credentialId?: string;
|
||||
vaultProfileId?: string;
|
||||
|
||||
@@ -37,16 +37,6 @@ function CertificateAuthInfo({
|
||||
);
|
||||
}
|
||||
|
||||
function OpksshAuthEditor() {
|
||||
return (
|
||||
<CertificateAuthInfo
|
||||
labelKey="hosts.opksshLabel"
|
||||
descriptionKey="hosts.opksshDesc"
|
||||
docsUrl="https://docs.termix.site/features/authentication/opkssh"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCaAuthEditor() {
|
||||
return (
|
||||
<CertificateAuthInfo
|
||||
@@ -62,12 +52,6 @@ let registered = false;
|
||||
export function ensureLegacyAuthUI(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerSshAuthEditor({
|
||||
id: "opkssh",
|
||||
pluginId: "core",
|
||||
titleKey: "hosts.filterAuthOpkssh",
|
||||
component: OpksshAuthEditor,
|
||||
});
|
||||
registerSshAuthEditor({
|
||||
id: "stepca",
|
||||
pluginId: "core",
|
||||
|
||||
+5
-16
@@ -645,10 +645,6 @@
|
||||
"docsLink": "View docs",
|
||||
"stepcaLabel": "Step CA",
|
||||
"stepcaDesc": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator under Admin Settings.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "This host connects through a Warpgate SSH proxy. Termix will handle the browser-based approval flow automatically after authenticating.",
|
||||
"agentLabel": "SSH Agent",
|
||||
"agentDesc": "Authenticate using an SSH agent running on the Termix server (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). The agent must be running on the machine where Termix is hosted.",
|
||||
"agentAuthenticationDesc": "Use keys from an SSH agent to authenticate this connection. Leave the socket path empty to use SSH_AUTH_SOCK.",
|
||||
@@ -1297,18 +1293,11 @@
|
||||
"mfaCodeOrPushPlaceholder": "Code or \"push\"",
|
||||
"mfaWaitingApproval": "Waiting for approval on your device...",
|
||||
"mfaSendRequest": "Send Request",
|
||||
"warpgateAuthRequired": "Warpgate Authentication Required",
|
||||
"warpgateSecurityKey": "Security Key",
|
||||
"warpgateAuthUrl": "Authentication URL",
|
||||
"warpgateOpenBrowser": "Open in Browser",
|
||||
"warpgateContinue": "I've Completed Authentication",
|
||||
"certAuthRequired": "{{provider}} Sign-in Required",
|
||||
"opksshAuthRequired": "OPKSSH Authentication Required",
|
||||
"opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.",
|
||||
"opksshOpenBrowser": "Open Browser to Authenticate",
|
||||
"opksshWaitingForAuth": "Waiting for authentication in browser...",
|
||||
"opksshAuthenticating": "Processing authentication...",
|
||||
"opksshSignInWith": "Sign in with {{provider}}",
|
||||
"browserSignInCode": "Security Key",
|
||||
"browserSignInUrl": "Sign-in URL",
|
||||
"browserSignInOpen": "Open in Browser",
|
||||
"browserSignInContinue": "I've Completed Sign-in",
|
||||
"browserSignInRequired": "{{provider}} Sign-in Required",
|
||||
"sudoPasswordPopupTitle": "Insert Password?",
|
||||
"connectionLogTitle": "Connection Log",
|
||||
"connectionLogCopy": "Copy logs to clipboard",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Bekyk dokumente",
|
||||
"stepcaLabel": "Stap CA",
|
||||
"stepcaDesc": "Meld aan deur jou identiteitsverskaffer en laat jou Step CA 'n kortstondige SSH-sertifikaat vir hierdie gasheer uitreik. Die CA-URL, wortelvingerafdruk en OIDC-voorsiening word deur 'n administrateur onder Admin-instellings ingestel.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Meld aan by hierdie gasheer met jou identiteitsverskaffer in plaas van 'n wagwoord of sleutel. Vereis dat OPKSSH op die bediener opgestel is.",
|
||||
"warpgateLabel": "Warpgate-poort",
|
||||
"warpgateDesc": "Hierdie gasheer koppel deur 'n Warpgate SSH-instaanbediener. Termix sal die blaaiergebaseerde goedkeuringsvloei outomaties hanteer na verifikasie.",
|
||||
"agentLabel": "SSH-agent",
|
||||
"agentDesc": "Verifieer deur 'n SSH-agent te gebruik wat op die Termix-bediener loop (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Die agent moet loop op die masjien waar Termix gehuisves word.",
|
||||
"agentAuthenticationDesc": "Gebruik sleutels van 'n SSH-agent om hierdie verbinding te verifieer. Los die sokpad leeg om SSH_AUTH_SOCK te gebruik.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Voer jou antwoord in",
|
||||
"mfaWaitingApproval": "Wag vir goedkeuring op jou toestel...",
|
||||
"mfaSendRequest": "Stuur versoek",
|
||||
"warpgateAuthRequired": "Warpgate-verifikasie vereis",
|
||||
"warpgateSecurityKey": "Sekuriteitsleutel",
|
||||
"warpgateAuthUrl": "Verifikasie-URL",
|
||||
"warpgateOpenBrowser": "Maak oop in blaaier",
|
||||
"warpgateContinue": "Ek het verifikasie voltooi",
|
||||
"certAuthRequired": "{{provider}} Aanmelding Vereis",
|
||||
"opksshAuthRequired": "OPKSSH-verifikasie vereis",
|
||||
"opksshAuthDescription": "Voltooi verifikasie in jou blaaier om voort te gaan. Hierdie sessie sal vir 24 uur geldig bly.",
|
||||
"opksshOpenBrowser": "Maak blaaier oop om te verifieer",
|
||||
"opksshWaitingForAuth": "Wag vir verifikasie in blaaier...",
|
||||
"opksshAuthenticating": "Verwerk verifikasie...",
|
||||
"opksshSignInWith": "Teken aan met {{provider}}",
|
||||
"browserSignInCode": "Sekuriteitsleutel",
|
||||
"browserSignInUrl": "Verifikasie-URL",
|
||||
"browserSignInOpen": "Maak oop in blaaier",
|
||||
"browserSignInContinue": "Ek het verifikasie voltooi",
|
||||
"browserSignInRequired": "{{provider}} Aanmelding Vereis",
|
||||
"sudoPasswordPopupTitle": "Wagwoord invoeg?",
|
||||
"connectionLogTitle": "Verbindingslogboek",
|
||||
"connectionLogCopy": "Kopieer logs na knipbord",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "عرض المستندات",
|
||||
"stepcaLabel": "الخطوة ج",
|
||||
"stepcaDesc": "سجّل الدخول عبر موفر الهوية الخاص بك، ودع جهة إصدار الشهادات المعتمدة (SA) التابعة لـ Step CA تُصدر شهادة SSH مؤقتة لهذا المضيف. يتم تحديد عنوان URL الخاص بجهة إصدار الشهادات المعتمدة، وبصمة الجذر، وموفر OIDC بواسطة مسؤول النظام ضمن إعدادات المسؤول.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "تسجيل الدخول إلى هذا المضيف باستخدام مزود الهوية بدلاً من كلمة المرور أو المفتاح. يتطلب إعداد OPKSSH على الخادم.",
|
||||
"warpgateLabel": "بوابة Warpgate",
|
||||
"warpgateDesc": "يتصل هذا المضيف عبر وكيل SSH من Warpgate. سيتولى Termix عملية الموافقة عبر المتصفح تلقائيًا بعد المصادقة.",
|
||||
"agentLabel": "وكيل SSH",
|
||||
"agentDesc": "المصادقة باستخدام وكيل SSH يعمل على خادم Termix (مثل Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). يجب أن يكون الوكيل قيد التشغيل على الجهاز الذي يستضيف Termix.",
|
||||
"agentAuthenticationDesc": "استخدم مفاتيح من وكيل SSH لمصادقة هذا الاتصال. اترك مسار المقبس فارغًا لاستخدام SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "أدخل ردك",
|
||||
"mfaWaitingApproval": "في انتظار الموافقة على جهازك...",
|
||||
"mfaSendRequest": "إرسال الطلب",
|
||||
"warpgateAuthRequired": "مطلوب مصادقة Warpgate",
|
||||
"warpgateSecurityKey": "مفتاح الأمان",
|
||||
"warpgateAuthUrl": "رابط المصادقة",
|
||||
"warpgateOpenBrowser": "فتح في المتصفح",
|
||||
"warpgateContinue": "لقد أكملت المصادقة",
|
||||
"certAuthRequired": "{{provider}} تسجيل الدخول مطلوب",
|
||||
"opksshAuthRequired": "مطلوب مصادقة OPKSSH",
|
||||
"opksshAuthDescription": "أكمل المصادقة في المتصفح للمتابعة. تبقى هذه الجلسة صالحة لمدة 24 ساعة.",
|
||||
"opksshOpenBrowser": "افتح المتصفح للمصادقة",
|
||||
"opksshWaitingForAuth": "في انتظار المصادقة في المتصفح...",
|
||||
"opksshAuthenticating": "جارٍ معالجة المصادقة...",
|
||||
"opksshSignInWith": "تسجيل الدخول باستخدام {{provider}}",
|
||||
"browserSignInCode": "مفتاح الأمان",
|
||||
"browserSignInUrl": "رابط المصادقة",
|
||||
"browserSignInOpen": "فتح في المتصفح",
|
||||
"browserSignInContinue": "لقد أكملت المصادقة",
|
||||
"browserSignInRequired": "{{provider}} تسجيل الدخول مطلوب",
|
||||
"sudoPasswordPopupTitle": "إدراج كلمة المرور؟",
|
||||
"connectionLogTitle": "سجل الاتصال",
|
||||
"connectionLogCopy": "نسخ السجلات إلى الحافظة",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Преглед на документи",
|
||||
"stepcaLabel": "Стъпка CA",
|
||||
"stepcaDesc": "Влезте чрез вашия доставчик на самоличност и позволете на вашия Step CA да издаде краткотраен SSH сертификат за този хост. URL адресът на CA, root пръстовият отпечатък и OIDC provisioner се задават от администратор в „Настройки на администратора“.",
|
||||
"opksshLabel": "ОПКСШ",
|
||||
"opksshDesc": "Влезте в този хост, използвайки вашия доставчик на самоличност вместо парола или ключ. Изисква се OPKSSH, настроен на сървъра.",
|
||||
"warpgateLabel": "Порталът на Уорпгейт",
|
||||
"warpgateDesc": "Този хост се свързва чрез Warpgate SSH прокси. Termix ще обработи процеса на одобрение, базиран на браузър, автоматично след удостоверяване.",
|
||||
"agentLabel": "SSH агент",
|
||||
"agentDesc": "Удостоверете се, използвайки SSH агент, работещ на Termix сървъра (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Агентът трябва да работи на машината, където се хоства Termix.",
|
||||
"agentAuthenticationDesc": "Използвайте ключове от SSH агент за удостоверяване на тази връзка. Оставете пътя на сокета празен, за да използвате SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Въведете отговора си",
|
||||
"mfaWaitingApproval": "Чака се одобрение на вашето устройство...",
|
||||
"mfaSendRequest": "Изпрати заявка",
|
||||
"warpgateAuthRequired": "Изисква се удостоверяване на Warpgate",
|
||||
"warpgateSecurityKey": "Ключ за сигурност",
|
||||
"warpgateAuthUrl": "URL адрес за удостоверяване",
|
||||
"warpgateOpenBrowser": "Отваряне в браузъра",
|
||||
"warpgateContinue": "Завърших удостоверяването",
|
||||
"certAuthRequired": "{{provider}} Необходимо е влизане",
|
||||
"opksshAuthRequired": "Изисква се удостоверяване на OPKSSH",
|
||||
"opksshAuthDescription": "За да продължите, завършете удостоверяването в браузъра си. Тази сесия ще остане валидна 24 часа.",
|
||||
"opksshOpenBrowser": "Отворете браузъра за удостоверяване",
|
||||
"opksshWaitingForAuth": "Чака се удостоверяване в браузъра...",
|
||||
"opksshAuthenticating": "Обработва се удостоверяване...",
|
||||
"opksshSignInWith": "Влезте с {{provider}}",
|
||||
"browserSignInCode": "Ключ за сигурност",
|
||||
"browserSignInUrl": "URL адрес за удостоверяване",
|
||||
"browserSignInOpen": "Отваряне в браузъра",
|
||||
"browserSignInContinue": "Завърших удостоверяването",
|
||||
"browserSignInRequired": "{{provider}} Необходимо е влизане",
|
||||
"sudoPasswordPopupTitle": "Въвеждане на парола?",
|
||||
"connectionLogTitle": "Дневник на връзките",
|
||||
"connectionLogCopy": "Копиране на лог файлове в клипборда",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "নথি দেখুন",
|
||||
"stepcaLabel": "স্টেপ সিএ",
|
||||
"stepcaDesc": "আপনার আইডেন্টিটি প্রোভাইডারের মাধ্যমে সাইন ইন করুন এবং আপনার স্টেপ সিএ-কে এই হোস্টের জন্য একটি স্বল্পস্থায়ী এসএসএইচ সার্টিফিকেট ইস্যু করতে দিন। সিএ ইউআরএল, রুট ফিঙ্গারপ্রিন্ট এবং ওআইডিসি প্রোভিশনার একজন অ্যাডমিনিস্ট্রেটর দ্বারা অ্যাডমিন সেটিংস-এর অধীনে সেট করা হয়।",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "পাসওয়ার্ড বা কী-এর পরিবর্তে আপনার আইডেন্টিটি প্রোভাইডার ব্যবহার করে এই হোস্টে সাইন ইন করুন। এর জন্য সার্ভারে OPKSSH সেট আপ করা আবশ্যক।",
|
||||
"warpgateLabel": "ওয়ার্পগেট গেটওয়ে",
|
||||
"warpgateDesc": "এই হোস্টটি একটি ওয়ার্পগেট এসএসএইচ প্রক্সির মাধ্যমে সংযুক্ত হয়। প্রমাণীকরণের পর টার্মিক্স স্বয়ংক্রিয়ভাবে ব্রাউজার-ভিত্তিক অনুমোদন প্রক্রিয়াটি পরিচালনা করবে।",
|
||||
"agentLabel": "এসএসএইচ এজেন্ট",
|
||||
"agentDesc": "Termix সার্ভারে চলমান একটি SSH এজেন্ট (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent) ব্যবহার করে প্রমাণীকরণ করুন। এজেন্টটি অবশ্যই সেই মেশিনে চালু থাকতে হবে যেখানে Termix হোস্ট করা আছে।",
|
||||
"agentAuthenticationDesc": "এই সংযোগটি প্রমাণীকরণের জন্য একটি SSH এজেন্ট থেকে কী ব্যবহার করুন। SSH_AUTH_SOCK ব্যবহার করতে সকেট পাথটি খালি রাখুন।",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "আপনার প্রতিক্রিয়া লিখুন",
|
||||
"mfaWaitingApproval": "আপনার ডিভাইসে অনুমোদনের জন্য অপেক্ষা করা হচ্ছে...",
|
||||
"mfaSendRequest": "অনুরোধ পাঠান",
|
||||
"warpgateAuthRequired": "ওয়ার্পগেট প্রমাণীকরণ আবশ্যক",
|
||||
"warpgateSecurityKey": "নিরাপত্তা চাবি",
|
||||
"warpgateAuthUrl": "প্রমাণীকরণ ইউআরএল",
|
||||
"warpgateOpenBrowser": "ব্রাউজারে খুলুন",
|
||||
"warpgateContinue": "আমি প্রমাণীকরণ সম্পন্ন করেছি।",
|
||||
"certAuthRequired": "{{provider}} সাইন ইন আবশ্যক",
|
||||
"opksshAuthRequired": "OPKSSH প্রমাণীকরণ আবশ্যক",
|
||||
"opksshAuthDescription": "চালিয়ে যাওয়ার জন্য আপনার ব্রাউজারে প্রমাণীকরণ সম্পন্ন করুন। এই সেশনটি ২৪ ঘণ্টার জন্য বৈধ থাকবে।",
|
||||
"opksshOpenBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন",
|
||||
"opksshWaitingForAuth": "ব্রাউজারে প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...",
|
||||
"opksshAuthenticating": "প্রমাণীকরণ প্রক্রিয়া চলছে...",
|
||||
"opksshSignInWith": "{{provider}} দিয়ে সাইন ইন করুন",
|
||||
"browserSignInCode": "নিরাপত্তা চাবি",
|
||||
"browserSignInUrl": "প্রমাণীকরণ ইউআরএল",
|
||||
"browserSignInOpen": "ব্রাউজারে খুলুন",
|
||||
"browserSignInContinue": "আমি প্রমাণীকরণ সম্পন্ন করেছি।",
|
||||
"browserSignInRequired": "{{provider}} সাইন ইন আবশ্যক",
|
||||
"sudoPasswordPopupTitle": "পাসওয়ার্ড দিন?",
|
||||
"connectionLogTitle": "সংযোগ লগ",
|
||||
"connectionLogCopy": "লগগুলি ক্লিপবোর্ডে কপি করুন",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Veure documents",
|
||||
"stepcaLabel": "Pas CA",
|
||||
"stepcaDesc": "Inicieu la sessió a través del vostre proveïdor d'identitat i deixeu que la vostra Step CA emeti un certificat SSH de curta durada per a aquest amfitrió. L'URL de la CA, l'empremta digital arrel i el proveïdor OIDC els estableix un administrador a Configuració de l'administrador.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Inicieu la sessió en aquest amfitrió amb el vostre proveïdor d'identitat en comptes d'una contrasenya o clau. Requereix que OPKSSH estigui configurat al servidor.",
|
||||
"warpgateLabel": "Porta de Warpgate",
|
||||
"warpgateDesc": "Aquest amfitrió es connecta a través d'un proxy SSH Warpgate. Termix gestionarà automàticament el flux d'aprovació basat en navegador després de l'autenticació.",
|
||||
"agentLabel": "Agent SSH",
|
||||
"agentDesc": "Autentiqueu-vos mitjançant un agent SSH que s'executi al servidor Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). L'agent ha d'estar executant-se a la màquina on s'allotja Termix.",
|
||||
"agentAuthenticationDesc": "Feu servir claus d'un agent SSH per autenticar aquesta connexió. Deixeu la ruta del sòcol buida per utilitzar SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Introdueix la teva resposta",
|
||||
"mfaWaitingApproval": "Esperant aprovació al dispositiu...",
|
||||
"mfaSendRequest": "Enviar sol·licitud",
|
||||
"warpgateAuthRequired": "Cal autenticació de Warpgate",
|
||||
"warpgateSecurityKey": "Clau de seguretat",
|
||||
"warpgateAuthUrl": "URL d'autenticació",
|
||||
"warpgateOpenBrowser": "Obre al navegador",
|
||||
"warpgateContinue": "He completat l'autenticació",
|
||||
"certAuthRequired": "{{provider}} Cal iniciar la sessió",
|
||||
"opksshAuthRequired": "Cal autenticació OPKSSH",
|
||||
"opksshAuthDescription": "Completeu l'autenticació al navegador per continuar. Aquesta sessió serà vàlida durant 24 hores.",
|
||||
"opksshOpenBrowser": "Obre el navegador per autenticar-te",
|
||||
"opksshWaitingForAuth": "Esperant l'autenticació al navegador...",
|
||||
"opksshAuthenticating": "Processant l'autenticació...",
|
||||
"opksshSignInWith": "Inicia la sessió amb {{provider}}",
|
||||
"browserSignInCode": "Clau de seguretat",
|
||||
"browserSignInUrl": "URL d'autenticació",
|
||||
"browserSignInOpen": "Obre al navegador",
|
||||
"browserSignInContinue": "He completat l'autenticació",
|
||||
"browserSignInRequired": "{{provider}} Cal iniciar la sessió",
|
||||
"sudoPasswordPopupTitle": "Voleu inserir la contrasenya?",
|
||||
"connectionLogTitle": "Registre de connexió",
|
||||
"connectionLogCopy": "Copia els registres al porta-retalls",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Zobrazit dokumenty",
|
||||
"stepcaLabel": "Krok CA",
|
||||
"stepcaDesc": "Přihlaste se prostřednictvím svého poskytovatele identity a nechte svou certifikační autoritu Step CA vydat krátkodobý SSH certifikát pro tohoto hostitele. URL certifikační autority, otisk kořenového serveru a zřizovatel OIDC nastavuje administrátor v Nastavení správce.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Přihlaste se k tomuto hostiteli pomocí svého poskytovatele identity namísto hesla nebo klíče. Vyžaduje nastavení OPKSSH na serveru.",
|
||||
"warpgateLabel": "Brána Warpgate",
|
||||
"warpgateDesc": "Tento hostitel se připojuje přes proxy Warpgate SSH. Termix po ověření automaticky zpracuje proces schvalování v prohlížeči.",
|
||||
"agentLabel": "SSH agent",
|
||||
"agentDesc": "Ověřte se pomocí SSH agenta spuštěného na serveru Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agent musí běžet na počítači, kde je hostován Termix.",
|
||||
"agentAuthenticationDesc": "K ověření tohoto připojení použijte klíče od SSH agenta. Pro použití SSH_AUTH_SOCK nechte cestu k socketu prázdnou.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Zadejte svou odpověď",
|
||||
"mfaWaitingApproval": "Čekání na schválení na vašem zařízení...",
|
||||
"mfaSendRequest": "Odeslat požadavek",
|
||||
"warpgateAuthRequired": "Vyžadováno ověřování Warpgate",
|
||||
"warpgateSecurityKey": "Bezpečnostní klíč",
|
||||
"warpgateAuthUrl": "URL ověřování",
|
||||
"warpgateOpenBrowser": "Otevřít v prohlížeči",
|
||||
"warpgateContinue": "Dokončil(a) jsem ověřování",
|
||||
"certAuthRequired": "{{provider}} Vyžaduje se přihlášení",
|
||||
"opksshAuthRequired": "Vyžadováno ověřování OPKSSH",
|
||||
"opksshAuthDescription": "Pro pokračování dokončete ověření ve vašem prohlížeči. Tato relace bude platná 24 hodin.",
|
||||
"opksshOpenBrowser": "Otevřít prohlížeč pro ověření",
|
||||
"opksshWaitingForAuth": "Čekání na ověření v prohlížeči...",
|
||||
"opksshAuthenticating": "Zpracování ověřování...",
|
||||
"opksshSignInWith": "Přihlásit se pomocí {{provider}}",
|
||||
"browserSignInCode": "Bezpečnostní klíč",
|
||||
"browserSignInUrl": "URL ověřování",
|
||||
"browserSignInOpen": "Otevřít v prohlížeči",
|
||||
"browserSignInContinue": "Dokončil(a) jsem ověřování",
|
||||
"browserSignInRequired": "{{provider}} Vyžaduje se přihlášení",
|
||||
"sudoPasswordPopupTitle": "Vložit heslo?",
|
||||
"connectionLogTitle": "Protokol připojení",
|
||||
"connectionLogCopy": "Kopírování protokolů do schránky",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Se dokumenter",
|
||||
"stepcaLabel": "Trin CA",
|
||||
"stepcaDesc": "Log ind via din identitetsudbyder, og lad din Step CA udstede et kortvarigt SSH-certifikat til denne vært. CA-URL'en, root-fingeraftrykket og OIDC-provisioneren indstilles af en administrator under administratorindstillinger.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Log ind på denne vært med din identitetsudbyder i stedet for en adgangskode eller nøgle. Kræver OPKSSH-opsætning på serveren.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "Denne vært opretter forbindelse via en Warpgate SSH-proxy. Termix håndterer den browserbaserede godkendelsesproces automatisk efter godkendelse.",
|
||||
"agentLabel": "SSH-agent",
|
||||
"agentDesc": "Godkend ved hjælp af en SSH-agent, der kører på Termix-serveren (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agenten skal køre på den maskine, hvor Termix hostes.",
|
||||
"agentAuthenticationDesc": "Brug nøgler fra en SSH-agent til at godkende denne forbindelse. Lad socket-stien være tom for at bruge SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Indtast dit svar",
|
||||
"mfaWaitingApproval": "Venter på godkendelse på din enhed...",
|
||||
"mfaSendRequest": "Send anmodning",
|
||||
"warpgateAuthRequired": "Warpgate-godkendelse påkrævet",
|
||||
"warpgateSecurityKey": "Sikkerhedsnøgle",
|
||||
"warpgateAuthUrl": "Godkendelses-URL",
|
||||
"warpgateOpenBrowser": "Åbn i browser",
|
||||
"warpgateContinue": "Jeg har gennemført godkendelsen",
|
||||
"certAuthRequired": "{{provider}} Log ind påkrævet",
|
||||
"opksshAuthRequired": "OPKSSH-godkendelse påkrævet",
|
||||
"opksshAuthDescription": "Fuldfør godkendelsen i din browser for at fortsætte. Denne session forbliver gyldig i 24 timer.",
|
||||
"opksshOpenBrowser": "Åbn browseren for at godkende",
|
||||
"opksshWaitingForAuth": "Venter på godkendelse i browseren...",
|
||||
"opksshAuthenticating": "Behandler godkendelse...",
|
||||
"opksshSignInWith": "Log ind med {{provider}}",
|
||||
"browserSignInCode": "Sikkerhedsnøgle",
|
||||
"browserSignInUrl": "Godkendelses-URL",
|
||||
"browserSignInOpen": "Åbn i browser",
|
||||
"browserSignInContinue": "Jeg har gennemført godkendelsen",
|
||||
"browserSignInRequired": "{{provider}} Log ind påkrævet",
|
||||
"sudoPasswordPopupTitle": "Indsæt adgangskode?",
|
||||
"connectionLogTitle": "Forbindelseslog",
|
||||
"connectionLogCopy": "Kopiér logfiler til udklipsholder",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Dokumentation anzeigen",
|
||||
"stepcaLabel": "Schritt CA",
|
||||
"stepcaDesc": "Melden Sie sich über Ihren Identitätsanbieter an und lassen Sie Ihre Step-Zertifizierungsstelle ein kurzlebiges SSH-Zertifikat für diesen Host ausstellen. Die URL der Zertifizierungsstelle, der Root-Fingerabdruck und der OIDC-Provisioner werden von einem Administrator unter „Admin-Einstellungen“ festgelegt.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Melden Sie sich mit Ihrem Identitätsanbieter an diesem Host an, anstatt mit einem Passwort oder Schlüssel. Erfordert, dass OPKSSH auf dem Server eingerichtet ist.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "Dieser Host verbindet sich über einen Warpgate SSH-Proxy. Termix übernimmt nach der Authentifizierung automatisch den browserbasierten Genehmigungsablauf.",
|
||||
"agentLabel": "SSH-Agent",
|
||||
"agentDesc": "Authentifizieren Sie sich mit einem SSH-Agenten, der auf dem Termix-Server läuft (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Der Agent muss auf dem Rechner laufen, auf dem Termix gehostet wird.",
|
||||
"agentAuthenticationDesc": "Verwenden Sie Schlüssel eines SSH-Agenten, um diese Verbindung zu authentifizieren. Lassen Sie den Socket-Pfad leer, um SSH_AUTH_SOCK zu verwenden.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Geben Sie Ihre Antwort ein",
|
||||
"mfaWaitingApproval": "Warten auf die Genehmigung Ihres Geräts...",
|
||||
"mfaSendRequest": "Anfrage senden",
|
||||
"warpgateAuthRequired": "Warpgate-Authentifizierung erforderlich",
|
||||
"warpgateSecurityKey": "Sicherheitsschlüssel",
|
||||
"warpgateAuthUrl": "Authentifizierungs-URL",
|
||||
"warpgateOpenBrowser": "Im Browser öffnen",
|
||||
"warpgateContinue": "Authentifizierung abgeschlossen",
|
||||
"certAuthRequired": "{{provider}} Anmeldung erforderlich",
|
||||
"opksshAuthRequired": "OPKSSH-Authentifizierung erforderlich",
|
||||
"opksshAuthDescription": "Schließen Sie die Authentifizierung in Ihrem Browser ab, um fortzufahren. Diese Sitzung bleibt 24 Stunden lang gültig.",
|
||||
"opksshOpenBrowser": "Browser zum Authentifizieren öffnen",
|
||||
"opksshWaitingForAuth": "Warten auf Authentifizierung im Browser...",
|
||||
"opksshAuthenticating": "Authentifizierung wird verarbeitet...",
|
||||
"opksshSignInWith": "Anmelden mit {{provider}}",
|
||||
"browserSignInCode": "Sicherheitsschlüssel",
|
||||
"browserSignInUrl": "Authentifizierungs-URL",
|
||||
"browserSignInOpen": "Im Browser öffnen",
|
||||
"browserSignInContinue": "Authentifizierung abgeschlossen",
|
||||
"browserSignInRequired": "{{provider}} Anmeldung erforderlich",
|
||||
"sudoPasswordPopupTitle": "Passwort eingeben?",
|
||||
"connectionLogTitle": "Verbindungsprotokoll",
|
||||
"connectionLogCopy": "Protokolle in die Zwischenablage kopieren",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Προβολή εγγράφων",
|
||||
"stepcaLabel": "Βήμα CA",
|
||||
"stepcaDesc": "Συνδεθείτε μέσω του παρόχου ταυτότητας και αφήστε την Step CA σας να εκδώσει ένα βραχύβιο πιστοποιητικό SSH για αυτόν τον κεντρικό υπολογιστή. Η διεύθυνση URL CA, το δακτυλικό αποτύπωμα root και ο πάροχος OIDC ορίζονται από έναν διαχειριστή στις Ρυθμίσεις διαχειριστή.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Συνδεθείτε σε αυτόν τον κεντρικό υπολογιστή χρησιμοποιώντας την υπηρεσία παροχής ταυτότητας αντί για κωδικό πρόσβασης ή κλειδί. Απαιτείται η ρύθμιση του OPKSSH στον διακομιστή.",
|
||||
"warpgateLabel": "Πύλη Warpgate",
|
||||
"warpgateDesc": "Αυτός ο κεντρικός υπολογιστής συνδέεται μέσω ενός διακομιστή μεσολάβησης Warpgate SSH. Το Termix θα χειριστεί αυτόματα τη ροή έγκρισης που βασίζεται σε πρόγραμμα περιήγησης μετά τον έλεγχο ταυτότητας.",
|
||||
"agentLabel": "Πράκτορας SSH",
|
||||
"agentDesc": "Πραγματοποιήστε έλεγχο ταυτότητας χρησιμοποιώντας έναν παράγοντα SSH που εκτελείται στον διακομιστή Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Ο παράγοντας πρέπει να εκτελείται στον υπολογιστή όπου φιλοξενείται το Termix.",
|
||||
"agentAuthenticationDesc": "Χρησιμοποιήστε κλειδιά από έναν παράγοντα SSH για να επαληθεύσετε αυτήν τη σύνδεση. Αφήστε τη διαδρομή υποδοχής κενή για να χρησιμοποιήσετε το SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Εισαγάγετε την απάντησή σας",
|
||||
"mfaWaitingApproval": "Αναμονή για έγκριση στη συσκευή σας...",
|
||||
"mfaSendRequest": "Αποστολή αιτήματος",
|
||||
"warpgateAuthRequired": "Απαιτείται έλεγχος ταυτότητας Warpgate",
|
||||
"warpgateSecurityKey": "Κλειδί ασφαλείας",
|
||||
"warpgateAuthUrl": "URL ελέγχου ταυτότητας",
|
||||
"warpgateOpenBrowser": "Άνοιγμα στο πρόγραμμα περιήγησης",
|
||||
"warpgateContinue": "Ολοκλήρωσα τον έλεγχο ταυτότητας",
|
||||
"certAuthRequired": "{{provider}} Απαιτείται σύνδεση",
|
||||
"opksshAuthRequired": "Απαιτείται έλεγχος ταυτότητας OPKSSH",
|
||||
"opksshAuthDescription": "Ολοκληρώστε τον έλεγχο ταυτότητας στο πρόγραμμα περιήγησής σας για να συνεχίσετε. Αυτή η περίοδος σύνδεσης θα παραμείνει έγκυρη για 24 ώρες.",
|
||||
"opksshOpenBrowser": "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας",
|
||||
"opksshWaitingForAuth": "Αναμονή για έλεγχο ταυτότητας στο πρόγραμμα περιήγησης...",
|
||||
"opksshAuthenticating": "Επεξεργασία ελέγχου ταυτότητας...",
|
||||
"opksshSignInWith": "Συνδεθείτε με {{provider}}",
|
||||
"browserSignInCode": "Κλειδί ασφαλείας",
|
||||
"browserSignInUrl": "URL ελέγχου ταυτότητας",
|
||||
"browserSignInOpen": "Άνοιγμα στο πρόγραμμα περιήγησης",
|
||||
"browserSignInContinue": "Ολοκλήρωσα τον έλεγχο ταυτότητας",
|
||||
"browserSignInRequired": "{{provider}} Απαιτείται σύνδεση",
|
||||
"sudoPasswordPopupTitle": "Εισαγωγή κωδικού πρόσβασης;",
|
||||
"connectionLogTitle": "Αρχείο καταγραφής σύνδεσης",
|
||||
"connectionLogCopy": "Αντιγραφή αρχείων καταγραφής στο πρόχειρο",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Ver documentación",
|
||||
"stepcaLabel": "Paso CA",
|
||||
"stepcaDesc": "Inicie sesión a través de su proveedor de identidad y permita que su CA de Step emita un certificado SSH de corta duración para este host. La URL de la CA, la huella digital raíz y el proveedor OIDC se configuran mediante un administrador en la sección de Configuración de administración.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Inicie sesión en este host usando su proveedor de identidad en lugar de una contraseña o clave. Requiere OPKSSH configurado en el servidor.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "Este host se conecta a través de un proxy SSH de Warpgate. Termix gestionará el flujo de aprobación basado en navegador automáticamente después de autenticarse.",
|
||||
"agentLabel": "Agente SSH",
|
||||
"agentDesc": "Autentíquese usando un agente SSH que se ejecuta en el servidor de Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). El agente debe estar ejecutándose en la máquina donde está alojado Termix.",
|
||||
"agentAuthenticationDesc": "Utilice las claves de un agente SSH para autenticar esta conexión. Deje la ruta del socket vacía para usar SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Introduzca su respuesta",
|
||||
"mfaWaitingApproval": "Esperando la aprobación en su dispositivo...",
|
||||
"mfaSendRequest": "Enviar solicitud",
|
||||
"warpgateAuthRequired": "Autenticación de Warpgate requerida",
|
||||
"warpgateSecurityKey": "Clave de seguridad",
|
||||
"warpgateAuthUrl": "URL de autenticación",
|
||||
"warpgateOpenBrowser": "Abrir en el navegador",
|
||||
"warpgateContinue": "He completado la autenticación",
|
||||
"certAuthRequired": "{{provider}} Se requiere iniciar sesión",
|
||||
"opksshAuthRequired": "Autenticación OPKSSH requerida",
|
||||
"opksshAuthDescription": "Completa la autenticación en tu navegador para continuar. Esta sesión seguirá siendo válida durante 24 horas.",
|
||||
"opksshOpenBrowser": "Abrir navegador para autenticarse",
|
||||
"opksshWaitingForAuth": "Esperando autenticación en el navegador...",
|
||||
"opksshAuthenticating": "Procesando autenticación...",
|
||||
"opksshSignInWith": "Iniciar sesión con {{provider}}",
|
||||
"browserSignInCode": "Clave de seguridad",
|
||||
"browserSignInUrl": "URL de autenticación",
|
||||
"browserSignInOpen": "Abrir en el navegador",
|
||||
"browserSignInContinue": "He completado la autenticación",
|
||||
"browserSignInRequired": "{{provider}} Se requiere iniciar sesión",
|
||||
"sudoPasswordPopupTitle": "¿Introducir contraseña?",
|
||||
"connectionLogTitle": "Registro de conexión",
|
||||
"connectionLogCopy": "Copiar registros al portapapeles",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Näytä dokumentit",
|
||||
"stepcaLabel": "Vaihe CA",
|
||||
"stepcaDesc": "Kirjaudu sisään identiteetintarjoajasi kautta ja anna vaiheen varmentajan myöntää lyhytaikainen SSH-varmenne tälle isännälle. Varmentajan URL-osoitteen, pääkäyttäjän sormenjäljen ja OIDC-varmenteen määrittäjän määrittää järjestelmänvalvojan asetuksissa.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Kirjaudu tähän isäntään käyttämällä identiteetintarjoajaasi salasanan tai avaimen sijaan. Edellyttää OPKSSH:n määrittämistä palvelimelle.",
|
||||
"warpgateLabel": "Warpgate-yhdyskäytävä",
|
||||
"warpgateDesc": "Tämä isäntä muodostaa yhteyden Warpgate SSH -välityspalvelimen kautta. Termix käsittelee selainpohjaisen hyväksyntäprosessin automaattisesti todennuksen jälkeen.",
|
||||
"agentLabel": "SSH-agentti",
|
||||
"agentDesc": "Todenna itsesi käyttämällä Termix-palvelimella käynnissä olevaa SSH-agenttia (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agentin on oltava käynnissä koneella, jolla Termix sijaitsee.",
|
||||
"agentAuthenticationDesc": "Käytä SSH-agentin avaimia tämän yhteyden todentamiseen. Jätä soketin polku tyhjäksi käyttääksesi SSH_AUTH_SOCK-ominaisuutta.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Kirjoita vastauksesi",
|
||||
"mfaWaitingApproval": "Odotetaan hyväksyntää laitteellasi...",
|
||||
"mfaSendRequest": "Lähetä pyyntö",
|
||||
"warpgateAuthRequired": "Warpgate-todennus vaaditaan",
|
||||
"warpgateSecurityKey": "Suojausavain",
|
||||
"warpgateAuthUrl": "Todennus-URL-osoite",
|
||||
"warpgateOpenBrowser": "Avaa selaimessa",
|
||||
"warpgateContinue": "Olen suorittanut todennuksen",
|
||||
"certAuthRequired": "{{provider}} Kirjautuminen vaaditaan",
|
||||
"opksshAuthRequired": "OPKSSH-todennus vaaditaan",
|
||||
"opksshAuthDescription": "Suorita todennus selaimessasi jatkaaksesi. Tämä istunto on voimassa 24 tuntia.",
|
||||
"opksshOpenBrowser": "Avaa selain todennusta varten",
|
||||
"opksshWaitingForAuth": "Odotetaan selaimen todennusta...",
|
||||
"opksshAuthenticating": "Todennusta käsitellään...",
|
||||
"opksshSignInWith": "Kirjaudu sisään tunnuksella {{provider}}",
|
||||
"browserSignInCode": "Suojausavain",
|
||||
"browserSignInUrl": "Todennus-URL-osoite",
|
||||
"browserSignInOpen": "Avaa selaimessa",
|
||||
"browserSignInContinue": "Olen suorittanut todennuksen",
|
||||
"browserSignInRequired": "{{provider}} Kirjautuminen vaaditaan",
|
||||
"sudoPasswordPopupTitle": "Syötä salasana?",
|
||||
"connectionLogTitle": "Yhteysloki",
|
||||
"connectionLogCopy": "Kopioi lokit leikepöydälle",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Voir la documentation",
|
||||
"stepcaLabel": "Étape CA",
|
||||
"stepcaDesc": "Connectez-vous via votre fournisseur d'identité et laissez votre autorité de certification Step CA émettre un certificat SSH temporaire pour cet hôte. L'URL de l'autorité de certification, l'empreinte racine et le provisionneur OIDC sont configurés par un administrateur dans les paramètres d'administration.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Connectez-vous à cet hôte en utilisant votre fournisseur d'identité au lieu d'un mot de passe ou d'une clé. Nécessite OPKSSH configuré sur le serveur.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "Cet hôte se connecte via un proxy SSH Warpgate. Termix gérera automatiquement le flux d'approbation basé sur le navigateur après l'authentification.",
|
||||
"agentLabel": "Agent SSH",
|
||||
"agentDesc": "Authentifiez-vous en utilisant un agent SSH exécuté sur le serveur Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). L'agent doit être en cours d'exécution sur la machine où Termix est hébergé.",
|
||||
"agentAuthenticationDesc": "Utilisez les clés d'un agent SSH pour authentifier cette connexion. Laissez le chemin du socket vide pour utiliser SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Saisissez votre réponse",
|
||||
"mfaWaitingApproval": "En attente d'approbation sur votre appareil...",
|
||||
"mfaSendRequest": "Envoyer une requête",
|
||||
"warpgateAuthRequired": "Authentification Warpgate requise",
|
||||
"warpgateSecurityKey": "Clé de sécurité",
|
||||
"warpgateAuthUrl": "URL d'authentification",
|
||||
"warpgateOpenBrowser": "Ouvrir dans le navigateur",
|
||||
"warpgateContinue": "J'ai terminé l'authentification",
|
||||
"certAuthRequired": "{{provider}} Connexion requise",
|
||||
"opksshAuthRequired": "Authentification OPKSSH requise",
|
||||
"opksshAuthDescription": "Terminez l'authentification dans votre navigateur pour continuer. Cette session restera valide pendant 24 heures.",
|
||||
"opksshOpenBrowser": "Ouvrir le navigateur pour s'authentifier",
|
||||
"opksshWaitingForAuth": "En attente de l'authentification dans le navigateur...",
|
||||
"opksshAuthenticating": "Traitement de l'authentification...",
|
||||
"opksshSignInWith": "Se connecter avec {{provider}}",
|
||||
"browserSignInCode": "Clé de sécurité",
|
||||
"browserSignInUrl": "URL d'authentification",
|
||||
"browserSignInOpen": "Ouvrir dans le navigateur",
|
||||
"browserSignInContinue": "J'ai terminé l'authentification",
|
||||
"browserSignInRequired": "{{provider}} Connexion requise",
|
||||
"sudoPasswordPopupTitle": "Insérer le mot de passe ?",
|
||||
"connectionLogTitle": "Journal de connexion",
|
||||
"connectionLogCopy": "Copier les journaux dans le presse-papiers",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "הצג מסמכים",
|
||||
"stepcaLabel": "שלב CA",
|
||||
"stepcaDesc": "היכנס דרך ספק הזהויות שלך ותן ל-Step CA שלך להנפיק אישור SSH קצר מועד עבור מארח זה. כתובת ה-URL של CA, טביעת האצבע של הבסיס וספק ה-OIDC מוגדרים על ידי מנהל מערכת תחת הגדרות מנהל מערכת.",
|
||||
"opksshLabel": "אופקש",
|
||||
"opksshDesc": "היכנס למארח זה באמצעות ספק הזהות שלך במקום סיסמה או מפתח. נדרשת הגדרת OPKSSH בשרת.",
|
||||
"warpgateLabel": "שער וורפגייט",
|
||||
"warpgateDesc": "מארח זה מתחבר דרך פרוקסי Warpgate SSH. Termix יטפל בזרימת האישור מבוססת הדפדפן באופן אוטומטי לאחר האימות.",
|
||||
"agentLabel": "סוכן SSH",
|
||||
"agentDesc": "בצע אימות באמצעות סוכן SSH הפועל על שרת Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). הסוכן חייב לפעול על המכונה שבה מתארח Termix.",
|
||||
"agentAuthenticationDesc": "השתמש במפתחות מסוכן SSH כדי לאמת חיבור זה. השאר את נתיב ה-socket ריק כדי להשתמש ב-SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "הזן את תגובתך",
|
||||
"mfaWaitingApproval": "ממתין לאישור במכשיר שלך...",
|
||||
"mfaSendRequest": "שלח בקשה",
|
||||
"warpgateAuthRequired": "נדרש אימות Warpgate",
|
||||
"warpgateSecurityKey": "מפתח אבטחה",
|
||||
"warpgateAuthUrl": "כתובת URL לאימות",
|
||||
"warpgateOpenBrowser": "פתח בדפדפן",
|
||||
"warpgateContinue": "סיימתי את האימות",
|
||||
"certAuthRequired": "{{provider}} Sign-in Required",
|
||||
"opksshAuthRequired": "נדרש אימות OPKSSH",
|
||||
"opksshAuthDescription": "השלם את האימות בדפדפן שלך כדי להמשיך. סשן זה יישאר תקף למשך 24 שעות.",
|
||||
"opksshOpenBrowser": "פתח את הדפדפן כדי לאמת",
|
||||
"opksshWaitingForAuth": "ממתין לאימות בדפדפן...",
|
||||
"opksshAuthenticating": "מעבד אימות...",
|
||||
"opksshSignInWith": "התחבר באמצעות {{provider}}",
|
||||
"browserSignInCode": "מפתח אבטחה",
|
||||
"browserSignInUrl": "כתובת URL לאימות",
|
||||
"browserSignInOpen": "פתח בדפדפן",
|
||||
"browserSignInContinue": "סיימתי את האימות",
|
||||
"browserSignInRequired": "{{provider}} Sign-in Required",
|
||||
"sudoPasswordPopupTitle": "להכניס סיסמה?",
|
||||
"connectionLogTitle": "יומן חיבור",
|
||||
"connectionLogCopy": "העתקת יומנים ללוח",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "दस्तावेज़ देखें",
|
||||
"stepcaLabel": "स्टेप सीए",
|
||||
"stepcaDesc": "अपने आइडेंटिटी प्रोवाइडर के माध्यम से साइन इन करें और अपने स्टेप सीए को इस होस्ट के लिए एक अल्पकालिक एसएसएच प्रमाणपत्र जारी करने दें। सीए यूआरएल, रूट फिंगरप्रिंट और ओआईडीसी प्रोविजनर को एडमिनिस्ट्रेटर द्वारा एडमिन सेटिंग्स के अंतर्गत सेट किया जाता है।",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "इस होस्ट में पासवर्ड या कुंजी के बजाय अपने पहचान प्रदाता का उपयोग करके साइन इन करें। सर्वर पर OPKSSH सेटअप आवश्यक है।",
|
||||
"warpgateLabel": "Warpgate गेटवे",
|
||||
"warpgateDesc": "यह होस्ट Warpgate SSH प्रॉक्सी के माध्यम से कनेक्ट होता है। Termix प्रमाणीकरण के बाद ब्राउज़र-आधारित अनुमोदन प्रवाह को स्वचालित रूप से संभालेगा।",
|
||||
"agentLabel": "SSH एजेंट",
|
||||
"agentDesc": "Termix सर्वर पर चल रहे SSH एजेंट (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent) का उपयोग करके प्रमाणित करें। एजेंट उस मशीन पर चल रहा होना चाहिए जहां Termix होस्ट किया गया है।",
|
||||
"agentAuthenticationDesc": "इस कनेक्शन को प्रमाणित करने के लिए SSH एजेंट से कुंजियों का उपयोग करें। SSH_AUTH_SOCK का उपयोग करने के लिए सॉकेट पथ को खाली छोड़ दें।",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "अपना जवाब दर्ज करें",
|
||||
"mfaWaitingApproval": "आपके डिवाइस की स्वीकृति का इंतजार है...",
|
||||
"mfaSendRequest": "अनुरोध भेजा",
|
||||
"warpgateAuthRequired": "Warpgate प्रमाणीकरण आवश्यक है",
|
||||
"warpgateSecurityKey": "सुरक्षा कुंजी",
|
||||
"warpgateAuthUrl": "प्रमाणीकरण URL",
|
||||
"warpgateOpenBrowser": "ब्राउज़र में खोलें",
|
||||
"warpgateContinue": "मैंने प्रमाणीकरण पूरा कर लिया है",
|
||||
"certAuthRequired": "{{provider}} साइन इन करना आवश्यक है",
|
||||
"opksshAuthRequired": "OPKSSH प्रमाणीकरण आवश्यक है",
|
||||
"opksshAuthDescription": "जारी रखने के लिए अपने ब्राउज़र में प्रमाणीकरण पूरा करें। यह सत्र 24 घंटे के लिए मान्य रहेगा।",
|
||||
"opksshOpenBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें",
|
||||
"opksshWaitingForAuth": "ब्राउज़र में प्रमाणीकरण की प्रतीक्षा हो रही है...",
|
||||
"opksshAuthenticating": "प्रमाणीकरण प्रक्रिया में है...",
|
||||
"opksshSignInWith": "{{provider}} से साइन इन करें",
|
||||
"browserSignInCode": "सुरक्षा कुंजी",
|
||||
"browserSignInUrl": "प्रमाणीकरण URL",
|
||||
"browserSignInOpen": "ब्राउज़र में खोलें",
|
||||
"browserSignInContinue": "मैंने प्रमाणीकरण पूरा कर लिया है",
|
||||
"browserSignInRequired": "{{provider}} साइन इन करना आवश्यक है",
|
||||
"sudoPasswordPopupTitle": "पासवर्ड डालें?",
|
||||
"connectionLogTitle": "कनेक्शन लॉग",
|
||||
"connectionLogCopy": "क्लिपबोर्ड पर लॉग कॉपी करें",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Dokumentumok megtekintése",
|
||||
"stepcaLabel": "CA lépés",
|
||||
"stepcaDesc": "Jelentkezzen be az identitásszolgáltatóján keresztül, és hagyja, hogy a lépéses hitelesítésszolgáltatója rövid életű SSH-tanúsítványt állítson ki ehhez a gazdagéphez. A hitelesítésszolgáltató URL-címét, a root ujjlenyomatát és az OIDC-kiszolgálót a rendszergazda állítja be az Adminisztrátori beállítások alatt.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Jelentkezzen be erre a gazdagépre az identitásszolgáltatójával jelszó vagy kulcs helyett. OPKSSH beállítását igényli a szerveren.",
|
||||
"warpgateLabel": "Warpgate átjáró",
|
||||
"warpgateDesc": "Ez a gazdagép egy Warpgate SSH proxyn keresztül csatlakozik. A Termix a hitelesítés után automatikusan kezeli a böngészőalapú jóváhagyási folyamatot.",
|
||||
"agentLabel": "SSH-ügynök",
|
||||
"agentDesc": "Hitelesítést végezzen egy, a Termix szerveren futó SSH ügynökkel (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Az ügynöknek azon a gépen kell futnia, amelyen a Termix fut.",
|
||||
"agentAuthenticationDesc": "SSH ügynöktől származó kulcsok használata a kapcsolat hitelesítéséhez. Az SSH_AUTH_SOCK használatához hagyja üresen a socket elérési útját.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Írd be a válaszod",
|
||||
"mfaWaitingApproval": "Várakozás a jóváhagyásra az eszközödön...",
|
||||
"mfaSendRequest": "Kérés küldése",
|
||||
"warpgateAuthRequired": "Warpgate hitelesítés szükséges",
|
||||
"warpgateSecurityKey": "Biztonsági kulcs",
|
||||
"warpgateAuthUrl": "Hitelesítési URL",
|
||||
"warpgateOpenBrowser": "Megnyitás böngészőben",
|
||||
"warpgateContinue": "Befejeztem a hitelesítést",
|
||||
"certAuthRequired": "{{provider}} Bejelentkezés szükséges",
|
||||
"opksshAuthRequired": "OPKSSH hitelesítés szükséges",
|
||||
"opksshAuthDescription": "A folytatáshoz végezze el a hitelesítést a böngészőjében. Ez a munkamenet 24 órán át érvényes marad.",
|
||||
"opksshOpenBrowser": "Böngésző megnyitása a hitelesítéshez",
|
||||
"opksshWaitingForAuth": "Várakozás a böngésző hitelesítésére...",
|
||||
"opksshAuthenticating": "Hitelesítés feldolgozása...",
|
||||
"opksshSignInWith": "Bejelentkezés {{provider}} felhasználónévvel",
|
||||
"browserSignInCode": "Biztonsági kulcs",
|
||||
"browserSignInUrl": "Hitelesítési URL",
|
||||
"browserSignInOpen": "Megnyitás böngészőben",
|
||||
"browserSignInContinue": "Befejeztem a hitelesítést",
|
||||
"browserSignInRequired": "{{provider}} Bejelentkezés szükséges",
|
||||
"sudoPasswordPopupTitle": "Jelszó beszúrása?",
|
||||
"connectionLogTitle": "Kapcsolati napló",
|
||||
"connectionLogCopy": "Naplók másolása a vágólapra",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Lihat dokumen",
|
||||
"stepcaLabel": "Langkah CA",
|
||||
"stepcaDesc": "Masuk melalui penyedia identitas Anda dan biarkan Step CA Anda menerbitkan sertifikat SSH berumur pendek untuk host ini. URL CA, sidik jari root, dan penyedia OIDC diatur oleh administrator di bawah Pengaturan Admin.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Masuk ke host ini menggunakan penyedia identitas Anda, bukan kata sandi atau kunci. Membutuhkan OPKSSH yang terpasang di server.",
|
||||
"warpgateLabel": "Gerbang Warpgate",
|
||||
"warpgateDesc": "Host ini terhubung melalui proxy SSH Warpgate. Termix akan menangani alur persetujuan berbasis browser secara otomatis setelah otentikasi.",
|
||||
"agentLabel": "Agen SSH",
|
||||
"agentDesc": "Lakukan otentikasi menggunakan agen SSH yang berjalan di server Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agen tersebut harus berjalan di mesin tempat Termix dihosting.",
|
||||
"agentAuthenticationDesc": "Gunakan kunci dari agen SSH untuk mengautentikasi koneksi ini. Biarkan jalur soket kosong untuk menggunakan SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Masukkan jawaban Anda",
|
||||
"mfaWaitingApproval": "Menunggu persetujuan di perangkat Anda...",
|
||||
"mfaSendRequest": "Kirim Permintaan",
|
||||
"warpgateAuthRequired": "Autentikasi Warpgate Diperlukan",
|
||||
"warpgateSecurityKey": "Kunci Keamanan",
|
||||
"warpgateAuthUrl": "URL Otentikasi",
|
||||
"warpgateOpenBrowser": "Buka di Browser",
|
||||
"warpgateContinue": "Saya telah menyelesaikan otentikasi.",
|
||||
"certAuthRequired": "{{provider}} Login Diperlukan",
|
||||
"opksshAuthRequired": "Otentikasi OPKSSH Diperlukan",
|
||||
"opksshAuthDescription": "Lakukan otentikasi di browser Anda untuk melanjutkan. Sesi ini akan tetap berlaku selama 24 jam.",
|
||||
"opksshOpenBrowser": "Buka Browser untuk Melakukan Otentikasi",
|
||||
"opksshWaitingForAuth": "Menunggu otentikasi di browser...",
|
||||
"opksshAuthenticating": "Memproses otentikasi...",
|
||||
"opksshSignInWith": "Masuk dengan {{provider}}",
|
||||
"browserSignInCode": "Kunci Keamanan",
|
||||
"browserSignInUrl": "URL Otentikasi",
|
||||
"browserSignInOpen": "Buka di Browser",
|
||||
"browserSignInContinue": "Saya telah menyelesaikan otentikasi.",
|
||||
"browserSignInRequired": "{{provider}} Login Diperlukan",
|
||||
"sudoPasswordPopupTitle": "Masukkan kata sandi?",
|
||||
"connectionLogTitle": "Log Koneksi",
|
||||
"connectionLogCopy": "Salin log ke papan klip",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Visualizza documentazione",
|
||||
"stepcaLabel": "Passo CA",
|
||||
"stepcaDesc": "Accedi tramite il tuo provider di identità e lascia che la tua CA Step emetta un certificato SSH temporaneo per questo host. L'URL della CA, l'impronta digitale della radice e il provisioner OIDC vengono impostati da un amministratore nelle Impostazioni di amministrazione.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Accedi a questo host usando il tuo provider di identità invece di una password o chiave. Richiede OPKSSH configurato sul server.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "Questo host si connette tramite un proxy SSH Warpgate. Termix gestirà automaticamente il flusso di approvazione basato su browser dopo l'autenticazione.",
|
||||
"agentLabel": "Agente SSH",
|
||||
"agentDesc": "Autenticati usando un agente SSH in esecuzione sul server Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). L'agente deve essere in esecuzione sulla macchina dove è ospitato Termix.",
|
||||
"agentAuthenticationDesc": "Utilizza le chiavi di un agente SSH per autenticare questa connessione. Lascia vuoto il percorso del socket per utilizzare SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Inserisci la tua risposta",
|
||||
"mfaWaitingApproval": "In attesa di approvazione sul tuo dispositivo...",
|
||||
"mfaSendRequest": "Invia richiesta",
|
||||
"warpgateAuthRequired": "Autenticazione Warpgate richiesta",
|
||||
"warpgateSecurityKey": "Chiave di sicurezza",
|
||||
"warpgateAuthUrl": "URL di autenticazione",
|
||||
"warpgateOpenBrowser": "Apri nel browser",
|
||||
"warpgateContinue": "Ho completato l'autenticazione",
|
||||
"certAuthRequired": "{{provider}} Accesso richiesto",
|
||||
"opksshAuthRequired": "Autenticazione OPKSSH richiesta",
|
||||
"opksshAuthDescription": "Completa l'autenticazione nel browser per continuare. Questa sessione rimarrà valida per 24 ore.",
|
||||
"opksshOpenBrowser": "Apri il browser per autenticarti",
|
||||
"opksshWaitingForAuth": "In attesa dell'autenticazione nel browser...",
|
||||
"opksshAuthenticating": "Autenticazione in corso...",
|
||||
"opksshSignInWith": "Accedi con {{provider}}",
|
||||
"browserSignInCode": "Chiave di sicurezza",
|
||||
"browserSignInUrl": "URL di autenticazione",
|
||||
"browserSignInOpen": "Apri nel browser",
|
||||
"browserSignInContinue": "Ho completato l'autenticazione",
|
||||
"browserSignInRequired": "{{provider}} Accesso richiesto",
|
||||
"sudoPasswordPopupTitle": "Inserisci password?",
|
||||
"connectionLogTitle": "Log di connessione",
|
||||
"connectionLogCopy": "Copia i log negli appunti",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "ドキュメントを表示",
|
||||
"stepcaLabel": "ステップCA",
|
||||
"stepcaDesc": "IDプロバイダー経由でサインインし、Step CAにこのホスト用の有効期限の短いSSH証明書を発行させてください。CAのURL、ルートフィンガープリント、およびOIDCプロビジョナーは、管理者が「管理設定」で設定します。",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "パスワードや鍵の代わりに、IDプロバイダーを使用してこのホストにサインインします。サーバーにOPKSSHのセットアップが必要です。",
|
||||
"warpgateLabel": "Warpgateゲートウェイ",
|
||||
"warpgateDesc": "このホストはWarpgate SSHプロキシ経由で接続します。認証後、Termixがブラウザベースの承認フローを自動的に処理します。",
|
||||
"agentLabel": "SSHエージェント",
|
||||
"agentDesc": "Termixサーバー上で動作しているSSHエージェント(Bitwarden、1Password、gpg-agent、KeeAgent、ssh-agent)を使用して認証します。エージェントはTermixがホストされているマシン上で実行されている必要があります。",
|
||||
"agentAuthenticationDesc": "この接続を認証するには、SSHエージェントのキーを使用します。SSH_AUTH_SOCKを使用する場合は、ソケットパスを空欄にしてください。",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "回答を入力してください",
|
||||
"mfaWaitingApproval": "お使いのデバイスでの承認をお待ちしています...",
|
||||
"mfaSendRequest": "リクエストを送信",
|
||||
"warpgateAuthRequired": "Warpgate認証が必要です",
|
||||
"warpgateSecurityKey": "セキュリティキー",
|
||||
"warpgateAuthUrl": "認証URL",
|
||||
"warpgateOpenBrowser": "ブラウザで開く",
|
||||
"warpgateContinue": "認証が完了しました",
|
||||
"certAuthRequired": "{{provider}} サインインが必要です",
|
||||
"opksshAuthRequired": "OPKSSH認証が必要です",
|
||||
"opksshAuthDescription": "ブラウザで認証を完了して続行してください。このセッションは24時間有効です。",
|
||||
"opksshOpenBrowser": "ブラウザを開いて認証",
|
||||
"opksshWaitingForAuth": "ブラウザでの認証を待機中...",
|
||||
"opksshAuthenticating": "認証を処理中...",
|
||||
"opksshSignInWith": "{{provider}} でサインイン",
|
||||
"browserSignInCode": "セキュリティキー",
|
||||
"browserSignInUrl": "認証URL",
|
||||
"browserSignInOpen": "ブラウザで開く",
|
||||
"browserSignInContinue": "認証が完了しました",
|
||||
"browserSignInRequired": "{{provider}} サインインが必要です",
|
||||
"sudoPasswordPopupTitle": "パスワードを入力しますか?",
|
||||
"connectionLogTitle": "接続ログ",
|
||||
"connectionLogCopy": "ログをクリップボードにコピー",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "문서 보기",
|
||||
"stepcaLabel": "스텝 CA",
|
||||
"stepcaDesc": "ID 공급자를 통해 로그인하고 Step CA에서 이 호스트에 대한 단기 SSH 인증서를 발급받으세요. CA URL, 루트 지문 및 OIDC 프로비저너는 관리자가 관리자 설정에서 설정합니다.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "비밀번호나 키 대신 ID 공급자로 이 호스트에 로그인합니다. 서버에 OPKSSH가 설정되어 있어야 합니다.",
|
||||
"warpgateLabel": "Warpgate 게이트웨이",
|
||||
"warpgateDesc": "이 호스트는 Warpgate SSH 프록시를 통해 연결됩니다. Termix는 인증 후 브라우저 기반 승인 과정을 자동으로 처리합니다.",
|
||||
"agentLabel": "SSH 에이전트",
|
||||
"agentDesc": "Termix 서버에서 실행 중인 SSH 에이전트(Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent)를 사용하여 인증합니다. 에이전트는 Termix가 호스팅된 시스템에서 실행 중이어야 합니다.",
|
||||
"agentAuthenticationDesc": "SSH 에이전트의 키를 사용하여 이 연결을 인증하십시오. SSH_AUTH_SOCK을 사용하려면 소켓 경로를 비워 두십시오.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "답변을 입력하세요",
|
||||
"mfaWaitingApproval": "기기 승인을 기다리는 중입니다...",
|
||||
"mfaSendRequest": "요청 보내기",
|
||||
"warpgateAuthRequired": "Warpgate 인증 필요",
|
||||
"warpgateSecurityKey": "보안 키",
|
||||
"warpgateAuthUrl": "인증 URL",
|
||||
"warpgateOpenBrowser": "브라우저에서 열기",
|
||||
"warpgateContinue": "인증 완료",
|
||||
"certAuthRequired": "{{provider}} 로그인 필요",
|
||||
"opksshAuthRequired": "OPKSSH 인증 필요",
|
||||
"opksshAuthDescription": "브라우저에서 인증을 완료하여 계속 진행하세요. 이 세션은 24시간 동안 유지됩니다.",
|
||||
"opksshOpenBrowser": "브라우저로 인증하기",
|
||||
"opksshWaitingForAuth": "브라우저에서 인증 대기 중...",
|
||||
"opksshAuthenticating": "인증 처리 중...",
|
||||
"opksshSignInWith": "{{provider}}로 로그인",
|
||||
"browserSignInCode": "보안 키",
|
||||
"browserSignInUrl": "인증 URL",
|
||||
"browserSignInOpen": "브라우저에서 열기",
|
||||
"browserSignInContinue": "인증 완료",
|
||||
"browserSignInRequired": "{{provider}} 로그인 필요",
|
||||
"sudoPasswordPopupTitle": "비밀번호 입력",
|
||||
"connectionLogTitle": "연결 로그",
|
||||
"connectionLogCopy": "로그를 클립보드에 복사",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Documenten bekijken",
|
||||
"stepcaLabel": "Stap CA",
|
||||
"stepcaDesc": "Meld u aan via uw identiteitsprovider en laat uw Step CA een kortstondig SSH-certificaat voor deze host uitgeven. De CA-URL, root-fingerprint en OIDC-provisioner worden door een beheerder ingesteld onder Beheerinstellingen.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Meld u aan bij deze host met uw identiteitsprovider in plaats van een wachtwoord of sleutel. Hiervoor moet OPKSSH op de server geconfigureerd zijn.",
|
||||
"warpgateLabel": "Warpgate-poort",
|
||||
"warpgateDesc": "Deze host maakt verbinding via een Warpgate SSH-proxy. Termix handelt de browsergebaseerde goedkeuringsprocedure automatisch af na authenticatie.",
|
||||
"agentLabel": "SSH-agent",
|
||||
"agentDesc": "Authenticeer met behulp van een SSH-agent die op de Termix-server draait (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). De agent moet draaien op de machine waarop Termix is gehost.",
|
||||
"agentAuthenticationDesc": "Gebruik sleutels van een SSH-agent om deze verbinding te authenticeren. Laat het socketpad leeg om SSH_AUTH_SOCK te gebruiken.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Voer uw antwoord in",
|
||||
"mfaWaitingApproval": "Wachten op goedkeuring op uw apparaat...",
|
||||
"mfaSendRequest": "Verzoek verzenden",
|
||||
"warpgateAuthRequired": "Authenticatie via Warpgate vereist",
|
||||
"warpgateSecurityKey": "Beveiligingssleutel",
|
||||
"warpgateAuthUrl": "Authenticatie-URL",
|
||||
"warpgateOpenBrowser": "Openen in browser",
|
||||
"warpgateContinue": "Ik heb de authenticatie voltooid.",
|
||||
"certAuthRequired": "{{provider}} Aanmelden vereist",
|
||||
"opksshAuthRequired": "OPKSSH-authenticatie vereist",
|
||||
"opksshAuthDescription": "Voltooi de authenticatie in uw browser om verder te gaan. Deze sessie blijft 24 uur geldig.",
|
||||
"opksshOpenBrowser": "Open de browser om te authenticeren.",
|
||||
"opksshWaitingForAuth": "Wachten op authenticatie in de browser...",
|
||||
"opksshAuthenticating": "Authenticatie wordt verwerkt...",
|
||||
"opksshSignInWith": "Aanmelden met {{provider}}",
|
||||
"browserSignInCode": "Beveiligingssleutel",
|
||||
"browserSignInUrl": "Authenticatie-URL",
|
||||
"browserSignInOpen": "Openen in browser",
|
||||
"browserSignInContinue": "Ik heb de authenticatie voltooid.",
|
||||
"browserSignInRequired": "{{provider}} Aanmelden vereist",
|
||||
"sudoPasswordPopupTitle": "Wachtwoord invoeren?",
|
||||
"connectionLogTitle": "Verbindingslogboek",
|
||||
"connectionLogCopy": "Kopieer logbestanden naar het klembord.",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Vis dokumenter",
|
||||
"stepcaLabel": "Trinn CA",
|
||||
"stepcaDesc": "Logg på via identitetsleverandøren din, og la Step CA utstede et kortvarig SSH-sertifikat for denne verten. CA-URL-en, rotfingeravtrykket og OIDC-provisioneren angis av en administrator under Administratorinnstillinger.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Logg på denne verten med identitetsleverandøren din i stedet for et passord eller en nøkkel. Krever at OPKSSH er konfigurert på serveren.",
|
||||
"warpgateLabel": "Warpgate-porten",
|
||||
"warpgateDesc": "Denne verten kobler seg til via en Warpgate SSH-proxy. Termix håndterer den nettleserbaserte godkjenningsflyten automatisk etter autentisering.",
|
||||
"agentLabel": "SSH-agent",
|
||||
"agentDesc": "Autentiser ved hjelp av en SSH-agent som kjører på Termix-serveren (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agenten må kjøre på maskinen der Termix er driftet.",
|
||||
"agentAuthenticationDesc": "Bruk nøkler fra en SSH-agent for å autentisere denne tilkoblingen. La socket-banen stå tom for å bruke SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Skriv inn svaret ditt",
|
||||
"mfaWaitingApproval": "Venter på godkjenning på enheten din ...",
|
||||
"mfaSendRequest": "Send forespørsel",
|
||||
"warpgateAuthRequired": "Warpgate-autentisering kreves",
|
||||
"warpgateSecurityKey": "Sikkerhetsnøkkel",
|
||||
"warpgateAuthUrl": "Autentiserings-URL",
|
||||
"warpgateOpenBrowser": "Åpne i nettleser",
|
||||
"warpgateContinue": "Jeg har fullført autentiseringen",
|
||||
"certAuthRequired": "{{provider}} Pålogging kreves",
|
||||
"opksshAuthRequired": "OPKSSH-autentisering kreves",
|
||||
"opksshAuthDescription": "Fullfør autentiseringen i nettleseren din for å fortsette. Denne økten vil være gyldig i 24 timer.",
|
||||
"opksshOpenBrowser": "Åpne nettleseren for å autentisere",
|
||||
"opksshWaitingForAuth": "Venter på autentisering i nettleseren...",
|
||||
"opksshAuthenticating": "Behandler autentisering ...",
|
||||
"opksshSignInWith": "Logg inn med {{provider}}",
|
||||
"browserSignInCode": "Sikkerhetsnøkkel",
|
||||
"browserSignInUrl": "Autentiserings-URL",
|
||||
"browserSignInOpen": "Åpne i nettleser",
|
||||
"browserSignInContinue": "Jeg har fullført autentiseringen",
|
||||
"browserSignInRequired": "{{provider}} Pålogging kreves",
|
||||
"sudoPasswordPopupTitle": "Sette inn passord?",
|
||||
"connectionLogTitle": "Tilkoblingslogg",
|
||||
"connectionLogCopy": "Kopier logger til utklippstavlen",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Zobacz dokumentację",
|
||||
"stepcaLabel": "Krok CA",
|
||||
"stepcaDesc": "Zaloguj się za pomocą swojego dostawcy tożsamości i pozwól, aby urząd certyfikacji Step CA wydał tymczasowy certyfikat SSH dla tego hosta. Adres URL urzędu certyfikacji, odcisk palca głównego i dostawca OIDC są ustawiane przez administratora w Ustawieniach administratora.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Zaloguj się do tego hosta, używając swojego dostawcy tożsamości, a nie hasła lub klucza. Wymagane jest skonfigurowanie OPKSSH na serwerze.",
|
||||
"warpgateLabel": "Brama Warpgate",
|
||||
"warpgateDesc": "Ten host łączy się przez proxy SSH Warpgate. Termix automatycznie obsłuży proces zatwierdzania w przeglądarce po uwierzytelnieniu.",
|
||||
"agentLabel": "Agent SSH",
|
||||
"agentDesc": "Uwierzytelnij się za pomocą agenta SSH uruchomionego na serwerze Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agent musi być uruchomiony na komputerze, na którym hostowany jest Termix.",
|
||||
"agentAuthenticationDesc": "Użyj kluczy agenta SSH do uwierzytelnienia tego połączenia. Pozostaw ścieżkę do gniazda pustą, aby użyć SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Wprowadź odpowiedź",
|
||||
"mfaWaitingApproval": "Oczekiwanie na zatwierdzenie na Twoim urządzeniu...",
|
||||
"mfaSendRequest": "Wyślij prośbę",
|
||||
"warpgateAuthRequired": "Wymagane uwierzytelnienie Warpgate",
|
||||
"warpgateSecurityKey": "Klucz bezpieczeństwa",
|
||||
"warpgateAuthUrl": "Adres URL uwierzytelniania",
|
||||
"warpgateOpenBrowser": "Otwórz w przeglądarce",
|
||||
"warpgateContinue": "Ukończyłem uwierzytelnianie",
|
||||
"certAuthRequired": "{{provider}} Wymagane zalogowanie",
|
||||
"opksshAuthRequired": "Wymagane uwierzytelnienie OPKSSH",
|
||||
"opksshAuthDescription": "Aby kontynuować, dokończ uwierzytelnianie w przeglądarce. Ta sesja będzie ważna przez 24 godziny.",
|
||||
"opksshOpenBrowser": "Otwórz przeglądarkę, aby uwierzytelnić",
|
||||
"opksshWaitingForAuth": "Oczekiwanie na uwierzytelnienie w przeglądarce...",
|
||||
"opksshAuthenticating": "Przetwarzanie uwierzytelniania...",
|
||||
"opksshSignInWith": "Zaloguj się za pomocą {{provider}}",
|
||||
"browserSignInCode": "Klucz bezpieczeństwa",
|
||||
"browserSignInUrl": "Adres URL uwierzytelniania",
|
||||
"browserSignInOpen": "Otwórz w przeglądarce",
|
||||
"browserSignInContinue": "Ukończyłem uwierzytelnianie",
|
||||
"browserSignInRequired": "{{provider}} Wymagane zalogowanie",
|
||||
"sudoPasswordPopupTitle": "Wprowadzić hasło?",
|
||||
"connectionLogTitle": "Log połączenia",
|
||||
"connectionLogCopy": "Kopiuj logi do schowka",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Ver documentos",
|
||||
"stepcaLabel": "Etapa CA",
|
||||
"stepcaDesc": "Faça login através do seu provedor de identidade e permita que a sua Autoridade Certificadora (CA) Step emita um certificado SSH de curta duração para este host. O URL da CA, a impressão digital da raiz e o provisionador OIDC são definidos por um administrador em Configurações de Administrador.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Faça login neste host usando seu provedor de identidade em vez de uma senha ou chave. Requer a configuração do OPKSSH no servidor.",
|
||||
"warpgateLabel": "Portal de Dobra",
|
||||
"warpgateDesc": "Este host se conecta através de um proxy SSH Warpgate. O Termix cuidará automaticamente do fluxo de aprovação baseado em navegador após a autenticação.",
|
||||
"agentLabel": "Agente SSH",
|
||||
"agentDesc": "Autentique-se usando um agente SSH em execução no servidor Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). O agente deve estar em execução na máquina onde o Termix está hospedado.",
|
||||
"agentAuthenticationDesc": "Use as chaves de um agente SSH para autenticar esta conexão. Deixe o caminho do socket vazio para usar SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Insira sua resposta",
|
||||
"mfaWaitingApproval": "Aguardando aprovação no seu dispositivo...",
|
||||
"mfaSendRequest": "Enviar solicitação",
|
||||
"warpgateAuthRequired": "Autenticação Warpgate necessária",
|
||||
"warpgateSecurityKey": "Chave de segurança",
|
||||
"warpgateAuthUrl": "URL de autenticação",
|
||||
"warpgateOpenBrowser": "Abrir no navegador",
|
||||
"warpgateContinue": "Concluí a autenticação.",
|
||||
"certAuthRequired": "{{provider}} É necessário fazer login",
|
||||
"opksshAuthRequired": "Autenticação OPKSSH necessária",
|
||||
"opksshAuthDescription": "Para continuar, complete a autenticação no seu navegador. Esta sessão permanecerá válida por 24 horas.",
|
||||
"opksshOpenBrowser": "Abra o navegador para autenticar.",
|
||||
"opksshWaitingForAuth": "Aguardando autenticação no navegador...",
|
||||
"opksshAuthenticating": "Processando autenticação...",
|
||||
"opksshSignInWith": "Faça login com {{provider}}",
|
||||
"browserSignInCode": "Chave de segurança",
|
||||
"browserSignInUrl": "URL de autenticação",
|
||||
"browserSignInOpen": "Abrir no navegador",
|
||||
"browserSignInContinue": "Concluí a autenticação.",
|
||||
"browserSignInRequired": "{{provider}} É necessário fazer login",
|
||||
"sudoPasswordPopupTitle": "Digite a senha?",
|
||||
"connectionLogTitle": "Registro de conexão",
|
||||
"connectionLogCopy": "Copiar registros para a área de transferência",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Ver documentação",
|
||||
"stepcaLabel": "Etapa CA",
|
||||
"stepcaDesc": "Faça login através do seu fornecedor de identidade e permita que a sua Autoridade Certificadora (CA) Step emita um certificado SSH de curta duração para este host. O URL da AC, a impressão digital da raiz e o provisionador OIDC são definidos por um administrador em Definições de Administrador.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Inicie sessão neste anfitrião usando o seu fornecedor de identidade em vez de uma palavra-passe ou chave. Requer que o OPKSSH esteja configurado no servidor.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "Este anfitrião liga-se através de um proxy SSH Warpgate. O Termix irá gerir automaticamente o fluxo de aprovação baseado no navegador após a autenticação.",
|
||||
"agentLabel": "Agente SSH",
|
||||
"agentDesc": "Autentique-se usando um agente SSH em execução no servidor Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). O agente deve estar em execução na máquina onde o Termix está alojado.",
|
||||
"agentAuthenticationDesc": "Utilize as chaves de um agente SSH para autenticar esta ligação. Deixe o caminho do socket vazio para utilizar SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Insira a sua resposta",
|
||||
"mfaWaitingApproval": "Aguarda aprovação no seu dispositivo...",
|
||||
"mfaSendRequest": "Enviar solicitação",
|
||||
"warpgateAuthRequired": "Autenticação Warpgate necessária",
|
||||
"warpgateSecurityKey": "Chave de segurança",
|
||||
"warpgateAuthUrl": "URL de autenticação",
|
||||
"warpgateOpenBrowser": "Abrir no navegador",
|
||||
"warpgateContinue": "Concluí a autenticação",
|
||||
"certAuthRequired": "{{provider}} É necessário fazer login",
|
||||
"opksshAuthRequired": "Autenticação OPKSSH necessária",
|
||||
"opksshAuthDescription": "Conclua a autenticação no seu navegador para continuar. Esta sessão permanecerá válida por 24 horas.",
|
||||
"opksshOpenBrowser": "Abrir navegador para autenticar",
|
||||
"opksshWaitingForAuth": "A aguardar autenticação no navegador...",
|
||||
"opksshAuthenticating": "A processar autenticação...",
|
||||
"opksshSignInWith": "Iniciar sessão com {{provider}}",
|
||||
"browserSignInCode": "Chave de segurança",
|
||||
"browserSignInUrl": "URL de autenticação",
|
||||
"browserSignInOpen": "Abrir no navegador",
|
||||
"browserSignInContinue": "Concluí a autenticação",
|
||||
"browserSignInRequired": "{{provider}} É necessário fazer login",
|
||||
"sudoPasswordPopupTitle": "Inserir Password?",
|
||||
"connectionLogTitle": "Registo da Ligação",
|
||||
"connectionLogCopy": "Copiar registos para a área de transferência",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Vizualizați documentele",
|
||||
"stepcaLabel": "Pasul CA",
|
||||
"stepcaDesc": "Conectați-vă prin intermediul furnizorului de identitate și permiteți autorității de certificare Step CA să emită un certificat SSH de scurtă durată pentru această gazdă. Adresa URL a autorității de certificare, amprenta rădăcină și furnizorul OIDC sunt setate de un administrator în Setări administrator.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Conectați-vă la această gazdă folosind furnizorul de identitate în loc de o parolă sau o cheie. Necesită configurarea OPKSSH pe server.",
|
||||
"warpgateLabel": "Poarta Warpgate",
|
||||
"warpgateDesc": "Această gazdă se conectează printr-un proxy SSH Warpgate. Termix va gestiona automat fluxul de aprobare bazat pe browser după autentificare.",
|
||||
"agentLabel": "Agent SSH",
|
||||
"agentDesc": "Autentificați-vă folosind un agent SSH care rulează pe serverul Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agentul trebuie să ruleze pe mașina pe care este găzduit Termix.",
|
||||
"agentAuthenticationDesc": "Folosește chei de la un agent SSH pentru a autentifica această conexiune. Lasă calea socketului goală pentru a utiliza SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Introduceți răspunsul dvs.",
|
||||
"mfaWaitingApproval": "Se așteaptă aprobarea pe dispozitivul dvs....",
|
||||
"mfaSendRequest": "Trimiteți cererea",
|
||||
"warpgateAuthRequired": "Autentificare Warpgate necesară",
|
||||
"warpgateSecurityKey": "Cheie de securitate",
|
||||
"warpgateAuthUrl": "URL de autentificare",
|
||||
"warpgateOpenBrowser": "Deschide în browser",
|
||||
"warpgateContinue": "Am finalizat autentificarea",
|
||||
"certAuthRequired": "{{provider}} Este necesară conectarea",
|
||||
"opksshAuthRequired": "Autentificare OPKSSH necesară",
|
||||
"opksshAuthDescription": "Finalizați autentificarea în browser pentru a continua. Această sesiune va rămâne valabilă timp de 24 de ore.",
|
||||
"opksshOpenBrowser": "Deschideți browserul pentru autentificare",
|
||||
"opksshWaitingForAuth": "Se așteaptă autentificarea în browser...",
|
||||
"opksshAuthenticating": "Se procesează autentificarea...",
|
||||
"opksshSignInWith": "Conectați-vă cu {{provider}}",
|
||||
"browserSignInCode": "Cheie de securitate",
|
||||
"browserSignInUrl": "URL de autentificare",
|
||||
"browserSignInOpen": "Deschide în browser",
|
||||
"browserSignInContinue": "Am finalizat autentificarea",
|
||||
"browserSignInRequired": "{{provider}} Este necesară conectarea",
|
||||
"sudoPasswordPopupTitle": "Introduceți parola?",
|
||||
"connectionLogTitle": "Jurnal de conectare",
|
||||
"connectionLogCopy": "Copiați jurnalele în clipboard",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Документация",
|
||||
"stepcaLabel": "Шаг CA",
|
||||
"stepcaDesc": "Войдите в систему через своего поставщика идентификации и позвольте вашему центру сертификации Step CA выдать краткосрочный SSH-сертификат для этого хоста. URL-адрес центра сертификации, отпечаток корневого сертификата и средство подготовки OIDC задаются администратором в разделе «Настройки администратора».",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Входите на этот хост через своего провайдера идентификации вместо пароля или ключа. Требуется настроенный OPKSSH на сервере.",
|
||||
"warpgateLabel": "Шлюз Warpgate",
|
||||
"warpgateDesc": "Этот хост подключается через SSH-прокси Warpgate. Termix автоматически обработает процесс подтверждения в браузере после аутентификации.",
|
||||
"agentLabel": "SSH-агент",
|
||||
"agentDesc": "Аутентификация с помощью SSH-агента, запущенного на сервере Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Агент должен работать на машине, где размещён Termix.",
|
||||
"agentAuthenticationDesc": "Для аутентификации этого соединения используйте ключи от SSH-агента. Оставьте поле пути к сокету пустым, чтобы использовать SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Введите свой ответ",
|
||||
"mfaWaitingApproval": "Ожидание подтверждения на вашем устройстве...",
|
||||
"mfaSendRequest": "Отправить запрос",
|
||||
"warpgateAuthRequired": "Требуется аутентификация Warpgate",
|
||||
"warpgateSecurityKey": "Ключ безопасности",
|
||||
"warpgateAuthUrl": "URL аутентификации",
|
||||
"warpgateOpenBrowser": "Открыть в браузере",
|
||||
"warpgateContinue": "Аутентификация завершена",
|
||||
"certAuthRequired": "{{provider}} Требуется вход в систему",
|
||||
"opksshAuthRequired": "Требуется аутентификация OPKSSH",
|
||||
"opksshAuthDescription": "Завершите аутентификацию в браузере, чтобы продолжить. Сеанс будет действителен в течение 24 часов.",
|
||||
"opksshOpenBrowser": "Открыть браузер для аутентификации",
|
||||
"opksshWaitingForAuth": "Ожидание аутентификации в браузере...",
|
||||
"opksshAuthenticating": "Обработка аутентификации...",
|
||||
"opksshSignInWith": "Войти через {{provider}}",
|
||||
"browserSignInCode": "Ключ безопасности",
|
||||
"browserSignInUrl": "URL аутентификации",
|
||||
"browserSignInOpen": "Открыть в браузере",
|
||||
"browserSignInContinue": "Аутентификация завершена",
|
||||
"browserSignInRequired": "{{provider}} Требуется вход в систему",
|
||||
"sudoPasswordPopupTitle": "Вставить пароль?",
|
||||
"connectionLogTitle": "Журнал подключения",
|
||||
"connectionLogCopy": "Копировать журналы в буфер обмена",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Прикажи документе",
|
||||
"stepcaLabel": "Корак CA",
|
||||
"stepcaDesc": "Пријавите се преко свог добављача идентитета и дозволите свом Step CA да изда краткотрајни SSH сертификат за овај хост. CA URL, root отисак прста и OIDC provisioner подешава администратор у подешавањима администратора.",
|
||||
"opksshLabel": "ОПКШ",
|
||||
"opksshDesc": "Пријавите се на овај хост користећи свог добављача идентитета уместо лозинке или кључа. Потребно је подешавање OPKSSH-а на серверу.",
|
||||
"warpgateLabel": "Капија Варпгејта",
|
||||
"warpgateDesc": "Овај хост се повезује преко Warpgate SSH проксија. Termix ће аутоматски обрадити ток одобрења заснован на прегледачу након аутентификације.",
|
||||
"agentLabel": "SSH агент",
|
||||
"agentDesc": "Аутентификујте се помоћу SSH агента који ради на Termix серверу (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Агент мора бити покренут на машини на којој се налази Termix.",
|
||||
"agentAuthenticationDesc": "Користите кључеве од SSH агента за аутентификацију ове везе. Оставите путању сокета празну да бисте користили SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Унесите свој одговор",
|
||||
"mfaWaitingApproval": "Чекање одобрења на вашем уређају...",
|
||||
"mfaSendRequest": "Пошаљи захтев",
|
||||
"warpgateAuthRequired": "Потребна је аутентификација Варпгејта",
|
||||
"warpgateSecurityKey": "Безбедносни кључ",
|
||||
"warpgateAuthUrl": "URL адреса за аутентификацију",
|
||||
"warpgateOpenBrowser": "Отвори у прегледачу",
|
||||
"warpgateContinue": "Завршио/ла сам аутентификацију",
|
||||
"certAuthRequired": "{{provider}} Потребна је пријава",
|
||||
"opksshAuthRequired": "Потребна је аутентификација OPKSSH",
|
||||
"opksshAuthDescription": "Завршите аутентификацију у прегледачу да бисте наставили. Ова сесија ће остати важећа 24 сата.",
|
||||
"opksshOpenBrowser": "Отворите прегледач за аутентификацију",
|
||||
"opksshWaitingForAuth": "Чекање аутентификације у прегледачу...",
|
||||
"opksshAuthenticating": "Обрада аутентификације...",
|
||||
"opksshSignInWith": "Пријавите се са {{provider}}",
|
||||
"browserSignInCode": "Безбедносни кључ",
|
||||
"browserSignInUrl": "URL адреса за аутентификацију",
|
||||
"browserSignInOpen": "Отвори у прегледачу",
|
||||
"browserSignInContinue": "Завршио/ла сам аутентификацију",
|
||||
"browserSignInRequired": "{{provider}} Потребна је пријава",
|
||||
"sudoPasswordPopupTitle": "Унети лозинку?",
|
||||
"connectionLogTitle": "Дневник везе",
|
||||
"connectionLogCopy": "Копирајте логове у међуспремник",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Visa dokument",
|
||||
"stepcaLabel": "Steg CA",
|
||||
"stepcaDesc": "Logga in via din identitetsleverantör och låt din Step CA utfärda ett kortlivat SSH-certifikat för den här värden. CA-URL:en, rotfingeravtrycket och OIDC-provisionern ställs in av en administratör under administratörsinställningar.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Logga in på den här värden med din identitetsleverantör istället för ett lösenord eller en nyckel. Kräver att OPKSSH är konfigurerat på servern.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "Denna värd ansluter via en Warpgate SSH-proxy. Termix hanterar det webbläsarbaserade godkännandeflödet automatiskt efter autentisering.",
|
||||
"agentLabel": "SSH-agent",
|
||||
"agentDesc": "Autentisera med en SSH-agent som körs på Termix-servern (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agenten måste köras på den maskin där Termix finns.",
|
||||
"agentAuthenticationDesc": "Använd nycklar från en SSH-agent för att autentisera den här anslutningen. Lämna socket-sökvägen tom om du vill använda SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Ange ditt svar",
|
||||
"mfaWaitingApproval": "Väntar på godkännande på din enhet...",
|
||||
"mfaSendRequest": "Skicka förfrågan",
|
||||
"warpgateAuthRequired": "Warpgate-autentisering krävs",
|
||||
"warpgateSecurityKey": "Säkerhetsnyckel",
|
||||
"warpgateAuthUrl": "Autentiserings-URL",
|
||||
"warpgateOpenBrowser": "Öppna i webbläsaren",
|
||||
"warpgateContinue": "Jag har slutfört autentiseringen",
|
||||
"certAuthRequired": "{{provider}} Inloggning krävs",
|
||||
"opksshAuthRequired": "OPKSSH-autentisering krävs",
|
||||
"opksshAuthDescription": "Slutför autentiseringen i din webbläsare för att fortsätta. Denna session kommer att vara giltig i 24 timmar.",
|
||||
"opksshOpenBrowser": "Öppna webbläsaren för att autentisera",
|
||||
"opksshWaitingForAuth": "Väntar på autentisering i webbläsaren...",
|
||||
"opksshAuthenticating": "Bearbetar autentisering...",
|
||||
"opksshSignInWith": "Logga in med {{provider}}",
|
||||
"browserSignInCode": "Säkerhetsnyckel",
|
||||
"browserSignInUrl": "Autentiserings-URL",
|
||||
"browserSignInOpen": "Öppna i webbläsaren",
|
||||
"browserSignInContinue": "Jag har slutfört autentiseringen",
|
||||
"browserSignInRequired": "{{provider}} Inloggning krävs",
|
||||
"sudoPasswordPopupTitle": "Ange lösenord?",
|
||||
"connectionLogTitle": "Anslutningslogg",
|
||||
"connectionLogCopy": "Kopiera loggar till urklipp",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "ดูเอกสาร",
|
||||
"stepcaLabel": "ขั้นตอน CA",
|
||||
"stepcaDesc": "ลงชื่อเข้าใช้ผ่านผู้ให้บริการยืนยันตัวตนของคุณ และให้ Step CA ออกใบรับรอง SSH ที่มีอายุสั้นสำหรับโฮสต์นี้ URL ของ CA, ลายนิ้วมือรูท และตัวจัดเตรียม OIDC จะถูกตั้งค่าโดยผู้ดูแลระบบภายใต้การตั้งค่าผู้ดูแลระบบ",
|
||||
"opksshLabel": "โอพีเคเอสเอช",
|
||||
"opksshDesc": "เข้าสู่ระบบโฮสต์นี้โดยใช้ผู้ให้บริการยืนยันตัวตนของคุณแทนรหัสผ่านหรือคีย์ จำเป็นต้องตั้งค่า OPKSSH บนเซิร์ฟเวอร์ก่อน",
|
||||
"warpgateLabel": "ประตูวาร์ปเกต",
|
||||
"warpgateDesc": "โฮสต์นี้เชื่อมต่อผ่านพร็อกซี SSH ของ Warpgate Termix จะจัดการขั้นตอนการอนุมัติผ่านเบราว์เซอร์โดยอัตโนมัติหลังจากยืนยันตัวตนแล้ว",
|
||||
"agentLabel": "เอเจนต์ SSH",
|
||||
"agentDesc": "ยืนยันตัวตนโดยใช้เอเจนต์ SSH ที่ทำงานอยู่บนเซิร์ฟเวอร์ Termix (เช่น Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent) เอเจนต์จะต้องทำงานอยู่บนเครื่องที่ติดตั้ง Termix อยู่",
|
||||
"agentAuthenticationDesc": "ใช้คีย์จากเอเจนต์ SSH เพื่อตรวจสอบสิทธิ์การเชื่อมต่อนี้ หากต้องการใช้ SSH_AUTH_SOCK ให้เว้นช่องเส้นทางซ็อกเก็ตว่างไว้",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "กรุณาป้อนคำตอบของคุณ",
|
||||
"mfaWaitingApproval": "กำลังรอการอนุมัติบนอุปกรณ์ของคุณ...",
|
||||
"mfaSendRequest": "ส่งคำขอ",
|
||||
"warpgateAuthRequired": "ต้องยืนยันตัวตนผ่านวาร์ปเกต",
|
||||
"warpgateSecurityKey": "รหัสความปลอดภัย",
|
||||
"warpgateAuthUrl": "URL สำหรับการตรวจสอบสิทธิ์",
|
||||
"warpgateOpenBrowser": "เปิดในเบราว์เซอร์",
|
||||
"warpgateContinue": "ฉันทำการยืนยันตัวตนเสร็จเรียบร้อยแล้ว",
|
||||
"certAuthRequired": "{{provider}} ต้องเข้าสู่ระบบ",
|
||||
"opksshAuthRequired": "ต้องใช้การตรวจสอบสิทธิ์ OPKSSH",
|
||||
"opksshAuthDescription": "โปรดยืนยันตัวตนในเบราว์เซอร์ของคุณเพื่อดำเนินการต่อ เซสชันนี้จะมีอายุใช้งาน 24 ชั่วโมง",
|
||||
"opksshOpenBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน",
|
||||
"opksshWaitingForAuth": "กำลังรอการยืนยันตัวตนในเบราว์เซอร์...",
|
||||
"opksshAuthenticating": "กำลังตรวจสอบสิทธิ์...",
|
||||
"opksshSignInWith": "ลงชื่อเข้าใช้ด้วย {{provider}}",
|
||||
"browserSignInCode": "รหัสความปลอดภัย",
|
||||
"browserSignInUrl": "URL สำหรับการตรวจสอบสิทธิ์",
|
||||
"browserSignInOpen": "เปิดในเบราว์เซอร์",
|
||||
"browserSignInContinue": "ฉันทำการยืนยันตัวตนเสร็จเรียบร้อยแล้ว",
|
||||
"browserSignInRequired": "{{provider}} ต้องเข้าสู่ระบบ",
|
||||
"sudoPasswordPopupTitle": "ใส่รหัสผ่าน?",
|
||||
"connectionLogTitle": "บันทึกการเชื่อมต่อ",
|
||||
"connectionLogCopy": "คัดลอกบันทึกไปยังคลิปบอร์ด",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Dokümanları görüntüle",
|
||||
"stepcaLabel": "Adım CA",
|
||||
"stepcaDesc": "Kimlik sağlayıcınız aracılığıyla oturum açın ve Step CA'nızın bu sunucu için kısa süreli bir SSH sertifikası düzenlemesine izin verin. CA URL'si, kök parmak izi ve OIDC sağlayıcısı, yönetici tarafından Yönetici Ayarları altında belirlenir.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Parola veya anahtar yerine kimlik sağlayıcınızı kullanarak bu sunucuda oturum açın. Sunucuda OPKSSH kurulumu gerektirir.",
|
||||
"warpgateLabel": "Warpgate Ağ Geçidi",
|
||||
"warpgateDesc": "Bu sunucu bir Warpgate SSH proxy'si üzerinden bağlanır. Termix, kimlik doğrulamasından sonra tarayıcı tabanlı onay akışını otomatik olarak yönetir.",
|
||||
"agentLabel": "SSH Ajanı",
|
||||
"agentDesc": "Termix sunucusunda çalışan bir SSH ajanı kullanarak kimlik doğrulayın (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Ajan, Termix'in barındırıldığı makinede çalışıyor olmalıdır.",
|
||||
"agentAuthenticationDesc": "Bu bağlantıyı doğrulamak için bir SSH aracısından gelen anahtarları kullanın. SSH_AUTH_SOCK kullanmak için soket yolunu boş bırakın.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Yanıtınızı girin",
|
||||
"mfaWaitingApproval": "Cihazınız için onay bekleniyor...",
|
||||
"mfaSendRequest": "İstek Gönder",
|
||||
"warpgateAuthRequired": "Warpgate Kimlik Doğrulaması Gerekli",
|
||||
"warpgateSecurityKey": "Güvenlik Anahtarı",
|
||||
"warpgateAuthUrl": "Kimlik Doğrulama URL'si",
|
||||
"warpgateOpenBrowser": "Tarayıcıda Aç",
|
||||
"warpgateContinue": "Kimlik Doğrulamayı Tamamladım",
|
||||
"certAuthRequired": "{{provider}} Giriş Yapmanız Gerekiyor",
|
||||
"opksshAuthRequired": "OPKSSH Kimlik Doğrulaması Gerekli",
|
||||
"opksshAuthDescription": "Devam etmek için tarayıcıda kimlik doğrulamasını tamamlayın. Bu oturum 24 saat geçerli olacak.",
|
||||
"opksshOpenBrowser": "Kimlik Doğrulaması için Tarayıcı Aç",
|
||||
"opksshWaitingForAuth": "Tarayıcıda kimlik doğrulaması bekleniyor...",
|
||||
"opksshAuthenticating": "Kimlik doğrulaması işleniyor...",
|
||||
"opksshSignInWith": "{{provider}} ile oturum aç",
|
||||
"browserSignInCode": "Güvenlik Anahtarı",
|
||||
"browserSignInUrl": "Kimlik Doğrulama URL'si",
|
||||
"browserSignInOpen": "Tarayıcıda Aç",
|
||||
"browserSignInContinue": "Kimlik Doğrulamayı Tamamladım",
|
||||
"browserSignInRequired": "{{provider}} Giriş Yapmanız Gerekiyor",
|
||||
"sudoPasswordPopupTitle": "Parola Girin?",
|
||||
"connectionLogTitle": "Bağlantı Günlüğü",
|
||||
"connectionLogCopy": "Günlükleri panoya kopyala",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Переглянути документи",
|
||||
"stepcaLabel": "Крок CA",
|
||||
"stepcaDesc": "Увійдіть через свого постачальника ідентифікаційних даних і дозвольте вашому центру сертифікації Step CA видати короткочасний SSH-сертифікат для цього хоста. URL-адресу центру сертифікації, кореневий відбиток і OIDC-провайдер встановлює адміністратор у розділі «Налаштування адміністратора».",
|
||||
"opksshLabel": "ОПКШ",
|
||||
"opksshDesc": "Увійдіть на цей хост, використовуючи свого постачальника ідентифікаційних даних замість пароля чи ключа. Потрібно налаштувати OPKSSH на сервері.",
|
||||
"warpgateLabel": "Брама Варпгейту",
|
||||
"warpgateDesc": "Цей хост підключається через проксі-сервер Warpgate SSH. Termix автоматично обробить процес затвердження на основі браузера після автентифікації.",
|
||||
"agentLabel": "SSH-агент",
|
||||
"agentDesc": "Автентифікуйтеся за допомогою SSH-агента, що працює на сервері Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Агент має бути запущений на машині, де розміщено Termix.",
|
||||
"agentAuthenticationDesc": "Використовуйте ключі від SSH-агента для автентифікації цього з'єднання. Залиште шлях сокета порожнім, щоб використовувати SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Введіть свою відповідь",
|
||||
"mfaWaitingApproval": "Очікування схвалення на вашому пристрої...",
|
||||
"mfaSendRequest": "Надіслати запит",
|
||||
"warpgateAuthRequired": "Потрібна автентифікація Warpgate",
|
||||
"warpgateSecurityKey": "Ключ безпеки",
|
||||
"warpgateAuthUrl": "URL-адреса автентифікації",
|
||||
"warpgateOpenBrowser": "Відкрити у браузері",
|
||||
"warpgateContinue": "Я завершив автентифікацію",
|
||||
"certAuthRequired": "{{provider}} Потрібен вхід",
|
||||
"opksshAuthRequired": "Потрібна автентифікація OPKSSH",
|
||||
"opksshAuthDescription": "Щоб продовжити, завершіть автентифікацію у вашому браузері. Цей сеанс залишатиметься дійсним протягом 24 годин.",
|
||||
"opksshOpenBrowser": "Відкрийте браузер для автентифікації",
|
||||
"opksshWaitingForAuth": "Очікування автентифікації у браузері...",
|
||||
"opksshAuthenticating": "Обробка автентифікації...",
|
||||
"opksshSignInWith": "Увійти за допомогою {{provider}}",
|
||||
"browserSignInCode": "Ключ безпеки",
|
||||
"browserSignInUrl": "URL-адреса автентифікації",
|
||||
"browserSignInOpen": "Відкрити у браузері",
|
||||
"browserSignInContinue": "Я завершив автентифікацію",
|
||||
"browserSignInRequired": "{{provider}} Потрібен вхід",
|
||||
"sudoPasswordPopupTitle": "Ввести пароль?",
|
||||
"connectionLogTitle": "Журнал підключення",
|
||||
"connectionLogCopy": "Копіювати журнали в буфер обміну",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "Xem tài liệu",
|
||||
"stepcaLabel": "Bước CA",
|
||||
"stepcaDesc": "Đăng nhập thông qua nhà cung cấp danh tính của bạn và cho phép Step CA cấp chứng chỉ SSH có thời hạn ngắn cho máy chủ này. URL CA, dấu vân tay gốc và trình cung cấp OIDC được quản trị viên thiết lập trong Cài đặt quản trị.",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Đăng nhập vào máy chủ này bằng nhà cung cấp danh tính thay vì mật khẩu hoặc khóa. Yêu cầu OPKSSH được thiết lập trên máy chủ.",
|
||||
"warpgateLabel": "Cổng Warpgate",
|
||||
"warpgateDesc": "Máy chủ này kết nối thông qua proxy SSH Warpgate. Termix sẽ tự động xử lý luồng phê duyệt trên trình duyệt sau khi xác thực.",
|
||||
"agentLabel": "SSH Agent",
|
||||
"agentDesc": "Xác thực bằng SSH agent chạy trên máy chủ Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agent phải đang chạy trên máy chạy Termix.",
|
||||
"agentAuthenticationDesc": "Sử dụng khóa từ SSH agent để xác thực kết nối này. Để trống đường dẫn socket để sử dụng SSH_AUTH_SOCK.",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "Nhập câu trả lời của bạn",
|
||||
"mfaWaitingApproval": "Đang chờ phê duyệt trên thiết bị của bạn...",
|
||||
"mfaSendRequest": "Gửi yêu cầu",
|
||||
"warpgateAuthRequired": "Yêu cầu xác thực Warpgate",
|
||||
"warpgateSecurityKey": "Khóa bảo mật",
|
||||
"warpgateAuthUrl": "URL xác thực",
|
||||
"warpgateOpenBrowser": "Mở trong trình duyệt",
|
||||
"warpgateContinue": "Tôi đã hoàn tất xác thực",
|
||||
"certAuthRequired": "{{provider}} Cần đăng nhập",
|
||||
"opksshAuthRequired": "Yêu cầu xác thực OPKSSH",
|
||||
"opksshAuthDescription": "Hoàn tất xác thực trên trình duyệt để tiếp tục. Phiên làm việc này sẽ có hiệu lực trong 24 giờ.",
|
||||
"opksshOpenBrowser": "Mở trình duyệt để xác thực",
|
||||
"opksshWaitingForAuth": "Đang chờ xác thực trên trình duyệt...",
|
||||
"opksshAuthenticating": "Đang xử lý xác thực...",
|
||||
"opksshSignInWith": "Đăng nhập bằng {{provider}}",
|
||||
"browserSignInCode": "Khóa bảo mật",
|
||||
"browserSignInUrl": "URL xác thực",
|
||||
"browserSignInOpen": "Mở trong trình duyệt",
|
||||
"browserSignInContinue": "Tôi đã hoàn tất xác thực",
|
||||
"browserSignInRequired": "{{provider}} Cần đăng nhập",
|
||||
"sudoPasswordPopupTitle": "Nhập mật khẩu?",
|
||||
"connectionLogTitle": "Nhật ký kết nối",
|
||||
"connectionLogCopy": "Sao chép nhật ký vào clipboard",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "查看文档",
|
||||
"stepcaLabel": "步骤 CA",
|
||||
"stepcaDesc": "通过您的身份提供商登录,并让您的 Step CA 为此主机颁发一个短期 SSH 证书。CA URL、根指纹和 OIDC 配置器由管理员在“管理设置”中设置。",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "使用身份提供商登录此主机,而非密码或密钥。需要在服务器上设置 OPKSSH。",
|
||||
"warpgateLabel": "Warpgate 网关",
|
||||
"warpgateDesc": "此主机通过 Warpgate SSH 代理连接。Termix 将在认证后自动处理基于浏览器的审批流程。",
|
||||
"agentLabel": "SSH 代理",
|
||||
"agentDesc": "使用运行在 Termix 服务器上的 SSH 代理进行认证(Bitwarden、1Password、gpg-agent、KeeAgent、ssh-agent)。代理必须在托管 Termix 的机器上运行。",
|
||||
"agentAuthenticationDesc": "使用 SSH 代理中的密钥来验证此连接。将套接字路径留空以使用 SSH_AUTH_SOCK。",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "请输入您的回复",
|
||||
"mfaWaitingApproval": "正在等待设备审核通过……",
|
||||
"mfaSendRequest": "发送请求",
|
||||
"warpgateAuthRequired": "需要 Warpgate 认证",
|
||||
"warpgateSecurityKey": "安全密钥",
|
||||
"warpgateAuthUrl": "认证 URL",
|
||||
"warpgateOpenBrowser": "在浏览器中打开",
|
||||
"warpgateContinue": "我已完成认证",
|
||||
"certAuthRequired": "{{provider}} 需要登录",
|
||||
"opksshAuthRequired": "需要 OPKSSH 认证",
|
||||
"opksshAuthDescription": "在浏览器中完成认证以继续。此会话将在 24 小时内有效。",
|
||||
"opksshOpenBrowser": "打开浏览器进行认证",
|
||||
"opksshWaitingForAuth": "等待浏览器认证...",
|
||||
"opksshAuthenticating": "正在处理认证...",
|
||||
"opksshSignInWith": "使用 {{provider}} 登录",
|
||||
"browserSignInCode": "安全密钥",
|
||||
"browserSignInUrl": "认证 URL",
|
||||
"browserSignInOpen": "在浏览器中打开",
|
||||
"browserSignInContinue": "我已完成认证",
|
||||
"browserSignInRequired": "{{provider}} 需要登录",
|
||||
"sudoPasswordPopupTitle": "输入密码?",
|
||||
"connectionLogTitle": "连接日志",
|
||||
"connectionLogCopy": "复制日志到剪贴板",
|
||||
|
||||
@@ -773,10 +773,6 @@
|
||||
"docsLink": "檢視文件",
|
||||
"stepcaLabel": "步驟 CA",
|
||||
"stepcaDesc": "透過您的身分提供者登錄,並讓您的 Step CA 為此主機頒發短期 SSH 憑證。 CA URL、根指紋和 OIDC 配置器由管理員在「管理設定」中設定。",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "使用您的識別提供者而非密碼或金鑰來登入此主機。需要在伺服器上設定 OPKSSH。",
|
||||
"warpgateLabel": "Warpgate 閘道",
|
||||
"warpgateDesc": "此主機透過 Warpgate SSH 代理連線。Termix 會在驗證後自動處理瀏覽器核准流程。",
|
||||
"agentLabel": "SSH 代理",
|
||||
"agentDesc": "使用在 Termix 伺服器上執行的 SSH 代理進行驗證(Bitwarden、1Password、gpg-agent、KeeAgent、ssh-agent)。代理必須在託管 Termix 的機器上執行。",
|
||||
"agentAuthenticationDesc": "使用 SSH 代理程式中的金鑰來驗證此連線。將套接字路徑留空以使用 SSH_AUTH_SOCK。",
|
||||
@@ -1421,18 +1417,11 @@
|
||||
"mfaMenuPlaceholder": "請輸入您的回复",
|
||||
"mfaWaitingApproval": "正在等待設備審核通過…",
|
||||
"mfaSendRequest": "發送請求",
|
||||
"warpgateAuthRequired": "需要 Warpgate 驗證",
|
||||
"warpgateSecurityKey": "安全金鑰",
|
||||
"warpgateAuthUrl": "驗證 URL",
|
||||
"warpgateOpenBrowser": "在瀏覽器中開啟",
|
||||
"warpgateContinue": "我已完成驗證",
|
||||
"certAuthRequired": "{{provider}} 需要登入",
|
||||
"opksshAuthRequired": "需要 OPKSSH 驗證",
|
||||
"opksshAuthDescription": "請在瀏覽器中完成驗證以繼續。此工作階段將持續有效 24 小時。",
|
||||
"opksshOpenBrowser": "開啟瀏覽器以進行驗證",
|
||||
"opksshWaitingForAuth": "正在等待瀏覽器中的驗證…",
|
||||
"opksshAuthenticating": "正在處理驗證…",
|
||||
"opksshSignInWith": "使用 {{provider}} 登入",
|
||||
"browserSignInCode": "安全金鑰",
|
||||
"browserSignInUrl": "驗證 URL",
|
||||
"browserSignInOpen": "在瀏覽器中開啟",
|
||||
"browserSignInContinue": "我已完成驗證",
|
||||
"browserSignInRequired": "{{provider}} 需要登入",
|
||||
"sudoPasswordPopupTitle": "輸入密碼?",
|
||||
"connectionLogTitle": "連線記錄",
|
||||
"connectionLogCopy": "複製記錄到剪貼簿",
|
||||
|
||||
@@ -60,7 +60,7 @@ export {
|
||||
} from "@/ssh/connection-log/ConnectionLogContext";
|
||||
export { TOTPDialog, type MFAPromptMode } from "@/ssh/dialogs/TOTPDialog";
|
||||
export { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog";
|
||||
export { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog";
|
||||
export { BrowserSignInDialog } from "@/ssh/dialogs/BrowserSignInDialog";
|
||||
|
||||
// Design tokens: the colour swatches folders, workspaces and tags pick from.
|
||||
export { FOLDER_COLORS } from "@/lib/theme";
|
||||
@@ -95,7 +95,6 @@ export { WidgetTitle } from "@/lib/widget-title";
|
||||
export { runVisibleInterval } from "@/lib/visible-interval";
|
||||
export { useIsMobile } from "@/hooks/use-mobile";
|
||||
export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog";
|
||||
export { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog";
|
||||
export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog";
|
||||
|
||||
// Terminal look: themes, fonts and clipboard, shared by every terminal-like
|
||||
|
||||
@@ -120,7 +120,6 @@ export function createHostEditorForm(
|
||||
username: host?.username ?? (host ? "" : "root"),
|
||||
sshPort: host?.sshPort ?? host?.port ?? 22,
|
||||
authType: host?.authType ?? "password",
|
||||
useWarpgate: host?.useWarpgate ?? false,
|
||||
shareSshAuth: host?.shareSshAuth ?? false,
|
||||
password: host?.hasPassword ? "existing_password" : (host?.password ?? ""),
|
||||
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
|
||||
@@ -391,7 +390,6 @@ export function buildHostEditorPayload(
|
||||
tags: form.tags,
|
||||
pin: form.pin,
|
||||
authType: form.authType,
|
||||
useWarpgate: form.useWarpgate,
|
||||
shareSshAuth: form.shareSshAuth,
|
||||
password:
|
||||
usesPassword || usesKey || usesCredential
|
||||
|
||||
+29
-19
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Shield, Copy, ExternalLink, Check } from "lucide-react";
|
||||
@@ -6,25 +6,33 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
|
||||
interface WarpgateDialogProps {
|
||||
interface BrowserSignInDialogProps {
|
||||
isOpen: boolean;
|
||||
/** Who is asking, e.g. the product name of an SSH gateway. */
|
||||
label: string;
|
||||
url: string;
|
||||
securityKey: string;
|
||||
/** A code to compare in the browser; "N/A" or empty hides it. */
|
||||
code: string;
|
||||
onContinue: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function WarpgateDialog({
|
||||
/**
|
||||
* A keyboard-interactive round finished in a browser: open the URL, compare
|
||||
* the code there, then continue.
|
||||
*/
|
||||
export function BrowserSignInDialog({
|
||||
isOpen,
|
||||
label,
|
||||
url,
|
||||
securityKey,
|
||||
code,
|
||||
onContinue,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
backgroundColor,
|
||||
}: WarpgateDialogProps) {
|
||||
}: BrowserSignInDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -44,7 +52,7 @@ export function WarpgateDialog({
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200 overflow-y-auto">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
className="absolute inset-0 bg-canvas"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-card border border-border w-full max-w-md mx-4 my-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
@@ -52,25 +60,27 @@ export function WarpgateDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.warpgateAuthRequired")}
|
||||
{t("terminal.browserSignInRequired", { provider: label })}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("terminal.warpgateSecurityKey")}
|
||||
</p>
|
||||
<div className="border border-border bg-muted/10 p-4 text-center">
|
||||
<div className="text-2xl font-mono font-bold tracking-wider text-accent-brand">
|
||||
{securityKey}
|
||||
{code && code !== "N/A" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("terminal.browserSignInCode")}
|
||||
</p>
|
||||
<div className="border border-border bg-muted/10 p-4 text-center">
|
||||
<div className="text-2xl font-mono font-bold tracking-wider text-accent-brand">
|
||||
{code}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("terminal.warpgateAuthUrl")}
|
||||
{t("terminal.browserSignInUrl")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -111,7 +121,7 @@ export function WarpgateDialog({
|
||||
onClick={onContinue}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("terminal.warpgateContinue")}
|
||||
{t("terminal.browserSignInContinue")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -120,7 +130,7 @@ export function WarpgateDialog({
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("terminal.warpgateOpenBrowser")}
|
||||
{t("terminal.browserSignInOpen")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,157 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface OPKSSHDialogProps {
|
||||
isOpen: boolean;
|
||||
authUrl: string;
|
||||
requestId: string;
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
/** Issuer name shown in the title; defaults to OPKSSH. */
|
||||
label?: string;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onSelectProvider?: (alias: string) => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function OPKSSHDialog({
|
||||
isOpen,
|
||||
authUrl,
|
||||
stage,
|
||||
error,
|
||||
providers,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onSelectProvider,
|
||||
backgroundColor,
|
||||
label,
|
||||
}: OPKSSHDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{label
|
||||
? t("terminal.certAuthRequired", { provider: label })
|
||||
: t("terminal.opksshAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
{stage === "chooser" && (
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("terminal.opksshAuthDescription")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
{stage === "chooser" && (
|
||||
<>
|
||||
{providers && providers.length > 0 && onSelectProvider ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.alias}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSelectProvider(provider.alias)}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("terminal.opksshSignInWith", {
|
||||
provider:
|
||||
provider.alias.charAt(0).toUpperCase() +
|
||||
provider.alias.slice(1),
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : authUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onOpenUrl}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(stage === "waiting" || stage === "authenticating") && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stage === "waiting"
|
||||
? t("terminal.opksshWaitingForAuth")
|
||||
: t("terminal.opksshAuthenticating")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === "error" && error && (
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
|
||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
|
||||
{t("common.error")}
|
||||
</p>
|
||||
<p className="text-xs text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user