feat: move vault ssh signing into a plugin (C5)

This commit is contained in:
LukeGus committed 2026-09-24 20:24:09 -05:00
1 parent b027717c46
commit 6e56c90aac
198 files changed
+19717 -3445

No files matched your search

-132
View File
@@ -1,132 +0,0 @@
/**
* Auth methods that still live in core for 2.9.0, registered through the same
* interfaces a plugin uses. Phase C moves each one into its own plugin by
* relocating its block from here; D1 deletes this file once it is empty.
*
* Nothing outside this file should branch on these type names.
*/
import { getErrorMessage } from "../utils/error-message.js";
import { sshLogger } from "../utils/logger.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 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 vaultProfileId(host: SshConnectHost): number | undefined {
return host.vaultProfile?.id ?? undefined;
}
const vaultProvider: SshAuthProvider = {
type: "vault",
pluginId: "core",
labelKey: "hosts.filterAuthVault",
needsUserInteraction: true,
interaction: "vault",
prepare: async (config, host, env) => {
const profileId = vaultProfileId(host);
if (!profileId) {
return {
status: "error",
code: "failed",
message: "Host has no Vault signer profile configured",
};
}
const { getVaultCert } = await import("../hosts/vault-signer-auth.js");
const cert = await getVaultCert(env.userId, profileId);
if (!cert) {
env.log(
"info",
"No valid Vault certificate found, requesting authentication",
);
return {
status: "interaction-required",
interaction: "vault",
message: VAULT_REQUIRED_MESSAGE,
flag: "requiresVaultAuth",
};
}
try {
await applyCertificateAuth(
config,
env.client,
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"Vault SSH signer authentication failed: " + getErrorMessage(error),
};
}
env.log("info", "Using cached Vault-signed certificate");
return { status: "ready" };
},
onAuthFailed: (host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
sshLogger.warn("Vault certificate authentication failed", {
operation: "vault_auth_failed",
hostId: env.hostId,
userId: env.userId,
});
const profileId = vaultProfileId(host);
if (profileId) {
void import("../hosts/vault-signer-auth.js")
.then(({ deleteVaultCert }) => deleteVaultCert(env.userId, profileId))
.catch(() => {});
}
return {
status: "interaction-required",
interaction: "vault",
message:
"Vault authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const { loadVaultProfileForHost, startVaultAuth } =
await import("../hosts/vault-oidc-auth.js");
const profile = await loadVaultProfileForHost(
request.hostId,
request.userId,
);
if (!profile) {
throw new Error("No Vault signer profile configured for this host");
}
await startVaultAuth(
request.userId,
request.hostId,
profile,
request.socket,
request.requestOrigin,
);
},
cancelInteraction: async (request) => {
if (request.hostId === undefined) return;
const { cancelVaultAuthByHost } =
await import("../hosts/vault-oidc-auth.js");
cancelVaultAuthByHost(request.userId, request.hostId);
},
};
let sshRegistered = false;
export function registerLegacySshAuthProviders(): void {
if (sshRegistered) return;
sshRegistered = true;
registerSshAuthProvider(vaultProvider);
}
/** Test helper. */
export function resetLegacyProvidersForTests(): void {
sshRegistered = false;
}
+2 -2
View File
@@ -2,8 +2,8 @@
* Login methods and second factors, from core and from plugins.
*
* Password login is registered by core and always present. Everything else
* (OIDC, LDAP, passkeys, TOTP, ...) registers here, from a plugin through
* ctx.auth or, in 2.9.0, from legacy-providers.ts.
* (OIDC, LDAP, passkeys, TOTP, ...) registers here from a plugin through
* ctx.auth.
*/
import type {
+4 -2
View File
@@ -7,6 +7,7 @@ import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import { registerVaultCompatRoutes } from "./routes/host-compat-routes.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
@@ -18,7 +19,6 @@ import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-pref
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import vaultRoutes from "./routes/vault.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
@@ -1795,7 +1795,9 @@ app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
const vaultCompatRoutes = express.Router();
registerVaultCompatRoutes(vaultCompatRoutes);
app.use("/vault", vaultCompatRoutes);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes);
-68
View File
@@ -978,11 +978,6 @@ const migrateSchema = () => {
"override_credential_username",
"INTEGER",
);
addColumnIfNotExists(
"ssh_data",
"vault_profile_id",
"INTEGER REFERENCES vault_profiles(id) ON DELETE SET NULL",
);
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
@@ -1579,67 +1574,6 @@ const migrateSchema = () => {
});
}
try {
sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS vault_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
folder TEXT,
tags TEXT,
vault_addr TEXT NOT NULL,
vault_namespace TEXT,
oidc_mount TEXT,
oidc_role TEXT,
ssh_mount TEXT,
ssh_role TEXT NOT NULL,
valid_principals TEXT,
key_type TEXT,
shared INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create vault_profiles table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM vault_tokens LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS vault_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
profile_id INTEGER NOT NULL,
ssh_cert TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_used TEXT,
UNIQUE(user_id, profile_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create vault_tokens table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
@@ -1878,7 +1812,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
addColumnIfNotExists("snippets", "sync_id", "TEXT");
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
@@ -1896,7 +1829,6 @@ const migrateSchema = () => {
"ssh_folders",
"snippets",
"snippet_folders",
"vault_profiles",
];
for (const table of syncIdTables) {
-72
View File
@@ -194,13 +194,6 @@ export const hosts = mysqlTable(
credentialId: int("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
overrideCredentialUsername: boolean("override_credential_username"),
// When authType is "vault", the host authenticates via a Vault SSH signer
// profile (shared settings, no secrets). The signing certificate is obtained
// per-user at connect time via an interactive Vault OIDC flow.
vaultProfileId: int("vault_profile_id").references(
() => vaultProfiles.id,
{ onDelete: "set null" },
),
enableTerminal: boolean("enable_terminal")
.notNull()
.default(true),
@@ -660,71 +653,6 @@ export const auditLogs = mysqlTable(
],
);
// 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
// interactive OIDC flow at connect time; no tokens or keys are stored here.
export const vaultProfiles = mysqlTable("vault_profiles", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
vaultNamespace: text("vault_namespace"),
// OIDC auth method mount + role used to obtain a Vault token interactively
oidcMount: text("oidc_mount"),
oidcRole: text("oidc_role"),
// SSH secrets engine mount + signer role used to sign the ephemeral key
sshMount: text("ssh_mount"),
sshRole: text("ssh_role").notNull(),
validPrincipals: text("valid_principals"),
// Ephemeral keypair algorithm to generate per connection
keyType: text("key_type"),
// When true the profile is visible/usable by all users on the server
shared: boolean("shared").notNull().default(false),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
// Transient: rows live only until the certificate expires. Secret fields are
// encrypted under the user's data-encryption key (see field-crypto.ts).
export const vaultTokens = mysqlTable(
"vault_tokens",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
profileId: int("profile_id")
.notNull()
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert").notNull(),
privateKey: text("private_key").notNull(),
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_vault_tokens_user_profile").on(table.userId, table.profileId)],
);
export const apiKeys = mysqlTable(
"api_keys",
{
-72
View File
@@ -195,13 +195,6 @@ export const hosts = pgTable(
credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
overrideCredentialUsername: boolean("override_credential_username"),
// When authType is "vault", the host authenticates via a Vault SSH signer
// profile (shared settings, no secrets). The signing certificate is obtained
// per-user at connect time via an interactive Vault OIDC flow.
vaultProfileId: integer("vault_profile_id").references(
() => vaultProfiles.id,
{ onDelete: "set null" },
),
enableTerminal: boolean("enable_terminal")
.notNull()
.default(true),
@@ -661,71 +654,6 @@ export const auditLogs = pgTable(
],
);
// 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
// interactive OIDC flow at connect time; no tokens or keys are stored here.
export const vaultProfiles = pgTable("vault_profiles", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
vaultNamespace: text("vault_namespace"),
// OIDC auth method mount + role used to obtain a Vault token interactively
oidcMount: text("oidc_mount"),
oidcRole: text("oidc_role"),
// SSH secrets engine mount + signer role used to sign the ephemeral key
sshMount: text("ssh_mount"),
sshRole: text("ssh_role").notNull(),
validPrincipals: text("valid_principals"),
// Ephemeral keypair algorithm to generate per connection
keyType: text("key_type"),
// When true the profile is visible/usable by all users on the server
shared: boolean("shared").notNull().default(false),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
// Transient: rows live only until the certificate expires. Secret fields are
// encrypted under the user's data-encryption key (see field-crypto.ts).
export const vaultTokens = pgTable(
"vault_tokens",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
profileId: integer("profile_id")
.notNull()
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert").notNull(),
privateKey: text("private_key").notNull(),
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_vault_tokens_user_profile").on(table.userId, table.profileId)],
);
export const apiKeys = pgTable(
"api_keys",
{
-72
View File
@@ -187,13 +187,6 @@ export const hosts = sqliteTable(
overrideCredentialUsername: integer("override_credential_username", {
mode: "boolean",
}),
// When authType is "vault", the host authenticates via a Vault SSH signer
// profile (shared settings, no secrets). The signing certificate is obtained
// per-user at connect time via an interactive Vault OIDC flow.
vaultProfileId: integer("vault_profile_id").references(
() => vaultProfiles.id,
{ onDelete: "set null" },
),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
.default(true),
@@ -653,71 +646,6 @@ export const auditLogs = sqliteTable(
],
);
// 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
// interactive OIDC flow at connect time; no tokens or keys are stored here.
export const vaultProfiles = sqliteTable("vault_profiles", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
folder: text("folder"),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
vaultNamespace: text("vault_namespace"),
// OIDC auth method mount + role used to obtain a Vault token interactively
oidcMount: text("oidc_mount"),
oidcRole: text("oidc_role"),
// SSH secrets engine mount + signer role used to sign the ephemeral key
sshMount: text("ssh_mount"),
sshRole: text("ssh_role").notNull(),
validPrincipals: text("valid_principals"),
// Ephemeral keypair algorithm to generate per connection
keyType: text("key_type"),
// When true the profile is visible/usable by all users on the server
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
// Transient: rows live only until the certificate expires. Secret fields are
// encrypted under the user's data-encryption key (see field-crypto.ts).
export const vaultTokens = sqliteTable(
"vault_tokens",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
profileId: integer("profile_id")
.notNull()
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
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_vault_tokens_user_profile").on(table.userId, table.profileId)],
);
export const apiKeys = sqliteTable(
"api_keys",
{
@@ -42,8 +42,6 @@ import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
import { UserRepository } from "./user-repository.js";
import { VaultProfileRepository } from "./vault-profile-repository.js";
import { VaultTokenRepository } from "./vault-token-repository.js";
/**
* The context every repository runs against.
@@ -416,20 +414,6 @@ export function createCurrentSharedCredentialSecretsRepository(): SharedCredenti
);
}
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
return new VaultProfileRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("vault_profile_repository_write"),
);
}
export function createCurrentVaultTokenRepository(): VaultTokenRepository {
return new VaultTokenRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("vault_token_repository_write"),
);
}
/**
* Loads the settings cache. Must run during startup on engines without a
* synchronous read, before anything calls getCurrentSettingValue.
@@ -18,7 +18,6 @@ export interface HostUpdateStateRecord {
rdpCredentialId: number | null;
vncCredentialId: number | null;
telnetCredentialId: number | null;
vaultProfileId: number | null;
authType: string;
parentHostId: number | null;
folder: string | null;
@@ -146,7 +145,6 @@ export class HostResolutionRepository {
rdpCredentialId: hosts.rdpCredentialId,
vncCredentialId: hosts.vncCredentialId,
telnetCredentialId: hosts.telnetCredentialId,
vaultProfileId: hosts.vaultProfileId,
authType: hosts.authType,
parentHostId: hosts.parentHostId,
folder: hosts.folder,
@@ -1,134 +0,0 @@
import { desc, eq, or } from "drizzle-orm";
import { randomUUID } from "crypto";
import { vaultProfiles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type VaultProfileRecord = typeof vaultProfiles.$inferSelect;
export interface VaultProfileCreateInput {
userId: string;
name: string;
description?: string | null;
folder?: string | null;
tags?: string | null;
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
shared?: boolean;
}
export type VaultProfileUpdateInput = Partial<
Omit<VaultProfileCreateInput, "userId">
> & {
updatedAt?: string;
};
export class VaultProfileRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listVisibleToUser(userId: string): Promise<VaultProfileRecord[]> {
return this.context.drizzle
.select()
.from(vaultProfiles)
.where(
or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)),
)
.orderBy(desc(vaultProfiles.updatedAt));
}
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
const [created] = await insertReturning(this.context, vaultProfiles, {
syncId: randomUUID(),
userId: input.userId,
name: input.name,
description: input.description,
folder: input.folder,
tags: input.tags,
vaultAddr: input.vaultAddr,
vaultNamespace: input.vaultNamespace,
oidcMount: input.oidcMount,
oidcRole: input.oidcRole,
sshMount: input.sshMount,
sshRole: input.sshRole,
validPrincipals: input.validPrincipals,
keyType: input.keyType,
shared: input.shared ?? false,
});
await this.afterWrite();
return created;
}
async findById(id: number): Promise<VaultProfileRecord | null> {
const rows = await this.context.drizzle
.select()
.from(vaultProfiles)
.where(eq(vaultProfiles.id, id))
.limit(1);
return rows[0] ?? null;
}
async updateById(
id: number,
input: VaultProfileUpdateInput,
): Promise<VaultProfileRecord | null> {
const [updated] = await updateReturning(
this.context,
vaultProfiles,
{
...input,
updatedAt: input.updatedAt ?? new Date().toISOString(),
},
eq(vaultProfiles.id, id),
);
if (updated) {
await this.afterWrite();
}
return updated ?? null;
}
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
const rows = await deleteReturning(
this.context,
vaultProfiles,
eq(vaultProfiles.id, id),
);
if (rows.length === 0) return null;
await this.afterWrite();
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(vaultProfiles)
.where(eq(vaultProfiles.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,126 +0,0 @@
import { and, eq } from "drizzle-orm";
import { vaultTokens } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { upsert } from "./returning.js";
export type VaultTokenRecord = typeof vaultTokens.$inferSelect;
export interface VaultTokenUpsertInput {
userId: string;
profileId: number;
sshCert: string;
privateKey: string;
expiresAt: string;
createdAt?: string;
}
export class VaultTokenRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(input: VaultTokenUpsertInput): Promise<void> {
const createdAt = input.createdAt ?? new Date().toISOString();
await upsert(
this.context,
vaultTokens,
{
userId: input.userId,
profileId: input.profileId,
sshCert: input.sshCert,
privateKey: input.privateKey,
expiresAt: input.expiresAt,
},
{
target: [vaultTokens.userId, vaultTokens.profileId],
set: {
sshCert: input.sshCert,
privateKey: input.privateKey,
expiresAt: input.expiresAt,
createdAt,
},
},
);
await this.afterWrite();
}
async findByUserAndProfile(
userId: string,
profileId: number,
): Promise<VaultTokenRecord | null> {
const rows = await this.context.drizzle
.select()
.from(vaultTokens)
.where(
and(
eq(vaultTokens.userId, userId),
eq(vaultTokens.profileId, profileId),
),
)
.limit(1);
return rows[0] ?? null;
}
async updateLastUsed(
userId: string,
profileId: number,
lastUsed = new Date().toISOString(),
): Promise<boolean> {
const result = await this.context.drizzle
.update(vaultTokens)
.set({ lastUsed })
.where(
and(
eq(vaultTokens.userId, userId),
eq(vaultTokens.profileId, profileId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserAndProfile(
userId: string,
profileId: number,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(vaultTokens)
.where(
and(
eq(vaultTokens.userId, userId),
eq(vaultTokens.profileId, profileId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(vaultTokens)
.where(eq(vaultTokens.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -23,10 +23,8 @@ import {
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
createCurrentVaultProfileRepository,
createCurrentSharedCredentialSecretsRepository,
createCurrentCredentialAccessRepository,
createCurrentVaultTokenRepository,
} from "../repositories/factory.js";
export async function deleteUserAndRelatedData(
@@ -96,11 +94,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. The opkssh plugin's
// cached certificates cascade the same way.
// every other plugin_settings row for this user. The opkssh and vault
// plugins' tables cascade the same way.
await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
@@ -1,6 +1,7 @@
/**
* The 2.8 URLs under /host that something outside Termix still depends on:
* the OPKSSH and Step CA redirect URIs registered with identity providers.
* The 2.8 auth callback URLs something outside Termix still depends on: the
* OPKSSH and Step CA redirect URIs registered with identity providers, and
* the Vault one listed in Vault OIDC roles.
* Each forwards to the plugin route that replaced it, so with the plugin off
* it signs nobody in.
*/
@@ -50,3 +51,21 @@ export function registerHostAuthCompatRoutes(router: Router): void {
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
});
}
export function registerVaultCompatRoutes(router: Router): void {
/**
* @openapi
* /vault/oidc/callback:
* get:
* summary: Vault OIDC callback (2.8 URL)
* description: The redirect URI Vault OIDC roles allowed before 2.9. Redirects to /plugin-api/vault/oidc/callback with the same query, so existing Vault roles keep working.
* tags:
* - Vault
* responses:
* 307:
* description: Redirect to the vault plugin's callback.
*/
router.get("/oidc/callback", (req, res) => {
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
});
}
@@ -42,7 +42,6 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
"authType",
"authMethod",
"credentialId",
"vaultProfileId",
"overrideCredentialUsername",
"shareSshAuth",
"password",
-6
View File
@@ -183,7 +183,6 @@ router.post(
authType,
shareSshAuth,
credentialId,
vaultProfileId,
key,
keyPassword,
keyType,
@@ -305,8 +304,6 @@ router.post(
authType: effectiveAuthType,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId:
effectiveAuthType === "vault" ? vaultProfileId || null : null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
@@ -858,7 +855,6 @@ router.put(
authType,
shareSshAuth,
credentialId,
vaultProfileId,
key,
keyPassword,
keyType,
@@ -981,8 +977,6 @@ router.put(
authType: effectiveAuthType,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId:
effectiveAuthType === "vault" ? vaultProfileId || null : null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
+2 -11
View File
@@ -15,7 +15,6 @@ import {
hosts,
sshCredentials,
sshFolders,
vaultProfiles,
userPreferences,
} from "../db/schema.js";
import {
@@ -47,11 +46,6 @@ const HOST_REFERENCES = [
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vaultProfileId",
syncField: "vaultProfileSyncId",
entityType: "vaultProfiles",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
@@ -78,11 +72,8 @@ export function registerCoreSyncEntities(): void {
order: 10,
});
registerEntity(CORE_OWNER, {
type: "vaultProfiles",
table: vaultProfiles,
order: 20,
});
// vaultProfiles (order 20) moved to the vault plugin. Hosts no longer
// reference it: a host's profile is the plugin's profileId host setting.
registerEntity(CORE_OWNER, {
type: "sshFolders",
@@ -7,8 +7,7 @@ import { getEntity } from "../../plugins/sync-registry.js";
* A plain string rather than a union: the set is whatever is registered, and a
* plugin can both register an entity and reference one. sync.ts resolves the
* name through the registry, so an unknown one is an error there rather than a
* silent fallthrough - which is what the old three-way if/else did, routing
* anything that was not hosts or sshCredentials to vaultProfiles.
* silent fallthrough.
*/
export type SyncReferenceEntity = string;
-448
View File
@@ -1,448 +0,0 @@
import express, { type Request, type Response } from "express";
import {
createCurrentVaultProfileRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { completeVaultAuth } from "../../hosts/vault-oidc-auth.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
async function userIsAdmin(userId: string): Promise<boolean> {
try {
const user = await createCurrentUserRepository().findById(userId);
return !!user?.isAdmin;
} catch {
return false;
}
}
function formatProfile(
row: Record<string, unknown>,
currentUserId: string,
): Record<string, unknown> {
return {
id: row.id,
name: row.name,
description: row.description,
folder: row.folder,
tags:
typeof row.tags === "string"
? row.tags
? (row.tags as string).split(",").filter(Boolean)
: []
: [],
vaultAddr: row.vaultAddr,
vaultNamespace: row.vaultNamespace,
oidcMount: row.oidcMount,
oidcRole: row.oidcRole,
sshMount: row.sshMount,
sshRole: row.sshRole,
validPrincipals: row.validPrincipals,
keyType: row.keyType,
shared: !!row.shared,
owned: row.userId === currentUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* @openapi
* /vault/oidc/callback:
* get:
* summary: Vault OIDC callback
* description: Unauthenticated endpoint the IdP redirects to after login. Correlates the authorization code to a pending session via the Vault-issued state parameter.
* tags:
* - Vault
* parameters:
* - in: query
* name: state
* required: true
* schema:
* type: string
* - in: query
* name: code
* required: true
* schema:
* type: string
* - in: query
* name: error
* schema:
* type: string
* responses:
* 200:
* description: HTML page confirming sign-in success or failure.
* 400:
* description: Missing parameters or authentication failure.
*/
router.get("/oidc/callback", async (req: Request, res: Response) => {
const state = String(req.query.state || "");
const code = String(req.query.code || "");
const oidcError = req.query.error ? String(req.query.error) : "";
const esc = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const html = (title: string, message: string) =>
`<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>
<style>body{font-family:system-ui,sans-serif;background:#0b0b0c;color:#e5e5e5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.card{max-width:420px;text-align:center;padding:24px;border:1px solid #2a2a2e;border-radius:8px}</style></head>
<body><div class="card"><h2>${esc(title)}</h2><p>${esc(message)}</p>
<script>setTimeout(function(){window.close()},1500)</script></div></body></html>`;
if (oidcError) {
return res
.status(400)
.send(html("Vault sign-in failed", `Vault returned: ${oidcError}`));
}
if (!state || !code) {
return res
.status(400)
.send(html("Vault sign-in failed", "Missing state or code."));
}
const result = await completeVaultAuth(state, code);
if (result.ok) {
return res.send(
html(
"Vault sign-in complete",
"You can close this window and return to Termix.",
),
);
}
return res
.status(400)
.send(
html("Vault sign-in failed", result.error || "Authentication failed."),
);
});
/**
* @openapi
* /vault/profiles:
* get:
* summary: List Vault profiles
* description: Returns all Vault signer profiles owned by the authenticated user or marked as shared.
* tags:
* - Vault
* responses:
* 200:
* description: Array of Vault profile objects.
* 500:
* description: Failed to list vault profiles.
*/
router.get(
"/profiles",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentVaultProfileRepository().listVisibleToUser(userId);
res.json(
rows.map((r) => formatProfile(r as Record<string, unknown>, userId)),
);
} catch (err) {
authLogger.error("Failed to list vault profiles", err);
res.status(500).json({ error: "Failed to list vault profiles" });
}
},
);
/**
* @openapi
* /vault/profiles:
* post:
* summary: Create a Vault profile
* description: Creates a new Vault signer profile owned by the authenticated user. The shared flag requires admin privileges.
* tags:
* - Vault
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - vaultAddr
* - sshRole
* properties:
* name:
* type: string
* vaultAddr:
* type: string
* vaultNamespace:
* type: string
* oidcMount:
* type: string
* oidcRole:
* type: string
* sshMount:
* type: string
* sshRole:
* type: string
* validPrincipals:
* type: string
* keyType:
* type: string
* shared:
* type: boolean
* responses:
* 201:
* description: Created Vault profile object.
* 400:
* description: Missing required fields.
* 403:
* description: Non-admin attempted to create a shared profile.
* 500:
* description: Failed to create vault profile.
*/
router.post(
"/profiles",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
name,
description,
folder,
tags,
vaultAddr,
vaultNamespace,
oidcMount,
oidcRole,
sshMount,
sshRole,
validPrincipals,
keyType,
shared,
} = req.body;
if (
!isNonEmptyString(name) ||
!isNonEmptyString(vaultAddr) ||
!isNonEmptyString(sshRole)
) {
return res
.status(400)
.json({ error: "name, vaultAddr and sshRole are required" });
}
const wantShared = !!shared;
if (wantShared && !(await userIsAdmin(userId))) {
return res.status(403).json({
error: "Only administrators can create shared Vault profiles",
});
}
try {
const inserted = await createCurrentVaultProfileRepository().create({
userId,
name: name.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
vaultAddr: vaultAddr.trim(),
vaultNamespace: vaultNamespace?.trim() || null,
oidcMount: oidcMount?.trim() || null,
oidcRole: oidcRole?.trim() || null,
sshMount: sshMount?.trim() || null,
sshRole: sshRole.trim(),
validPrincipals: validPrincipals?.trim() || null,
keyType: keyType?.trim() || null,
shared: wantShared,
});
res
.status(201)
.json(formatProfile(inserted as Record<string, unknown>, userId));
} catch (err) {
authLogger.error("Failed to create vault profile", err);
res.status(500).json({ error: "Failed to create vault profile" });
}
},
);
/**
* @openapi
* /vault/profiles/{id}:
* put:
* summary: Update a Vault profile
* description: Updates a Vault signer profile. Only the owner may edit; toggling shared to true requires admin privileges.
* tags:
* - Vault
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Updated Vault profile object.
* 400:
* description: Invalid profile id.
* 403:
* description: Non-owner attempted to edit, or non-admin attempted to share.
* 404:
* description: Profile not found.
* 500:
* description: Failed to update vault profile.
*/
router.put(
"/profiles/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (!Number.isFinite(id)) {
return res.status(400).json({ error: "Invalid profile id" });
}
try {
const repository = createCurrentVaultProfileRepository();
const existing = await repository.findById(id);
if (!existing) {
return res.status(404).json({ error: "Profile not found" });
}
if (existing.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can edit this profile" });
}
const body = req.body;
const fields: VaultProfileUpdateInput = {
updatedAt: new Date().toISOString(),
};
if (body.name !== undefined) fields.name = body.name?.trim();
if (body.description !== undefined)
fields.description = body.description?.trim() || null;
if (body.folder !== undefined)
fields.folder = body.folder?.trim() || null;
if (body.tags !== undefined)
fields.tags = Array.isArray(body.tags)
? body.tags.join(",")
: body.tags || "";
if (body.vaultAddr !== undefined && isNonEmptyString(body.vaultAddr))
fields.vaultAddr = body.vaultAddr.trim();
if (body.vaultNamespace !== undefined)
fields.vaultNamespace = body.vaultNamespace?.trim() || null;
if (body.oidcMount !== undefined)
fields.oidcMount = body.oidcMount?.trim() || null;
if (body.oidcRole !== undefined)
fields.oidcRole = body.oidcRole?.trim() || null;
if (body.sshMount !== undefined)
fields.sshMount = body.sshMount?.trim() || null;
if (body.sshRole !== undefined && isNonEmptyString(body.sshRole))
fields.sshRole = body.sshRole.trim();
if (body.validPrincipals !== undefined)
fields.validPrincipals = body.validPrincipals?.trim() || null;
if (body.keyType !== undefined)
fields.keyType = body.keyType?.trim() || null;
if (body.shared !== undefined) {
if (!!body.shared && !(await userIsAdmin(userId))) {
return res.status(403).json({
error: "Only administrators can share Vault profiles",
});
}
fields.shared = !!body.shared;
}
const updated = await repository.updateById(id, fields);
if (!updated) {
return res.status(404).json({ error: "Profile not found" });
}
res.json(formatProfile(updated as Record<string, unknown>, userId));
} catch (err) {
authLogger.error("Failed to update vault profile", err);
res.status(500).json({ error: "Failed to update vault profile" });
}
},
);
/**
* @openapi
* /vault/profiles/{id}:
* delete:
* summary: Delete a Vault profile
* description: Permanently deletes a Vault signer profile. Only the owner may delete it.
* tags:
* - Vault
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deletion confirmed.
* 400:
* description: Invalid profile id.
* 403:
* description: Non-owner attempted to delete.
* 404:
* description: Profile not found.
* 500:
* description: Failed to delete vault profile.
*/
router.delete(
"/profiles/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (!Number.isFinite(id)) {
return res.status(400).json({ error: "Invalid profile id" });
}
try {
const repository = createCurrentVaultProfileRepository();
const existing = await repository.findById(id);
if (!existing) {
return res.status(404).json({ error: "Profile not found" });
}
if (existing.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can delete this profile" });
}
const deleted = await repository.deleteById(id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"vaultProfiles",
deleted.syncId,
);
}
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete vault profile", err);
res.status(500).json({ error: "Failed to delete vault profile" });
}
},
);
export default router;
@@ -2,8 +2,7 @@
* Every SSH auth type the server knows how to connect with.
*
* Core registers password, key, credential, agent and none. The rest come from
* plugins through ctx.auth.registerSshAuthProvider, or for 2.9.0 from
* src/backend/auth/legacy-providers.ts. A type with no provider fails the
* plugins through ctx.auth.registerSshAuthProvider. A type with no provider fails the
* connect with a message naming the plugin that would provide it.
*/
@@ -1,4 +1,3 @@
import { registerLegacySshAuthProviders } from "../../auth/legacy-providers.js";
import { registerBuiltinSshAuthProviders } from "./builtin-providers.js";
let registered = false;
@@ -11,7 +10,6 @@ export function ensureCoreSshAuthProviders(): void {
if (registered) return;
registered = true;
registerBuiltinSshAuthProviders();
registerLegacySshAuthProviders();
}
/** Test helper, pairs with resetSshAuthRegistryForTests. */
-1
View File
@@ -41,7 +41,6 @@ export interface SshConnectHost {
*/
pluginSettings?: Record<string, Record<string, unknown>> | null;
forceKeyboardInteractive?: boolean | null;
vaultProfile?: { id?: number | null } | null;
terminalConfig?: Record<string, unknown> | null;
jumpHosts?: Array<{ hostId: number }> | null;
useSocks5?: boolean | null;
-10
View File
@@ -600,7 +600,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
let resolvedSocks5Username = socks5Username;
let resolvedSocks5Password = socks5Password;
let resolvedSocks5ProxyChain = socks5ProxyChain;
let resolvedVaultProfileId: number | null = null;
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById, resolveHostBySyncId } =
@@ -625,9 +624,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
resolvedVaultProfileId =
(resolvedHost.vaultProfile as { id?: number } | undefined)?.id ??
null;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
@@ -695,9 +691,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
resolvedVaultProfileId =
(resolvedHost.vaultProfile as { id?: number } | undefined)?.id ??
null;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
@@ -787,9 +780,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
keyPassword: resolvedCredentials.keyPassword,
certPublicKey: resolvedCredentials.certPublicKey,
terminalConfig: resolvedTerminalConfig ?? null,
vaultProfile: resolvedVaultProfileId
? { id: resolvedVaultProfileId }
: null,
jumpHosts: resolvedJumpHosts,
useSocks5: resolvedUseSocks5,
socks5Host: resolvedSocks5Host,
-21
View File
@@ -3,7 +3,6 @@ import { findUsableCredential } from "./usable-credential.js";
import { resolveExternalSecretRefs } from "./external-secrets.js";
import {
createCurrentHostResolutionRepository,
createCurrentVaultProfileRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
@@ -232,26 +231,6 @@ export async function resolveHostById(
sharedAuthResolution === "recipient-override" ? userId : ownerId,
);
// Resolve a Vault SSH signer profile (shared settings, no secrets). The
// certificate itself is obtained per-user at connect time via Vault OIDC.
if (host.vaultProfileId && sharedAuthResolution !== "recipient-override") {
try {
const profile = await createCurrentVaultProfileRepository().findById(
host.vaultProfileId as number,
);
if (profile) {
(host as Record<string, unknown>).vaultProfile = profile;
host.authType = "vault";
}
} catch (e) {
sshLogger.warn("Failed to resolve vault profile for host", {
operation: "host_resolver_vault_profile",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
// Keyboard-interactive handlers run synchronously mid-handshake and can
// only read their host settings from here.
try {
-214
View File
@@ -1,214 +0,0 @@
// Orchestrates the interactive Vault OIDC login that yields a signed,
// short-lived SSH certificate. Mirrors the OPKSSH session manager but is
// driven entirely over Vault's HTTP API (no external binary).
//
// Lifecycle:
// - startVaultAuth(): generate ephemeral keypair, ask Vault for an OIDC
// auth_url, stash a pending session keyed by the Vault-issued `state`,
// and send the auth_url to the browser over the terminal WebSocket.
// - The browser completes OIDC; the IdP redirects to VAULT_OIDC_CALLBACK_PATH.
// - completeVaultAuth(): exchange the code for a Vault token, sign the
// ephemeral key, cache the cert, and notify the browser to reconnect.
import { WebSocket } from "ws";
import {
createCurrentHostResolutionRepository,
createCurrentVaultProfileRepository,
} from "../database/repositories/factory.js";
import { sshLogger } from "../utils/logger.js";
import {
type VaultProfileConfig,
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
storeVaultCert,
} from "./vault-signer-auth.js";
export const VAULT_OIDC_CALLBACK_PATH = "/vault/oidc/callback";
const AUTH_TIMEOUT = 5 * 60 * 1000;
interface VaultAuthSession {
state: string;
userId: string;
hostId: number;
profile: VaultProfileConfig;
clientNonce: string;
ephemeralPrivateKey: string;
ephemeralPublicKey: string;
ws: WebSocket;
timeout: NodeJS.Timeout;
}
// Keyed by the Vault-issued OIDC `state` so the unauthenticated browser callback
// can correlate back to the originating session.
const sessions = new Map<string, VaultAuthSession>();
function rowToProfileConfig(row: Record<string, unknown>): VaultProfileConfig {
return {
id: row.id as number,
vaultAddr: row.vaultAddr as string,
vaultNamespace: (row.vaultNamespace as string | null) ?? null,
oidcMount: (row.oidcMount as string | null) ?? null,
oidcRole: (row.oidcRole as string | null) ?? null,
sshMount: (row.sshMount as string | null) ?? null,
sshRole: row.sshRole as string,
validPrincipals: (row.validPrincipals as string | null) ?? null,
keyType: (row.keyType as string | null) ?? null,
};
}
/** Load the Vault profile referenced by a host, or null if not configured. */
export async function loadVaultProfileForHost(
hostId: number,
userId: string,
): Promise<VaultProfileConfig | null> {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
if (!host || host.vaultProfileId == null) return null;
const profile = await createCurrentVaultProfileRepository().findById(
host.vaultProfileId as number,
);
if (!profile) return null;
return rowToProfileConfig(profile as Record<string, unknown>);
}
/**
* Begin an interactive Vault OIDC login and send the auth URL to the browser.
*/
export async function startVaultAuth(
userId: string,
hostId: number,
profile: VaultProfileConfig,
ws: WebSocket,
requestOrigin: string,
): Promise<void> {
const ephemeral = generateEphemeralKeyPair(profile.keyType);
const redirectUri = `${requestOrigin}${VAULT_OIDC_CALLBACK_PATH}`;
const { authUrl, state, clientNonce } = await startVaultOidc(
profile,
redirectUri,
);
const existing = sessions.get(state);
if (existing) clearTimeout(existing.timeout);
const timeout = setTimeout(() => {
sessions.delete(state);
}, AUTH_TIMEOUT);
sessions.set(state, {
state,
userId,
hostId,
profile,
clientNonce,
ephemeralPrivateKey: ephemeral.privateKey,
ephemeralPublicKey: ephemeral.publicKey,
ws,
timeout,
});
sshLogger.info("Started Vault OIDC auth session", {
operation: "vault_oidc_start",
userId,
hostId,
profileId: profile.id,
});
ws.send(
JSON.stringify({
type: "vault_auth_url",
hostId,
url: authUrl,
}),
);
}
/**
* Complete a Vault OIDC login from the browser callback: exchange the code for a
* Vault token, sign the ephemeral key, cache the certificate, and notify the
* browser to resume the SSH connection.
*/
export async function completeVaultAuth(
state: string,
code: string,
): Promise<{ ok: boolean; error?: string }> {
const session = sessions.get(state);
if (!session) {
return { ok: false, error: "No matching Vault authentication session" };
}
try {
const token = await completeVaultOidc(session.profile, {
state,
code,
clientNonce: session.clientNonce,
});
const signedCert = await signWithVault(
session.profile,
token,
session.ephemeralPublicKey,
);
const expiresAt = await storeVaultCert(
session.userId,
session.profile.id,
session.ephemeralPrivateKey,
signedCert,
);
sshLogger.success("Completed Vault OIDC auth and cached certificate", {
operation: "vault_oidc_complete",
userId: session.userId,
hostId: session.hostId,
profileId: session.profile.id,
});
session.ws.send(
JSON.stringify({
type: "vault_completed",
hostId: session.hostId,
expiresAt,
}),
);
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
sshLogger.error("Vault OIDC completion failed", e, {
operation: "vault_oidc_complete_error",
userId: session.userId,
hostId: session.hostId,
});
try {
session.ws.send(
JSON.stringify({
type: "vault_error",
hostId: session.hostId,
error: msg,
}),
);
} catch {
// ws may be closed
}
return { ok: false, error: msg };
} finally {
clearTimeout(session.timeout);
sessions.delete(state);
}
}
/** Cancel a pending Vault auth session (e.g. user dismissed the dialog). */
export function cancelVaultAuthByHost(userId: string, hostId: number): void {
for (const [state, s] of sessions) {
if (s.userId === userId && s.hostId === hostId) {
clearTimeout(s.timeout);
sessions.delete(state);
}
}
}
-139
View File
@@ -1,139 +0,0 @@
// HashiCorp Vault SSH signer authentication.
//
// 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),
// yielding a short-lived Vault token.
// 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 applyCertificateAuth.
//
// No Vault tokens, AppRole secrets, or long-lived private keys are ever stored.
// Vault connection SETTINGS live in shareable vault_profiles rows.
//
// The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and
// is re-exported here so callers have a single import surface.
import { createCurrentVaultTokenRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { parseCertValidBefore } from "./vault-signer-core.js";
export type {
VaultProfileConfig,
EphemeralKeyPair,
} from "./vault-signer-core.js";
export {
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
parseCertValidBefore,
} from "./vault-signer-core.js";
// Re-sign once we're within this many seconds of expiry.
const EXPIRY_SKEW_SECONDS = 60;
function cacheRecordId(userId: string, profileId: number): string {
return `vault-${userId}-${profileId}`;
}
/**
* Store an ephemeral private key + signed certificate for a user/profile.
* Returns the expiry as an ISO string.
*/
export async function storeVaultCert(
userId: string,
profileId: number,
privateKey: string,
signedCert: string,
): Promise<string> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const validBefore = parseCertValidBefore(signedCert);
const expiresMs =
validBefore > 0 ? validBefore * 1000 : Date.now() + 5 * 60 * 1000;
const expiresAt = new Date(expiresMs).toISOString();
const recordId = cacheRecordId(userId, profileId);
const encryptedCert = FieldCrypto.encryptField(
signedCert,
userDataKey,
recordId,
"ssh_cert",
);
const encryptedKey = FieldCrypto.encryptField(
privateKey,
userDataKey,
recordId,
"private_key",
);
await createCurrentVaultTokenRepository().upsert({
userId,
profileId,
sshCert: encryptedCert,
privateKey: encryptedKey,
expiresAt,
});
return expiresAt;
}
/**
* Return a cached, still-valid ephemeral key + certificate for a user/profile,
* or null if none exists or it has (nearly) expired.
*/
export async function getVaultCert(
userId: string,
profileId: number,
): Promise<{ privateKey: string; sshCert: string } | null> {
const repository = createCurrentVaultTokenRepository();
const row = await repository.findByUserAndProfile(userId, profileId);
if (!row) return null;
const expiresMs = new Date(row.expiresAt).getTime();
if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) {
await deleteVaultCert(userId, profileId);
return null;
}
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const recordId = cacheRecordId(userId, profileId);
const sshCert = FieldCrypto.decryptField(
row.sshCert,
userDataKey,
recordId,
"ssh_cert",
);
const privateKey = FieldCrypto.decryptField(
row.privateKey,
userDataKey,
recordId,
"private_key",
);
await repository.updateLastUsed(userId, profileId);
return { privateKey, sshCert };
}
export async function deleteVaultCert(
userId: string,
profileId: number,
): Promise<void> {
await createCurrentVaultTokenRepository().deleteByUserAndProfile(
userId,
profileId,
);
}
-265
View File
@@ -1,265 +0,0 @@
// Pure (DB-free) HashiCorp Vault SSH signer logic: ephemeral key generation,
// the Vault OIDC HTTP flow, certificate signing, and certificate parsing.
//
// Kept separate from vault-signer-auth.ts (which adds the encrypted per-user
// certificate cache) so this layer can be unit-tested without the native SQLite
// dependency.
import crypto from "crypto";
import ssh2Pkg from "ssh2";
import { sshLogger } from "../utils/logger.js";
const { utils: ssh2Utils } = ssh2Pkg;
export interface VaultProfileConfig {
id: number;
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
}
export interface EphemeralKeyPair {
privateKey: string;
publicKey: string;
}
export function normalizeAddr(addr: string): string {
return addr.trim().replace(/\/+$/, "");
}
export function trimMount(
mount: string | null | undefined,
fallback: string,
): string {
return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, "");
}
function vaultHeaders(profile: VaultProfileConfig): Record<string, string> {
const headers: Record<string, string> = {};
if (profile.vaultNamespace?.trim()) {
headers["X-Vault-Namespace"] = profile.vaultNamespace.trim();
}
return headers;
}
async function vaultRequest(
url: string,
method: "GET" | "POST" | "PUT",
headers: Record<string, string>,
body?: Record<string, unknown>,
): Promise<any> {
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
throw new Error(
`Failed to reach Vault at ${url}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
const text = await response.text();
let json: any;
if (text) {
try {
json = JSON.parse(text);
} catch {
// leave undefined for non-JSON bodies
}
}
if (!response.ok) {
const errs =
json && Array.isArray(json.errors) && json.errors.length
? json.errors.join("; ")
: text || `HTTP ${response.status}`;
throw new Error(`Vault request failed (${response.status}): ${errs}`);
}
return json;
}
/** Generate an ephemeral SSH keypair in OpenSSH format. */
export function generateEphemeralKeyPair(
keyType?: string | null,
): EphemeralKeyPair {
let ssh2Type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
const options: { bits?: number } = {};
switch ((keyType || "ssh-ed25519").trim()) {
case "ssh-rsa":
ssh2Type = "rsa";
options.bits = 4096;
break;
case "ecdsa-sha2-nistp256":
ssh2Type = "ecdsa";
options.bits = 256;
break;
case "ssh-ed25519":
default:
ssh2Type = "ed25519";
break;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return { privateKey: pair.private, publicKey: pair.public };
}
/**
* Start a Vault OIDC login: returns the IdP auth URL plus the state/nonce
* needed to complete the callback. `redirectUri` must be allowed both by the
* Vault role's allowed_redirect_uris and by the IdP.
*/
export async function startVaultOidc(
profile: VaultProfileConfig,
redirectUri: string,
): Promise<{ authUrl: string; state: string; clientNonce: string }> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const clientNonce = crypto.randomBytes(20).toString("hex");
const json = await vaultRequest(
`${addr}/v1/auth/${mount}/oidc/auth_url`,
"POST",
vaultHeaders(profile),
{
role: profile.oidcRole?.trim() || "",
redirect_uri: redirectUri,
client_nonce: clientNonce,
},
);
const authUrl: string | undefined = json?.data?.auth_url;
if (!authUrl) {
throw new Error("Vault did not return an OIDC auth_url");
}
// Vault embeds the state it generated in the auth_url; we need it to correlate
// the browser callback back to this login attempt.
let state = "";
try {
state = new URL(authUrl).searchParams.get("state") || "";
} catch {
const m = authUrl.match(/[?&]state=([^&]+)/);
state = m ? decodeURIComponent(m[1]) : "";
}
if (!state) {
throw new Error("Could not determine OIDC state from Vault auth_url");
}
return { authUrl, state, clientNonce };
}
/** Complete the Vault OIDC callback and return a short-lived Vault token. */
export async function completeVaultOidc(
profile: VaultProfileConfig,
params: { state: string; code: string; clientNonce: string },
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`);
url.searchParams.set("state", params.state);
url.searchParams.set("code", params.code);
url.searchParams.set("client_nonce", params.clientNonce);
const json = await vaultRequest(url.toString(), "GET", vaultHeaders(profile));
const token: string | undefined = json?.auth?.client_token;
if (!token) {
throw new Error("Vault OIDC callback did not return a client token");
}
return token;
}
/** Sign an SSH public key with Vault, returning the OpenSSH certificate line. */
export async function signWithVault(
profile: VaultProfileConfig,
vaultToken: string,
publicKey: string,
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.sshMount, "ssh-client-signer");
const headers = { ...vaultHeaders(profile), "X-Vault-Token": vaultToken };
const body: Record<string, unknown> = {
public_key: publicKey.trim(),
cert_type: "user",
};
if (profile.validPrincipals?.trim()) {
body.valid_principals = profile.validPrincipals.trim();
}
const json = await vaultRequest(
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
"POST",
headers,
body,
);
const signedKey: string | undefined = json?.data?.signed_key;
if (!signedKey) {
throw new Error("Vault sign response did not include a signed_key");
}
return signedKey.trim();
}
/**
* Parse the "valid before" Unix timestamp out of an OpenSSH certificate.
* Returns 0 if it can't be parsed (caller falls back to a conservative TTL).
*/
export function parseCertValidBefore(signedKey: string): number {
try {
const parts = signedKey.trim().split(/\s+/);
if (parts.length < 2) return 0;
const certType = parts[0];
const blob = Buffer.from(parts[1], "base64");
let pos = 0;
const readString = (): void => {
const len = blob.readUInt32BE(pos);
pos += 4 + len;
};
const readUint64 = (): number => {
const hi = blob.readUInt32BE(pos);
const lo = blob.readUInt32BE(pos + 4);
pos += 8;
return hi * 0x100000000 + lo;
};
readString(); // format id
readString(); // nonce
let keyFields: number;
if (certType.startsWith("ssh-ed25519")) keyFields = 1;
else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2;
else if (certType.startsWith("ssh-rsa")) keyFields = 2;
else if (certType.startsWith("ssh-dss")) keyFields = 4;
else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2;
else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3;
else return 0;
for (let i = 0; i < keyFields; i++) readString();
readUint64(); // serial
pos += 4; // type (uint32)
readString(); // key id
readString(); // valid principals
readUint64(); // valid after
return readUint64(); // valid before
} catch (e) {
sshLogger.warn("Failed to parse Vault-signed certificate expiry", {
operation: "vault_cert_parse",
error: e instanceof Error ? e.message : String(e),
});
return 0;
}
}
+4
View File
@@ -381,6 +381,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/step-ca-settings-migration.js");
await runStepCaSettingsMigration();
const { runVaultSettingsMigration } =
await import("./utils/crypto-migration/vault-settings-migration.js");
await runVaultSettingsMigration();
const { runSecretSourcesTokenMigration } =
await import("./utils/crypto-migration/secret-sources-token-migration.js");
await runSecretSourcesTokenMigration();
@@ -195,7 +195,6 @@ describe("HostResolutionRepository", () => {
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
vaultProfileId: null,
authType: "password",
parentHostId: null,
folder: null,
@@ -1,115 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { VaultProfileRepository } from "../../../database/repositories/vault-profile-repository.js";
describe("VaultProfileRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<VaultProfileRepository> {
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 vault_profiles (
id, user_id, name, vault_addr, ssh_role, shared, updated_at
)
VALUES (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'),
(2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'),
(3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z');
`);
return new VaultProfileRepository(context, onWrite);
}
it("lists profiles owned by or shared with the user", async () => {
const repo = await createRepository();
const rows = await repo.listVisibleToUser("user-1");
expect(rows.map((row) => row.id)).toEqual([2, 1]);
});
it("creates and reads a profile", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const created = await repo.create({
userId: "user-1",
name: "new",
description: "desc",
folder: "folder",
tags: "prod,ssh",
vaultAddr: "https://vault.new",
vaultNamespace: "ns",
oidcMount: "oidc",
oidcRole: "oidc-role",
sshMount: "ssh",
sshRole: "ssh-role",
validPrincipals: "root",
keyType: "ed25519",
shared: true,
});
const found = await repo.findById(created.id);
expect(found).toMatchObject({
userId: "user-1",
name: "new",
vaultAddr: "https://vault.new",
sshRole: "ssh-role",
shared: true,
});
expect(writeCount).toBe(1);
});
it("updates and deletes profiles", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.updateById(1, {
name: "renamed",
shared: true,
updatedAt: "2026-02-01T00:00:00.000Z",
});
expect(updated).toMatchObject({
id: 1,
name: "renamed",
shared: true,
updatedAt: "2026-02-01T00:00:00.000Z",
});
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
expect(await repo.deleteById(1)).toEqual({ syncId: null });
expect(await repo.deleteById(1)).toBeNull();
expect(await repo.findById(1)).toBeNull();
expect(writeCount).toBe(2);
});
it("deletes all profiles for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(repo.deleteByUserId("user-2")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findById(2)).toBeNull();
expect(await repo.findById(3)).toBeNull();
expect((await repo.findById(1))?.userId).toBe("user-1");
expect(writeCount).toBe(1);
});
});
@@ -1,107 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { VaultTokenRepository } from "../../../database/repositories/vault-token-repository.js";
describe("VaultTokenRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<VaultTokenRepository> {
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 vault_profiles (id, user_id, name, vault_addr, ssh_role)
VALUES (1, 'user-1', 'one', 'http://vault', 'r'), (2, 'user-2', 'two', 'http://vault', 'r');
INSERT INTO vault_tokens (
user_id, profile_id, ssh_cert, private_key, expires_at
)
VALUES
('user-1', 1, 'cert-1', 'key-1', '2099-01-01T00:00:00.000Z'),
('user-2', 2, 'cert-2', 'key-2', '2099-01-01T00:00:00.000Z');
`);
return new VaultTokenRepository(context, onWrite);
}
it("finds and upserts a token by user and profile", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const existing = await repo.findByUserAndProfile("user-1", 1);
expect(existing?.sshCert).toBe("cert-1");
await repo.upsert({
userId: "user-1",
profileId: 1,
sshCert: "new-cert",
privateKey: "new-key",
expiresAt: "2099-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
const updated = await repo.findByUserAndProfile("user-1", 1);
expect(updated).toMatchObject({
sshCert: "new-cert",
privateKey: "new-key",
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.findByUserAndProfile("user-1", 1))?.lastUsed).toBe(
"2026-01-02T00:00:00.000Z",
);
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(true);
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(false);
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
expect(writeCount).toBe(2);
});
it("deletes all tokens for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsert({
userId: "user-1",
profileId: 2,
sshCert: "cert-extra",
privateKey: "key-extra",
expiresAt: "2099-03-01T00:00:00.000Z",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
expect(await repo.findByUserAndProfile("user-1", 2)).toBeNull();
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
expect(writeCount).toBe(2);
});
});
@@ -1,5 +1,5 @@
/**
* The OPKSSH and Step CA redirect URIs identity providers were registered with before
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact.
*/
@@ -62,3 +62,29 @@ describe("the old Step CA callback", () => {
);
});
});
describe("the old Vault callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const { registerVaultCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
const router = express.Router();
registerVaultCompatRoutes(router);
const app = express();
app.use("/vault", router);
const vaultServer = http.createServer(app);
await new Promise<void>((resolve) => vaultServer.listen(0, resolve));
const port = (vaultServer.address() as AddressInfo).port;
try {
const response = await fetch(
`http://127.0.0.1:${port}/vault/oidc/callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/vault/oidc/callback?code=abc&state=xyz",
);
} finally {
await new Promise<void>((resolve) => vaultServer.close(() => resolve()));
}
});
});
@@ -33,7 +33,6 @@ describe("isValidEntityType", () => {
"hosts",
"sshCredentials",
"sshFolders",
"vaultProfiles",
"userPreferences",
]) {
expect(isValidEntityType(type)).toBe(true);
@@ -23,7 +23,6 @@ describe("sync references", () => {
rdpCredentialId: 13,
vncCredentialId: null,
telnetCredentialId: null,
vaultProfileId: 4,
parentHostId: 3,
},
async (entityType, id) => `${entityType}-${id}`,
@@ -34,17 +33,14 @@ describe("sync references", () => {
rdpCredentialSyncId: "sshCredentials-13",
vncCredentialSyncId: null,
telnetCredentialSyncId: null,
vaultProfileSyncId: "vaultProfiles-4",
parentHostSyncId: "hosts-3",
});
expect(row).not.toHaveProperty("credentialId");
expect(row).not.toHaveProperty("vaultProfileId");
});
it("resolves stable sync IDs to IDs from the receiving database", async () => {
const ids = new Map([
["sshCredentials:credential-sync", 91],
["vaultProfiles:vault-sync", 37],
["hosts:parent-sync", 52],
]);
const row = await deserializeSyncReferences(
@@ -55,7 +51,6 @@ describe("sync references", () => {
rdpCredentialSyncId: null,
vncCredentialSyncId: null,
telnetCredentialSyncId: null,
vaultProfileSyncId: "vault-sync",
parentHostSyncId: "parent-sync",
},
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
@@ -66,7 +61,6 @@ describe("sync references", () => {
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
vaultProfileId: 37,
parentHostId: 52,
});
expect(row).not.toHaveProperty("credentialSyncId");
@@ -19,8 +19,7 @@ const mocks = vi.hoisted(() => ({
verifier: () => {},
createHostVerifier: vi.fn(),
applyCertificateAuth: vi.fn(),
getVaultCert: vi.fn(),
deleteVaultCert: vi.fn(),
forgetCert: vi.fn(),
applyAgentAuth: vi.fn(),
performPortKnocking: vi.fn(),
createJumpHostChain: vi.fn(),
@@ -41,10 +40,6 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
applyCertificateAuth: mocks.applyCertificateAuth,
}));
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
getVaultCert: mocks.getVaultCert,
deleteVaultCert: mocks.deleteVaultCert,
}));
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
applyAgentAuth: mocks.applyAgentAuth,
performPortKnocking: mocks.performPortKnocking,
@@ -107,6 +102,39 @@ function host(overrides: Partial<SshConnectHost> = {}): SshConnectHost {
};
}
/**
* A plugin-style provider that needs a browser sign-in until it has a
* certificate, and forgets it when the host rejects it.
*/
function registerSignInProvider(hasCert: boolean) {
return registerSshAuthProvider({
type: "signin",
pluginId: "fixture-plugin",
labelKey: "fixture.label",
needsUserInteraction: true,
interaction: "signin",
prepare: async (config) => {
if (!hasCert) {
return {
status: "interaction-required",
interaction: "signin",
message: "Sign in first",
};
}
config.password = "cert";
return { status: "ready" };
},
onAuthFailed: (_host, env) => {
mocks.forgetCert(env.userId, env.hostId);
return {
status: "interaction-required",
interaction: "signin",
message: "Sign in again",
};
},
});
}
async function build(
target: SshConnectHost,
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
@@ -286,30 +314,20 @@ describe("buildConnectConfig per auth type", () => {
setSshAuthTypeOwnerSource(() => []);
});
it("vault needs a profile, then a cached certificate", async () => {
const noProfile = await build(host({ authType: "vault" }));
expect(noProfile.outcome).toMatchObject({ status: "error" });
// Vault is the vault plugin's provider: see
// plugins/vault/tests/backend/vault.test.ts.
mocks.getVaultCert.mockResolvedValueOnce(null);
const noCert = await build(
host({ authType: "vault", vaultProfile: { id: 3 } }),
);
// The file manager turns the flag into requiresVaultAuth in its 401.
expect(noCert.outcome).toMatchObject({
status: "interaction-required",
interaction: "vault",
flag: "requiresVaultAuth",
it("a vault host without the vault plugin names it", async () => {
setSshAuthTypeOwnerSource(() => [
{ type: "vault", pluginId: "vault", pluginName: "HashiCorp Vault" },
]);
const { outcome } = await build(host({ authType: "vault" }));
expect(outcome).toEqual({
status: "error",
code: "provider-missing",
message: "This host uses vault, which needs the HashiCorp Vault plugin",
});
mocks.getVaultCert.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
const ready = await build(
host({ authType: "vault", vaultProfile: { id: 3 } }),
);
expect(ready.outcome.status).toBe("ready");
expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3);
setSshAuthTypeOwnerSource(() => []);
});
// Tailscale is a plugin-registered SSH auth provider now: see
@@ -454,20 +472,19 @@ describe("connectHost", () => {
});
it("throws the auth outcome instead of connecting", async () => {
mocks.getVaultCert.mockResolvedValue(null);
const vaultHost = () =>
host({ authType: "vault", vaultProfile: { id: 3 } });
const attempt = connectHost(vaultHost(), {
const dispose = registerSignInProvider(false);
const attempt = connectHost(host({ authType: "signin" }), {
userId: "user-1",
purpose: "tmux",
});
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
await expect(
connectHost(vaultHost(), {
connectHost(host({ authType: "signin" }), {
userId: "user-1",
purpose: "tmux",
}).catch((error) => error.code),
).resolves.toBe("vault-required");
).resolves.toBe("signin-required");
dispose();
});
it("goes through the jump chain and forwards to the target", async () => {
@@ -619,19 +636,17 @@ describe("connectHost", () => {
it("lets the provider react to an auth failure", async () => {
FakeSshClient.nextBehaviour = ["auth-fail"];
mocks.getVaultCert.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
const dispose = registerSignInProvider(true);
await expect(
connectHost(host({ authType: "vault", vaultProfile: { id: 3 } }), {
connectHost(host({ authType: "signin" }), {
userId: "user-1",
purpose: "fleet",
}),
).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() =>
expect(mocks.deleteVaultCert).toHaveBeenCalledWith("user-1", 3),
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
);
dispose();
});
it("pools under the old key shape", async () => {
@@ -11,7 +11,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostOwnerId,
findHostById,
}),
createCurrentVaultProfileRepository: () => ({}),
createCurrentUserRepository: () => ({ findById: vi.fn() }),
}));
+1 -10
View File
@@ -6,7 +6,6 @@ const state = vi.hoisted(() => ({
isAdminBypass: false,
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
vaultProfile: null as Record<string, unknown> | null,
auditCalls: [] as Record<string, unknown>[],
folderCredentialId: null as number | null,
sharedSecret: null as Record<string, unknown> | null,
@@ -26,9 +25,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.overrideCredentialId,
}),
createCurrentVaultProfileRepository: () => ({
findById: async () => state.vaultProfile,
}),
createCurrentUserRepository: () => ({
findById: async (userId: string) => ({ id: userId, username: userId }),
}),
@@ -115,7 +111,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
keyType: null,
credentialId: null,
shareSshAuth: false,
vaultProfileId: null,
sudoPassword: "owner-sudo",
autostartPassword: "auto-pass",
autostartKey: null,
@@ -136,7 +131,6 @@ beforeEach(() => {
state.isAdminBypass = false;
state.overrideCredentialId = null;
state.credentials.clear();
state.vaultProfile = null;
state.auditCalls = [];
state.folderCredentialId = null;
state.sharedSecret = null;
@@ -372,13 +366,11 @@ describe("resolveHostById", () => {
expect(host.key).toBe("RECIPIENT-KEY");
});
it("fully replaces Vault authentication with the recipient override", async () => {
it("fully replaces a plugin auth type with the recipient override", async () => {
state.host = baseHost({
authType: "vault",
password: null,
vaultProfileId: 7,
});
state.vaultProfile = { id: 7 };
state.overrideCredentialId = 5;
state.credentials.set("5:recipient", {
id: 5,
@@ -399,7 +391,6 @@ describe("resolveHostById", () => {
expect(host.authType).toBe("key");
expect(host.key).toBe("RECIPIENT-KEY");
expect(host.certPublicKey).toBe("ssh-ed25519-cert-v01@example certificate");
expect(host.vaultProfile).toBeUndefined();
});
it("falls back to the host username when the override credential has none", async () => {
@@ -1,268 +0,0 @@
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
import { execFileSync } from "child_process";
import { mkdtempSync, readFileSync, rmSync } from "fs";
import os from "os";
import path from "path";
import ssh2Pkg from "ssh2";
import {
generateEphemeralKeyPair,
parseCertValidBefore,
startVaultOidc,
completeVaultOidc,
signWithVault,
type VaultProfileConfig,
} from "../../hosts/vault-signer-core.js";
const { utils: ssh2Utils } = ssh2Pkg;
describe("generateEphemeralKeyPair", () => {
for (const keyType of [
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ssh-rsa",
] as const) {
it(`generates a parseable ${keyType} keypair`, () => {
const pair = generateEphemeralKeyPair(keyType);
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
// Both halves must be parseable by the same library that signs/connects.
const priv = ssh2Utils.parseKey(pair.privateKey);
expect(priv instanceof Error).toBe(false);
const pub = ssh2Utils.parseKey(pair.publicKey);
expect(pub instanceof Error).toBe(false);
});
}
it("defaults to ed25519 for unknown key types", () => {
const pair = generateEphemeralKeyPair("nonsense");
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
});
});
describe("parseCertValidBefore", () => {
let cert = "";
let signedAt = 0;
let haveSshKeygen = true;
beforeAll(() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
try {
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/ca`,
"-N",
"",
"-q",
]);
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/user`,
"-N",
"",
"-q",
]);
signedAt = Math.floor(Date.now() / 1000);
execFileSync("ssh-keygen", [
"-s",
`${dir}/ca`,
"-I",
"test-id",
"-n",
"root",
"-V",
"+60m",
`${dir}/user.pub`,
]);
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
} catch {
haveSshKeygen = false;
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reads valid_before from a real ssh-keygen certificate", () => {
if (!haveSshKeygen) {
console.warn("ssh-keygen unavailable; skipping real-cert parse test");
return;
}
const validBefore = parseCertValidBefore(cert);
// -V +60m => valid_before is roughly signedAt + 3600 (start rounds to minute)
expect(validBefore).toBeGreaterThan(signedAt + 3300);
expect(validBefore).toBeLessThan(signedAt + 3900);
});
it("returns 0 for malformed input", () => {
expect(parseCertValidBefore("")).toBe(0);
expect(parseCertValidBefore("not-a-cert")).toBe(0);
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
});
});
describe("Vault HTTP flow (mocked fetch)", () => {
const profile: VaultProfileConfig = {
id: 1,
vaultAddr: "https://vault.example.com:8200/",
vaultNamespace: "team-a",
oidcMount: "oidc",
oidcRole: "developer",
sshMount: "ssh-client-signer",
sshRole: "my-role",
validPrincipals: "root,deploy",
keyType: "ssh-ed25519",
};
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetch(
impl: (
url: string,
init: RequestInit,
) => { status?: number; body: unknown },
) {
const calls: Array<{ url: string; init: RequestInit }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
const { status = 200, body } = impl(url, init);
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
} as Response;
}),
);
return calls;
}
it("startVaultOidc posts auth_url and extracts state", async () => {
const calls = mockFetch(() => ({
body: {
data: {
auth_url:
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
},
},
}));
const result = await startVaultOidc(
profile,
"https://termix/vault/oidc/callback",
);
expect(result.state).toBe("ST-abc123");
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
);
expect(calls[0].init.method).toBe("POST");
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
role: "developer",
redirect_uri: "https://termix/vault/oidc/callback",
});
expect(body.client_nonce).toBe(result.clientNonce);
});
it("completeVaultOidc returns the client token", async () => {
const calls = mockFetch(() => ({
body: { auth: { client_token: "hvs.TESTTOKEN" } },
}));
const token = await completeVaultOidc(profile, {
state: "ST-abc123",
code: "auth-code",
clientNonce: "nonce123",
});
expect(token).toBe("hvs.TESTTOKEN");
const url = new URL(calls[0].url);
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
expect(url.searchParams.get("state")).toBe("ST-abc123");
expect(url.searchParams.get("code")).toBe("auth-code");
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
expect(calls[0].init.method).toBe("GET");
});
it("signWithVault posts the public key and returns signed_key", async () => {
const calls = mockFetch(() => ({
body: {
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
},
}));
const cert = await signWithVault(
profile,
"hvs.TESTTOKEN",
"ssh-ed25519 AAAApub comment",
);
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
);
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
public_key: "ssh-ed25519 AAAApub comment",
cert_type: "user",
valid_principals: "root,deploy",
});
});
it("surfaces Vault error messages", async () => {
mockFetch(() => ({
status: 400,
body: { errors: ["role not found", "permission denied"] },
}));
await expect(
signWithVault(profile, "tok", "ssh-ed25519 AAAA"),
).rejects.toThrow(/role not found; permission denied/);
});
});
// Live integration against a real Vault (e.g. `vault server -dev` in Docker).
// Runs only when VAULT_ADDR + VAULT_TOKEN are set and the SSH signer mount
// (VAULT_SSH_MOUNT/VAULT_SSH_ROLE) has been configured by the test harness.
describe("Vault live signing", () => {
const addr = process.env.VAULT_ADDR;
const token = process.env.VAULT_TOKEN;
const run = !!addr && !!token;
it.skipIf(!run)("signs an ephemeral key against a real Vault", async () => {
const profile: VaultProfileConfig = {
id: 99,
vaultAddr: addr!,
sshMount: process.env.VAULT_SSH_MOUNT || "ssh-client-signer",
sshRole: process.env.VAULT_SSH_ROLE || "my-role",
validPrincipals: "root",
keyType: "ssh-ed25519",
};
const pair = generateEphemeralKeyPair(profile.keyType);
const before = Math.floor(Date.now() / 1000);
const cert = await signWithVault(profile, token!, pair.publicKey);
expect(cert).toMatch(/-cert-v01@openssh\.com /);
// The signed cert must parse with the same library used to connect.
const parsed = ssh2Utils.parseKey(cert);
expect(parsed instanceof Error).toBe(false);
const validBefore = parseCertValidBefore(cert);
expect(validBefore).toBeGreaterThan(before);
});
});
@@ -96,15 +96,16 @@ describe("core sync entities", () => {
registerCoreSyncEntities();
});
// The frozen array also lists networkTopology, snippets/snippetFolders and
// dashboardServiceLinks/homepageItems, now registered by the
// network-topology, snippets and homepage plugins rather than core.
// The frozen array also lists networkTopology, snippets/snippetFolders,
// dashboardServiceLinks/homepageItems and vaultProfiles, now registered by
// the network-topology, snippets, homepage and vault plugins rather than core.
const PLUGIN_OWNED_TYPES = new Set([
"networkTopology",
"snippets",
"snippetFolders",
"dashboardServiceLinks",
"homepageItems",
"vaultProfiles",
]);
it("registers every core-owned entity the Electron client knows about", () => {
@@ -0,0 +1,152 @@
/**
* The Vault host settings migration.
*
* Every host's ssh_data.vault_profile_id must come through into the vault
* plugin's profileId host setting, and an install that used Vault keeps the
* 2.8 redirect URI. 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>>,
legacyProfiles: 0,
adoptedProfiles: 0,
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "vault" ? { 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: unknown) => {
const text = JSON.stringify(query);
if (text.includes("vault_profile_id")) {
if (state.columnGone) throw new Error("no such column");
return state.hostRows.filter((row) => row.vault_profile_id != null);
}
const count = text.includes("p_vault_profiles")
? state.adoptedProfiles
: state.legacyProfiles;
return Array.from({ length: Math.min(count, 1) }, () => ({ "1": 1 }));
},
runStatement: async () => {},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runVaultSettingsMigration } from "../../utils/crypto-migration/vault-settings-migration.js";
const setting = (scopeId: string | null, key: string) =>
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
?.value;
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.legacyProfiles = 0;
state.adoptedProfiles = 0;
state.pluginInstalled = true;
state.columnGone = false;
});
describe("runVaultSettingsMigration", () => {
it("moves every host's profile, keeps the old redirect URI, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, vault_profile_id: 7 },
{ id: 2, vault_profile_id: null },
{ id: 3, vault_profile_id: "9" },
];
state.adoptedProfiles = 2;
const first = await runVaultSettingsMigration();
expect(first).toEqual({
hostsMoved: 2,
hostsSkipped: 0,
legacyCallback: true,
});
expect(setting("1", "profileId")).toBe("7");
expect(setting("2", "profileId")).toBeUndefined();
expect(setting("3", "profileId")).toBe("9");
expect(setting(null, "legacyCallback")).toBe("true");
const rows = state.pluginRows.length;
const second = await runVaultSettingsMigration();
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
legacyCallback: false,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("keeps a host's value already set in the plugin", async () => {
state.pluginRows.push({
scope: "host",
scopeId: "1",
key: "profileId",
value: "4",
});
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(setting("1", "profileId")).toBe("4");
});
it("keeps the old redirect URI for profiles the plugin has not adopted yet", async () => {
state.legacyProfiles = 1;
const result = await runVaultSettingsMigration();
expect(result.legacyCallback).toBe(true);
});
it("uses the new redirect URI on a fresh install, and profiles made later do not change that", async () => {
state.columnGone = true;
const first = await runVaultSettingsMigration();
expect(first.legacyCallback).toBe(false);
expect(setting(null, "legacyCallback")).toBe("false");
state.adoptedProfiles = 3;
await runVaultSettingsMigration();
expect(setting(null, "legacyCallback")).toBe("false");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,149 @@
/**
* Moves each host's Vault signer profile out of ssh_data and into the vault
* plugin's profileId host setting.
*
* An install that used Vault keeps sending the pre-2.9 redirect URI
* (/vault/oidc/callback), because that is the one its Vault OIDC roles
* allow: the plugin's legacyCallback setting is turned on.
*
* 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 or setting that already has a value 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 { selectRows } from "./raw-rows.js";
const PLUGIN_ID = "vault";
const SETTING_KEY = "profileId";
interface LegacyHostRow {
id: number;
vault_profile_id: number | string | null;
}
export interface VaultSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
legacyCallback: boolean;
}
async function legacyRows(): Promise<LegacyHostRow[]> {
try {
// Raw SQL: schema.ts no longer declares vault_profile_id.
return await selectRows<LegacyHostRow>(
sql`SELECT id, vault_profile_id FROM ssh_data WHERE vault_profile_id IS NOT NULL`,
);
} catch {
// A fresh install never had the column.
return [];
}
}
/** Whether 2.8 had any Vault profile, before or after the plugin adopted them. */
async function hadProfiles(): Promise<boolean> {
for (const table of ["vault_profiles", "p_vault_profiles"]) {
try {
const rows = await selectRows<unknown>(
sql`SELECT 1 FROM ${sql.identifier(table)} LIMIT 1`,
);
if (rows.length > 0) return true;
} catch {
// table not there
}
}
return false;
}
export async function runVaultSettingsMigration(): Promise<VaultSettingsMigrationResult> {
const result: VaultSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
legacyCallback: false,
};
// 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();
const rows = await legacyRows();
for (const row of rows) {
const profileId = Number(row.vault_profile_id);
if (!Number.isInteger(profileId) || profileId <= 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
);
if (existing) {
result.hostsSkipped++;
continue;
}
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
JSON.stringify(profileId),
);
result.hostsMoved++;
} catch (error) {
databaseLogger.warn("Vault host settings migration failed for a host", {
operation: "vault_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Decided once, on the first boot with the plugin: written either way, so
// profiles created on a fresh install later never turn it on.
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
);
if (!existing || existing.value === null) {
result.legacyCallback = rows.length > 0 || (await hadProfiles());
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
JSON.stringify(result.legacyCallback),
);
}
} catch (error) {
databaseLogger.warn("Vault legacy callback migration failed", {
operation: "vault_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved the Vault profile for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "vault_settings_migration" },
);
}
return result;
}
-1
View File
@@ -40,7 +40,6 @@ class FieldCrypto {
// webhook URLs, which are credentials like any other.
notification_channels: new Set(["config"]),
termix_identity_ca: new Set(["privateKey"]),
vault_tokens: new Set(["sshCert", "privateKey"]),
};
static encryptField(
-3
View File
@@ -152,8 +152,6 @@ export type Host = {
autostartKeyPassword?: string;
credentialId?: number;
vaultProfileId?: number | null;
vaultProfile?: { id?: number | null };
overrideCredentialUsername?: boolean;
userId?: string;
enableTerminal: boolean;
@@ -299,7 +297,6 @@ export interface HostData {
keyType?: string;
sudoPassword?: string;
credentialId?: number | null;
vaultProfileId?: number | null;
connectionOrigin?: "local" | "remote" | null;
overrideCredentialUsername?: boolean;
enableTerminal?: boolean;
-21
View File
@@ -35,7 +35,6 @@ export type Host = {
| "agent";
shareSshAuth?: boolean;
credentialId?: string;
vaultProfileId?: string;
overrideCredentialUsername?: boolean;
password?: string;
hasPassword?: boolean;
@@ -200,26 +199,6 @@ export type Credential = {
permissionLevel?: "use" | "manage";
};
// HashiCorp Vault SSH signer profile — shareable connection settings only
// (no secrets). Users authenticate to Vault via OIDC at connect time.
export type VaultProfile = {
id: string;
name: string;
description?: string;
folder?: string;
tags?: string[];
vaultAddr: string;
vaultNamespace?: string;
oidcMount?: string;
oidcRole?: string;
sshMount?: string;
sshRole: string;
validPrincipals?: string;
keyType?: string;
shared: boolean;
owned: boolean;
};
export type HostFolder = {
name: string;
children: (Host | HostFolder)[];
-60
View File
@@ -1,60 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export interface VaultProfilePayload {
name: string;
description?: string | null;
folder?: string | null;
tags?: string[];
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
shared?: boolean;
}
export async function getVaultProfiles(): Promise<Record<string, unknown>[]> {
try {
const response = await authApi.get("/vault/profiles");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch vault profiles");
}
}
export async function createVaultProfile(
payload: VaultProfilePayload,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/vault/profiles", payload);
return response.data;
} catch (error) {
throw handleApiError(error, "create vault profile");
}
}
export async function updateVaultProfile(
id: number,
payload: Partial<VaultProfilePayload>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(`/vault/profiles/${id}`, payload);
return response.data;
} catch (error) {
throw handleApiError(error, "update vault profile");
}
}
export async function deleteVaultProfile(
id: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/vault/profiles/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete vault profile");
}
}
-24
View File
@@ -607,30 +607,6 @@
"authenticationMethodDesc": "Select the one method Termix will use to authenticate this SSH connection. Other methods are not tried automatically.",
"storedCredential": "Stored Credential",
"selectACredential": "Select a credential...",
"vaultProfile": "Vault Signer Profile",
"selectAVaultProfile": "Select a Vault profile...",
"vaultProfileHint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"vaultNewProfile": "New profile",
"vaultManageProfiles": "Manage Vault profiles",
"vaultAddrLabel": "Vault Address",
"vaultNamespaceLabel": "Namespace",
"vaultOidcMountLabel": "OIDC Auth Mount",
"vaultOidcRoleLabel": "OIDC Role",
"vaultSshMountLabel": "SSH Secrets Mount",
"vaultSshRoleLabel": "SSH Signer Role",
"vaultValidPrincipalsLabel": "Valid Principals",
"vaultKeyTypeLabel": "Ephemeral Key Type",
"vaultSharedLabel": "Share with all users",
"vaultCreateProfile": "Create profile",
"vaultProfileCreated": "Vault profile created",
"vaultProfileSaved": "Vault profile saved",
"vaultProfileDeleted": "Vault profile deleted",
"vaultProfileSaveFailed": "Failed to save Vault profile",
"vaultProfileDeleteFailed": "Failed to delete Vault profile",
"vaultSaveProfile": "Save profile",
"vaultProfileValidationError": "Name, Vault address and SSH signer role are required",
"vaultNoProfiles": "No Vault profiles yet.",
"vaultSharedBadge": "shared",
"keyTypeLabel": "Key Type",
"keyTypeAuto": "Auto Detect",
"keyPasteTab": "Paste",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Kies die een metode wat Termix sal gebruik om hierdie SSH-verbinding te verifieer. Ander metodes word nie outomaties probeer nie.",
"storedCredential": "Gestoorde geloofsbriewe",
"selectACredential": "Kies 'n geloofsbrief...",
"vaultProfile": "Kluis Ondertekenaar Profiel",
"selectAVaultProfile": "Kies 'n kluisprofiel...",
"vaultProfileHint": "Instellings kom van die gedeelde profiel; jy sal by Vault aanmeld via OIDC wanneer jy koppel. Geen geheime word gestoor nie.",
"vaultNewProfile": "Nuwe profiel",
"secretRefHint": "Jy kan 'n 1Password-verwysing (op://vault/item/field) in plaas van die geheim plak; dit word van jou geheime bron afgehaal wanneer jy koppel.",
"secretSourcesManage": "Bestuur geheime bronne",
"secretSourcesTitle": "Geheime bronne",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Naam, bediener-URL en token word vereis",
"secretSourceSaved": "Geheime bron gestoor",
"secretSourceDeleted": "Geheime bron verwyder",
"vaultManageProfiles": "Bestuur Vault-profiele",
"vaultAddrLabel": "Kluisadres",
"vaultNamespaceLabel": "Naamruimte",
"vaultOidcMountLabel": "OIDC-magtigingsmontering",
"vaultOidcRoleLabel": "OIDC-rol",
"vaultSshMountLabel": "SSH-geheime berg",
"vaultSshRoleLabel": "SSH-ondertekenaarrol",
"vaultValidPrincipalsLabel": "Geldige Prinsipale",
"vaultKeyTypeLabel": "Efemere Sleuteltipe",
"vaultSharedLabel": "Deel met alle gebruikers",
"vaultCreateProfile": "Skep profiel",
"vaultProfileCreated": "Kluisprofiel geskep",
"vaultProfileSaved": "Kluisprofiel gestoor",
"vaultProfileDeleted": "Kluisprofiel uitgevee",
"vaultProfileSaveFailed": "Kon nie kluisprofiel stoor nie",
"vaultProfileDeleteFailed": "Kon nie kluisprofiel uitvee nie",
"vaultSaveProfile": "Stoor profiel",
"vaultProfileValidationError": "Naam, kluisadres en SSH-ondertekenrol word vereis",
"vaultNoProfiles": "Geen Vault-profiele nog nie.",
"vaultSharedBadge": "gedeel",
"keyTypeLabel": "Sleuteltipe",
"keyTypeAuto": "Outomatiese opsporing",
"keyPasteTab": "Plak",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "اختر الطريقة التي سيستخدمها Termix للتحقق من صحة اتصال SSH هذا. لن يتم تجربة الطرق الأخرى تلقائيًا.",
"storedCredential": "بيانات اعتماد مخزنة",
"selectACredential": "اختيار بيانات اعتماد...",
"vaultProfile": "ملف توقيع Vault",
"selectAVaultProfile": "اختيار ملف توقيع Vault...",
"vaultProfileHint": "تأتي الإعدادات من الملف المشترك؛ ستقوم بتسجيل الدخول إلى Vault عبر OIDC عند الاتصال. لا يتم تخزين أي أسرار.",
"vaultNewProfile": "ملف جديد",
"secretRefHint": "يمكنك لصق مرجع 1Password (op://vault/item/field) بدلاً من السر؛ يتم جلبه من مصدر السر الخاص بك عند الاتصال.",
"secretSourcesManage": "إدارة المصادر السرية",
"secretSourcesTitle": "مصادر سرية",
@@ -739,26 +735,6 @@
"secretSourceRequired": "الاسم وعنوان URL للخادم والرمز المميز مطلوبة",
"secretSourceSaved": "تم حفظ المصدر السري",
"secretSourceDeleted": "تم حذف المصدر السري",
"vaultManageProfiles": "إدارة ملفات Vault",
"vaultAddrLabel": "عنوان Vault",
"vaultNamespaceLabel": "مساحة الاسم",
"vaultOidcMountLabel": "تثبيت مصادقة OIDC",
"vaultOidcRoleLabel": "دور OIDC",
"vaultSshMountLabel": "تثبيت أسرار SSH",
"vaultSshRoleLabel": "دور موقع SSH",
"vaultValidPrincipalsLabel": "المستخدمون المسموح بهم",
"vaultKeyTypeLabel": "نوع المفتاح المؤقت",
"vaultSharedLabel": "مشاركة مع جميع المستخدمين",
"vaultCreateProfile": "إنشاء ملف تعريف",
"vaultProfileCreated": "تم إنشاء ملف تعريف Vault",
"vaultProfileSaved": "تم حفظ ملف تعريف Vault",
"vaultProfileDeleted": "تم حذف ملف تعريف Vault",
"vaultProfileSaveFailed": "فشل حفظ ملف تعريف Vault",
"vaultProfileDeleteFailed": "فشل حذف ملف تعريف Vault",
"vaultSaveProfile": "حفظ ملف التعريف",
"vaultProfileValidationError": "الاسم وعنوان Vault ودور الموقّع SSH مطلوب",
"vaultNoProfiles": "لا توجد ملفات تعريف Vault بعد.",
"vaultSharedBadge": "مشترك",
"keyTypeLabel": "نوع المفتاح",
"keyTypeAuto": "اكتشاف تلقائي",
"keyPasteTab": "لصق",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Изберете единствения метод, който Termix ще използва за удостоверяване на тази SSH връзка. Други методи не се опитват автоматично.",
"storedCredential": "Съхранени идентификационни данни",
"selectACredential": "Изберете идентификационен номер...",
"vaultProfile": "Профил на подписващ в трезора",
"selectAVaultProfile": "Изберете профил в трезора...",
"vaultProfileHint": "Настройките идват от споделения профил; ще влезете в Vault чрез OIDC, когато се свържете. Не се съхраняват тайни.",
"vaultNewProfile": "Нов профил",
"secretRefHint": "Можете да поставите препратка към 1Password (op://vault/item/field) вместо тайната; тя се извлича от вашия източник на тайна при свързване.",
"secretSourcesManage": "Управлявайте тайните източници",
"secretSourcesTitle": "Тайни източници",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Име, URL адрес на сървъра и токен са задължителни",
"secretSourceSaved": "Секретният източник е запазен",
"secretSourceDeleted": "Секретният източник е изтрит",
"vaultManageProfiles": "Управление на профили в хранилището",
"vaultAddrLabel": "Адрес на трезора",
"vaultNamespaceLabel": "Именно пространство",
"vaultOidcMountLabel": "OIDC Authentication Mount",
"vaultOidcRoleLabel": "Роля на OIDC",
"vaultSshMountLabel": "SSH Secrets Mount",
"vaultSshRoleLabel": "Роля на SSH подписващ",
"vaultValidPrincipalsLabel": "Валидни принципали",
"vaultKeyTypeLabel": "Тип ефимерен ключ",
"vaultSharedLabel": "Споделяне с всички потребители",
"vaultCreateProfile": "Създаване на профил",
"vaultProfileCreated": "Профилът на трезора е създаден",
"vaultProfileSaved": "Профилът на трезора е запазен",
"vaultProfileDeleted": "Профилът в трезора е изтрит",
"vaultProfileSaveFailed": "Запазването на профила в Трезора не бе успешно",
"vaultProfileDeleteFailed": "Изтриването на профила в Vault не бе успешно",
"vaultSaveProfile": "Запазване на профил",
"vaultProfileValidationError": "Име, адрес на трезора и роля на SSH подписващ са задължителни",
"vaultNoProfiles": "Все още няма профили в Трезора.",
"vaultSharedBadge": "споделено",
"keyTypeLabel": "Тип ключ",
"keyTypeAuto": "Автоматично откриване",
"keyPasteTab": "Паста",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "এই SSH সংযোগটি প্রমাণীকরণের জন্য Termix যে পদ্ধতিটি ব্যবহার করবে, সেটি নির্বাচন করুন। অন্য পদ্ধতিগুলো স্বয়ংক্রিয়ভাবে চেষ্টা করা হয় না।",
"storedCredential": "সংরক্ষিত পরিচয়পত্র",
"selectACredential": "একটি শংসাপত্র নির্বাচন করুন...",
"vaultProfile": "ভল্ট স্বাক্ষরকারীর প্রোফাইল",
"selectAVaultProfile": "একটি ভল্ট প্রোফাইল নির্বাচন করুন...",
"vaultProfileHint": "সেটিংস শেয়ার করা প্রোফাইল থেকে আসে; সংযোগ করার সময় আপনি OIDC-এর মাধ্যমে ভল্টে সাইন ইন করবেন। কোনো গোপনীয় তথ্য সংরক্ষিত থাকে না।",
"vaultNewProfile": "নতুন প্রোফাইল",
"secretRefHint": "আপনি সিক্রেটের পরিবর্তে একটি 1Password রেফারেন্স (op://vault/item/field) পেস্ট করতে পারেন; সংযোগ করার সময় এটি আপনার সিক্রেট সোর্স থেকে নিয়ে আসা হয়।",
"secretSourcesManage": "গোপন উৎস পরিচালনা করুন",
"secretSourcesTitle": "গোপন সূত্র",
@@ -739,26 +735,6 @@
"secretSourceRequired": "নাম, সার্ভার ইউআরএল এবং টোকেন প্রয়োজন।",
"secretSourceSaved": "গোপন উৎস সংরক্ষিত",
"secretSourceDeleted": "গোপন সূত্র মুছে ফেলা হয়েছে",
"vaultManageProfiles": "ভল্ট প্রোফাইলগুলি পরিচালনা করুন",
"vaultAddrLabel": "ভল্ট ঠিকানা",
"vaultNamespaceLabel": "নেমস্পেস",
"vaultOidcMountLabel": "OIDC অথোরাইজেশন মাউন্ট",
"vaultOidcRoleLabel": "OIDC ভূমিকা",
"vaultSshMountLabel": "SSH সিক্রেটস মাউন্ট",
"vaultSshRoleLabel": "SSH স্বাক্ষরকারীর ভূমিকা",
"vaultValidPrincipalsLabel": "বৈধ অধ্যক্ষরা",
"vaultKeyTypeLabel": "ক্ষণস্থায়ী কী টাইপ",
"vaultSharedLabel": "সকল ব্যবহারকারীর সাথে শেয়ার করুন",
"vaultCreateProfile": "প্রোফাইল তৈরি করুন",
"vaultProfileCreated": "ভল্ট প্রোফাইল তৈরি করা হয়েছে",
"vaultProfileSaved": "ভল্ট প্রোফাইল সংরক্ষিত হয়েছে",
"vaultProfileDeleted": "ভল্ট প্রোফাইল মুছে ফেলা হয়েছে",
"vaultProfileSaveFailed": "ভল্ট প্রোফাইল সংরক্ষণ করতে ব্যর্থ হয়েছে",
"vaultProfileDeleteFailed": "ভল্ট প্রোফাইল মুছে ফেলা ব্যর্থ হয়েছে",
"vaultSaveProfile": "প্রোফাইল সংরক্ষণ করুন",
"vaultProfileValidationError": "নাম, ভল্ট অ্যাড্রেস এবং SSH সাইনার রোল প্রয়োজন।",
"vaultNoProfiles": "এখনো কোনো ভল্ট প্রোফাইল তৈরি হয়নি।",
"vaultSharedBadge": "শেয়ার করা",
"keyTypeLabel": "চাবির ধরন",
"keyTypeAuto": "স্বয়ংক্রিয়ভাবে সনাক্ত করুন",
"keyPasteTab": "পেস্ট",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Seleccioneu el mètode que Termix utilitzarà per autenticar aquesta connexió SSH. Els altres mètodes no es proven automàticament.",
"storedCredential": "Credencial emmagatzemada",
"selectACredential": "Selecciona una credencial...",
"vaultProfile": "Perfil del signant de la caixa forta",
"selectAVaultProfile": "Seleccioneu un perfil de Vault...",
"vaultProfileHint": "La configuració prové del perfil compartit; iniciaràs la sessió a Vault mitjançant OIDC quan et connectis. No s'emmagatzemen secrets.",
"vaultNewProfile": "Nou perfil",
"secretRefHint": "Podeu enganxar una referència 1Password (op://vault/item/field) en comptes del secret; s'obté de la font del secret quan us connecteu.",
"secretSourcesManage": "Gestiona fonts secretes",
"secretSourcesTitle": "Fonts secretes",
@@ -739,26 +735,6 @@
"secretSourceRequired": "El nom, l'URL del servidor i el testimoni són obligatoris",
"secretSourceSaved": "Font secreta desada",
"secretSourceDeleted": "Font secreta suprimida",
"vaultManageProfiles": "Gestiona els perfils de Vault",
"vaultAddrLabel": "Adreça de la caixa forta",
"vaultNamespaceLabel": "Espai de noms",
"vaultOidcMountLabel": "Muntatge d'autenticació OIDC",
"vaultOidcRoleLabel": "Rol de l'OIDC",
"vaultSshMountLabel": "Muntanya de Secrets SSH",
"vaultSshRoleLabel": "Rol de signant SSH",
"vaultValidPrincipalsLabel": "Principals vàlids",
"vaultKeyTypeLabel": "Tipus de clau efímera",
"vaultSharedLabel": "Comparteix amb tots els usuaris",
"vaultCreateProfile": "Crea un perfil",
"vaultProfileCreated": "Perfil de la caixa forta creat",
"vaultProfileSaved": "Perfil de la caixa forta desat",
"vaultProfileDeleted": "Perfil de la caixa forta suprimit",
"vaultProfileSaveFailed": "No s'ha pogut desar el perfil de Vault",
"vaultProfileDeleteFailed": "No s'ha pogut suprimir el perfil de Vault",
"vaultSaveProfile": "Desa el perfil",
"vaultProfileValidationError": "El nom, l'adreça de la caixa forta i el rol de signant SSH són obligatoris.",
"vaultNoProfiles": "Encara no hi ha perfils de Vault.",
"vaultSharedBadge": "compartit",
"keyTypeLabel": "Tipus de clau",
"keyTypeAuto": "Detecció automàtica",
"keyPasteTab": "Enganxa",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Vyberte jednu metodu, kterou Termix použije k ověření tohoto SSH připojení. Ostatní metody se nebudou automaticky zkoušet.",
"storedCredential": "Uložené přihlašovací údaje",
"selectACredential": "Vyberte pověření...",
"vaultProfile": "Profil podepisujícího trezoru",
"selectAVaultProfile": "Vyberte profil úložiště...",
"vaultProfileHint": "Nastavení pocházejí ze sdíleného profilu; po připojení se do úložiště přihlásíte přes OIDC. Žádné tajné kódy se neukládají.",
"vaultNewProfile": "Nový profil",
"secretRefHint": "Místo tajného hesla můžete vložit odkaz na 1Password (op://vault/item/field); při připojování se načte ze zdroje vašeho tajného hesla.",
"secretSourcesManage": "Správa tajných zdrojů",
"secretSourcesTitle": "Tajné zdroje",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Jméno, URL adresa serveru a token jsou povinné",
"secretSourceSaved": "Tajný zdroj uložen",
"secretSourceDeleted": "Tajný zdroj smazán",
"vaultManageProfiles": "Správa profilů úložiště",
"vaultAddrLabel": "Adresa trezoru",
"vaultNamespaceLabel": "Jmenný prostor",
"vaultOidcMountLabel": "Připojení autorizace OIDC",
"vaultOidcRoleLabel": "Role OIDC",
"vaultSshMountLabel": "SSH tajemství Mount",
"vaultSshRoleLabel": "Role SSH podepisujícího",
"vaultValidPrincipalsLabel": "Platné principy",
"vaultKeyTypeLabel": "Typ dočasného klíče",
"vaultSharedLabel": "Sdílet se všemi uživateli",
"vaultCreateProfile": "Vytvořit profil",
"vaultProfileCreated": "Profil úložiště vytvořen",
"vaultProfileSaved": "Profil trezoru uložen",
"vaultProfileDeleted": "Profil trezoru smazán",
"vaultProfileSaveFailed": "Uložení profilu úložiště se nezdařilo.",
"vaultProfileDeleteFailed": "Profil v úložišti se nepodařilo smazat.",
"vaultSaveProfile": "Uložit profil",
"vaultProfileValidationError": "Jméno, adresa trezoru a role SSH signatáře jsou povinné.",
"vaultNoProfiles": "Zatím žádné profily v úložišti Vault.",
"vaultSharedBadge": "sdílené",
"keyTypeLabel": "Typ klíče",
"keyTypeAuto": "Automatická detekce",
"keyPasteTab": "Pasta",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Vælg den metode, som Termix vil bruge til at godkende denne SSH-forbindelse. Andre metoder afprøves ikke automatisk.",
"storedCredential": "Gemte legitimationsoplysninger",
"selectACredential": "Vælg en legitimationsoplysninger...",
"vaultProfile": "Profil for underskriver af arkiv",
"selectAVaultProfile": "Vælg en Vault-profil...",
"vaultProfileHint": "Indstillingerne kommer fra den delte profil; du logger ind på Vault via OIDC, når du opretter forbindelse. Der gemmes ingen hemmeligheder.",
"vaultNewProfile": "Ny profil",
"secretRefHint": "Du kan indsætte en 1Password-reference (op://vault/item/field) i stedet for hemmeligheden; den hentes fra din hemmelige kilde, når du opretter forbindelse.",
"secretSourcesManage": "Administrer hemmelige kilder",
"secretSourcesTitle": "Hemmelige kilder",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Navn, server-URL og token er påkrævet",
"secretSourceSaved": "Hemmelig kilde gemt",
"secretSourceDeleted": "Hemmelig kilde slettet",
"vaultManageProfiles": "Administrer Vault-profiler",
"vaultAddrLabel": "Adresse i boksen",
"vaultNamespaceLabel": "Navnerum",
"vaultOidcMountLabel": "OIDC-godkendelsesmontering",
"vaultOidcRoleLabel": "OIDC-rolle",
"vaultSshMountLabel": "SSH-hemmeligheder monteres",
"vaultSshRoleLabel": "SSH-underskriverrolle",
"vaultValidPrincipalsLabel": "Gyldige principper",
"vaultKeyTypeLabel": "Kortvarig nøgletype",
"vaultSharedLabel": "Del med alle brugere",
"vaultCreateProfile": "Opret profil",
"vaultProfileCreated": "Vault-profil oprettet",
"vaultProfileSaved": "Vault-profil gemt",
"vaultProfileDeleted": "Vault-profil slettet",
"vaultProfileSaveFailed": "Kunne ikke gemme Vault-profilen",
"vaultProfileDeleteFailed": "Kunne ikke slette Vault-profilen",
"vaultSaveProfile": "Gem profil",
"vaultProfileValidationError": "Navn, Vault-adresse og SSH-underskriverrolle er påkrævet",
"vaultNoProfiles": "Ingen Vault-profiler endnu.",
"vaultSharedBadge": "delt",
"keyTypeLabel": "Nøgletype",
"keyTypeAuto": "Automatisk registrering",
"keyPasteTab": "Indsæt",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Wählen Sie die Methode aus, die Termix zur Authentifizierung dieser SSH-Verbindung verwenden soll. Andere Methoden werden nicht automatisch ausprobiert.",
"storedCredential": "Gespeicherte Anmeldedaten",
"selectACredential": "Anmeldedaten auswählen...",
"vaultProfile": "Vault-Signer-Profil",
"selectAVaultProfile": "Vault-Profil auswählen...",
"vaultProfileHint": "Die Einstellungen stammen aus dem freigegebenen Profil; Sie melden sich beim Verbinden per OIDC bei Vault an. Es werden keine Geheimnisse gespeichert.",
"vaultNewProfile": "Neues Profil",
"secretRefHint": "Sie können anstelle des Geheimnisses eine 1Password-Referenz (op://vault/item/field) einfügen; diese wird beim Verbindungsaufbau aus Ihrer Geheimnisquelle abgerufen.",
"secretSourcesManage": "Geheime Quellen verwalten",
"secretSourcesTitle": "Geheime Quellen",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Name, Server-URL und Token werden benötigt.",
"secretSourceSaved": "Geheime Quelle gerettet",
"secretSourceDeleted": "Geheime Quelle gelöscht",
"vaultManageProfiles": "Vault-Profile verwalten",
"vaultAddrLabel": "Vault-Adresse",
"vaultNamespaceLabel": "Namespace",
"vaultOidcMountLabel": "OIDC-Auth-Mount",
"vaultOidcRoleLabel": "OIDC-Rolle",
"vaultSshMountLabel": "SSH-Secrets-Mount",
"vaultSshRoleLabel": "SSH-Signer-Rolle",
"vaultValidPrincipalsLabel": "Gültige Principals",
"vaultKeyTypeLabel": "Ephemerer Schlüsseltyp",
"vaultSharedLabel": "Mit allen Benutzern teilen",
"vaultCreateProfile": "Profil erstellen",
"vaultProfileCreated": "Vault-Profil erstellt",
"vaultProfileSaved": "Vault-Profil gespeichert",
"vaultProfileDeleted": "Vault-Profil gelöscht",
"vaultProfileSaveFailed": "Fehler beim Speichern des Vault-Profils",
"vaultProfileDeleteFailed": "Fehler beim Löschen des Vault-Profils",
"vaultSaveProfile": "Profil speichern",
"vaultProfileValidationError": "Name, Vault-Adresse und SSH-Signer-Rolle sind erforderlich",
"vaultNoProfiles": "Noch keine Vault-Profile.",
"vaultSharedBadge": "geteilt",
"keyTypeLabel": "Schlüsseltyp",
"keyTypeAuto": "Automatisch erkennen",
"keyPasteTab": "Einfügen",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Επιλέξτε τη μέθοδο που θα χρησιμοποιήσει το Termix για τον έλεγχο ταυτότητας αυτής της σύνδεσης SSH. Άλλες μέθοδοι δεν δοκιμάζονται αυτόματα.",
"storedCredential": "Αποθηκευμένα διαπιστευτήρια",
"selectACredential": "Επιλέξτε ένα πιστοποιητικό...",
"vaultProfile": "Προφίλ Υπογράφοντος Vault",
"selectAVaultProfile": "Επιλέξτε ένα προφίλ Vault...",
"vaultProfileHint": "Οι ρυθμίσεις προέρχονται από το κοινόχρηστο προφίλ. Θα συνδεθείτε στο Vault μέσω OIDC όταν συνδεθείτε. Δεν αποθηκεύονται μυστικά.",
"vaultNewProfile": "Νέο προφίλ",
"secretRefHint": "Μπορείτε να επικολλήσετε μια αναφορά 1Password (op://vault/item/field) αντί για το μυστικό. Αυτό ανακτάται από την μυστική σας πηγή κατά τη σύνδεση.",
"secretSourcesManage": "Διαχείριση μυστικών πηγών",
"secretSourcesTitle": "Μυστικές πηγές",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Απαιτούνται όνομα, διεύθυνση URL διακομιστή και διακριτικό",
"secretSourceSaved": "Η μυστική πηγή αποθηκεύτηκε",
"secretSourceDeleted": "Η μυστική πηγή διαγράφηκε",
"vaultManageProfiles": "Διαχείριση προφίλ Vault",
"vaultAddrLabel": "Διεύθυνση θησαυροφυλακίου",
"vaultNamespaceLabel": "Χώρος ονομάτων",
"vaultOidcMountLabel": "Μοντάζ ελέγχου ταυτότητας OIDC",
"vaultOidcRoleLabel": "Ρόλος OIDC",
"vaultSshMountLabel": "SSH Secrets Mount",
"vaultSshRoleLabel": "Ρόλος Υπογράφοντος SSH",
"vaultValidPrincipalsLabel": "Έγκυρες Αρχές",
"vaultKeyTypeLabel": "Εφήμερος Τύπος Κλειδιού",
"vaultSharedLabel": "Κοινοποίηση με όλους τους χρήστες",
"vaultCreateProfile": "Δημιουργία προφίλ",
"vaultProfileCreated": "Δημιουργήθηκε προφίλ Vault",
"vaultProfileSaved": "Το προφίλ του Vault αποθηκεύτηκε",
"vaultProfileDeleted": "Το προφίλ Vault διαγράφηκε",
"vaultProfileSaveFailed": "Αποτυχία αποθήκευσης προφίλ Vault",
"vaultProfileDeleteFailed": "Αποτυχία διαγραφής προφίλ Vault",
"vaultSaveProfile": "Αποθήκευση προφίλ",
"vaultProfileValidationError": "Απαιτούνται όνομα, διεύθυνση vault και ρόλος υπογράφοντος SSH",
"vaultNoProfiles": "Δεν υπάρχουν ακόμη προφίλ Vault.",
"vaultSharedBadge": "κοινόχρηστο",
"keyTypeLabel": "Τύπος κλειδιού",
"keyTypeAuto": "Αυτόματη ανίχνευση",
"keyPasteTab": "Πάστα",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Seleccione el método que Termix utilizará para autenticar esta conexión SSH. Los demás métodos no se intentarán automáticamente.",
"storedCredential": "Credencial almacenada",
"selectACredential": "Seleccionar una credencial...",
"vaultProfile": "Perfil de firmante de Vault",
"selectAVaultProfile": "Seleccionar un perfil de Vault...",
"vaultProfileHint": "La configuración proviene del perfil compartido; se iniciará sesión en Vault a través de OIDC al conectarse. No se almacenan secretos.",
"vaultNewProfile": "Nuevo perfil",
"secretRefHint": "Puedes pegar una referencia de 1Password (op://vault/item/field) en lugar de la clave secreta; esta se obtiene de tu fuente de claves secretas al conectarte.",
"secretSourcesManage": "Gestionar fuentes secretas",
"secretSourcesTitle": "Fuentes secretas",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Se requieren nombre, URL del servidor y token.",
"secretSourceSaved": "Fuente secreta guardada",
"secretSourceDeleted": "Fuente secreta eliminada",
"vaultManageProfiles": "Administrar perfiles de Vault",
"vaultAddrLabel": "Dirección de Vault",
"vaultNamespaceLabel": "Espacio de nombres",
"vaultOidcMountLabel": "Montaje de autenticación OIDC",
"vaultOidcRoleLabel": "Rol OIDC",
"vaultSshMountLabel": "Montaje de secretos SSH",
"vaultSshRoleLabel": "Rol de firmante SSH",
"vaultValidPrincipalsLabel": "Principals válidos",
"vaultKeyTypeLabel": "Tipo de clave efímera",
"vaultSharedLabel": "Compartir con todos los usuarios",
"vaultCreateProfile": "Crear perfil",
"vaultProfileCreated": "Perfil de Vault creado",
"vaultProfileSaved": "Perfil de Vault guardado",
"vaultProfileDeleted": "Perfil de Vault eliminado",
"vaultProfileSaveFailed": "Error al guardar el perfil de Vault",
"vaultProfileDeleteFailed": "Error al eliminar el perfil de Vault",
"vaultSaveProfile": "Guardar perfil",
"vaultProfileValidationError": "Se requieren el nombre, la dirección de Vault y el rol de firmante SSH",
"vaultNoProfiles": "Aún no hay perfiles de Vault.",
"vaultSharedBadge": "compartido",
"keyTypeLabel": "Tipo de clave",
"keyTypeAuto": "Detección automática",
"keyPasteTab": "Pegar",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Valitse yksi menetelmä, jota Termix käyttää tämän SSH-yhteyden todentamiseen. Muita menetelmiä ei kokeilla automaattisesti.",
"storedCredential": "Tallennetut tunnistetiedot",
"selectACredential": "Valitse tunnistetiedot...",
"vaultProfile": "Varaston allekirjoittajan profiili",
"selectAVaultProfile": "Valitse holviprofiili...",
"vaultProfileHint": "Asetukset tulevat jaetusta profiilista; kirjaudut Holviin OIDC:n kautta, kun muodostat yhteyden. Salaisuuksia ei tallenneta.",
"vaultNewProfile": "Uusi profiili",
"secretRefHint": "Voit liittää 1Password-viittauksen (op://vault/item/field) salaisuuden sijaan; se noudetaan salaisuuden lähteestäsi yhteyden muodostamisen yhteydessä.",
"secretSourcesManage": "Hallitse salaisia lähteitä",
"secretSourcesTitle": "Salaiset lähteet",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Nimi, palvelimen URL-osoite ja tunnus vaaditaan",
"secretSourceSaved": "Salainen lähde tallennettu",
"secretSourceDeleted": "Salainen lähde poistettu",
"vaultManageProfiles": "Hallinnoi holviprofiileja",
"vaultAddrLabel": "Holvin osoite",
"vaultNamespaceLabel": "Nimiavaruus",
"vaultOidcMountLabel": "OIDC-todennusliitos",
"vaultOidcRoleLabel": "OIDC-rooli",
"vaultSshMountLabel": "SSH-salaisuuksien kiinnitys",
"vaultSshRoleLabel": "SSH-allekirjoittajan rooli",
"vaultValidPrincipalsLabel": "Voimassa olevat pääasialliset",
"vaultKeyTypeLabel": "Lyhytaikainen avaintyyppi",
"vaultSharedLabel": "Jaa kaikkien käyttäjien kanssa",
"vaultCreateProfile": "Luo profiili",
"vaultProfileCreated": "Holvin profiili luotu",
"vaultProfileSaved": "Holvin profiili tallennettu",
"vaultProfileDeleted": "Holvin profiili poistettu",
"vaultProfileSaveFailed": "Holvin profiilin tallentaminen epäonnistui",
"vaultProfileDeleteFailed": "Holviprofiilin poistaminen epäonnistui",
"vaultSaveProfile": "Tallenna profiili",
"vaultProfileValidationError": "Nimi, holvin osoite ja SSH-allekirjoittajan rooli vaaditaan",
"vaultNoProfiles": "Ei vielä holviprofiileja.",
"vaultSharedBadge": "jaettu",
"keyTypeLabel": "Avaimen tyyppi",
"keyTypeAuto": "Automaattinen tunnistus",
"keyPasteTab": "Liitä",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Sélectionnez la méthode que Termix utilisera pour authentifier cette connexion SSH. Les autres méthodes ne seront pas essayées automatiquement.",
"storedCredential": "Identifiant enregistré",
"selectACredential": "Sélectionner un identifiant...",
"vaultProfile": "Profil de signature Vault",
"selectAVaultProfile": "Sélectionner un profil Vault...",
"vaultProfileHint": "Les paramètres proviennent du profil partagé ; vous vous connecterez à Vault via OIDC lors de la connexion. Aucun secret n'est stocké.",
"vaultNewProfile": "Nouveau profil",
"secretRefHint": "Vous pouvez coller une référence 1Password (op://vault/item/field) à la place du secret ; elle sera récupérée depuis votre source de secret lors de la connexion.",
"secretSourcesManage": "Gérer les sources secrètes",
"secretSourcesTitle": "Sources secrètes",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Le nom, l'URL du serveur et le jeton sont requis.",
"secretSourceSaved": "Source secrète sauvegardée",
"secretSourceDeleted": "Source secrète supprimée",
"vaultManageProfiles": "Gérer les profils Vault",
"vaultAddrLabel": "Adresse Vault",
"vaultNamespaceLabel": "Espace de noms",
"vaultOidcMountLabel": "Point de montage d'authentification OIDC",
"vaultOidcRoleLabel": "Rôle OIDC",
"vaultSshMountLabel": "Point de montage des secrets SSH",
"vaultSshRoleLabel": "Rôle de signature SSH",
"vaultValidPrincipalsLabel": "Principaux valides",
"vaultKeyTypeLabel": "Type de clé éphémère",
"vaultSharedLabel": "Partager avec tous les utilisateurs",
"vaultCreateProfile": "Créer un profil",
"vaultProfileCreated": "Profil Vault créé",
"vaultProfileSaved": "Profil Vault enregistré",
"vaultProfileDeleted": "Profil Vault supprimé",
"vaultProfileSaveFailed": "Échec de l'enregistrement du profil Vault",
"vaultProfileDeleteFailed": "Échec de la suppression du profil Vault",
"vaultSaveProfile": "Enregistrer le profil",
"vaultProfileValidationError": "Le nom, l'adresse Vault et le rôle de signataire SSH sont requis",
"vaultNoProfiles": "Aucun profil Vault pour le moment",
"vaultSharedBadge": "partagé",
"keyTypeLabel": "Type de clé",
"keyTypeAuto": "Détection automatique",
"keyPasteTab": "Coller",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "בחר את השיטה שבה Termix ישתמש כדי לאמת חיבור SSH זה. שיטות אחרות לא ינוסו אוטומטית.",
"storedCredential": "אישורים מאוחסנים",
"selectACredential": "בחר אישור...",
"vaultProfile": "פרופיל חותם בכספת",
"selectAVaultProfile": "בחר פרופיל כספת...",
"vaultProfileHint": "ההגדרות מגיעות מהפרופיל המשותף; תיכנס לכספת דרך OIDC כשתתחבר. לא נשמרים סודות.",
"vaultNewProfile": "פרופיל חדש",
"secretRefHint": "ניתן להדביק הפניה ל-1Password (op://vault/item/field) במקום הסוד; היא נלקחת ממקור הסוד שלך בעת ההתחברות.",
"secretSourcesManage": "ניהול מקורות סודיים",
"secretSourcesTitle": "מקורות סודיים",
@@ -739,26 +735,6 @@
"secretSourceRequired": "נדרשים שם, כתובת URL של השרת וטופס",
"secretSourceSaved": "מקור סודי נשמר",
"secretSourceDeleted": "מקור סודי נמחק",
"vaultManageProfiles": "ניהול פרופילי כספת",
"vaultAddrLabel": "כתובת הכספת",
"vaultNamespaceLabel": "מרחב השמות",
"vaultOidcMountLabel": "אישור OIDC",
"vaultOidcRoleLabel": "תפקיד OIDC",
"vaultSshMountLabel": "סודות SSH מאוורר",
"vaultSshRoleLabel": "תפקיד חותם SSH",
"vaultValidPrincipalsLabel": "עקרונות תקפים",
"vaultKeyTypeLabel": "סוג מפתח זמני",
"vaultSharedLabel": "שתף עם כל המשתמשים",
"vaultCreateProfile": "צור פרופיל",
"vaultProfileCreated": "פרופיל הכספת נוצר",
"vaultProfileSaved": "פרופיל הכספת נשמר",
"vaultProfileDeleted": "פרופיל הכספת נמחק",
"vaultProfileSaveFailed": "שמירת פרופיל הכספת נכשלה",
"vaultProfileDeleteFailed": "נכשלה מחיקת פרופיל הכספת",
"vaultSaveProfile": "שמור פרופיל",
"vaultProfileValidationError": "שם, כתובת כספת ותפקיד חתימה ב-SSH נדרשים",
"vaultNoProfiles": "אין עדיין פרופילים בכספת.",
"vaultSharedBadge": "מְשׁוּתָף",
"keyTypeLabel": "סוג מפתח",
"keyTypeAuto": "זיהוי אוטומטי",
"keyPasteTab": "לְהַדבִּיק",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "वह तरीका चुनें जिसका उपयोग टर्मिक्स इस एसएसएच कनेक्शन को प्रमाणित करने के लिए करेगा। अन्य तरीकों को स्वचालित रूप से नहीं आजमाया जाएगा।",
"storedCredential": "संग्रहीत क्रेडेंशियल",
"selectACredential": "एक क्रेडेंशियल चुनें...",
"vaultProfile": "Vault साइनर प्रोफ़ाइल",
"selectAVaultProfile": "एक Vault प्रोफ़ाइल चुनें...",
"vaultProfileHint": "सेटिंग साझा प्रोफ़ाइल से आती हैं; कनेक्ट करते समय आप OIDC के माध्यम से Vault में साइन इन करेंगे। कोई सीक्रेट संग्रहीत नहीं किए जाते।",
"vaultNewProfile": "नई प्रोफ़ाइल",
"secretRefHint": "आप गुप्त पासवर्ड के स्थान पर 1Password संदर्भ (op://vault/item/field) पेस्ट कर सकते हैं; कनेक्ट करते समय इसे आपके गुप्त पासवर्ड स्रोत से प्राप्त किया जाता है।",
"secretSourcesManage": "गुप्त स्रोतों का प्रबंधन करें",
"secretSourcesTitle": "गुप्त सूत्रों",
@@ -739,26 +735,6 @@
"secretSourceRequired": "नाम, सर्वर यूआरएल और टोकन आवश्यक हैं।",
"secretSourceSaved": "गुप्त स्रोत सुरक्षित",
"secretSourceDeleted": "गुप्त सूत्र हटा दिया गया",
"vaultManageProfiles": "Vault प्रोफ़ाइल प्रबंधित करें",
"vaultAddrLabel": "Vault पता",
"vaultNamespaceLabel": "नेमस्पेस",
"vaultOidcMountLabel": "OIDC ऑथ माउंट",
"vaultOidcRoleLabel": "OIDC रोल",
"vaultSshMountLabel": "SSH सीक्रेट माउंट",
"vaultSshRoleLabel": "SSH साइनर रोल",
"vaultValidPrincipalsLabel": "मान्य प्रिंसिपल",
"vaultKeyTypeLabel": "एफेमरल कुंजी प्रकार",
"vaultSharedLabel": "सभी उपयोगकर्ताओं के साथ साझा करें",
"vaultCreateProfile": "प्रोफ़ाइल बनाएँ",
"vaultProfileCreated": "Vault प्रोफ़ाइल बनाई गई",
"vaultProfileSaved": "Vault प्रोफ़ाइल सहेजी गई",
"vaultProfileDeleted": "Vault प्रोफ़ाइल हटाई गई",
"vaultProfileSaveFailed": "Vault प्रोफ़ाइल सहेजने में विफल",
"vaultProfileDeleteFailed": "Vault प्रोफ़ाइल हटाने में विफल",
"vaultSaveProfile": "प्रोफ़ाइल सहेजें",
"vaultProfileValidationError": "नाम, Vault पता और SSH हस्ताक्षरकर्ता भूमिका आवश्यक हैं",
"vaultNoProfiles": "अभी तक कोई Vault प्रोफ़ाइल नहीं है।",
"vaultSharedBadge": "साझा",
"keyTypeLabel": "कुंजी प्रकार",
"keyTypeAuto": "स्वतः पहचानें",
"keyPasteTab": "चिपकाएँ",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Válassza ki azt az egy módszert, amelyet a Termix az SSH-kapcsolat hitelesítéséhez használ. A többi módszert a rendszer nem próbálja ki automatikusan.",
"storedCredential": "Tárolt hitelesítő adatok",
"selectACredential": "Válasszon hitelesítő adatot...",
"vaultProfile": "Vault aláírói profil",
"selectAVaultProfile": "Válasszon ki egy Vault profilt...",
"vaultProfileHint": "A beállítások a megosztott profilból származnak; a csatlakozáskor OIDC-n keresztül fog bejelentkezni a Vaultba. A rendszer nem tárol titkos adatokat.",
"vaultNewProfile": "Új profil",
"secretRefHint": "A titkos kód helyett beilleszthet egy 1Password hivatkozást (op://vault/item/field); ezt a titkos kód forrásából kéri le a rendszer a csatlakozáskor.",
"secretSourcesManage": "Titkos források kezelése",
"secretSourcesTitle": "Titkos források",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Név, szerver URL és token megadása kötelező",
"secretSourceSaved": "Titkos forrás mentve",
"secretSourceDeleted": "Titkos forrás törölve",
"vaultManageProfiles": "Vault profilok kezelése",
"vaultAddrLabel": "Trezor címe",
"vaultNamespaceLabel": "Névtér",
"vaultOidcMountLabel": "OIDC hitelesítési csatolás",
"vaultOidcRoleLabel": "OIDC szerepkör",
"vaultSshMountLabel": "SSH titkok felhúzása",
"vaultSshRoleLabel": "SSH aláíró szerepkör",
"vaultValidPrincipalsLabel": "Érvényes megbízók",
"vaultKeyTypeLabel": "Efemer kulcstípus",
"vaultSharedLabel": "Megosztás az összes felhasználóval",
"vaultCreateProfile": "Profil létrehozása",
"vaultProfileCreated": "Létrehozva a trezor profilja",
"vaultProfileSaved": "A trezor profilja mentve",
"vaultProfileDeleted": "Tárolóprofil törölve",
"vaultProfileSaveFailed": "Nem sikerült menteni a Vault profilját",
"vaultProfileDeleteFailed": "Nem sikerült törölni a Vault profilját",
"vaultSaveProfile": "Profil mentése",
"vaultProfileValidationError": "Név, trezor címe és SSH aláírói szerepkör megadása kötelező.",
"vaultNoProfiles": "Még nincsenek Vault-profilok.",
"vaultSharedBadge": "megosztott",
"keyTypeLabel": "Kulcstípus",
"keyTypeAuto": "Automatikus felismerés",
"keyPasteTab": "Paszta",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Pilih salah satu metode yang akan digunakan Termix untuk mengautentikasi koneksi SSH ini. Metode lain tidak dicoba secara otomatis.",
"storedCredential": "Kredensial Tersimpan",
"selectACredential": "Pilih kredensial...",
"vaultProfile": "Profil Penandatangan Vault",
"selectAVaultProfile": "Pilih profil Vault...",
"vaultProfileHint": "Pengaturan berasal dari profil bersama; Anda akan masuk ke Vault melalui OIDC saat terhubung. Tidak ada rahasia yang disimpan.",
"vaultNewProfile": "Profil baru",
"secretRefHint": "Anda dapat menempelkan referensi 1Password (op://vault/item/field) sebagai pengganti rahasia; referensi tersebut akan diambil dari sumber rahasia Anda saat terhubung.",
"secretSourcesManage": "Mengelola sumber rahasia",
"secretSourcesTitle": "Sumber rahasia",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Nama, URL server, dan token wajib diisi.",
"secretSourceSaved": "Sumber rahasia tersimpan",
"secretSourceDeleted": "Sumber rahasia telah dihapus.",
"vaultManageProfiles": "Kelola profil Vault",
"vaultAddrLabel": "Alamat Brankas",
"vaultNamespaceLabel": "Ruang nama",
"vaultOidcMountLabel": "OIDC Otorisasi Pemasangan",
"vaultOidcRoleLabel": "Peran OIDC",
"vaultSshMountLabel": "Pemasangan Rahasia SSH",
"vaultSshRoleLabel": "Peran Penandatangan SSH",
"vaultValidPrincipalsLabel": "Prinsip-prinsip yang Sah",
"vaultKeyTypeLabel": "Jenis Kunci Sementara",
"vaultSharedLabel": "Bagikan dengan semua pengguna",
"vaultCreateProfile": "Buat profil",
"vaultProfileCreated": "Profil Vault telah dibuat.",
"vaultProfileSaved": "Profil Vault tersimpan.",
"vaultProfileDeleted": "Profil Vault dihapus",
"vaultProfileSaveFailed": "Gagal menyimpan profil Vault",
"vaultProfileDeleteFailed": "Gagal menghapus profil Vault",
"vaultSaveProfile": "Simpan profil",
"vaultProfileValidationError": "Nama, alamat Vault, dan peran penandatangan SSH wajib diisi.",
"vaultNoProfiles": "Belum ada profil Vault.",
"vaultSharedBadge": "dibagikan",
"keyTypeLabel": "Jenis Kunci",
"keyTypeAuto": "Deteksi Otomatis",
"keyPasteTab": "Pasta",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Seleziona il metodo che Termix utilizzerà per autenticare questa connessione SSH. Gli altri metodi non verranno tentati automaticamente.",
"storedCredential": "Credenziale memorizzata",
"selectACredential": "Seleziona una credenziale...",
"vaultProfile": "Profilo firmatario Vault",
"selectAVaultProfile": "Seleziona un profilo Vault...",
"vaultProfileHint": "Le impostazioni provengono dal profilo condiviso; effettuerai l'accesso a Vault tramite OIDC quando ti connetti. Nessun segreto è memorizzato.",
"vaultNewProfile": "Nuovo profilo",
"secretRefHint": "Al posto del segreto, puoi incollare un riferimento a 1Password (op://vault/item/field); questo verrà recuperato dalla tua fonte di segreti al momento della connessione.",
"secretSourcesManage": "Gestire le fonti segrete",
"secretSourcesTitle": "Fonti segrete",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Sono richiesti nome, URL del server e token.",
"secretSourceSaved": "fonte segreta salvata",
"secretSourceDeleted": "Fonte segreta eliminata",
"vaultManageProfiles": "Gestisci profili Vault",
"vaultAddrLabel": "Indirizzo Vault",
"vaultNamespaceLabel": "Namespace",
"vaultOidcMountLabel": "Mount di autenticazione OIDC",
"vaultOidcRoleLabel": "Ruolo OIDC",
"vaultSshMountLabel": "Mount dei segreti SSH",
"vaultSshRoleLabel": "Ruolo firmatario SSH",
"vaultValidPrincipalsLabel": "Principal validi",
"vaultKeyTypeLabel": "Tipo di chiave effimera",
"vaultSharedLabel": "Condividi con tutti gli utenti",
"vaultCreateProfile": "Crea profilo",
"vaultProfileCreated": "Profilo Vault creato",
"vaultProfileSaved": "Profilo Vault salvato",
"vaultProfileDeleted": "Profilo Vault eliminato",
"vaultProfileSaveFailed": "Salvataggio profilo Vault non riuscito",
"vaultProfileDeleteFailed": "Eliminazione profilo Vault non riuscita",
"vaultSaveProfile": "Salva profilo",
"vaultProfileValidationError": "Nome, indirizzo Vault e ruolo di firma SSH sono obbligatori",
"vaultNoProfiles": "Nessun profilo Vault ancora.",
"vaultSharedBadge": "condiviso",
"keyTypeLabel": "Tipo chiave",
"keyTypeAuto": "Rilevamento automatico",
"keyPasteTab": "Incolla",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "TermixがこのSSH接続を認証するために使用する方法を1つ選択してください。他の方法は自動的には試行されません。",
"storedCredential": "保存済み認証情報",
"selectACredential": "認証情報を選択...",
"vaultProfile": "Vault 署名者プロファイル",
"selectAVaultProfile": "Vault プロファイルを選択...",
"vaultProfileHint": "設定は共有プロファイルから取得され、接続時に OIDC で Vault にサインインします。シークレットは保存されません。",
"vaultNewProfile": "新規プロファイル",
"secretRefHint": "シークレットの代わりに1Passwordの参照(op://vault/item/field)を貼り付けることもできます。これは接続時にシークレットソースから取得されます。",
"secretSourcesManage": "秘密情報源の管理",
"secretSourcesTitle": "秘密の情報源",
@@ -739,26 +735,6 @@
"secretSourceRequired": "名前、サーバーURL、トークンは必須です。",
"secretSourceSaved": "秘密の情報源が保存されました",
"secretSourceDeleted": "秘密情報源を削除",
"vaultManageProfiles": "Vault プロファイルの管理",
"vaultAddrLabel": "Vault アドレス",
"vaultNamespaceLabel": "名前空間",
"vaultOidcMountLabel": "OIDC 認証マウント",
"vaultOidcRoleLabel": "OIDC ロール",
"vaultSshMountLabel": "SSH シークレットマウント",
"vaultSshRoleLabel": "SSH 署名者ロール",
"vaultValidPrincipalsLabel": "有効なプリンシパル",
"vaultKeyTypeLabel": "エフェメラルキータイプ",
"vaultSharedLabel": "すべてのユーザーと共有",
"vaultCreateProfile": "プロファイルを作成",
"vaultProfileCreated": "Vaultプロファイルが作成されました",
"vaultProfileSaved": "Vaultプロファイルが保存されました",
"vaultProfileDeleted": "Vaultプロファイルが削除されました",
"vaultProfileSaveFailed": "Vaultプロファイルの保存に失敗しました",
"vaultProfileDeleteFailed": "Vaultプロファイルの削除に失敗しました",
"vaultSaveProfile": "プロファイルを保存",
"vaultProfileValidationError": "名前、Vaultアドレス、SSH署名ロールは必須です",
"vaultNoProfiles": "まだVaultプロファイルがありません。",
"vaultSharedBadge": "共有",
"keyTypeLabel": "鍵の種類",
"keyTypeAuto": "自動検出",
"keyPasteTab": "貼り付け",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Termix에서 이 SSH 연결을 인증하는 데 사용할 방법을 하나 선택하십시오. 다른 방법은 자동으로 시도되지 않습니다.",
"storedCredential": "저장된 자격 증명",
"selectACredential": "자격 증명 선택...",
"vaultProfile": "Vault 서명자 프로필",
"selectAVaultProfile": "Vault 프로필 선택...",
"vaultProfileHint": "설정은 공유 프로필에서 가져옵니다. 연결 시 OIDC를 통해 Vault에 로그인하게 됩니다. 비밀은 저장되지 않습니다.",
"vaultNewProfile": "새 프로필",
"secretRefHint": "비밀 키 대신 1Password 참조(op://vault/item/field)를 붙여넣을 수 있습니다. 연결 시 비밀 키 소스에서 참조가 자동으로 가져와집니다.",
"secretSourcesManage": "비밀 소스를 관리하세요",
"secretSourcesTitle": "비밀 정보원",
@@ -739,26 +735,6 @@
"secretSourceRequired": "이름, 서버 URL 및 토큰은 필수 입력 사항입니다.",
"secretSourceSaved": "비밀 소스 저장됨",
"secretSourceDeleted": "비밀 소스 삭제됨",
"vaultManageProfiles": "Vault 프로필 관리",
"vaultAddrLabel": "Vault 주소",
"vaultNamespaceLabel": "네임스페이스",
"vaultOidcMountLabel": "OIDC 인증 마운트",
"vaultOidcRoleLabel": "OIDC 역할",
"vaultSshMountLabel": "SSH 시크릿 마운트",
"vaultSshRoleLabel": "SSH 서명자 역할",
"vaultValidPrincipalsLabel": "유효한 프린시펄",
"vaultKeyTypeLabel": "임시 키 유형",
"vaultSharedLabel": "모든 사용자와 공유",
"vaultCreateProfile": "프로필 생성",
"vaultProfileCreated": "Vault 프로필 생성됨",
"vaultProfileSaved": "Vault 프로필 저장됨",
"vaultProfileDeleted": "Vault 프로필 삭제됨",
"vaultProfileSaveFailed": "Vault 프로필 저장 실패",
"vaultProfileDeleteFailed": "Vault 프로필 삭제 실패",
"vaultSaveProfile": "프로필 저장",
"vaultProfileValidationError": "이름, Vault 주소 및 SSH 서명자 역할이 필요합니다.",
"vaultNoProfiles": "아직 Vault 프로필이 없습니다.",
"vaultSharedBadge": "공유",
"keyTypeLabel": "키 유형",
"keyTypeAuto": "자동 감지",
"keyPasteTab": "붙여넣기",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Selecteer de methode die Termix zal gebruiken om deze SSH-verbinding te authenticeren. Andere methoden worden niet automatisch geprobeerd.",
"storedCredential": "Opgeslagen inloggegevens",
"selectACredential": "Selecteer een certificaat...",
"vaultProfile": "Vault-ondertekenaarprofiel",
"selectAVaultProfile": "Selecteer een Vault-profiel...",
"vaultProfileHint": "De instellingen zijn afkomstig van het gedeelde profiel; u meldt zich bij Vault aan via OIDC wanneer u verbinding maakt. Er worden geen geheimen opgeslagen.",
"vaultNewProfile": "Nieuw profiel",
"secretRefHint": "Je kunt in plaats van het geheim een 1Password-referentie (op://vault/item/field) plakken; deze wordt bij het verbinden opgehaald uit je geheime bron.",
"secretSourcesManage": "Beheer geheime bronnen",
"secretSourcesTitle": "Geheime bronnen",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Naam, server-URL en token zijn vereist.",
"secretSourceSaved": "Geheime bron opgeslagen",
"secretSourceDeleted": "Geheime bron verwijderd",
"vaultManageProfiles": "Beheer Vault-profielen",
"vaultAddrLabel": "Kluisadres",
"vaultNamespaceLabel": "Naamruimte",
"vaultOidcMountLabel": "OIDC-authenticatiekoppeling",
"vaultOidcRoleLabel": "OIDC-rol",
"vaultSshMountLabel": "SSH-geheimen Mount",
"vaultSshRoleLabel": "SSH-ondertekenaarrol",
"vaultValidPrincipalsLabel": "Geldige principes",
"vaultKeyTypeLabel": "Tijdelijk sleuteltype",
"vaultSharedLabel": "Delen met alle gebruikers",
"vaultCreateProfile": "Profiel aanmaken",
"vaultProfileCreated": "Vault-profiel aangemaakt",
"vaultProfileSaved": "Vault-profiel opgeslagen",
"vaultProfileDeleted": "Kluisprofiel verwijderd",
"vaultProfileSaveFailed": "Het opslaan van het Vault-profiel is mislukt.",
"vaultProfileDeleteFailed": "Het verwijderen van het Vault-profiel is mislukt.",
"vaultSaveProfile": "Profiel opslaan",
"vaultProfileValidationError": "Naam, kluisadres en SSH-ondertekeningsrol zijn vereist.",
"vaultNoProfiles": "Nog geen Vault-profielen beschikbaar.",
"vaultSharedBadge": "gedeeld",
"keyTypeLabel": "Sleuteltype",
"keyTypeAuto": "Automatische detectie",
"keyPasteTab": "Plakken",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Velg den metoden Termix vil bruke for å autentisere denne SSH-tilkoblingen. Andre metoder blir ikke prøvd automatisk.",
"storedCredential": "Lagret legitimasjon",
"selectACredential": "Velg en legitimasjon...",
"vaultProfile": "Profil for hvelvsigner",
"selectAVaultProfile": "Velg en Vault-profil...",
"vaultProfileHint": "Innstillingene kommer fra den delte profilen. Du logger deg på Vault via OIDC når du kobler til. Ingen hemmeligheter lagres.",
"vaultNewProfile": "Ny profil",
"secretRefHint": "Du kan lime inn en 1Password-referanse (op://vault/item/field) i stedet for hemmeligheten; den hentes fra hemmelighetskilden din når du kobler til.",
"secretSourcesManage": "Administrer hemmelige kilder",
"secretSourcesTitle": "Hemmelige kilder",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Navn, server-URL og token er obligatorisk",
"secretSourceSaved": "Hemmelig kilde lagret",
"secretSourceDeleted": "Hemmelig kilde slettet",
"vaultManageProfiles": "Administrer Vault-profiler",
"vaultAddrLabel": "Hvelvadresse",
"vaultNamespaceLabel": "Navnerom",
"vaultOidcMountLabel": "OIDC-autentiseringsmontering",
"vaultOidcRoleLabel": "OIDC-rolle",
"vaultSshMountLabel": "SSH-hemmeligheter monteres",
"vaultSshRoleLabel": "SSH-signatørrolle",
"vaultValidPrincipalsLabel": "Gyldige prinsipaler",
"vaultKeyTypeLabel": "Kortvarig nøkkeltype",
"vaultSharedLabel": "Del med alle brukere",
"vaultCreateProfile": "Opprett profil",
"vaultProfileCreated": "Vault-profil opprettet",
"vaultProfileSaved": "Vault-profilen er lagret",
"vaultProfileDeleted": "Vault-profilen er slettet",
"vaultProfileSaveFailed": "Kunne ikke lagre Vault-profilen",
"vaultProfileDeleteFailed": "Kunne ikke slette Vault-profilen",
"vaultSaveProfile": "Lagre profil",
"vaultProfileValidationError": "Navn, hvelvadresse og SSH-signerrolle er obligatorisk",
"vaultNoProfiles": "Ingen Vault-profiler ennå.",
"vaultSharedBadge": "delt",
"keyTypeLabel": "Nøkkeltype",
"keyTypeAuto": "Automatisk deteksjon",
"keyPasteTab": "Lim inn",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Wybierz jedną metodę, której Termix użyje do uwierzytelnienia tego połączenia SSH. Inne metody nie są próbowane automatycznie.",
"storedCredential": "Zapisane poświadczenie",
"selectACredential": "Wybierz poświadczenie...",
"vaultProfile": "Profil podpisujący Vault",
"selectAVaultProfile": "Wybierz profil Vault...",
"vaultProfileHint": "Ustawienia pochodzą z profilu współdzielonego. Po połączeniu zalogujesz się do Vault przez OIDC. Nie są przechowywane żadne sekrety.",
"vaultNewProfile": "Nowy profil",
"secretRefHint": "Zamiast klucza tajnego możesz wkleić odnośnik 1Password (op://vault/item/field). Zostanie on pobrany ze źródła klucza tajnego podczas nawiązywania połączenia.",
"secretSourcesManage": "Zarządzaj tajnymi źródłami",
"secretSourcesTitle": "Tajne źródła",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Wymagane są nazwa, adres URL serwera i token",
"secretSourceSaved": "Tajne źródło uratowane",
"secretSourceDeleted": "Usunięto tajne źródło",
"vaultManageProfiles": "Zarządzaj profilami Vault",
"vaultAddrLabel": "Adres Vault",
"vaultNamespaceLabel": "Przestrzeń nazw",
"vaultOidcMountLabel": "Mount uwierzytelniania OIDC",
"vaultOidcRoleLabel": "Rola OIDC",
"vaultSshMountLabel": "Mount sekretów SSH",
"vaultSshRoleLabel": "Rola podpisująca SSH",
"vaultValidPrincipalsLabel": "Dozwoleni użytkownicy (principals)",
"vaultKeyTypeLabel": "Typ klucza efemerycznego",
"vaultSharedLabel": "Udostępnij wszystkim użytkownikom",
"vaultCreateProfile": "Utwórz profil",
"vaultProfileCreated": "Utworzono profil Vault",
"vaultProfileSaved": "Zapisano profil Vault",
"vaultProfileDeleted": "Usunięto profil Vault",
"vaultProfileSaveFailed": "Nie udało się zapisać profilu Vault",
"vaultProfileDeleteFailed": "Nie udało się usunąć profilu Vault",
"vaultSaveProfile": "Zapisz profil",
"vaultProfileValidationError": "Wymagane są nazwa, adres Vault i rola podpisująca SSH",
"vaultNoProfiles": "Brak profili Vault.",
"vaultSharedBadge": "współdzielony",
"keyTypeLabel": "Typ klucza",
"keyTypeAuto": "Automatyczne wykrywanie",
"keyPasteTab": "Wklej",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Selecione o método que o Termix usará para autenticar esta conexão SSH. Outros métodos não serão tentados automaticamente.",
"storedCredential": "Credencial armazenada",
"selectACredential": "Selecione uma credencial...",
"vaultProfile": "Perfil do assinante do Vault",
"selectAVaultProfile": "Selecione um perfil do Vault...",
"vaultProfileHint": "As configurações são provenientes do perfil compartilhado; você fará login no Vault via OIDC ao se conectar. Nenhum segredo é armazenado.",
"vaultNewProfile": "Novo perfil",
"secretRefHint": "Você pode colar uma referência do 1Password (op://vault/item/field) em vez do segredo; ela é obtida da sua fonte de segredos ao conectar-se.",
"secretSourcesManage": "Gerenciar fontes secretas",
"secretSourcesTitle": "Fontes secretas",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Nome, URL do servidor e token são obrigatórios.",
"secretSourceSaved": "Fonte secreta salva",
"secretSourceDeleted": "Fonte secreta excluída",
"vaultManageProfiles": "Gerenciar perfis do Vault",
"vaultAddrLabel": "Endereço do cofre",
"vaultNamespaceLabel": "Espaço de nomes",
"vaultOidcMountLabel": "Montagem de autenticação OIDC",
"vaultOidcRoleLabel": "Função OIDC",
"vaultSshMountLabel": "Montagem de segredos SSH",
"vaultSshRoleLabel": "Função de assinatura SSH",
"vaultValidPrincipalsLabel": "Principais válidos",
"vaultKeyTypeLabel": "Tipo de chave efêmera",
"vaultSharedLabel": "Compartilhe com todos os usuários",
"vaultCreateProfile": "Criar perfil",
"vaultProfileCreated": "Perfil do Vault criado",
"vaultProfileSaved": "Perfil do Vault salvo",
"vaultProfileDeleted": "Perfil do Vault excluído",
"vaultProfileSaveFailed": "Falha ao salvar o perfil do Vault",
"vaultProfileDeleteFailed": "Falha ao excluir o perfil do Vault",
"vaultSaveProfile": "Salvar perfil",
"vaultProfileValidationError": "Nome, endereço do Vault e função de assinatura SSH são obrigatórios.",
"vaultNoProfiles": "Ainda não há perfis no Vault.",
"vaultSharedBadge": "compartilhado",
"keyTypeLabel": "Tipo de chave",
"keyTypeAuto": "Detecção automática",
"keyPasteTab": "Colar",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Selecione o método que o Termix irá utilizar para autenticar esta ligação SSH. Outros métodos não serão tentados automaticamente.",
"storedCredential": "Credencial guardada",
"selectACredential": "Selecionar uma credencial...",
"vaultProfile": "Perfil de assinatura Vault",
"selectAVaultProfile": "Selecionar um perfil Vault...",
"vaultProfileHint": "As definições provêm do perfil partilhado; ao ligar, iniciará sessão no Vault via OIDC. Nenhum segredo é armazenado.",
"vaultNewProfile": "Novo perfil",
"secretRefHint": "Pode colar uma referência do 1Password (op://vault/item/field) em vez do segredo; é obtida a partir da sua fonte de segredos ao conectar-se.",
"secretSourcesManage": "Gerir fontes secretas",
"secretSourcesTitle": "Fontes secretas",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Nome, URL do servidor e token são obrigatórios.",
"secretSourceSaved": "Fonte secreta guardada",
"secretSourceDeleted": "Fonte secreta excluída",
"vaultManageProfiles": "Gerir perfis Vault",
"vaultAddrLabel": "Endereço Vault",
"vaultNamespaceLabel": "Namespace",
"vaultOidcMountLabel": "Mount de autenticação OIDC",
"vaultOidcRoleLabel": "Função OIDC",
"vaultSshMountLabel": "Mount de segredos SSH",
"vaultSshRoleLabel": "Função de assinatura SSH",
"vaultValidPrincipalsLabel": "Principais válidos",
"vaultKeyTypeLabel": "Tipo de chave efémera",
"vaultSharedLabel": "Partilhar com todos os utilizadores",
"vaultCreateProfile": "Criar perfil",
"vaultProfileCreated": "Perfil Vault criado",
"vaultProfileSaved": "Perfil Vault guardado",
"vaultProfileDeleted": "Perfil Vault eliminado",
"vaultProfileSaveFailed": "Falha ao guardar perfil Vault",
"vaultProfileDeleteFailed": "Falha ao eliminar perfil Vault",
"vaultSaveProfile": "Guardar perfil",
"vaultProfileValidationError": "Nome, endereço Vault e função de signatário SSH são obrigatórios",
"vaultNoProfiles": "Ainda sem perfis Vault.",
"vaultSharedBadge": "partilhado",
"keyTypeLabel": "Tipo de chave",
"keyTypeAuto": "Detetar automaticamente",
"keyPasteTab": "Colar",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Selectați metoda pe care Termix o va folosi pentru autentificarea acestei conexiuni SSH. Celelalte metode nu sunt încercate automat.",
"storedCredential": "Acreditare stocată",
"selectACredential": "Selectați o acreditare...",
"vaultProfile": "Profilul semnatarului din seif",
"selectAVaultProfile": "Selectați un profil Vault...",
"vaultProfileHint": "Setările provin din profilul partajat; te vei conecta la Vault prin OIDC când te conectezi. Nu sunt stocate secrete.",
"vaultNewProfile": "Profil nou",
"secretRefHint": "Puteți lipi o referință 1Password (op://vault/item/field) în loc de secret; acesta este preluat din sursa secretului la conectare.",
"secretSourcesManage": "Gestionați sursele secrete",
"secretSourcesTitle": "Surse secrete",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Numele, adresa URL a serverului și tokenul sunt obligatorii",
"secretSourceSaved": "Sursă secretă salvată",
"secretSourceDeleted": "Sursa secretă a fost ștearsă",
"vaultManageProfiles": "Gestionați profilurile din seif",
"vaultAddrLabel": "Adresă seif",
"vaultNamespaceLabel": "Spațiu de nume",
"vaultOidcMountLabel": "Montare autentificare OIDC",
"vaultOidcRoleLabel": "Rolul OIDC",
"vaultSshMountLabel": "Secretele SSH Mount",
"vaultSshRoleLabel": "Rolul semnatarului SSH",
"vaultValidPrincipalsLabel": "Principali validi",
"vaultKeyTypeLabel": "Tip de cheie efemeră",
"vaultSharedLabel": "Partajați cu toți utilizatorii",
"vaultCreateProfile": "Creați un profil",
"vaultProfileCreated": "Profilul seifului a fost creat",
"vaultProfileSaved": "Profilul seifului a fost salvat",
"vaultProfileDeleted": "Profilul seifului a fost șters",
"vaultProfileSaveFailed": "Nu s-a putut salva profilul din Seif",
"vaultProfileDeleteFailed": "Nu s-a putut șterge profilul Vault",
"vaultSaveProfile": "Salvați profilul",
"vaultProfileValidationError": "Numele, adresa seifului și rolul semnatarului SSH sunt obligatorii.",
"vaultNoProfiles": "Încă nu există profiluri Vault.",
"vaultSharedBadge": "partajat",
"keyTypeLabel": "Tipul cheii",
"keyTypeAuto": "Detectare automată",
"keyPasteTab": "Pastă",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Выберите один из методов, который Termix будет использовать для аутентификации этого SSH-соединения. Другие методы автоматически не используются.",
"storedCredential": "Сохранённые учётные данные",
"selectACredential": "Выберите учётные данные...",
"vaultProfile": "Профиль подписчика Vault",
"selectAVaultProfile": "Выберите профиль Vault...",
"vaultProfileHint": "Настройки берутся из общего профиля; при подключении вы войдёте в Vault через OIDC. Секреты не хранятся.",
"vaultNewProfile": "Новый профиль",
"secretRefHint": "Вместо секретного ключа можно вставить ссылку на 1Password (op://vault/item/field); она будет получена из вашего источника секретных ключей при подключении.",
"secretSourcesManage": "Управление секретными источниками",
"secretSourcesTitle": "Секретные источники",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Необходимо указать имя, URL-адрес сервера и токен.",
"secretSourceSaved": "Секретный источник сохранен",
"secretSourceDeleted": "Секретный источник удален",
"vaultManageProfiles": "Управление профилями Vault",
"vaultAddrLabel": "Адрес Vault",
"vaultNamespaceLabel": "Пространство имён",
"vaultOidcMountLabel": "Монтирование аутентификации OIDC",
"vaultOidcRoleLabel": "Роль OIDC",
"vaultSshMountLabel": "Монтирование секретов SSH",
"vaultSshRoleLabel": "Роль подписчика SSH",
"vaultValidPrincipalsLabel": "Допустимые принципалы",
"vaultKeyTypeLabel": "Тип эфемерного ключа",
"vaultSharedLabel": "Общий доступ для всех пользователей",
"vaultCreateProfile": "Создать профиль",
"vaultProfileCreated": "Профиль Vault создан",
"vaultProfileSaved": "Профиль Vault сохранён",
"vaultProfileDeleted": "Профиль Vault удалён",
"vaultProfileSaveFailed": "Не удалось сохранить профиль Vault",
"vaultProfileDeleteFailed": "Не удалось удалить профиль Vault",
"vaultSaveProfile": "Сохранить профиль",
"vaultProfileValidationError": "Требуются имя, адрес Vault и роль SSH подписанта",
"vaultNoProfiles": "Пока нет профилей Vault.",
"vaultSharedBadge": "общий",
"keyTypeLabel": "Тип ключа",
"keyTypeAuto": "Автоопределение",
"keyPasteTab": "Вставить",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Изаберите једну методу коју ће Termix користити за аутентификацију ове SSH везе. Остале методе се не испробавају аутоматски.",
"storedCredential": "Сачувани акредитив",
"selectACredential": "Изаберите акредитив...",
"vaultProfile": "Профил потписника трезора",
"selectAVaultProfile": "Изаберите профил трезора...",
"vaultProfileHint": "Подешавања долазе из дељеног профила; пријавићете се у Vault преко OIDC-а када се повежете. Ниједне тајне се не чувају.",
"vaultNewProfile": "Нови профил",
"secretRefHint": "Можете да налепите референцу 1Password (op://vault/item/field) уместо тајне лозинке; она се преузима из вашег тајног извора приликом повезивања.",
"secretSourcesManage": "Управљајте тајним изворима",
"secretSourcesTitle": "Тајни извори",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Потребно је име, URL сервера и токен",
"secretSourceSaved": "Тајни извор сачуван",
"secretSourceDeleted": "Тајни извор је обрисан",
"vaultManageProfiles": "Управљање профилима трезора",
"vaultAddrLabel": "Адреса трезора",
"vaultNamespaceLabel": "Именски простор",
"vaultOidcMountLabel": "OIDC аутентификација монтирања",
"vaultOidcRoleLabel": "Улога OIDC-а",
"vaultSshMountLabel": "SSH тајне носач",
"vaultSshRoleLabel": "Улога SSH потписника",
"vaultValidPrincipalsLabel": "Важећи принципали",
"vaultKeyTypeLabel": "Тип ефемерног кључа",
"vaultSharedLabel": "Подели са свим корисницима",
"vaultCreateProfile": "Направи профил",
"vaultProfileCreated": "Профил трезора је креиран",
"vaultProfileSaved": "Профил трезора је сачуван",
"vaultProfileDeleted": "Профил трезора је обрисан",
"vaultProfileSaveFailed": "Чување профила у трезору није успело",
"vaultProfileDeleteFailed": "Брисање профила у Трезору није успело",
"vaultSaveProfile": "Сачувај профил",
"vaultProfileValidationError": "Име, адреса трезора и улога SSH потписника су обавезни",
"vaultNoProfiles": "Још нема профила у Трезору.",
"vaultSharedBadge": "дељено",
"keyTypeLabel": "Тип кључа",
"keyTypeAuto": "Аутоматско откривање",
"keyPasteTab": "Залепи",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Välj den metod som Termix kommer att använda för att autentisera denna SSH-anslutning. Andra metoder testas inte automatiskt.",
"storedCredential": "Lagrade autentiseringsuppgifter",
"selectACredential": "Välj en behörighetsinloggning...",
"vaultProfile": "Profil för valvsignerare",
"selectAVaultProfile": "Välj en Vault-profil...",
"vaultProfileHint": "Inställningarna kommer från den delade profilen; du loggar in på Vault via OIDC när du ansluter. Inga hemligheter lagras.",
"vaultNewProfile": "Ny profil",
"secretRefHint": "Du kan klistra in en 1Password-referens (op://vault/item/field) istället för hemligheten; den hämtas från din hemliga källa när du ansluter.",
"secretSourcesManage": "Hantera hemliga källor",
"secretSourcesTitle": "Hemliga källor",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Namn, server-URL och token krävs",
"secretSourceSaved": "Hemlig källa sparad",
"secretSourceDeleted": "Hemlig källa raderad",
"vaultManageProfiles": "Hantera Vault-profiler",
"vaultAddrLabel": "Valvadress",
"vaultNamespaceLabel": "Namnrymd",
"vaultOidcMountLabel": "OIDC-autentiseringsmontering",
"vaultOidcRoleLabel": "OIDC-roll",
"vaultSshMountLabel": "SSH-hemligheter monteras",
"vaultSshRoleLabel": "SSH-signerarroll",
"vaultValidPrincipalsLabel": "Giltiga principer",
"vaultKeyTypeLabel": "Tillfällig nyckeltyp",
"vaultSharedLabel": "Dela med alla användare",
"vaultCreateProfile": "Skapa profil",
"vaultProfileCreated": "Arkivprofil skapad",
"vaultProfileSaved": "Arkivprofilen har sparats",
"vaultProfileDeleted": "Arkivprofil raderad",
"vaultProfileSaveFailed": "Kunde inte spara arkivprofilen",
"vaultProfileDeleteFailed": "Misslyckades med att ta bort Arkiv-profilen",
"vaultSaveProfile": "Spara profil",
"vaultProfileValidationError": "Namn, valvadress och SSH-signerarroll krävs",
"vaultNoProfiles": "Inga Vault-profiler ännu.",
"vaultSharedBadge": "delad",
"keyTypeLabel": "Nyckeltyp",
"keyTypeAuto": "Automatisk identifiering",
"keyPasteTab": "Klistra",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "เลือกวิธีการตรวจสอบสิทธิ์การเชื่อมต่อ SSH ที่ Termix จะใช้เพียงวิธีเดียวเท่านั้น ระบบจะไม่ลองใช้วิธีการอื่นโดยอัตโนมัติ",
"storedCredential": "ข้อมูลประจำตัวที่บันทึกไว้",
"selectACredential": "เลือกข้อมูลประจำตัว...",
"vaultProfile": "โปรไฟล์ผู้ลงนาม Vault",
"selectAVaultProfile": "เลือกโปรไฟล์ Vault...",
"vaultProfileHint": "การตั้งค่าต่างๆ มาจากโปรไฟล์ที่ใช้ร่วมกัน คุณจะเข้าสู่ระบบ Vault ผ่าน OIDC เมื่อเชื่อมต่อ ไม่มีข้อมูลลับใดๆ ถูกจัดเก็บไว้",
"vaultNewProfile": "โปรไฟล์ใหม่",
"secretRefHint": "คุณสามารถวางข้อมูลอ้างอิง 1Password (op://vault/item/field) แทนรหัสลับได้ โดยระบบจะดึงข้อมูลนี้จากแหล่งข้อมูลลับของคุณเมื่อทำการเชื่อมต่อ",
"secretSourcesManage": "จัดการแหล่งข้อมูลลับ",
"secretSourcesTitle": "แหล่งข่าวลับ",
@@ -739,26 +735,6 @@
"secretSourceRequired": "จำเป็นต้องระบุชื่อ ที่อยู่ URL ของเซิร์ฟเวอร์ และโทเค็น",
"secretSourceSaved": "แหล่งข่าวลับถูกบันทึกไว้แล้ว",
"secretSourceDeleted": "แหล่งข่าวลับถูกลบแล้ว",
"vaultManageProfiles": "จัดการโปรไฟล์ Vault",
"vaultAddrLabel": "ที่อยู่ห้องนิรภัย",
"vaultNamespaceLabel": "เนมสเปซ",
"vaultOidcMountLabel": "OIDC Auth Mount",
"vaultOidcRoleLabel": "บทบาทของ OIDC",
"vaultSshMountLabel": "SSH Secrets Mount",
"vaultSshRoleLabel": "บทบาทผู้ลงนาม SSH",
"vaultValidPrincipalsLabel": "หลักการที่ถูกต้อง",
"vaultKeyTypeLabel": "ประเภทคีย์ชั่วคราว",
"vaultSharedLabel": "แชร์กับผู้ใช้ทั้งหมด",
"vaultCreateProfile": "สร้างโปรไฟล์",
"vaultProfileCreated": "สร้างโปรไฟล์ Vault แล้ว",
"vaultProfileSaved": "บันทึกโปรไฟล์ Vault แล้ว",
"vaultProfileDeleted": "โปรไฟล์ Vault ถูกลบแล้ว",
"vaultProfileSaveFailed": "ไม่สามารถบันทึกโปรไฟล์ Vault ได้",
"vaultProfileDeleteFailed": "ไม่สามารถลบโปรไฟล์ Vault ได้",
"vaultSaveProfile": "บันทึกโปรไฟล์",
"vaultProfileValidationError": "จำเป็นต้องระบุชื่อ ที่อยู่ Vault และบทบาทผู้ลงนาม SSH",
"vaultNoProfiles": "ยังไม่มีโปรไฟล์ Vault ใดๆ",
"vaultSharedBadge": "แชร์",
"keyTypeLabel": "ประเภทกุญแจ",
"keyTypeAuto": "ตรวจจับอัตโนมัติ",
"keyPasteTab": "แปะ",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Termix'in bu SSH bağlantısını doğrulamak için kullanacağı yöntemi seçin. Diğer yöntemler otomatik olarak denenmez.",
"storedCredential": "Kayıtlı Kimlik",
"selectACredential": "Bir kimlik seçin...",
"vaultProfile": "Vault İmzalayıcı Profili",
"selectAVaultProfile": "Bir Vault profili seçin...",
"vaultProfileHint": "Ayarlar paylaşılan profilden gelir; bağlandığınızda OIDC ile Vault'ta oturum açarsınız. Hiçbir gizli bilgi saklanmaz.",
"vaultNewProfile": "Yeni profil",
"secretRefHint": "Gizli anahtar yerine 1Password referansını (op://vault/item/field) yapıştırabilirsiniz; bağlantı kurulurken bu referans gizli anahtar kaynağınızdan alınır.",
"secretSourcesManage": "Gizli kaynakları yönetin",
"secretSourcesTitle": "Gizli kaynaklar",
@@ -739,26 +735,6 @@
"secretSourceRequired": "İsim, sunucu URL'si ve belirteç (token) gereklidir.",
"secretSourceSaved": "Gizli kaynak kaydedildi.",
"secretSourceDeleted": "Gizli kaynak silindi",
"vaultManageProfiles": "Vault Profillerini Yönet",
"vaultAddrLabel": "Vault Adresi",
"vaultNamespaceLabel": "Ad Alanı",
"vaultOidcMountLabel": "OIDC Kimlik Doğrulama Bağlama",
"vaultOidcRoleLabel": "OIDC Rolü",
"vaultSshMountLabel": "SSH Gizli Dizin Bağlama",
"vaultSshRoleLabel": "SSH İmzalayıcı Rolü",
"vaultValidPrincipalsLabel": "Geçerli Asıllar",
"vaultKeyTypeLabel": "Geçici Anahtar Türü",
"vaultSharedLabel": "Tüm Kullanıcılarla Paylaş",
"vaultCreateProfile": "Profil oluştur",
"vaultProfileCreated": "Vault profili oluşturuldu",
"vaultProfileSaved": "Vault profili kaydedildi",
"vaultProfileDeleted": "Vault profili silindi",
"vaultProfileSaveFailed": "Vault profili kaydedilemedi",
"vaultProfileDeleteFailed": "Vault profili silinemedi",
"vaultSaveProfile": "Profili kaydet",
"vaultProfileValidationError": "Ad, Vault adresi ve SSH imzalama rolü gereklidir",
"vaultNoProfiles": "Henüz Vault profili yok.",
"vaultSharedBadge": "paylaşılan",
"keyTypeLabel": "Anahtar Türü",
"keyTypeAuto": "Otomatik Algıla",
"keyPasteTab": "Yapıştır",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Виберіть один метод, який Termix використовуватиме для автентифікації цього SSH-з’єднання. Інші методи не перевірятимуться автоматично.",
"storedCredential": "Збережені облікові дані",
"selectACredential": "Виберіть облікові дані...",
"vaultProfile": "Профіль підписанта сховища",
"selectAVaultProfile": "Виберіть профіль Сховища...",
"vaultProfileHint": "Налаштування надходять зі спільного профілю; ви входите у Сейф через OIDC під час підключення. Секрети не зберігаються.",
"vaultNewProfile": "Новий профіль",
"secretRefHint": "Ви можете вставити посилання на 1Password (op://vault/item/field) замість секретного коду; він отримується з вашого секретного джерела під час підключення.",
"secretSourcesManage": "Керування секретними джерелами",
"secretSourcesTitle": "Секретні джерела",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Ім'я, URL-адреса сервера та токен обов'язкові",
"secretSourceSaved": "Секретне джерело збережено",
"secretSourceDeleted": "Секретне джерело видалено",
"vaultManageProfiles": "Керування профілями Сховища",
"vaultAddrLabel": "Адреса сховища",
"vaultNamespaceLabel": "Простір імен",
"vaultOidcMountLabel": "Монтування автентифікації OIDC",
"vaultOidcRoleLabel": "Роль OIDC",
"vaultSshMountLabel": "SSH-секрети кріплення",
"vaultSshRoleLabel": "Роль підписувача SSH",
"vaultValidPrincipalsLabel": "Дійсні принципали",
"vaultKeyTypeLabel": "Тип тимчасового ключа",
"vaultSharedLabel": "Поділитися з усіма користувачами",
"vaultCreateProfile": "Створити профіль",
"vaultProfileCreated": "Профіль сховища створено",
"vaultProfileSaved": "Профіль сховища збережено",
"vaultProfileDeleted": "Профіль сховища видалено",
"vaultProfileSaveFailed": "Не вдалося зберегти профіль Сейфа",
"vaultProfileDeleteFailed": "Не вдалося видалити профіль Сейфа",
"vaultSaveProfile": "Зберегти профіль",
"vaultProfileValidationError": "Ім'я, адреса сховища та роль підписувача SSH обов'язкові",
"vaultNoProfiles": "Поки що немає профілів Сейфа.",
"vaultSharedBadge": "спільний",
"keyTypeLabel": "Тип ключа",
"keyTypeAuto": "Автоматичне виявлення",
"keyPasteTab": "Вставити",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "Hãy chọn phương thức mà Termix sẽ sử dụng để xác thực kết nối SSH này. Các phương thức khác sẽ không được thử tự động.",
"storedCredential": "Thông tin xác thực đã lưu",
"selectACredential": "Chọn thông tin xác thực...",
"vaultProfile": "Hồ sơ người ký Vault",
"selectAVaultProfile": "Chọn hồ sơ Vault...",
"vaultProfileHint": "Cài đặt từ hồ sơ dùng chung; bạn sẽ đăng nhập Vault qua OIDC khi kết nối. Không lưu bí mật.",
"vaultNewProfile": "Hồ sơ mới",
"secretRefHint": "Bạn có thể dán mã tham chiếu 1Password (op://vault/item/field) thay cho mã bí mật; mã này sẽ được lấy từ nguồn mã bí mật của bạn khi kết nối.",
"secretSourcesManage": "Quản lý các nguồn tin mật",
"secretSourcesTitle": "Nguồn tin bí mật",
@@ -739,26 +735,6 @@
"secretSourceRequired": "Tên người dùng, URL máy chủ và mã thông báo là bắt buộc.",
"secretSourceSaved": "Nguồn bí mật đã được lưu",
"secretSourceDeleted": "Nguồn tin bí mật đã bị xóa.",
"vaultManageProfiles": "Quản lý hồ sơ Vault",
"vaultAddrLabel": "Địa chỉ Vault",
"vaultNamespaceLabel": "Không gian tên",
"vaultOidcMountLabel": "Điểm gắn xác thực OIDC",
"vaultOidcRoleLabel": "Vai trò OIDC",
"vaultSshMountLabel": "Điểm gắn bí mật SSH",
"vaultSshRoleLabel": "Vai trò ký SSH",
"vaultValidPrincipalsLabel": "Các danh tính hợp lệ",
"vaultKeyTypeLabel": "Loại khóa tạm thời",
"vaultSharedLabel": "Chia sẻ với tất cả người dùng",
"vaultCreateProfile": "Tạo hồ sơ",
"vaultProfileCreated": "Đã tạo hồ sơ Vault",
"vaultProfileSaved": "Đã lưu hồ sơ Vault",
"vaultProfileDeleted": "Đã xóa hồ sơ Vault",
"vaultProfileSaveFailed": "Không thể lưu hồ sơ Vault",
"vaultProfileDeleteFailed": "Không thể xóa hồ sơ Vault",
"vaultSaveProfile": "Lưu hồ sơ",
"vaultProfileValidationError": "Tên, địa chỉ Vault và vai trò ký SSH là bắt buộc",
"vaultNoProfiles": "Chưa có hồ sơ Vault nào.",
"vaultSharedBadge": "được chia sẻ",
"keyTypeLabel": "Loại khóa",
"keyTypeAuto": "Tự động phát hiện",
"keyPasteTab": "Dán",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "请选择 Termix 用于验证此 SSH 连接的一种方法。其他方法不会自动尝试。",
"storedCredential": "已存储凭据",
"selectACredential": "选择凭据...",
"vaultProfile": "Vault 签名者配置文件",
"selectAVaultProfile": "选择 Vault 配置文件...",
"vaultProfileHint": "设置来自共享配置文件;连接时将使用 OIDC 登录 Vault。不会存储任何机密。",
"vaultNewProfile": "新建配置文件",
"secretRefHint": "您可以粘贴 1Password 引用(op://vault/item/field)而不是密钥;连接时会从您的密钥源中获取该引用。",
"secretSourcesManage": "管理秘密来源",
"secretSourcesTitle": "秘密消息来源",
@@ -739,26 +735,6 @@
"secretSourceRequired": "需要提供名称、服务器 URL 和令牌。",
"secretSourceSaved": "秘密来源已保存",
"secretSourceDeleted": "秘密来源已删除",
"vaultManageProfiles": "管理 Vault 配置文件",
"vaultAddrLabel": "Vault 地址",
"vaultNamespaceLabel": "命名空间",
"vaultOidcMountLabel": "OIDC 认证挂载点",
"vaultOidcRoleLabel": "OIDC 角色",
"vaultSshMountLabel": "SSH 机密挂载点",
"vaultSshRoleLabel": "SSH 签名者角色",
"vaultValidPrincipalsLabel": "有效主体",
"vaultKeyTypeLabel": "临时密钥类型",
"vaultSharedLabel": "与所有用户共享",
"vaultCreateProfile": "创建配置文件",
"vaultProfileCreated": "Vault 配置文件已创建",
"vaultProfileSaved": "Vault 配置文件已保存",
"vaultProfileDeleted": "Vault 配置文件已删除",
"vaultProfileSaveFailed": "保存 Vault 配置文件失败",
"vaultProfileDeleteFailed": "删除 Vault 配置文件失败",
"vaultSaveProfile": "保存配置文件",
"vaultProfileValidationError": "名称、Vault 地址和 SSH 签名者角色为必填项",
"vaultNoProfiles": "暂无 Vault 配置文件。",
"vaultSharedBadge": "共享",
"keyTypeLabel": "密钥类型",
"keyTypeAuto": "自动检测",
"keyPasteTab": "粘贴",
-24
View File
@@ -719,10 +719,6 @@
"authenticationMethodDesc": "請選擇 Termix 用於驗證此 SSH 連線的一種方法。其他方法不會自動嘗試。",
"storedCredential": "儲存的憑證",
"selectACredential": "選取憑證...",
"vaultProfile": "Vault 簽署者設定檔",
"selectAVaultProfile": "選取 Vault 設定檔...",
"vaultProfileHint": "設定來自共用設定檔;連線時您將透過 OIDC 登入 Vault。不會儲存任何機密。",
"vaultNewProfile": "新增設定檔",
"secretRefHint": "您可以貼上 1Password 引用(op://vault/item/field)而不是密鑰;連接時會從您的密鑰來源中獲取該引用。",
"secretSourcesManage": "管理秘密來源",
"secretSourcesTitle": "秘密消息來源",
@@ -739,26 +735,6 @@
"secretSourceRequired": "需要提供名稱、伺服器 URL 和令牌。",
"secretSourceSaved": "秘密來源已儲存",
"secretSourceDeleted": "秘密來源已刪除",
"vaultManageProfiles": "管理 Vault 設定檔",
"vaultAddrLabel": "Vault 位址",
"vaultNamespaceLabel": "命名空間",
"vaultOidcMountLabel": "OIDC 驗證掛載",
"vaultOidcRoleLabel": "OIDC 角色",
"vaultSshMountLabel": "SSH 機密掛載",
"vaultSshRoleLabel": "SSH 簽署者角色",
"vaultValidPrincipalsLabel": "有效主體",
"vaultKeyTypeLabel": "暫時金鑰類型",
"vaultSharedLabel": "與所有使用者共用",
"vaultCreateProfile": "建立設定檔",
"vaultProfileCreated": "Vault 設定檔已建立",
"vaultProfileSaved": "Vault 設定檔已儲存",
"vaultProfileDeleted": "Vault 設定檔已刪除",
"vaultProfileSaveFailed": "無法儲存 Vault 設定檔",
"vaultProfileDeleteFailed": "無法刪除 Vault 設定檔",
"vaultSaveProfile": "儲存設定檔",
"vaultProfileValidationError": "名稱、Vault 位址和 SSH 簽署者角色為必填",
"vaultNoProfiles": "尚無 Vault 設定檔。",
"vaultSharedBadge": "已共用",
"keyTypeLabel": "金鑰類型",
"keyTypeAuto": "自動偵測",
"keyPasteTab": "貼上",
-8
View File
@@ -1783,14 +1783,6 @@ export {
deployCredentialToHost,
} from "@/api/credentials-api";
export {
getVaultProfiles,
createVaultProfile,
updateVaultProfile,
deleteVaultProfile,
type VaultProfilePayload,
} from "@/api/vault-profiles-api";
// ============================================================================
// SNIPPETS API
// ============================================================================
+2 -65
View File
@@ -41,7 +41,6 @@ import {
updateSSHHost,
getSnippets,
getUserInfo,
getVaultProfiles,
getHostPassword,
adminCreateUserHost,
adminUpdateUserHost,
@@ -57,7 +56,7 @@ import {
parseCustomThemes,
type SavedCustomTheme,
} from "@/api/open-tabs-api";
import type { Host, VaultProfile } from "@/types/ui-types";
import type { Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api";
@@ -78,7 +77,6 @@ import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
import { withProtocolSettings } from "./host-protocols";
import { canEditHost } from "./host-permissions";
import { VaultProfileManager } from "./VaultProfileManager";
import {
getHostEditorSection,
isSshGroupTab,
@@ -167,8 +165,6 @@ export function HostEditor({
const [saving, setSaving] = useState(false);
const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]);
const [isOidcUser, setIsOidcUser] = useState(false);
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
const [showVaultManager, setShowVaultManager] = useState(false);
const [showSecretSources, setShowSecretSources] = useState(false);
const [quickCredentialName, setQuickCredentialName] = useState("");
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
@@ -232,12 +228,6 @@ export function HostEditor({
setField("customThemeColors", { ...theme.colors });
};
const reloadVaultProfiles = () => {
getVaultProfiles()
.then((res) => setVaultProfiles(res as unknown as VaultProfile[]))
.catch(() => {});
};
useEffect(() => {
getUserInfo()
.then((info) => setIsOidcUser(info.is_oidc))
@@ -251,7 +241,6 @@ export function HostEditor({
loadSnippets
.then((res) => setSnippets(mapSnippetResponse(res)))
.catch(() => {});
reloadVaultProfiles();
}, [adminTargetUserId]);
useEffect(() => {
@@ -465,7 +454,7 @@ export function HostEditor({
};
// Shared hosts: view-level recipients see a read-only editor; edit-level
// recipients may change the host but never its credential/vault references
// recipients may change the host but never its credential references
// or auth type (owner-only, enforced server-side too).
const isSharedHost = !!host?.isShared;
const readOnly = isSharedHost && host !== null && !canEditHost(host);
@@ -873,58 +862,6 @@ export function HostEditor({
</div>
</>
)}
{authMethod === "vault" && (
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.vaultProfile")}
</label>
<Select2
value={form.vaultProfileId}
onChange={(e) =>
setField("vaultProfileId", e.target.value)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t("hosts.selectAVaultProfile")}
</option>
{vaultProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.shared ? `${p.name} (shared)` : p.name}
</option>
))}
</Select2>
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
{t("hosts.vaultProfileHint")}
</p>
<div className="flex items-center gap-2 shrink-0">
<a
href="https://docs.termix.site/features/authentication/vault"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80"
onClick={() => setShowVaultManager((v) => !v)}
>
{t("hosts.vaultManageProfiles")}
</button>
</div>
</div>
</div>
)}
{authMethod === "vault" && showVaultManager && (
<VaultProfileManager
profiles={vaultProfiles}
onChanged={reloadVaultProfiles}
onClose={() => setShowVaultManager(false)}
/>
)}
{authMethod === "credential" && (
<>
<div className="flex flex-col gap-1.5">
-5
View File
@@ -134,7 +134,6 @@ export function createHostEditorForm(
: d?.credentialId != null
? String(d.credentialId)
: "",
vaultProfileId: host?.vaultProfileId ?? "",
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
folder: host?.folder ?? "",
parentHostId: host?.parentHostId ?? "",
@@ -294,7 +293,6 @@ export function omitOwnerSshAuthFromSharedEdit(
keyType: _keyType,
sudoPassword: _sudoPassword,
credentialId: _credentialId,
vaultProfileId: _vaultProfileId,
overrideCredentialUsername: _overrideCredentialUsername,
shareSshAuth: _shareSshAuth,
...editableFields
@@ -325,7 +323,6 @@ export function buildHostEditorPayload(
const usesKey = form.authType === "key";
const usesPassword = form.authType === "password";
const usesAgent = form.authType === "agent";
const usesVault = form.authType === "vault";
// With SSH off, the host's primary protocol is the first plugin protocol
// switched on, and its port stands in for the host port.
const primaryProtocol = protocols.enableSsh
@@ -410,8 +407,6 @@ export function buildHostEditorPayload(
keyType: usesKey && form.keyType !== "auto" ? form.keyType : null,
credentialId:
usesCredential && form.credentialId ? Number(form.credentialId) : null,
vaultProfileId:
usesVault && form.vaultProfileId ? Number(form.vaultProfileId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
enableTerminal: form.enableTerminal,
-4
View File
@@ -78,10 +78,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
keyPassword: h.keyPassword,
keyType: h.keyType,
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
vaultProfileId:
(h as { vaultProfileId?: number | string | null }).vaultProfileId != null
? String((h as { vaultProfileId?: number | string }).vaultProfileId)
: undefined,
notes: h.notes,
pin: h.pin ?? false,
sortOrder: h.sortOrder ?? null,
-274
View File
@@ -1,274 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
createVaultProfile,
updateVaultProfile,
deleteVaultProfile,
type VaultProfilePayload,
} from "@/main-axios";
import type { VaultProfile } from "@/types/ui-types";
import { Select2 } from "@/components/select2";
type FormState = VaultProfilePayload & { id?: string };
const emptyForm: FormState = {
name: "",
vaultAddr: "",
vaultNamespace: "",
oidcMount: "oidc",
oidcRole: "",
sshMount: "ssh-client-signer",
sshRole: "",
validPrincipals: "",
keyType: "ssh-ed25519",
shared: false,
};
function toForm(p: VaultProfile): FormState {
return {
id: p.id,
name: p.name,
vaultAddr: p.vaultAddr,
vaultNamespace: p.vaultNamespace ?? "",
oidcMount: p.oidcMount ?? "oidc",
oidcRole: p.oidcRole ?? "",
sshMount: p.sshMount ?? "ssh-client-signer",
sshRole: p.sshRole,
validPrincipals: p.validPrincipals ?? "",
keyType: p.keyType ?? "ssh-ed25519",
shared: p.shared,
};
}
export function VaultProfileManager({
profiles,
onChanged,
onClose,
}: {
profiles: VaultProfile[];
onChanged: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const setField = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((p) => (p ? { ...p, [k]: v } : p));
const handleSave = async () => {
if (!form) return;
if (!form.name.trim() || !form.vaultAddr.trim() || !form.sshRole.trim()) {
toast.error(t("hosts.vaultProfileValidationError"));
return;
}
setSaving(true);
try {
const payload: VaultProfilePayload = {
name: form.name.trim(),
vaultAddr: form.vaultAddr.trim(),
vaultNamespace: form.vaultNamespace?.trim() || null,
oidcMount: form.oidcMount?.trim() || null,
oidcRole: form.oidcRole?.trim() || null,
sshMount: form.sshMount?.trim() || null,
sshRole: form.sshRole.trim(),
validPrincipals: form.validPrincipals?.trim() || null,
keyType: form.keyType?.trim() || null,
shared: !!form.shared,
};
if (form.id) {
await updateVaultProfile(Number(form.id), payload);
toast.success(t("hosts.vaultProfileSaved"));
} else {
await createVaultProfile(payload);
toast.success(t("hosts.vaultProfileCreated"));
}
setForm(null);
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("hosts.vaultProfileSaveFailed")));
} finally {
setSaving(false);
}
};
const handleDelete = async (p: VaultProfile) => {
try {
await deleteVaultProfile(Number(p.id));
toast.success(t("hosts.vaultProfileDeleted"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("hosts.vaultProfileDeleteFailed")));
}
};
const field = (label: string, key: keyof FormState, placeholder?: string) => (
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</label>
<Input
className="h-8 text-xs"
placeholder={placeholder}
value={(form?.[key] as string) ?? ""}
onChange={(e) => setField(key, e.target.value as FormState[typeof key])}
/>
</div>
);
return (
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.vaultManageProfiles")}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
{!form && (
<>
<div className="flex flex-col divide-y divide-border/50">
{profiles.length === 0 && (
<span className="text-[11px] text-muted-foreground py-1">
{t("hosts.vaultNoProfiles")}
</span>
)}
{profiles.map((p) => (
<div
key={p.id}
className="flex items-center justify-between py-1.5"
>
<div className="flex flex-col min-w-0">
<span className="text-xs text-foreground truncate">
{p.name}
{p.shared && (
<span className="ml-1 text-[9px] text-accent-brand">
{t("hosts.vaultSharedBadge")}
</span>
)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{p.vaultAddr}
</span>
</div>
{p.owned && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
title="Edit"
onClick={() => setForm(toForm(p))}
className="size-7 flex items-center justify-center rounded text-muted-foreground/60 hover:text-foreground hover:bg-muted-foreground/10"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
title="Delete"
onClick={() => handleDelete(p)}
className="size-7 flex items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-3.5" />
</button>
</div>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px] self-start"
onClick={() => setForm({ ...emptyForm })}
>
<Plus className="size-3 mr-1" /> {t("hosts.vaultNewProfile")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{field(t("hosts.friendlyNameLabel"), "name", "Production Vault")}
{field(
t("hosts.vaultAddrLabel"),
"vaultAddr",
"https://vault:8200",
)}
{field(t("hosts.vaultNamespaceLabel"), "vaultNamespace", "admin")}
{field(t("hosts.vaultOidcMountLabel"), "oidcMount", "oidc")}
{field(t("hosts.vaultOidcRoleLabel"), "oidcRole", "default")}
{field(
t("hosts.vaultSshMountLabel"),
"sshMount",
"ssh-client-signer",
)}
{field(t("hosts.vaultSshRoleLabel"), "sshRole", "my-role")}
{field(
t("hosts.vaultValidPrincipalsLabel"),
"validPrincipals",
"root,deploy",
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.vaultKeyTypeLabel")}
</label>
<Select2
value={form.keyType ?? "ssh-ed25519"}
onChange={(e) => setField("keyType", e.target.value)}
className="flex h-8 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="ssh-ed25519">Ed25519</option>
<option value="ecdsa-sha2-nistp256">ECDSA (nistp256)</option>
<option value="ssh-rsa">RSA (4096)</option>
</Select2>
</div>
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={!!form.shared}
onChange={(e) => setField("shared", e.target.checked)}
/>
{t("hosts.vaultSharedLabel")}
</label>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setForm(null)}
disabled={saving}
>
{t("hosts.cancelBtn")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{form.id
? t("hosts.vaultSaveProfile")
: t("hosts.vaultCreateProfile")}
</Button>
</div>
</div>
)}
</div>
);
}
@@ -50,7 +50,6 @@ describe("omitOwnerSshAuthFromSharedEdit", () => {
key: "owner-key",
keyPassword: "owner-passphrase",
keyType: "ssh-ed25519",
vaultProfileId: "9",
overrideCredentialUsername: true,
shareSshAuth: true,
sudoPassword: "owner-sudo",
@@ -70,7 +69,6 @@ describe("omitOwnerSshAuthFromSharedEdit", () => {
for (const field of [
"authType",
"credentialId",
"vaultProfileId",
"overrideCredentialUsername",
"shareSshAuth",
"password",
@@ -128,32 +126,6 @@ describe("buildHostEditorPayload auth field isolation", () => {
expect(payload.password).toBe("newpass");
});
it("drops the vaultProfileId when switching a host away from vault auth", () => {
const form = {
...createHostEditorForm(null),
authType: "password" as const,
password: "newpass",
vaultProfileId: "9",
};
const payload = buildHostEditorPayload(form, sshOnly);
expect(payload.vaultProfileId).toBeNull();
expect(payload.password).toBe("newpass");
});
it("sends vaultProfileId when authType is vault", () => {
const form = {
...createHostEditorForm(null),
authType: "vault" as const,
vaultProfileId: "9",
};
const payload = buildHostEditorPayload(form, sshOnly);
expect(payload.vaultProfileId).toBe(9);
});
it("sends credentialId and optional password when authType is credential", () => {
const form = {
...createHostEditorForm(null),