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

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

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { TokenStore } from "./token-store.js";
import {
completeVaultOidc,
generateEphemeralKeyPair,
signWithVault,
startVaultOidc,
type VaultProfileConfig,
} from "./vault-client.js";
/** The redirect URI 2.8 had Vault roles allow. */
export const LEGACY_CALLBACK_PATH = "/vault/oidc/callback";
export const CALLBACK_PATH = "/plugin-api/vault/oidc/callback";
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
/** The terminal socket, as far as a sign-in needs it. */
export interface SignInSocket {
send: (data: string) => void;
on?: (event: "close", listener: () => void) => void;
}
export interface StartRequest {
userId: string;
hostId: number;
profile: VaultProfileConfig;
socket: SignInSocket;
requestOrigin: string;
}
export interface CallbackResult {
ok: boolean;
message: string;
}
interface Session {
state: string;
userId: string;
hostId: number;
profile: VaultProfileConfig;
clientNonce: string;
privateKey: string;
publicKey: string;
socket: SignInSocket;
timeout: ReturnType<typeof setTimeout>;
}
function send(socket: SignInSocket, message: Record<string, unknown>): void {
try {
socket.send(JSON.stringify(message));
} catch {
// socket already gone
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export type AuthSessions = ReturnType<typeof createAuthSessions>;
/**
* Vault OIDC sign-ins in flight, keyed by the state Vault put in its auth
* URL so the unauthenticated browser callback finds its way back. Created
* per activation; closeAll runs on deactivate.
*/
export function createAuthSessions(ctx: PluginContext, tokens: TokenStore) {
const sessions = new Map<string, Session>();
function end(session: Session): void {
clearTimeout(session.timeout);
sessions.delete(session.state);
}
async function callbackPath(): Promise<string> {
const legacy = await ctx.settings.get<boolean>("legacyCallback");
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
}
return {
/** Asks Vault for an auth URL and sends it to the terminal. */
async start(request: StartRequest): Promise<void> {
try {
const keyPair = generateEphemeralKeyPair(request.profile.keyType);
const redirectUri = `${request.requestOrigin}${await callbackPath()}`;
const { authUrl, state, clientNonce } = await startVaultOidc(
ctx.fetch,
request.profile,
redirectUri,
);
const existing = sessions.get(state);
if (existing) end(existing);
const session: Session = {
state,
userId: request.userId,
hostId: request.hostId,
profile: request.profile,
clientNonce,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
socket: request.socket,
timeout: setTimeout(() => {
const current = sessions.get(state);
if (current) end(current);
}, AUTH_TIMEOUT_MS),
};
sessions.set(state, session);
request.socket.on?.("close", () => {
const current = sessions.get(state);
if (current) end(current);
});
send(request.socket, {
type: "vault_auth_url",
hostId: request.hostId,
requestId: state,
url: authUrl,
});
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Failed to start Vault authentication: ${message}`);
send(request.socket, {
type: "vault_error",
hostId: request.hostId,
error: message,
});
}
},
/**
* The identity provider's redirect, via Vault: exchanges the code for a
* Vault token, has Vault sign the ephemeral key and caches the result.
*/
async complete(state: string, code: string): Promise<CallbackResult> {
const session = sessions.get(state);
if (!session) {
return {
ok: false,
message: "This sign-in request is no longer active.",
};
}
end(session);
try {
const token = await completeVaultOidc(ctx.fetch, session.profile, {
state,
code,
clientNonce: session.clientNonce,
});
const signed = await signWithVault(
ctx.fetch,
session.profile,
token,
session.publicKey,
);
const expiresAt = await tokens.save(
session.userId,
session.profile.id,
session.privateKey,
signed,
);
ctx.log.info(
`Vault signed a certificate for host ${session.hostId} with profile ${session.profile.id}`,
);
send(session.socket, {
type: "vault_completed",
hostId: session.hostId,
requestId: state,
expiresAt,
});
return {
ok: true,
message: "You can close this window and return to Termix.",
};
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Vault sign-in failed: ${message}`);
send(session.socket, {
type: "vault_error",
hostId: session.hostId,
requestId: state,
error: message,
});
return { ok: false, message };
}
},
/** Cancels by request id, or every sign-in the user has for a host. */
cancel(userId: string, hostId?: number, requestId?: string): void {
for (const session of [...sessions.values()]) {
if (session.userId !== userId) continue;
if (
requestId ? session.state === requestId : session.hostId === hostId
) {
end(session);
}
}
},
closeAll(): void {
for (const session of [...sessions.values()]) end(session);
},
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { profiles as profilesTable, tokens as tokensTable } from "./tables.js";
import { createProfileStore, SYNC_ENTITY } from "./profile-store.js";
import { createTokenStore } from "./token-store.js";
import { createAuthSessions } from "./auth-session.js";
import { createVaultProvider } from "./provider.js";
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const profilesDb = await ctx.db.define(profilesTable);
const tokensDb = await ctx.db.define(tokensTable);
const profiles = createProfileStore(ctx, profilesDb, tokensDb);
const tokens = createTokenStore(ctx, tokensDb);
const sessions = createAuthSessions(ctx, tokens);
ctx.disposables.add(() => sessions.closeAll());
ctx.auth.registerSshAuthProvider(
createVaultProvider(ctx, profiles, tokens, sessions),
);
// Same wire name as 2.8, where core synced it. Hosts point at a profile
// through the profileId host setting, not a column, so nothing references it.
ctx.sync.registerEntity({
type: SYNC_ENTITY,
table: profilesDb,
order: 20,
});
registerRoutes(
ctx,
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
profiles,
sessions,
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+139
View File
@@ -0,0 +1,139 @@
import { randomUUID } from "node:crypto";
import { desc, eq, or } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { VaultProfileConfig } from "./vault-client.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export const SYNC_ENTITY = "vaultProfiles";
export interface ProfileRow {
id: number;
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 | number;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export type ProfileInput = Partial<
Omit<ProfileRow, "id" | "userId" | "syncId" | "createdAt" | "updatedAt">
>;
export function toConfig(row: ProfileRow): VaultProfileConfig {
return {
id: row.id,
vaultAddr: row.vaultAddr,
vaultNamespace: row.vaultNamespace ?? null,
oidcMount: row.oidcMount ?? null,
oidcRole: row.oidcRole ?? null,
sshMount: row.sshMount ?? null,
sshRole: row.sshRole,
validPrincipals: row.validPrincipals ?? null,
keyType: row.keyType ?? null,
};
}
export type ProfileStore = ReturnType<typeof createProfileStore>;
export function createProfileStore(
ctx: PluginContext,
table: Table,
tokensTable: Table,
) {
const client = () => ctx.db.client<Drizzle>();
async function findById(id: number): Promise<ProfileRow | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(eq(table.id, id))
.limit(1);
return rows[0] ?? null;
}
async function findBySyncId(syncId: string): Promise<ProfileRow | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(eq(table.syncId, syncId))
.limit(1);
return rows[0] ?? null;
}
return {
findById,
/** The user's own profiles plus every shared one. */
async listVisible(userId: string): Promise<ProfileRow[]> {
return (await client())
.select()
.from(table)
.where(or(eq(table.userId, userId), eq(table.shared, true)))
.orderBy(desc(table.updatedAt));
},
async create(
userId: string,
input: ProfileInput & {
name: string;
vaultAddr: string;
sshRole: string;
},
): Promise<ProfileRow> {
const syncId = randomUUID();
const now = new Date().toISOString();
await (await client()).insert(table).values({
...input,
userId,
shared: input.shared ? true : false,
syncId,
createdAt: now,
updatedAt: now,
});
await ctx.db.persist();
return (await findBySyncId(syncId)) as ProfileRow;
},
async update(id: number, input: ProfileInput): Promise<ProfileRow | null> {
await (
await client()
)
.update(table)
.set({ ...input, updatedAt: new Date().toISOString() })
.where(eq(table.id, id));
await ctx.db.persist();
return findById(id);
},
/** Deletes the profile, its cached certificates and records a tombstone. */
async remove(row: ProfileRow): Promise<void> {
const db = await client();
await db.delete(tokensTable).where(eq(tokensTable.profileId, row.id));
await db.delete(table).where(eq(table.id, row.id));
await ctx.db.persist();
await ctx.sync.recordTombstone(row.userId, SYNC_ENTITY, row.syncId);
},
};
}
+124
View File
@@ -0,0 +1,124 @@
import type {
PluginContext,
PluginSshAuthProvider,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import type { AuthSessions, SignInSocket } from "./auth-session.js";
import {
toConfig,
type ProfileRow,
type ProfileStore,
} from "./profile-store.js";
import type { TokenStore } from "./token-store.js";
export const AUTH_TYPE = "vault";
const REQUIRED_MESSAGE =
"Vault SSH signer authentication required. Please open a Terminal connection first.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/** The profile a host points at, from its host settings. */
export async function profileForHost(
ctx: PluginContext,
profiles: ProfileStore,
hostId: number,
): Promise<ProfileRow | null> {
const raw = await ctx.settings.getHost<number | string>(hostId, "profileId");
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return null;
return profiles.findById(id);
}
/**
* Hosts with auth type "vault" connect with a certificate Vault signed for
* the host's profile. Without one, the terminal is asked for a browser
* sign-in ("vault_auth_required"), which startInteraction runs.
*/
export function createVaultProvider(
ctx: PluginContext,
profiles: ProfileStore,
tokens: TokenStore,
sessions: AuthSessions,
): PluginSshAuthProvider {
return {
type: AUTH_TYPE,
labelKey: "hosts.filterAuthVault",
needsUserInteraction: true,
supportsBackground: false,
interaction: AUTH_TYPE,
prepare: async (config, host: PluginSshHost, env) => {
const profile = await profileForHost(ctx, profiles, host.id);
if (!profile) {
return {
status: "error",
code: "failed",
message: "Host has no Vault signer profile configured",
};
}
const cert = await tokens.get(env.userId, profile.id);
if (!cert) {
env.log(
"info",
"No valid Vault certificate found, requesting authentication",
);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message: REQUIRED_MESSAGE,
flag: "requiresVaultAuth",
};
}
try {
await applyCertificateAuth(
config as never,
env.client as never,
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"Vault SSH signer authentication failed: " +
(error instanceof Error ? error.message : String(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 undefined;
ctx.log.warn("Vault certificate authentication failed, forgetting it");
void profileForHost(ctx, profiles, host.id)
.then((profile) =>
profile ? tokens.remove(env.userId, profile.id) : undefined,
)
.catch(() => undefined);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message:
"Vault authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const profile = await profileForHost(ctx, profiles, request.hostId);
if (!profile) {
throw new Error("No Vault signer profile configured for this host");
}
await sessions.start({
userId: request.userId,
hostId: request.hostId,
profile: toConfig(profile),
socket: request.socket as SignInSocket,
requestOrigin: request.requestOrigin,
});
},
cancelInteraction: (request) => {
sessions.cancel(request.userId, request.hostId, request.requestId);
},
};
}
+337
View File
@@ -0,0 +1,337 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AuthSessions } from "./auth-session.js";
import type {
ProfileInput,
ProfileRow,
ProfileStore,
} from "./profile-store.js";
/**
* Vault sends the browser back here without a Termix session: the sign-in is
* matched by the state Vault issued.
*/
export const PUBLIC_PATHS = ["/oidc/callback"];
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Vault sign-in complete" : "Vault sign-in failed"}</h1><p>${escapeHtml(message)}</p></main>
${ok ? "<script>setTimeout(function(){window.close()},1500)</script>" : ""}</body></html>`;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function optional(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function formatProfile(row: ProfileRow, userId: string) {
return {
id: row.id,
name: row.name,
description: row.description,
folder: row.folder,
tags: row.tags ? row.tags.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 === userId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function parseId(req: Request): number | null {
const id = Number.parseInt(String(req.params.id), 10);
return Number.isFinite(id) ? id : null;
}
export function registerRoutes(
ctx: PluginContext,
router: Router,
profiles: ProfileStore,
sessions: AuthSessions,
): void {
const userId = () => ctx.currentActor() as string;
/**
* @openapi
* /plugin-api/vault/oidc/callback:
* get:
* summary: Vault OIDC callback
* description: Public. The redirect URI a Vault OIDC role allows. Matches the sign-in by the state Vault issued, has Vault sign the ephemeral key and tells the terminal to reconnect. The 2.8 URI /vault/oidc/callback redirects here.
* tags: [Vault]
* parameters:
* - in: query
* name: state
* schema:
* type: string
* - in: query
* name: code
* schema:
* type: string
* - in: query
* name: error
* schema:
* type: string
* responses:
* 200:
* description: Signed in; an HTML page that closes itself.
* 400:
* description: The sign-in failed or is no longer active.
*/
router.get("/oidc/callback", async (req: Request, res: Response) => {
const state = typeof req.query.state === "string" ? req.query.state : "";
const code = typeof req.query.code === "string" ? req.query.code : "";
const error = typeof req.query.error === "string" ? req.query.error : "";
const reply = (ok: boolean, message: string) =>
res
.status(ok ? 200 : 400)
.type("html")
.send(resultPage(ok, message));
if (error) return reply(false, `Vault returned: ${error}`);
if (!state || !code) return reply(false, "Missing state or code.");
const result = await sessions.complete(state, code);
return reply(result.ok, result.message);
});
router.use("/profiles", ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/vault/profiles:
* get:
* summary: List Vault profiles
* description: The caller's own Vault signer profiles and every shared one. Needs vault.use.
* tags: [Vault]
* responses:
* 200:
* description: Array of Vault profile objects.
* 403:
* description: Missing vault.use.
*/
router.get("/profiles", async (_req: Request, res: Response) => {
const rows = await profiles.listVisible(userId());
res.json(rows.map((row) => formatProfile(row, userId())));
});
/**
* @openapi
* /plugin-api/vault/profiles:
* post:
* summary: Create a Vault profile
* description: Creates a Vault signer profile owned by the caller. Needs vault.use, and vault.share to mark it shared.
* 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: The created profile.
* 400:
* description: Missing required fields.
* 403:
* description: Missing vault.use, or vault.share for a shared profile.
*/
router.post("/profiles", async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
if (
!isNonEmptyString(body.name) ||
!isNonEmptyString(body.vaultAddr) ||
!isNonEmptyString(body.sshRole)
) {
return res
.status(400)
.json({ error: "name, vaultAddr and sshRole are required" });
}
const shared = !!body.shared;
if (shared && !(await ctx.rbac.has("share"))) {
return res
.status(403)
.json({ error: "You are not allowed to share Vault profiles" });
}
const row = await profiles.create(userId(), {
name: body.name.trim(),
description: optional(body.description),
folder: optional(body.folder),
tags: Array.isArray(body.tags)
? body.tags.join(",")
: typeof body.tags === "string"
? body.tags
: "",
vaultAddr: body.vaultAddr.trim(),
vaultNamespace: optional(body.vaultNamespace),
oidcMount: optional(body.oidcMount),
oidcRole: optional(body.oidcRole),
sshMount: optional(body.sshMount),
sshRole: body.sshRole.trim(),
validPrincipals: optional(body.validPrincipals),
keyType: optional(body.keyType),
shared,
});
res.status(201).json(formatProfile(row, userId()));
});
/**
* @openapi
* /plugin-api/vault/profiles/{id}:
* put:
* summary: Update a Vault profile
* description: Only the owner may edit a profile. Needs vault.use, and vault.share to mark it shared.
* tags: [Vault]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: The updated profile.
* 400:
* description: Invalid profile id.
* 403:
* description: Not the owner, or sharing without vault.share.
* 404:
* description: Profile not found.
*/
router.put("/profiles/:id", async (req: Request, res: Response) => {
const id = parseId(req);
if (id === null)
return res.status(400).json({ error: "Invalid profile id" });
const existing = await profiles.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 ?? {}) as Record<string, unknown>;
const fields: ProfileInput = {};
if (isNonEmptyString(body.name)) fields.name = body.name.trim();
if (body.description !== undefined)
fields.description = optional(body.description);
if (body.folder !== undefined) fields.folder = optional(body.folder);
if (body.tags !== undefined) {
fields.tags = Array.isArray(body.tags)
? body.tags.join(",")
: typeof body.tags === "string"
? body.tags
: "";
}
if (isNonEmptyString(body.vaultAddr))
fields.vaultAddr = body.vaultAddr.trim();
if (body.vaultNamespace !== undefined)
fields.vaultNamespace = optional(body.vaultNamespace);
if (body.oidcMount !== undefined)
fields.oidcMount = optional(body.oidcMount);
if (body.oidcRole !== undefined) fields.oidcRole = optional(body.oidcRole);
if (body.sshMount !== undefined) fields.sshMount = optional(body.sshMount);
if (isNonEmptyString(body.sshRole)) fields.sshRole = body.sshRole.trim();
if (body.validPrincipals !== undefined)
fields.validPrincipals = optional(body.validPrincipals);
if (body.keyType !== undefined) fields.keyType = optional(body.keyType);
if (body.shared !== undefined) {
if (body.shared && !(await ctx.rbac.has("share"))) {
return res
.status(403)
.json({ error: "You are not allowed to share Vault profiles" });
}
fields.shared = !!body.shared;
}
const updated = await profiles.update(id, fields);
if (!updated) return res.status(404).json({ error: "Profile not found" });
res.json(formatProfile(updated, userId()));
});
/**
* @openapi
* /plugin-api/vault/profiles/{id}:
* delete:
* summary: Delete a Vault profile
* description: Deletes a profile and every certificate cached for it. Only the owner may delete it. Needs vault.use.
* tags: [Vault]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 400:
* description: Invalid profile id.
* 403:
* description: Not the owner.
* 404:
* description: Profile not found.
*/
router.delete("/profiles/:id", async (req: Request, res: Response) => {
const id = parseId(req);
if (id === null)
return res.status(400).json({ error: "Invalid profile id" });
const existing = await profiles.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" });
}
await profiles.remove(existing);
res.json({ success: true });
});
}
+74
View File
@@ -0,0 +1,74 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Vault signer profiles: connection settings only, no secrets. A shared
* profile is visible to every user. Adopted from core's vault_profiles.
*/
export const profiles = adoptLegacyTable(
"vault_profiles",
defineTable("profiles", {
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
folder: text(),
tags: text(),
vaultAddr: text().notNull(),
vaultNamespace: text(),
oidcMount: text(),
oidcRole: text(),
sshMount: text(),
sshRole: text().notNull(),
validPrincipals: text(),
keyType: text(),
shared: boolean().notNull().default(false),
syncId: varchar().unique(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* One signed certificate and its ephemeral key per user and profile, sealed
* with ctx.secrets.seal. Adopted from core's vault_tokens. Rows 2.8 wrote were
* encrypted with the user's data key; they do not unseal and are dropped.
*
* profileId has no foreign key on a fresh install (the SDK has no builder
* for one to the plugin's own table), so deleting a profile deletes its
* tokens in code. Upgraded installs keep 2.8's cascading key.
*/
export const tokens = adoptLegacyTable(
"vault_tokens",
defineTable(
"tokens",
{
id: id(),
userId: refUser(),
profileId: integer().notNull(),
sshCert: text().notNull(),
privateKey: text().notNull(),
createdAt: text().notNull().defaultNow(),
expiresAt: varchar().notNull(),
lastUsed: text(),
},
{
uniques: [
{
name: "idx_vault_tokens_user_profile",
columns: ["userId", "profileId"],
},
],
},
),
);
export const tables = [profiles, tokens];
+114
View File
@@ -0,0 +1,114 @@
import { and, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parseCertValidBefore } from "./vault-client.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
// Sign again once a certificate is this close to expiring.
const EXPIRY_SKEW_MS = 60 * 1000;
// When the certificate's expiry cannot be read.
const FALLBACK_TTL_MS = 5 * 60 * 1000;
export interface CachedCertificate {
privateKey: string;
sshCert: string;
}
export type TokenStore = ReturnType<typeof createTokenStore>;
export function createTokenStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
const match = (userId: string, profileId: number) =>
and(eq(table.userId, userId), eq(table.profileId, profileId));
async function find(userId: string, profileId: number) {
const rows = await (
await client()
)
.select()
.from(table)
.where(match(userId, profileId))
.limit(1);
return rows[0] ?? null;
}
async function remove(userId: string, profileId: number): Promise<void> {
if (!(await find(userId, profileId))) return;
await (await client()).delete(table).where(match(userId, profileId));
await ctx.db.persist();
}
return {
/** Stores a signed certificate and returns when it expires. */
async save(
userId: string,
profileId: number,
privateKey: string,
sshCert: string,
): Promise<string> {
const validBefore = parseCertValidBefore(sshCert);
const expiresAt = new Date(
validBefore > 0 ? validBefore * 1000 : Date.now() + FALLBACK_TTL_MS,
).toISOString();
const values = {
sshCert: await ctx.secrets.seal(sshCert),
privateKey: await ctx.secrets.seal(privateKey),
createdAt: new Date().toISOString(),
expiresAt,
lastUsed: null,
};
if (await find(userId, profileId)) {
await (
await client()
)
.update(table)
.set(values)
.where(match(userId, profileId));
} else {
await (
await client()
)
.insert(table)
.values({ userId, profileId, ...values });
}
await ctx.db.persist();
return expiresAt;
},
/** A usable certificate, or null. Expired or unreadable rows are deleted. */
async get(
userId: string,
profileId: number,
): Promise<CachedCertificate | null> {
const row = await find(userId, profileId);
if (!row) return null;
const expiresAt = new Date(row.expiresAt).getTime();
if (
!Number.isFinite(expiresAt) ||
expiresAt - EXPIRY_SKEW_MS < Date.now()
) {
await remove(userId, profileId);
return null;
}
const sshCert = await ctx.secrets.unseal(row.sshCert);
const privateKey = await ctx.secrets.unseal(row.privateKey);
if (!sshCert || !privateKey) {
await remove(userId, profileId);
return null;
}
await (
await client()
)
.update(table)
.set({ lastUsed: new Date().toISOString() })
.where(match(userId, profileId));
await ctx.db.persist({ lazy: true });
return { sshCert, privateKey };
},
remove,
};
}
+286
View File
@@ -0,0 +1,286 @@
import crypto from "node:crypto";
import ssh2Pkg from "ssh2";
import type { PluginFetch } from "@termix/plugin-sdk/backend";
const { utils: ssh2Utils } = ssh2Pkg;
/**
* Vault's HTTP API for SSH signing: an OIDC login for a short-lived Vault
* token, then the SSH secrets engine signs an ephemeral public key. No Vault
* token or long-lived key is ever stored.
*/
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;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
type VaultJson = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
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;
}
/**
* The profile's own Vault server may be private: someone configured it by
* name, which 2.8 allowed too. Nothing else private is reachable.
*/
export function allowedHosts(profile: VaultProfileConfig): string[] {
try {
return [new URL(normalizeAddr(profile.vaultAddr)).hostname];
} catch {
return [];
}
}
async function vaultRequest(
fetch: PluginFetch,
profile: VaultProfileConfig,
url: string,
method: "GET" | "POST",
headers: Record<string, string>,
body?: Record<string, unknown>,
): Promise<VaultJson> {
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
allowPrivateHosts: allowedHosts(profile),
});
} 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: VaultJson;
if (text) {
try {
json = JSON.parse(text);
} catch {
// not JSON
}
}
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;
}
/** An ephemeral SSH keypair in OpenSSH format. */
export function generateEphemeralKeyPair(
keyType?: string | null,
): EphemeralKeyPair {
let type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
const options: { bits?: number } = {};
switch ((keyType || "ssh-ed25519").trim()) {
case "ssh-rsa":
type = "rsa";
options.bits = 4096;
break;
case "ecdsa-sha2-nistp256":
type = "ecdsa";
options.bits = 256;
break;
default:
type = "ed25519";
}
const pair = ssh2Utils.generateKeyPairSync(type as never, options as never);
return { privateKey: pair.private, publicKey: pair.public };
}
/**
* Starts a Vault OIDC login. `redirectUri` must be allowed by the Vault
* role's allowed_redirect_uris and by the identity provider.
*/
export async function startVaultOidc(
fetch: PluginFetch,
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(
fetch,
profile,
`${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 puts its own state in the auth_url; the callback is matched on it.
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 };
}
/** Completes the OIDC callback and returns a short-lived Vault token. */
export async function completeVaultOidc(
fetch: PluginFetch,
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(
fetch,
profile,
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;
}
/** Signs an SSH public key with Vault, returning the OpenSSH certificate line. */
export async function signWithVault(
fetch: PluginFetch,
profile: VaultProfileConfig,
vaultToken: string,
publicKey: string,
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.sshMount, "ssh-client-signer");
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(
fetch,
profile,
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
"POST",
{ ...vaultHeaders(profile), "X-Vault-Token": vaultToken },
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();
}
/**
* The "valid before" Unix timestamp of an OpenSSH certificate, or 0 when it
* cannot be parsed (the caller then uses a short fallback).
*/
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 skipString = (): 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;
};
skipString(); // format id
skipString(); // 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++) skipString();
readUint64(); // serial
pos += 4; // type
skipString(); // key id
skipString(); // valid principals
readUint64(); // valid after
return readUint64();
} catch {
return 0;
}
}
@@ -0,0 +1,54 @@
import { toast } from "sonner";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
const CALLBACK_PATH = "plugin-api/vault/oidc/callback";
const LEGACY_CALLBACK_PATH = "vault/oidc/callback";
/** The redirect URI to allow in the Vault OIDC role. */
export function redirectUri(
legacy: boolean,
baseUri = document.baseURI,
): string {
return new URL(
legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH,
baseUri,
).toString();
}
export function RedirectUriSetting({ values }: SettingsComponentProps) {
const { t } = useTranslation();
const uri = redirectUri(values.legacyCallback === true);
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium">
{t("settings.redirectUri.label")}
</span>
<div className="flex gap-2">
<Input
value={uri}
readOnly
className="rounded-none font-mono text-xs"
/>
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={async () => {
if (await copyToClipboard(uri)) toast.success(t("common.copied"));
else toast.error(t("common.copyFailed"));
}}
>
{t("common.copy")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
{t("settings.redirectUri.description")}
</p>
</div>
);
}
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState } from "react";
import {
usePermission,
usePluginApi,
useTranslation,
type SshAuthEditorProps,
} from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import { VaultProfileManager } from "./VaultProfileManager";
import type { VaultProfile } from "./types";
const PLUGIN_ID = "vault";
const DOCS_URL = "https://docs.termix.site/features/authentication/vault";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
/**
* The host editor's "vault" auth method: pick a signer profile, and manage
* profiles in place. The choice is the profileId host setting, which the
* editor saves with the rest of the host's plugin settings.
*/
export function VaultAuthEditor({ form, setField }: SshAuthEditorProps) {
const { t } = useTranslation();
const api = usePluginApi();
const canUse = usePermission("use");
const [profiles, setProfiles] = useState<VaultProfile[]>([]);
const [showManager, setShowManager] = useState(false);
const bag = (form.pluginSettings as PluginSettingsBag | undefined) ?? {};
const profileId = bag[PLUGIN_ID]?.profileId;
const reload = useCallback(() => {
if (!canUse) return;
api
.get<VaultProfile[]>("/profiles")
.then(({ data }) => setProfiles(Array.isArray(data) ? data : []))
.catch(() => setProfiles([]));
}, [api, canUse]);
useEffect(() => {
reload();
}, [reload]);
const select = (value: string) => {
setField("pluginSettings", {
...bag,
[PLUGIN_ID]: {
...(bag[PLUGIN_ID] ?? {}),
profileId: value ? Number(value) : null,
},
});
};
return (
<>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("editor.profile")}
</label>
<Select2
value={profileId != null ? String(profileId) : ""}
onChange={(e) => select(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("editor.selectProfile")}</option>
{profiles.map((profile) => (
<option key={profile.id} value={String(profile.id)}>
{profile.shared
? t("editor.sharedProfile", { name: profile.name })
: profile.name}
</option>
))}
</Select2>
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
{t("editor.hint")}
</p>
<div className="flex items-center gap-2 shrink-0">
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("editor.docs")}
</a>
{canUse && (
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80"
onClick={() => setShowManager((shown) => !shown)}
>
{t("profiles.manage")}
</button>
)}
</div>
</div>
</div>
{showManager && (
<VaultProfileManager
profiles={profiles}
onChanged={reload}
onClose={() => setShowManager(false)}
/>
)}
</>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from "react";
import { AlertCircle, KeyRound, Loader2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
/** What the ssh-terminal plugin hands a "terminal.overlay" component. */
export interface TerminalOverlayProps {
host: { id: number | string; [key: string]: unknown } | undefined;
backgroundColor?: string;
subscribe: (
listener: (message: { type: string; [key: string]: unknown }) => void,
) => () => void;
holdConnectTimeout: (held: boolean) => void;
fail: (message: string) => void;
disconnect: () => void;
send: (type: string, data?: unknown) => void;
connectPayload: () => Record<string, unknown>;
}
interface DialogState {
stage: "waiting" | "error";
error?: string;
hostId?: unknown;
requestId?: string;
}
/** How long the dialog waits before giving up on the connection. */
const DIALOG_TIMEOUT_MS = 300_000;
const footerButton =
"rounded-none text-[10px] font-bold uppercase tracking-widest";
/**
* Runs the Vault OIDC sign-in for a terminal: asks the server to start it,
* opens Vault's auth URL in a popup, and reconnects once Vault has signed
* the certificate.
*/
export function VaultOverlay({
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
backgroundColor,
}: TerminalOverlayProps) {
const { t } = useTranslation();
const [dialog, setDialog] = useState<DialogState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const popupRef = useRef<Window | null>(null);
const heldRef = useRef(false);
// A second "auth required" after a sign-in means the certificate did not work.
const failedRef = useRef(false);
useEffect(() => {
const clearTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
const closePopup = () => {
try {
popupRef.current?.close();
} catch {
// already closed
}
popupRef.current = null;
};
const hold = (held: boolean) => {
if (heldRef.current === held) return;
heldRef.current = held;
holdConnectTimeout(held);
};
const unsubscribe = subscribe((message) => {
switch (message.type) {
case "vault_auth_required":
hold(true);
if (failedRef.current) {
clearTimer();
hold(false);
setDialog({ stage: "error", error: t("dialog.failed") });
fail(t("dialog.failed"));
} else {
failedRef.current = true;
send("vault_start_auth", { hostId: message.hostId });
}
break;
case "vault_auth_url":
try {
popupRef.current = window.open(
String(message.url ?? ""),
"termix-vault-oidc",
"width=540,height=720",
);
} catch {
popupRef.current = null;
}
setDialog({
stage: "waiting",
hostId: message.hostId,
requestId:
typeof message.requestId === "string"
? message.requestId
: undefined,
});
clearTimer();
timerRef.current = setTimeout(() => {
setDialog(null);
hold(false);
disconnect();
}, DIALOG_TIMEOUT_MS);
break;
case "vault_completed":
clearTimer();
closePopup();
setDialog(null);
send("vault_auth_completed", connectPayload());
break;
case "vault_error":
failedRef.current = true;
clearTimer();
closePopup();
hold(false);
setDialog({ stage: "error", error: String(message.error ?? "") });
break;
case "connected":
case "sessionAttached":
failedRef.current = false;
hold(false);
break;
case "session_closed":
clearTimer();
setDialog(null);
hold(false);
break;
}
});
return () => {
unsubscribe();
clearTimer();
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
}, [
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
t,
]);
if (!dialog) return null;
const close = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
try {
popupRef.current?.close();
} catch {
// already closed
}
if (dialog.stage === "waiting") {
send("vault_cancel", {
hostId: dialog.hostId,
requestId: dialog.requestId,
});
}
setDialog(null);
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<KeyRound className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{dialog.stage === "error"
? t("dialog.failed")
: t("dialog.title")}
</h3>
</div>
</div>
<div className="p-4 flex flex-col gap-4">
{dialog.stage === "waiting" ? (
<div className="flex items-center gap-3 py-2">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{t("dialog.description")}
</p>
</div>
) : (
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive/90 whitespace-pre-wrap break-words">
{dialog.error || t("dialog.failed")}
</p>
</div>
)}
<div className="flex justify-end gap-2">
{dialog.stage === "waiting" && (
<Button
type="button"
variant="outline"
className={footerButton}
onClick={() => {
try {
popupRef.current?.focus();
} catch {
// popup gone
}
}}
>
{t("dialog.reopen")}
</Button>
)}
<Button
type="button"
variant="ghost"
className={footerButton}
onClick={close}
>
{dialog.stage === "error"
? t("common.close")
: t("common.cancel")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,286 @@
import { useState } from "react";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import {
usePermission,
usePluginApi,
useToast,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, Select2 } from "@termix/plugin-sdk/ui";
import {
errorMessage,
type VaultProfile,
type VaultProfilePayload,
} from "./types";
type FormState = {
id?: number;
name: string;
vaultAddr: string;
vaultNamespace: string;
oidcMount: string;
oidcRole: string;
sshMount: string;
sshRole: string;
validPrincipals: string;
keyType: string;
shared: boolean;
};
type TextKey = Exclude<keyof FormState, "id" | "shared">;
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,
};
}
const blank = (value: string) => value.trim() || null;
/** Create, edit and delete the caller's Vault profiles. */
export function VaultProfileManager({
profiles,
onChanged,
onClose,
}: {
profiles: VaultProfile[];
onChanged: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const api = usePluginApi();
const toast = useToast();
const canShare = usePermission("share");
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((current) => (current ? { ...current, [key]: value } : current));
const handleSave = async () => {
if (!form) return;
if (!form.name.trim() || !form.vaultAddr.trim() || !form.sshRole.trim()) {
toast.error(t("profiles.validationError"));
return;
}
setSaving(true);
try {
const payload: VaultProfilePayload = {
name: form.name.trim(),
vaultAddr: form.vaultAddr.trim(),
vaultNamespace: blank(form.vaultNamespace),
oidcMount: blank(form.oidcMount),
oidcRole: blank(form.oidcRole),
sshMount: blank(form.sshMount),
sshRole: form.sshRole.trim(),
validPrincipals: blank(form.validPrincipals),
keyType: blank(form.keyType),
shared: canShare && form.shared,
};
if (form.id) {
await api.put(`/profiles/${form.id}`, payload);
toast.success(t("profiles.saved"));
} else {
await api.post("/profiles", payload);
toast.success(t("profiles.created"));
}
setForm(null);
onChanged();
} catch (error) {
toast.error(errorMessage(error, t("profiles.saveFailed")));
} finally {
setSaving(false);
}
};
const handleDelete = async (profile: VaultProfile) => {
try {
await api.delete(`/profiles/${profile.id}`);
toast.success(t("profiles.deleted"));
onChanged();
} catch (error) {
toast.error(errorMessage(error, t("profiles.deleteFailed")));
}
};
const field = (label: string, key: TextKey, 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 rounded-none"
placeholder={placeholder}
value={form?.[key] ?? ""}
onChange={(e) => setField(key, e.target.value)}
/>
</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("profiles.manage")}
</span>
<button
type="button"
aria-label={t("common.close")}
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("profiles.none")}
</span>
)}
{profiles.map((profile) => (
<div
key={profile.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">
{profile.name}
{profile.shared && (
<span className="ml-1 text-[9px] text-accent-brand">
{t("profiles.sharedBadge")}
</span>
)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{profile.vaultAddr}
</span>
</div>
{profile.owned && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
title={t("profiles.edit")}
onClick={() => setForm(toForm(profile))}
className="size-7 flex items-center justify-center text-muted-foreground/60 hover:text-foreground hover:bg-muted-foreground/10"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
title={t("profiles.delete")}
onClick={() => handleDelete(profile)}
className="size-7 flex items-center justify-center 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 rounded-none"
onClick={() => setForm({ ...emptyForm })}
>
<Plus className="size-3 mr-1" /> {t("profiles.new")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{field(t("profiles.name"), "name", "Production Vault")}
{field(t("profiles.vaultAddr"), "vaultAddr", "https://vault:8200")}
{field(t("profiles.namespace"), "vaultNamespace", "admin")}
{field(t("profiles.oidcMount"), "oidcMount", "oidc")}
{field(t("profiles.oidcRole"), "oidcRole", "default")}
{field(t("profiles.sshMount"), "sshMount", "ssh-client-signer")}
{field(t("profiles.sshRole"), "sshRole", "my-role")}
{field(
t("profiles.validPrincipals"),
"validPrincipals",
"root,deploy",
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("profiles.keyType")}
</label>
<Select2
value={form.keyType}
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>
{canShare && (
<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("profiles.shareWithAll")}
</label>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-none"
onClick={() => setForm(null)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-none border-accent-brand/40 text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{form.id ? t("profiles.save") : t("profiles.create")}
</Button>
</div>
</div>
)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { VaultAuthEditor } from "./VaultAuthEditor";
import { VaultOverlay } from "./VaultOverlay";
import { RedirectUriSetting } from "./RedirectUriSetting";
export function activate(app: TermixApp): void {
app.registerSshAuthEditor({
authType: "vault",
titleKey: "hosts.filterAuthVault",
component: VaultAuthEditor,
});
// The Vault sign-in while a terminal connects to a Vault host.
app.registerSlotContribution("terminal.overlay", {
actionId: "vault.signIn",
titleKey: "dialog.title",
kind: "component",
component: VaultOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
+34
View File
@@ -0,0 +1,34 @@
/** A Vault signer profile as GET /profiles returns it. */
export interface VaultProfile {
id: number;
name: 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;
owned: boolean;
}
export interface VaultProfilePayload {
name: 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 function errorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" ? data.error : fallback;
}