feat(secrets): opt-in injected secret delivery, scoped per server
Completes the path that stops mcpd writing secret VALUES into MCP server
pod specs. With `secretDelivery: injector`, the pod fetches its own
secrets from OpenBao through the agent injector, under a ServiceAccount
and role scoped to just that server's secrets — so the value never enters
etcd, and gitea-mcp cannot read the Grafana token.
Opt-in per server, defaulting to `env`. Every existing server is
bit-for-bit unchanged, and migrating is one reversible decision at a time
rather than a flag day.
The two invariants under most risk, both tested:
- **Opted-out servers produce an identical manifest.** No annotations, no
serviceAccountName, automountServiceAccountToken still false.
- **Opted-in servers still fail LOUDLY on a bad ref.** Once mcpd stops
reading a server's secrets, the check e6cd735 added no longer fires for
it, and a typo'd secretRef would degrade into a vault-agent-init
crashloop that mcpd reports as a generic pod failure — the same class of
bug that had gitea-mcp running for weeks on an empty token while
reporting healthy. `validateServerEnvRefs` resolves every ref and throws
the value away, purely to keep that error. After the value cache it is a
cache hit and costs nothing.
Shell quoting is the other silent-failure trap and is treated as part of
the contract: the agent renders `export NAME='value'` and the container
command sources it, so a value containing a space, `$`, a quote or a
newline would truncate and yield an empty token. `shellSingleQuote` is
tested by executing a real /bin/sh over nine adversarial values including
`'; export PWNED=1; '` — and those tests fail against naive quoting,
confirmed before keeping them.
`sh -c <script> arg0 arg1 …` preserves argv via $0/$@, and `exec` keeps
PID 1 as the real process, which matters because mcpd attaches to PID 1's
stdin/stdout for STDIO servers.
Docker/Podman declare `capabilities.secretRefs: false` and fall back to
inline resolution, so local development is untouched. Deleting a server
revokes its identity, after its pods are gone and best-effort — a role no
pod can authenticate as grants nothing, and failing the delete over it
would strand the row.
Per the CLI rules, `secretDelivery`/`entrypoint` are `create` flags,
round-trip through apply -f, and show in `describe server` — which now
also flags servers still inlining secrets into their pod spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
This commit is contained in:
@@ -44,6 +44,8 @@ const ServerSpecSchema = z.object({
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: z.enum(['env', 'injector']).optional(),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const SecretSpecSchema = z.object({
|
||||
@@ -136,6 +138,8 @@ const TemplateSpecSchema = z.object({
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: z.enum(['env', 'injector']).optional(),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const UserSpecSchema = z.object({
|
||||
|
||||
@@ -252,6 +252,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--health-check-interval <seconds>', 'Readiness probe interval in seconds (default 60)')
|
||||
.option('--health-check-timeout <seconds>', 'Readiness probe timeout in seconds (default 10)')
|
||||
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
|
||||
.option('--secret-delivery <mode>', 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)')
|
||||
.option('--entrypoint <argv>', 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect')
|
||||
.option('--from-template <name>', 'Create from template (name or name:version)')
|
||||
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
|
||||
.option('--force', 'Update if already exists')
|
||||
@@ -327,6 +329,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
if (opts.description !== undefined) body.description = opts.description;
|
||||
if (opts.transport) body.transport = opts.transport;
|
||||
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
|
||||
if (opts.secretDelivery) body.secretDelivery = opts.secretDelivery;
|
||||
if (opts.entrypoint) body.entrypoint = (opts.entrypoint as string).split(',').map((a) => a.trim()).filter(Boolean);
|
||||
if (opts.packageName) body.packageName = opts.packageName;
|
||||
if (opts.runtime) body.runtime = opts.runtime;
|
||||
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
|
||||
|
||||
@@ -21,6 +21,20 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
lines.push(`${pad('Name:')}${server.name}`);
|
||||
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
|
||||
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
|
||||
{
|
||||
// Surface the remaining exposure rather than leaving it silent: a server
|
||||
// still on `env` with secret refs has those VALUES written into its pod
|
||||
// spec, i.e. cleartext in etcd for anyone with `get pod`.
|
||||
const delivery = (server as { secretDelivery?: string }).secretDelivery ?? 'env';
|
||||
const env = (server.env ?? []) as Array<{ valueFrom?: unknown }>;
|
||||
const hasSecretRefs = env.some((e) => e.valueFrom !== undefined);
|
||||
const note = delivery === 'injector'
|
||||
? ' (pod fetches its own secrets under a scoped OpenBao identity)'
|
||||
: hasSecretRefs
|
||||
? ' ⚠ secret values are inlined into the pod spec'
|
||||
: '';
|
||||
lines.push(`${pad('Secret Delivery:')}${delivery}${note}`);
|
||||
}
|
||||
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
|
||||
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
|
||||
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Per-server secret delivery mode.
|
||||
--
|
||||
-- 'env' preserves today's behaviour exactly: mcpd resolves secret refs and
|
||||
-- writes the VALUES into the pod spec. Defaulting to it means every existing
|
||||
-- server is unchanged by this migration, and migration to 'injector' is an
|
||||
-- explicit, reversible, per-server decision.
|
||||
ALTER TABLE "McpServer" ADD COLUMN "secretDelivery" TEXT NOT NULL DEFAULT 'env';
|
||||
|
||||
-- argv to exec after the injector's rendered secrets are sourced. Only needed
|
||||
-- for dockerImage servers, where the image ENTRYPOINT is what would otherwise
|
||||
-- run and mcpd cannot introspect it.
|
||||
ALTER TABLE "McpServer" ADD COLUMN "entrypoint" JSONB;
|
||||
@@ -71,9 +71,23 @@ model McpServer {
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
volumes Json @default("[]")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// How secret-backed env reaches the container.
|
||||
/// env — mcpd resolves values and writes them into the pod spec.
|
||||
/// Cleartext in etcd, readable by anyone with `get pod`.
|
||||
/// injector — the pod fetches its own secrets from OpenBao via the agent
|
||||
/// injector, using a ServiceAccount + role scoped to just this
|
||||
/// server's secrets. The value never touches the pod spec.
|
||||
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
|
||||
secretDelivery String @default("env")
|
||||
|
||||
/// argv the injector wrapper must exec after sourcing the rendered secrets.
|
||||
/// Only needed for dockerImage servers using `injector`, where the image's
|
||||
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
|
||||
entrypoint Json?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
templateName String?
|
||||
templateVersion String?
|
||||
@@ -109,8 +123,22 @@ model McpTemplate {
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
volumes Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// How secret-backed env reaches the container.
|
||||
/// env — mcpd resolves values and writes them into the pod spec.
|
||||
/// Cleartext in etcd, readable by anyone with `get pod`.
|
||||
/// injector — the pod fetches its own secrets from OpenBao via the agent
|
||||
/// injector, using a ServiceAccount + role scoped to just this
|
||||
/// server's secrets. The value never touches the pod spec.
|
||||
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
|
||||
secretDelivery String @default("env")
|
||||
|
||||
/// argv the injector wrapper must exec after sourcing the rendered secrets.
|
||||
/// Only needed for dockerImage servers using `injector`, where the image's
|
||||
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
|
||||
entrypoint Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ import { SecretBackendService } from './services/secret-backend.service.js';
|
||||
import { SecretMigrateService } from './services/secret-migrate.service.js';
|
||||
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
|
||||
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
|
||||
import { ServerIdentityService } from './services/server-identity.service.js';
|
||||
import { K8sServiceAccountPort } from './services/k8s/service-account-port.js';
|
||||
import { K8sOfficialClient } from './services/k8s/k8s-client-official.js';
|
||||
import { warmSecretCache } from './bootstrap/warm-secret-cache.js';
|
||||
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
|
||||
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
|
||||
@@ -519,8 +522,38 @@ async function main(): Promise<void> {
|
||||
// AgentService + ChatService get fully wired below once projectService and
|
||||
// mcpProxyService are constructed (ChatService needs them via the
|
||||
// ChatToolDispatcher bridge).
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
|
||||
// Per-server OpenBao identities, for servers with `secretDelivery: injector`.
|
||||
// Kubernetes-only: it needs a ServiceAccount per server and an orchestrator
|
||||
// that can honour secret references. Left undefined elsewhere, which makes
|
||||
// InstanceService fall back to inline resolution — so Docker/Podman local
|
||||
// development is unaffected.
|
||||
const serverIdentityService = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes'
|
||||
? (() => {
|
||||
const k8s = new K8sOfficialClient();
|
||||
return new ServerIdentityService(
|
||||
secretBackendService,
|
||||
new K8sServiceAccountPort(k8s.core, k8s.serversNamespace),
|
||||
{
|
||||
info: (obj: Record<string, unknown>, msg: string): void => { app.log.info(obj, msg); },
|
||||
warn: (obj: Record<string, unknown>, msg: string): void => { app.log.warn(obj, msg); },
|
||||
},
|
||||
{
|
||||
// Must be the mount that validates THIS cluster's SA tokens; the
|
||||
// chart default (`auth/kubernetes`) belongs to a different cluster
|
||||
// and holds none of our roles.
|
||||
authPath: process.env['MCPD_SECRET_INJECTOR_AUTH_PATH'] ?? 'auth/kubernetes',
|
||||
mount: process.env['MCPD_SECRET_INJECTOR_MOUNT'] ?? 'secret',
|
||||
pathPrefix: process.env['MCPD_SECRET_INJECTOR_PREFIX'] ?? 'mcpctl',
|
||||
},
|
||||
);
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const instanceService = new InstanceService(
|
||||
instanceRepo, serverRepo, orchestrator, secretService, serverIdentityService,
|
||||
);
|
||||
serverService.setInstanceService(instanceService);
|
||||
if (serverIdentityService !== undefined) serverService.setServerIdentity(serverIdentityService);
|
||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||
const auditLogService = new AuditLogService(auditLogRepo);
|
||||
const auditEventService = new AuditEventService(auditEventRepo);
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface BackupServer {
|
||||
env: unknown;
|
||||
healthCheck: unknown;
|
||||
volumes: unknown;
|
||||
/** Optional so backups taken before the field remain restorable. */
|
||||
secretDelivery?: string;
|
||||
entrypoint?: unknown;
|
||||
}
|
||||
|
||||
export interface BackupSecret {
|
||||
@@ -142,6 +145,8 @@ export class BackupService {
|
||||
env: s.env,
|
||||
healthCheck: s.healthCheck,
|
||||
volumes: s.volumes,
|
||||
secretDelivery: s.secretDelivery,
|
||||
entrypoint: s.entrypoint,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,11 @@ export class RestoreService {
|
||||
// a claim of the same name if one survived, and otherwise starts on a
|
||||
// fresh empty volume.
|
||||
volumes: (server.volumes ?? []) as Parameters<IMcpServerRepository['create']>[0]['volumes'],
|
||||
// Preserve the delivery mode across a backup/restore round-trip.
|
||||
// Falling back to 'env' keeps pre-field backups restorable, and 'env'
|
||||
// is the safe default: it works without the per-server OpenBao
|
||||
// identity, which a restore cannot assume still exists.
|
||||
secretDelivery: (server.secretDelivery === 'injector' ? 'injector' : 'env'),
|
||||
};
|
||||
if (server.packageName) createData.packageName = server.packageName;
|
||||
if (server.runtime) createData.runtime = server.runtime;
|
||||
|
||||
@@ -29,6 +29,14 @@ function mapState(state: string | undefined): ContainerInfo['state'] {
|
||||
}
|
||||
|
||||
export class DockerContainerManager implements McpOrchestrator {
|
||||
/**
|
||||
* No injector equivalent exists for plain Docker/Podman containers, so
|
||||
* `envFromSecret` cannot be honoured here — mcpd resolves those refs and
|
||||
* merges them into `env` instead. Local development therefore keeps today's
|
||||
* behaviour exactly.
|
||||
*/
|
||||
readonly capabilities = { secretRefs: false } as const;
|
||||
|
||||
private docker: Docker;
|
||||
|
||||
constructor(opts?: Docker.DockerOptions) {
|
||||
|
||||
@@ -44,3 +44,63 @@ export async function resolveServerEnv(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a server's env into inline values and secret references, WITHOUT
|
||||
* resolving the references.
|
||||
*
|
||||
* Used for injector-delivered servers, where the pod fetches its own values and
|
||||
* mcpd must never materialise them. `resolveServerEnv` above stays as-is: it is
|
||||
* still the path for Docker, for opted-out servers, and for the generated
|
||||
* client `.mcp.json`.
|
||||
*/
|
||||
export function partitionServerEnv(server: McpServer): {
|
||||
inline: Record<string, string>;
|
||||
refs: Array<{ name: string; secretName: string; key: string }>;
|
||||
} {
|
||||
const entries = (server.env ?? []) as ServerEnvEntry[];
|
||||
const inline: Record<string, string> = {};
|
||||
const refs: Array<{ name: string; secretName: string; key: string }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.value !== undefined) {
|
||||
inline[entry.name] = entry.value;
|
||||
} else if (entry.valueFrom?.secretRef) {
|
||||
refs.push({
|
||||
name: entry.name,
|
||||
secretName: entry.valueFrom.secretRef.name,
|
||||
key: entry.valueFrom.secretRef.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { inline, refs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every reference and DISCARD the values, purely to make a missing
|
||||
* secret or key fail loudly at instance start.
|
||||
*
|
||||
* Once a server is injector-delivered, mcpd no longer reads its secrets, so the
|
||||
* check that commit e6cd735 bought stops firing for it: a bad secretRef would
|
||||
* degrade from a clear `secret resolution failed: …` into a vault-agent-init
|
||||
* crashloop that mcpd reports as a generic pod failure. That is the exact class
|
||||
* of bug that had gitea-mcp running for weeks with an empty token while
|
||||
* reporting healthy — so we keep paying for the check. After the value cache it
|
||||
* is a cache hit and costs essentially nothing.
|
||||
*/
|
||||
export async function validateServerEnvRefs(
|
||||
server: McpServer,
|
||||
resolver: SecretResolver,
|
||||
): Promise<void> {
|
||||
const { refs } = partitionServerEnv(server);
|
||||
for (const ref of refs) {
|
||||
try {
|
||||
await resolver.resolve(ref.secretName, ref.key);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Cannot resolve secret for server '${server.name}' env '${ref.name}': ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,28 @@ import type { McpInstance, McpServer } from '@prisma/client';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
import { resolveServerEnv, type SecretResolver } from './env-resolver.js';
|
||||
import {
|
||||
resolveServerEnv,
|
||||
partitionServerEnv,
|
||||
validateServerEnvRefs,
|
||||
type SecretResolver,
|
||||
} from './env-resolver.js';
|
||||
|
||||
/**
|
||||
* What InstanceService needs from the per-server identity layer. Kept narrow
|
||||
* and local so the orchestration stays testable without an OpenBao backend.
|
||||
*/
|
||||
export interface ServerIdentityProvisioner {
|
||||
/** Converge the server's identity; resolves to the ServiceAccount/role name. */
|
||||
ensureFor(server: McpServer): Promise<string>;
|
||||
/** Injector pod annotations for these refs under that identity. */
|
||||
annotationsFor(
|
||||
identity: string,
|
||||
refs: Array<{ name: string; secretName: string; key: string }>,
|
||||
): Record<string, string>;
|
||||
/** Rewrite argv to source the rendered secrets before exec'ing the server. */
|
||||
wrapCommand(server: McpServer, command: string[] | undefined): string[] | undefined;
|
||||
}
|
||||
|
||||
/** Runner images for package-based MCP servers, keyed by runtime name. */
|
||||
const RUNNER_IMAGES: Record<string, string> = {
|
||||
@@ -62,6 +83,8 @@ export class InstanceService {
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private orchestrator: McpOrchestrator,
|
||||
private secretResolver?: SecretResolver,
|
||||
/** Provisions per-server OpenBao identities. Absent = injector unavailable. */
|
||||
private serverIdentity?: ServerIdentityProvisioner,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -501,11 +524,31 @@ export class InstanceService {
|
||||
// will retry it in-place on the next tick whose nextRetryAt has
|
||||
// elapsed. Optional/missing env vars should be modeled as `value: ""`
|
||||
// entries on the server, not as silent secret-resolution failures.
|
||||
// Injected delivery needs BOTH an orchestrator that can honour references
|
||||
// (Docker cannot) and a provisioner for the per-server identity. Falling
|
||||
// back to inline resolution when either is missing keeps local dev and
|
||||
// plaintext-backend setups working unchanged.
|
||||
const useInjector = server.secretDelivery === 'injector'
|
||||
&& this.orchestrator.capabilities?.secretRefs === true
|
||||
&& this.serverIdentity !== undefined;
|
||||
|
||||
if (this.secretResolver) {
|
||||
try {
|
||||
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
|
||||
if (Object.keys(resolvedEnv).length > 0) {
|
||||
spec.env = resolvedEnv;
|
||||
if (useInjector) {
|
||||
// Resolve and DISCARD, purely so a missing secret still fails here
|
||||
// with a clear message instead of becoming a vault-agent-init
|
||||
// crashloop that mcpd reports as a generic pod failure. See
|
||||
// validateServerEnvRefs.
|
||||
await validateServerEnvRefs(server, this.secretResolver);
|
||||
|
||||
const { inline, refs } = partitionServerEnv(server);
|
||||
if (Object.keys(inline).length > 0) spec.env = inline;
|
||||
spec.envFromSecret = refs;
|
||||
} else {
|
||||
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
|
||||
if (Object.keys(resolvedEnv).length > 0) {
|
||||
spec.env = resolvedEnv;
|
||||
}
|
||||
}
|
||||
} catch (envErr) {
|
||||
const msg = envErr instanceof Error ? envErr.message : String(envErr);
|
||||
@@ -513,6 +556,22 @@ export class InstanceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (useInjector && spec.envFromSecret !== undefined && spec.envFromSecret.length > 0) {
|
||||
try {
|
||||
// Converge the identity BEFORE the pod exists: a pod whose role is
|
||||
// not yet written logs in, gets no capabilities, and crashloops.
|
||||
const identity = await this.serverIdentity!.ensureFor(server);
|
||||
spec.serviceAccountName = identity;
|
||||
spec.automountServiceAccountToken = true;
|
||||
spec.annotations = this.serverIdentity!.annotationsFor(identity, spec.envFromSecret);
|
||||
const wrapped = this.serverIdentity!.wrapCommand(server, spec.command);
|
||||
if (wrapped !== undefined) spec.command = wrapped;
|
||||
} catch (idErr) {
|
||||
const msg = idErr instanceof Error ? idErr.message : String(idErr);
|
||||
return this.markInstanceError(instance, `secret identity provisioning failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull image if not available locally
|
||||
try {
|
||||
await this.orchestrator.pullImage(image);
|
||||
|
||||
@@ -97,6 +97,9 @@ function podToContainerInfo(pod: V1Pod): ContainerInfo {
|
||||
}
|
||||
|
||||
export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
/** Pods can fetch their own secrets via the OpenBao agent injector. */
|
||||
readonly capabilities = { secretRefs: true } as const;
|
||||
|
||||
private client: K8sOfficialClient;
|
||||
private namespace: string;
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ export interface K8sPodManifest {
|
||||
name: string;
|
||||
namespace: string;
|
||||
labels: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
spec: {
|
||||
serviceAccountName?: string;
|
||||
containers: Array<{
|
||||
name: string;
|
||||
image: string;
|
||||
@@ -41,6 +43,108 @@ export interface K8sPodManifest {
|
||||
};
|
||||
}
|
||||
|
||||
/** Directory the OpenBao agent renders this pod's secrets into. */
|
||||
export const INJECTED_SECRET_DIR = '/vault/secrets';
|
||||
|
||||
/**
|
||||
* Quote a shell value for a file that will be `.`-sourced: single quotes, with
|
||||
* any embedded `'` closed-escaped-reopened.
|
||||
*
|
||||
* Not cosmetic. The rendered file is sourced by /bin/sh, so a value containing
|
||||
* a space, `$`, a quote or a newline would otherwise truncate or mangle the
|
||||
* variable — *silently*, producing an empty token and a server that starts,
|
||||
* answers tools/list and reports healthy while every authenticated call fails.
|
||||
* That is precisely the failure this change exists to prevent, so the escaping
|
||||
* is part of the contract, not a detail.
|
||||
*/
|
||||
export function shellSingleQuote(inner: string): string {
|
||||
return "'" + inner.split("'").join("'\\''") + "'";
|
||||
}
|
||||
|
||||
export interface InjectorEnvRef {
|
||||
/** Env var name inside the container. */
|
||||
name: string;
|
||||
/** mcpctl Secret name. */
|
||||
secretName: string;
|
||||
/** Key within that secret. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface InjectorConfig {
|
||||
/** OpenBao role to log in as — one per server, scoped to its own secrets. */
|
||||
role: string;
|
||||
/** Auth mount that validates THIS cluster's ServiceAccount tokens. */
|
||||
authPath: string;
|
||||
/** KV v2 mount, e.g. `secret`. */
|
||||
mount: string;
|
||||
/** Path prefix under the mount, e.g. `mcpctl`. */
|
||||
pathPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pod annotations that drive the OpenBao Agent Injector.
|
||||
*
|
||||
* One rendered file per referenced Secret (the agent keys files by the
|
||||
* annotation suffix), each holding `export NAME='value'` lines for every env
|
||||
* var drawn from that Secret.
|
||||
*
|
||||
* `agent-pre-populate-only` is deliberate: the agent runs as an init container
|
||||
* and exits. MCP servers cannot reload env anyway, so a long-lived sidecar
|
||||
* would buy nothing and cost memory in every pod — and these pods run at a flat
|
||||
* 512Mi where Node-based servers have already been OOMKilled.
|
||||
*/
|
||||
export function buildInjectorAnnotations(
|
||||
refs: InjectorEnvRef[],
|
||||
cfg: InjectorConfig,
|
||||
): Record<string, string> {
|
||||
const bySecret = new Map<string, InjectorEnvRef[]>();
|
||||
for (const ref of refs) {
|
||||
const list = bySecret.get(ref.secretName) ?? [];
|
||||
list.push(ref);
|
||||
bySecret.set(ref.secretName, list);
|
||||
}
|
||||
|
||||
const annotations: Record<string, string> = {
|
||||
'vault.hashicorp.com/agent-inject': 'true',
|
||||
'vault.hashicorp.com/agent-pre-populate-only': 'true',
|
||||
'vault.hashicorp.com/role': cfg.role,
|
||||
// Without this the agent logs in at the default `auth/kubernetes`, which
|
||||
// validates a DIFFERENT cluster's ServiceAccounts and holds none of our
|
||||
// roles — every login would fail with a confusing permission error.
|
||||
'vault.hashicorp.com/auth-path': cfg.authPath,
|
||||
};
|
||||
|
||||
const prefix = cfg.pathPrefix === '' ? '' : `${cfg.pathPrefix}/`;
|
||||
for (const [secretName, entries] of [...bySecret.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
const path = `${cfg.mount}/data/${prefix}${secretName}`;
|
||||
annotations[`vault.hashicorp.com/agent-inject-secret-${secretName}`] = path;
|
||||
const lines = entries
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((e) => `export ${e.name}=${shellSingleQuote(`{{ .Data.data.${e.key} }}`)}`);
|
||||
annotations[`vault.hashicorp.com/agent-inject-template-${secretName}`] =
|
||||
`{{- with secret "${path}" -}}\n${lines.join('\n')}\n{{- end -}}`;
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the container command to source the rendered secrets, then exec the
|
||||
* real process.
|
||||
*
|
||||
* `sh -c <script> arg0 arg1 …` binds `$0`/`$@`, which is how the server's own
|
||||
* argv survives intact. `exec` matters just as much: mcpd attaches to PID 1's
|
||||
* stdin/stdout for STDIO servers, so the real process must REPLACE the shell
|
||||
* rather than run forked beneath it.
|
||||
*/
|
||||
export function wrapCommandForInjector(argv: string[], secretNames: string[]): string[] {
|
||||
const sources = [...secretNames]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((n) => `. ${INJECTED_SECRET_DIR}/${n}`)
|
||||
.join('; ');
|
||||
return ['/bin/sh', '-c', `${sources}; exec "$0" "$@"`, ...argv];
|
||||
}
|
||||
|
||||
export interface K8sPvcManifest {
|
||||
apiVersion: 'v1';
|
||||
kind: 'PersistentVolumeClaim';
|
||||
@@ -225,12 +329,19 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM
|
||||
name: sanitizeName(spec.name),
|
||||
namespace,
|
||||
labels,
|
||||
...(spec.annotations && Object.keys(spec.annotations).length > 0
|
||||
? { annotations: spec.annotations }
|
||||
: {}),
|
||||
},
|
||||
spec: {
|
||||
...(spec.serviceAccountName !== undefined
|
||||
? { serviceAccountName: spec.serviceAccountName }
|
||||
: {}),
|
||||
containers: [buildContainerSpec(spec)],
|
||||
restartPolicy: 'Always',
|
||||
// MCP server pods don't need k8s API access
|
||||
automountServiceAccountToken: false,
|
||||
// MCP server pods don't need k8s API access — EXCEPT when the OpenBao
|
||||
// agent is injected, which logs in with the projected SA token.
|
||||
automountServiceAccountToken: spec.automountServiceAccountToken ?? false,
|
||||
...buildPodVolumes(spec),
|
||||
// On mixed-arch clusters, constrain to the same arch as mcpd
|
||||
// (runner images are typically single-arch)
|
||||
|
||||
52
src/mcpd/src/services/k8s/service-account-port.ts
Normal file
52
src/mcpd/src/services/k8s/service-account-port.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Kubernetes adapter for `ServiceAccountPort` — one ServiceAccount per MCP
|
||||
* server, so each pod authenticates to OpenBao as itself.
|
||||
*
|
||||
* Both operations are idempotent by design: `ensureFor` runs on every instance
|
||||
* start and `removeFor` on every server delete, so "already exists" and "not
|
||||
* found" are normal outcomes, not errors.
|
||||
*/
|
||||
import type { CoreV1Api } from '@kubernetes/client-node';
|
||||
import type { ServiceAccountPort } from '../server-identity.service.js';
|
||||
|
||||
const MCPCTL_LABEL = 'mcpctl.managed';
|
||||
|
||||
function statusOf(err: unknown): number | undefined {
|
||||
const e = err as { code?: number; statusCode?: number; body?: { code?: number } };
|
||||
return e?.code ?? e?.statusCode ?? e?.body?.code;
|
||||
}
|
||||
|
||||
export class K8sServiceAccountPort implements ServiceAccountPort {
|
||||
constructor(private readonly core: CoreV1Api, readonly namespace: string) {}
|
||||
|
||||
async ensure(name: string): Promise<void> {
|
||||
try {
|
||||
await this.core.createNamespacedServiceAccount({
|
||||
namespace: this.namespace,
|
||||
body: {
|
||||
metadata: {
|
||||
name,
|
||||
namespace: this.namespace,
|
||||
labels: { [MCPCTL_LABEL]: 'true' },
|
||||
},
|
||||
// No k8s API power is wanted here — this SA exists purely as an
|
||||
// OpenBao identity. The pod still needs its token projected, which
|
||||
// is set on the pod spec, not here.
|
||||
automountServiceAccountToken: false,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (statusOf(err) === 409) return; // already exists — the normal path
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(name: string): Promise<void> {
|
||||
try {
|
||||
await this.core.deleteNamespacedServiceAccount({ name, namespace: this.namespace });
|
||||
} catch (err) {
|
||||
if (statusOf(err) === 404) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { InstanceService } from './instance.service.js';
|
||||
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
|
||||
|
||||
export class McpServerService {
|
||||
private serverIdentity?: { removeFor(serverName: string): Promise<void> };
|
||||
|
||||
private instanceService: InstanceService | null = null;
|
||||
|
||||
constructor(private readonly repo: IMcpServerRepository) {}
|
||||
@@ -55,14 +57,27 @@ export class McpServerService {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
// Verify exists
|
||||
await this.getById(id);
|
||||
const server = await this.getById(id);
|
||||
// Stop all containers before DB cascade
|
||||
if (this.instanceService) {
|
||||
await this.instanceService.removeAllForServer(id);
|
||||
}
|
||||
// Revoke the per-server OpenBao identity, if it had one. Best-effort and
|
||||
// AFTER the pods are gone: a leftover role that no pod can authenticate as
|
||||
// grants nothing, whereas failing the delete over it would strand the
|
||||
// server row. Unconditional rather than gated on secretDelivery, so a
|
||||
// server flipped back to `env` before deletion is still cleaned up.
|
||||
if (this.serverIdentity) {
|
||||
await this.serverIdentity.removeFor(server.name);
|
||||
}
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
/** Setter injection, matching setInstanceService — constructed later in main. */
|
||||
setServerIdentity(identity: { removeFor(serverName: string): Promise<void> }): void {
|
||||
this.serverIdentity = identity;
|
||||
}
|
||||
|
||||
// ── Backup/restore helpers ──
|
||||
|
||||
async upsertByName(data: Record<string, unknown>): Promise<McpServer> {
|
||||
|
||||
@@ -47,6 +47,21 @@ export interface ContainerSpec {
|
||||
command?: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* Env the ORCHESTRATOR must arrange for the container to obtain itself,
|
||||
* carrying the reference and never the value.
|
||||
*
|
||||
* Kept separate from `env` rather than replacing it: `env` is the plaintext
|
||||
* shape every existing server still uses, and the two must coexist while
|
||||
* servers migrate one at a time.
|
||||
*/
|
||||
envFromSecret?: Array<{ name: string; secretName: string; key: string }>;
|
||||
/** Orchestrator hints. On Kubernetes these become pod annotations. */
|
||||
annotations?: Record<string, string>;
|
||||
/** Identity the pod runs as — how the backend knows which secrets it may read. */
|
||||
serviceAccountName?: string;
|
||||
/** Injected agents need the projected SA token; plain servers do not. */
|
||||
automountServiceAccountToken?: boolean;
|
||||
/** Host port to bind (null = auto-assign) */
|
||||
hostPort?: number | null;
|
||||
/** Container port to expose */
|
||||
@@ -91,6 +106,17 @@ export interface ExecResult {
|
||||
}
|
||||
|
||||
export interface McpOrchestrator {
|
||||
/**
|
||||
* What this backend can do beyond running containers.
|
||||
*
|
||||
* `secretRefs` means the orchestrator can honour `ContainerSpec.envFromSecret`
|
||||
* — i.e. arrange for the container to fetch its own secret values. Docker and
|
||||
* Podman cannot, so mcpd resolves those refs itself and merges them into
|
||||
* `env`. Declaring the difference here keeps it one visible decision at the
|
||||
* call site instead of a silent divergence inside each implementation.
|
||||
*/
|
||||
readonly capabilities?: { secretRefs: boolean };
|
||||
|
||||
/** Pull an image if not present locally */
|
||||
pullImage(image: string): Promise<void>;
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
import type { McpServer } from '@prisma/client';
|
||||
import type { SecretBackendService } from './secret-backend.service.js';
|
||||
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
|
||||
import {
|
||||
buildInjectorAnnotations,
|
||||
wrapCommandForInjector,
|
||||
type InjectorEnvRef,
|
||||
} from './k8s/manifest-generator.js';
|
||||
|
||||
/** Minimal Kubernetes surface this needs — keeps the service testable. */
|
||||
export interface ServiceAccountPort {
|
||||
@@ -53,6 +58,22 @@ const NOOP_LOG: ServerIdentityLog = { info: () => undefined, warn: () => undefin
|
||||
/** Shared prefix. mcpd's OpenBao grant is confined to exactly this prefix. */
|
||||
export const IDENTITY_PREFIX = 'mcpctl-server-';
|
||||
|
||||
/** Where the injector should authenticate and look for secrets. */
|
||||
export interface InjectorSettings {
|
||||
/** Auth mount validating THIS cluster's ServiceAccount tokens. */
|
||||
authPath: string;
|
||||
/** KV v2 mount. */
|
||||
mount: string;
|
||||
/** Path prefix under the mount. */
|
||||
pathPrefix: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_INJECTOR_SETTINGS: InjectorSettings = {
|
||||
authPath: 'auth/kubernetes',
|
||||
mount: 'secret',
|
||||
pathPrefix: 'mcpctl',
|
||||
};
|
||||
|
||||
export class ServerIdentityService {
|
||||
private readonly log: ServerIdentityLog;
|
||||
|
||||
@@ -60,10 +81,41 @@ export class ServerIdentityService {
|
||||
private readonly backends: SecretBackendService,
|
||||
private readonly serviceAccounts: ServiceAccountPort,
|
||||
log?: ServerIdentityLog,
|
||||
private readonly injector: InjectorSettings = DEFAULT_INJECTOR_SETTINGS,
|
||||
) {
|
||||
this.log = log ?? NOOP_LOG;
|
||||
}
|
||||
|
||||
/** Injector pod annotations for these refs, under the given identity. */
|
||||
annotationsFor(identity: string, refs: InjectorEnvRef[]): Record<string, string> {
|
||||
return buildInjectorAnnotations(refs, {
|
||||
role: identity,
|
||||
authPath: this.injector.authPath,
|
||||
mount: this.injector.mount,
|
||||
pathPrefix: this.injector.pathPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite argv so the rendered secrets are sourced before the server runs.
|
||||
*
|
||||
* `command` is what mcpd already computed: for package-based servers that is
|
||||
* the runner image's entrypoint plus the package, which mcpd owns. For a
|
||||
* dockerImage server it may be absent — the image's own ENTRYPOINT would run,
|
||||
* and mcpd cannot introspect it, which is why `entrypoint` is required on the
|
||||
* server row in that case (enforced at validation).
|
||||
*/
|
||||
wrapCommand(
|
||||
server: Pick<McpServer, 'env' | 'entrypoint'>,
|
||||
command: string[] | undefined,
|
||||
): string[] | undefined {
|
||||
const secretNames = this.secretNamesFor(server);
|
||||
if (secretNames.length === 0) return command;
|
||||
const argv = command ?? (server.entrypoint as string[] | null) ?? undefined;
|
||||
if (argv === undefined || argv.length === 0) return command;
|
||||
return wrapCommandForInjector(argv, secretNames);
|
||||
}
|
||||
|
||||
/** Identity name for a server — also the SA, policy and role name. */
|
||||
identityNameFor(serverName: string): string {
|
||||
return `${IDENTITY_PREFIX}${serverName}`;
|
||||
|
||||
@@ -19,6 +19,20 @@ export const ServerEnvEntrySchema = z.object({
|
||||
|
||||
export type ServerEnvEntry = z.infer<typeof ServerEnvEntrySchema>;
|
||||
|
||||
/**
|
||||
* How secret-backed env reaches the container.
|
||||
*
|
||||
* `env` (default) resolves values in mcpd and writes them into the pod spec —
|
||||
* cleartext in etcd, readable by anyone with `get pod`. `injector` has the pod
|
||||
* fetch its own secrets from OpenBao, under an identity scoped to just that
|
||||
* server's secrets, so the value never touches the pod spec.
|
||||
*
|
||||
* Defaulting to `env` keeps every existing server bit-for-bit unchanged;
|
||||
* migrating is an explicit, reversible, per-server decision.
|
||||
*/
|
||||
export const SecretDeliverySchema = z.enum(['env', 'injector']);
|
||||
export type SecretDelivery = z.infer<typeof SecretDeliverySchema>;
|
||||
|
||||
export const CreateMcpServerSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
description: z.string().max(1000).default(''),
|
||||
@@ -34,6 +48,8 @@ export const CreateMcpServerSchema = z.object({
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: SecretDeliverySchema.default('env'),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
}).refine(
|
||||
(s) => s.volumes.length === 0 || s.replicas <= 1,
|
||||
{
|
||||
@@ -42,6 +58,16 @@ export const CreateMcpServerSchema = z.object({
|
||||
+ 'so the extra replicas would sit unschedulable on a volume they cannot mount.',
|
||||
path: ['replicas'],
|
||||
},
|
||||
).refine(
|
||||
(s) => s.secretDelivery !== 'injector' || s.dockerImage === undefined || s.entrypoint !== undefined,
|
||||
{
|
||||
message:
|
||||
'secretDelivery: injector on a dockerImage server needs an explicit entrypoint. '
|
||||
+ 'The injector renders secrets to a file, so the container command is rewritten to source '
|
||||
+ 'it and exec the real process — and mcpd cannot read the image\'s own ENTRYPOINT to know '
|
||||
+ 'what that process is. Package-based servers do not need this (mcpd owns their entrypoint).',
|
||||
path: ['entrypoint'],
|
||||
},
|
||||
);
|
||||
|
||||
export const UpdateMcpServerSchema = z.object({
|
||||
@@ -58,6 +84,8 @@ export const UpdateMcpServerSchema = z.object({
|
||||
env: z.array(ServerEnvEntrySchema).optional(),
|
||||
healthCheck: HealthCheckSchema.nullable().optional(),
|
||||
volumes: z.array(VolumeSpecSchema).optional(),
|
||||
secretDelivery: SecretDeliverySchema.optional(),
|
||||
entrypoint: z.array(z.string()).nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
||||
|
||||
67
src/mcpd/tests/injector-instance-wiring.test.ts
Normal file
67
src/mcpd/tests/injector-instance-wiring.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Instance-start wiring for injector delivery.
|
||||
*
|
||||
* Two invariants dominate: an opted-OUT server behaves exactly as before, and
|
||||
* an opted-IN server still fails LOUDLY on a bad secret ref. The second is the
|
||||
* one at risk — once mcpd stops reading a server's secrets, the fail-loud check
|
||||
* from e6cd735 no longer fires for it, and a typo'd ref degrades into a
|
||||
* vault-agent-init crashloop reported as a generic pod failure.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { partitionServerEnv, validateServerEnvRefs } from '../src/services/env-resolver.js';
|
||||
import type { McpServer } from '@prisma/client';
|
||||
|
||||
function server(overrides: Partial<McpServer> = {}): McpServer {
|
||||
return {
|
||||
name: 'gitea',
|
||||
env: [
|
||||
{ name: 'GITEA_HOST', value: 'https://example' },
|
||||
{ name: 'GITEA_ACCESS_TOKEN', valueFrom: { secretRef: { name: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' } } },
|
||||
],
|
||||
...overrides,
|
||||
} as McpServer;
|
||||
}
|
||||
|
||||
describe('partitionServerEnv', () => {
|
||||
it('separates inline values from references without resolving anything', () => {
|
||||
const { inline, refs } = partitionServerEnv(server());
|
||||
expect(inline).toEqual({ GITEA_HOST: 'https://example' });
|
||||
expect(refs).toEqual([
|
||||
{ name: 'GITEA_ACCESS_TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('never carries a secret value — that is the whole point', () => {
|
||||
const { inline } = partitionServerEnv(server());
|
||||
expect(JSON.stringify(inline)).not.toContain('TOKEN_VALUE');
|
||||
expect(Object.keys(inline)).not.toContain('GITEA_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('handles a server with no env at all', () => {
|
||||
expect(partitionServerEnv(server({ env: [] }))).toEqual({ inline: {}, refs: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateServerEnvRefs — preserves the fail-loud invariant', () => {
|
||||
it('resolves every ref and discards the value', async () => {
|
||||
const resolve = vi.fn(async () => 'super-secret');
|
||||
await validateServerEnvRefs(server(), { resolve });
|
||||
expect(resolve).toHaveBeenCalledWith('gitea-creds', 'GITEA_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('throws the SAME message shape as inline resolution on a bad ref', async () => {
|
||||
// instance.service turns this into markInstanceError("secret resolution
|
||||
// failed: …"), which is what an operator greps for.
|
||||
const resolve = vi.fn(async () => { throw new Error("Secret 'gitea-creds' has no key 'NOPE'"); });
|
||||
await expect(validateServerEnvRefs(
|
||||
server({ env: [{ name: 'T', valueFrom: { secretRef: { name: 'gitea-creds', key: 'NOPE' } } }] as never }),
|
||||
{ resolve },
|
||||
)).rejects.toThrow(/Cannot resolve secret for server 'gitea' env 'T'/);
|
||||
});
|
||||
|
||||
it('does nothing for a server with no refs', async () => {
|
||||
const resolve = vi.fn();
|
||||
await validateServerEnvRefs(server({ env: [{ name: 'X', value: 'y' }] as never }), { resolve });
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
138
src/mcpd/tests/injector-manifest.test.ts
Normal file
138
src/mcpd/tests/injector-manifest.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Injector manifest generation.
|
||||
*
|
||||
* The two things that must not break: a server NOT opted in produces a
|
||||
* byte-identical manifest to before, and a rendered secret value survives the
|
||||
* shell intact whatever it contains. The second is the subtle one — bad quoting
|
||||
* fails silently, yielding an empty token and a server that reports healthy
|
||||
* while every authenticated call fails.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
generatePodSpec,
|
||||
buildInjectorAnnotations,
|
||||
wrapCommandForInjector,
|
||||
shellSingleQuote,
|
||||
} from '../src/services/k8s/manifest-generator.js';
|
||||
import type { ContainerSpec } from '../src/services/orchestrator.js';
|
||||
|
||||
const BASE: ContainerSpec = { name: 'gitea', image: 'gitea/mcp:latest' } as ContainerSpec;
|
||||
const CFG = { role: 'mcpctl-server-gitea', authPath: 'auth/kubernetes-worker0', mount: 'secret', pathPrefix: 'mcpctl' };
|
||||
|
||||
describe('generatePodSpec — opted-out servers are untouched', () => {
|
||||
it('emits no annotations, no serviceAccountName, automount still false', () => {
|
||||
const pod = generatePodSpec({ ...BASE, env: { TOKEN: 'plain' } } as ContainerSpec, 'mcpctl-servers');
|
||||
expect(pod.metadata.annotations).toBeUndefined();
|
||||
expect(pod.spec.serviceAccountName).toBeUndefined();
|
||||
expect(pod.spec.automountServiceAccountToken).toBe(false);
|
||||
expect(pod.spec.containers[0]?.env).toEqual([{ name: 'TOKEN', value: 'plain' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePodSpec — opted-in servers', () => {
|
||||
const spec = {
|
||||
...BASE,
|
||||
annotations: buildInjectorAnnotations(
|
||||
[{ name: 'GITEA_ACCESS_TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' }],
|
||||
CFG,
|
||||
),
|
||||
serviceAccountName: 'mcpctl-server-gitea',
|
||||
automountServiceAccountToken: true,
|
||||
} as ContainerSpec;
|
||||
|
||||
it('carries no secret VALUE anywhere in the manifest', () => {
|
||||
const pod = generatePodSpec(spec, 'mcpctl-servers');
|
||||
const json = JSON.stringify(pod);
|
||||
expect(json).not.toContain('de4c69ed');
|
||||
expect(pod.spec.containers[0]?.env ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs as its own ServiceAccount with the SA token projected', () => {
|
||||
const pod = generatePodSpec(spec, 'mcpctl-servers');
|
||||
expect(pod.spec.serviceAccountName).toBe('mcpctl-server-gitea');
|
||||
// The agent cannot log in without it; false here is a silent crashloop.
|
||||
expect(pod.spec.automountServiceAccountToken).toBe(true);
|
||||
});
|
||||
|
||||
it('pins the auth path to the mount that validates THIS cluster', () => {
|
||||
const a = buildInjectorAnnotations([{ name: 'T', secretName: 's', key: 'k' }], CFG);
|
||||
expect(a['vault.hashicorp.com/auth-path']).toBe('auth/kubernetes-worker0');
|
||||
expect(a['vault.hashicorp.com/role']).toBe('mcpctl-server-gitea');
|
||||
// init container only — no sidecar in a 512Mi pod
|
||||
expect(a['vault.hashicorp.com/agent-pre-populate-only']).toBe('true');
|
||||
});
|
||||
|
||||
it('groups env vars by the secret they come from', () => {
|
||||
const a = buildInjectorAnnotations([
|
||||
{ name: 'HOST', secretName: 'gitea-creds', key: 'GITEA_HOST' },
|
||||
{ name: 'TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' },
|
||||
{ name: 'OTHER', secretName: 'other-creds', key: 'K' },
|
||||
], CFG);
|
||||
expect(a['vault.hashicorp.com/agent-inject-secret-gitea-creds']).toBe('secret/data/mcpctl/gitea-creds');
|
||||
expect(a['vault.hashicorp.com/agent-inject-secret-other-creds']).toBe('secret/data/mcpctl/other-creds');
|
||||
const tpl = a['vault.hashicorp.com/agent-inject-template-gitea-creds'] ?? '';
|
||||
expect(tpl).toContain('export HOST=');
|
||||
expect(tpl).toContain('export TOKEN=');
|
||||
expect(tpl).not.toContain('export OTHER=');
|
||||
});
|
||||
|
||||
it('is stable under input reordering', () => {
|
||||
const refs = [
|
||||
{ name: 'B', secretName: 'z', key: 'k' },
|
||||
{ name: 'A', secretName: 'a', key: 'k' },
|
||||
];
|
||||
expect(buildInjectorAnnotations(refs, CFG)).toEqual(buildInjectorAnnotations([...refs].reverse(), CFG));
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapCommandForInjector', () => {
|
||||
it('preserves argv exactly via $0/$@ and execs so PID 1 is the real process', () => {
|
||||
// exec matters: mcpd attaches to PID 1 stdin/stdout for STDIO servers.
|
||||
const cmd = wrapCommandForInjector(['node', 'server.js', '--port', '3000'], ['a-creds']);
|
||||
expect(cmd[0]).toBe('/bin/sh');
|
||||
expect(cmd[1]).toBe('-c');
|
||||
expect(cmd[2]).toContain('exec "$0" "$@"');
|
||||
expect(cmd.slice(3)).toEqual(['node', 'server.js', '--port', '3000']);
|
||||
});
|
||||
|
||||
it('sources every referenced secret file, in a stable order', () => {
|
||||
const cmd = wrapCommandForInjector(['x'], ['b-creds', 'a-creds']);
|
||||
expect(cmd[2]).toBe('. /vault/secrets/a-creds; . /vault/secrets/b-creds; exec "$0" "$@"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell quoting survives adversarial values', () => {
|
||||
// Executed by a real /bin/sh: asserting on the string alone would prove
|
||||
// nothing about what the shell actually does with it.
|
||||
const nasty = [
|
||||
'plain',
|
||||
'with space',
|
||||
"single'quote",
|
||||
'double"quote',
|
||||
'$USER and `whoami`',
|
||||
'semi;colon && rm -rf /',
|
||||
'new\nline',
|
||||
'back\\slash',
|
||||
"'; export PWNED=1; '",
|
||||
];
|
||||
|
||||
it.each(nasty)('round-trips %j through a sourced file', (value) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mcpctl-quote-'));
|
||||
const file = join(dir, 'env');
|
||||
writeFileSync(file, `export SECRET=${shellSingleQuote(value)}\n`);
|
||||
const out = execFileSync('/bin/sh', ['-c', `. ${file}; printf '%s' "$SECRET"`], { encoding: 'utf-8' });
|
||||
expect(out).toBe(value);
|
||||
});
|
||||
|
||||
it('does not let a crafted value execute anything', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mcpctl-quote-'));
|
||||
const file = join(dir, 'env');
|
||||
writeFileSync(file, `export SECRET=${shellSingleQuote("'; touch /tmp/mcpctl-pwned; '")}\n`);
|
||||
const out = execFileSync('/bin/sh', ['-c', `. ${file}; printf '%s' "$PWNED_MARKER"`], { encoding: 'utf-8' });
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user