mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move vault ssh signing into a plugin (C5)
This commit is contained in:
1 parent
b027717c46
commit
6e56c90aac
198 files changed
+19717
-3445
No files matched your search
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)[];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "لصق",
|
||||
|
||||
@@ -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": "Паста",
|
||||
|
||||
@@ -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": "পেস্ট",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Πάστα",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ä",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "לְהַדבִּיק",
|
||||
|
||||
@@ -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": "चिपकाएँ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "貼り付け",
|
||||
|
||||
@@ -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": "붙여넣기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Вставить",
|
||||
|
||||
@@ -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": "Залепи",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "แปะ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вставити",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "粘贴",
|
||||
|
||||
@@ -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": "貼上",
|
||||
|
||||
@@ -1783,14 +1783,6 @@ export {
|
||||
deployCredentialToHost,
|
||||
} from "@/api/credentials-api";
|
||||
|
||||
export {
|
||||
getVaultProfiles,
|
||||
createVaultProfile,
|
||||
updateVaultProfile,
|
||||
deleteVaultProfile,
|
||||
type VaultProfilePayload,
|
||||
} from "@/api/vault-profiles-api";
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPETS API
|
||||
// ============================================================================
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user