feat: store backup config in DB secret instead of env var

Move backup SSH keys and repo URL from MCPD_BACKUP_REPO env var to a
"backup-ssh" secret in the database. Keys are auto-generated on first
init and stored back into the secret. Also fix ERR_HTTP_HEADERS_SENT
crash caused by reply.send() without return in routes when onSend hook
is registered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-08 13:53:12 +00:00
parent 6bce1431ae
commit af4b3fb702
10 changed files with 193 additions and 136 deletions

View File

@@ -235,15 +235,12 @@ _mcpctl() {
backup)
local backup_sub=$(_mcpctl_get_subcmd $subcmd_pos)
if [[ -z "$backup_sub" ]]; then
COMPREPLY=($(compgen -W "log key restore help" -- "$cur"))
COMPREPLY=($(compgen -W "log restore help" -- "$cur"))
else
case "$backup_sub" in
log)
COMPREPLY=($(compgen -W "-n --limit -h --help" -- "$cur"))
;;
key)
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
;;
restore)
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
;;

View File

@@ -353,9 +353,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content-f
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x
# backup subcommands
set -l backup_cmds log key restore
set -l backup_cmds log restore
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a log -d 'Show backup commit history'
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a key -d 'Show SSH public key for deploy key setup'
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a restore -d 'Restore mcpctl state from backup history'
# backup log options

View File

@@ -9,6 +9,7 @@ export interface BackupDeps {
interface BackupStatus {
enabled: boolean;
repoUrl: string | null;
publicKey: string | null;
gitReachable: boolean;
lastSyncAt: string | null;
lastPushAt: string | null;
@@ -33,12 +34,12 @@ export function createBackupCommand(deps: BackupDeps): Command {
if (!status.enabled) {
deps.log('Backup: disabled');
deps.log('');
deps.log('To enable, set MCPD_BACKUP_REPO in your mcpd environment:');
deps.log(' 1. Create a bare git repo (e.g. on Gitea, GitHub, or local)');
deps.log(' 2. Add MCPD_BACKUP_REPO=<repo-url> to stack/.env');
deps.log(' 3. Redeploy mcpd (bash deploy.sh)');
deps.log(' 4. Run: mcpctl backup key');
deps.log(' Add the SSH key as a deploy key (with write access) in your git host');
deps.log('To enable, create a backup-ssh secret:');
deps.log(' mcpctl create secret backup-ssh --data repoUrl=ssh://git@host/repo.git');
deps.log('');
deps.log('After creating the secret, restart mcpd. An SSH keypair will be');
deps.log('auto-generated and stored in the secret. Run mcpctl backup to see');
deps.log('the public key, then add it as a deploy key in your git host.');
return;
}
@@ -65,6 +66,10 @@ export function createBackupCommand(deps: BackupDeps): Command {
if (status.lastError) {
deps.log(`Error: ${status.lastError}`);
}
if (status.publicKey) {
deps.log('');
deps.log(`SSH key: ${status.publicKey}`);
}
});
cmd
@@ -109,16 +114,6 @@ export function createBackupCommand(deps: BackupDeps): Command {
}
});
cmd
.command('key')
.description('Show SSH public key for deploy key setup')
.action(async () => {
const { publicKey } = await deps.client.get<{ publicKey: string }>('/api/v1/backup/key');
deps.log(publicKey);
deps.log('');
deps.log('Add this key as a deploy key (with write access) in your Git hosting provider.');
});
// ── Restore subcommand group ──
const restore = new Command('restore')
.description('Restore mcpctl state from backup history');

View File

@@ -73,13 +73,38 @@ describe('backup command', () => {
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
});
it('shows SSH public key', async () => {
mockClient.get.mockResolvedValue({ publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local' });
it('shows SSH public key in status when enabled', async () => {
mockClient.get.mockResolvedValue({
enabled: true,
repoUrl: 'ssh://git@host/repo.git',
publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local',
gitReachable: true,
lastSyncAt: null,
lastPushAt: null,
lastError: null,
pendingCount: 0,
});
await makeCmd().parseAsync(['key'], { from: 'user' });
await makeCmd().parseAsync([], { from: 'user' });
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/key');
expect(log).toHaveBeenCalledWith('ssh-ed25519 AAAA... mcpd@mcpctl.local');
expect(log).toHaveBeenCalledWith(expect.stringContaining('ssh-ed25519 AAAA... mcpd@mcpctl.local'));
});
it('shows setup instructions when disabled', async () => {
mockClient.get.mockResolvedValue({
enabled: false,
repoUrl: null,
publicKey: null,
gitReachable: false,
lastSyncAt: null,
lastPushAt: null,
lastError: null,
pendingCount: 0,
});
await makeCmd().parseAsync([], { from: 'user' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('mcpctl create secret backup-ssh'));
});
it('shows commit log', async () => {

View File

@@ -367,7 +367,7 @@ async function main(): Promise<void> {
}
}
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
return reply.code(403).send({ error: 'Forbidden' });
}
});
@@ -395,81 +395,78 @@ async function main(): Promise<void> {
// ── Git-based backup ──
const gitBackup = new GitBackupService(prisma);
// Hook: enqueue backup after successful mutations
if (gitBackup.enabled) {
const kindFromSegment: Record<string, BackupKind | undefined> = {
servers: 'server', secrets: 'secret', projects: 'project',
templates: 'template', users: 'user', groups: 'group',
rbac: 'rbac', prompts: 'prompt',
};
app.addHook('onSend', async (request, reply, payload) => {
if (reply.statusCode >= 400) return payload;
const method = request.method;
if (method === 'GET' || method === 'HEAD') return payload;
// Hook: enqueue backup after successful mutations (checks enabled at call time)
const kindFromSegment: Record<string, BackupKind | undefined> = {
servers: 'server', secrets: 'secret', projects: 'project',
templates: 'template', users: 'user', groups: 'group',
rbac: 'rbac', prompts: 'prompt',
};
app.addHook('onSend', async (request, reply, payload) => {
if (!gitBackup.enabled) return payload;
if (reply.statusCode >= 400) return payload;
const method = request.method;
if (method === 'GET' || method === 'HEAD') return payload;
const urlMatch = request.url.match(/^\/api\/v1\/([a-z-]+)(?:\/([^/?]+))?/);
if (!urlMatch) return payload;
const kind = kindFromSegment[urlMatch[1]!];
if (!kind) return payload;
const urlMatch = request.url.match(/^\/api\/v1\/([a-z-]+)(?:\/([^/?]+))?/);
if (!urlMatch) return payload;
const kind = kindFromSegment[urlMatch[1]!];
if (!kind) return payload;
let action: 'create' | 'update' | 'delete';
if (method === 'DELETE') action = 'delete';
else if (method === 'POST') action = 'create';
else action = 'update';
let action: 'create' | 'update' | 'delete';
if (method === 'DELETE') action = 'delete';
else if (method === 'POST') action = 'create';
else action = 'update';
// Get resource name: from URL for update/delete, from response body for create
const nameField = kind === 'user' ? 'email' : 'name';
let resourceName = urlMatch[2];
if (!resourceName && typeof payload === 'string') {
try {
const body = JSON.parse(payload);
resourceName = body[nameField];
} catch { /* ignore parse errors */ }
}
if (!resourceName) return payload;
// Get resource name: from URL for update/delete, from response body for create
const nameField = kind === 'user' ? 'email' : 'name';
let resourceName = urlMatch[2];
if (!resourceName && typeof payload === 'string') {
try {
const body = JSON.parse(payload);
resourceName = body[nameField];
} catch { /* ignore parse errors */ }
}
if (!resourceName) return payload;
const userName = request.userId ?? 'system';
gitBackup.enqueue(kind, resourceName, action, userName).catch((err) => {
app.log.error({ err }, `Git backup enqueue failed for ${kind}/${resourceName}`);
});
return payload;
const userName = request.userId ?? 'system';
gitBackup.enqueue(kind, resourceName, action, userName).catch((err) => {
app.log.error({ err }, `Git backup enqueue failed for ${kind}/${resourceName}`);
});
}
return payload;
});
if (gitBackup.enabled) {
// Import callback: apply a parsed YAML doc to the DB via services
const importResource = async (kind: BackupKind, _name: string, doc: Record<string, unknown>) => {
const data = { ...doc };
delete data.kind; // strip the kind field before passing to service
switch (kind) {
case 'server': await serverService.upsertByName(data); break;
case 'secret': await secretService.upsertByName(data); break;
case 'project': await projectService.upsertByName(data, 'system'); break;
case 'user': await userService.upsertByEmail(data); break;
case 'group': await groupService.upsertByName(data); break;
case 'rbac': await rbacDefinitionService.upsertByName(data); break;
case 'prompt': await promptService.upsertByName(data); break;
case 'template': await templateService.upsertByName(data); break;
}
};
const deleteResource = async (kind: BackupKind, name: string) => {
switch (kind) {
case 'server': await serverService.deleteByName(name); break;
case 'secret': await secretService.deleteByName(name); break;
case 'project': await projectService.deleteByName(name); break;
case 'user': await userService.deleteByEmail(name); break;
case 'group': await groupService.deleteByName(name); break;
case 'rbac': await rbacDefinitionService.deleteByName(name); break;
case 'prompt': await promptService.deleteByName(name); break;
case 'template': await templateService.deleteByName(name); break;
}
};
gitBackup.setCallbacks(importResource, deleteResource);
// Init async — don't block server startup
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
}
// Import/delete callbacks for git restore
const importResource = async (kind: BackupKind, _name: string, doc: Record<string, unknown>) => {
const data = { ...doc };
delete data.kind;
switch (kind) {
case 'server': await serverService.upsertByName(data); break;
case 'secret': await secretService.upsertByName(data); break;
case 'project': await projectService.upsertByName(data, 'system'); break;
case 'user': await userService.upsertByEmail(data); break;
case 'group': await groupService.upsertByName(data); break;
case 'rbac': await rbacDefinitionService.upsertByName(data); break;
case 'prompt': await promptService.upsertByName(data); break;
case 'template': await templateService.upsertByName(data); break;
}
};
const deleteResource = async (kind: BackupKind, name: string) => {
switch (kind) {
case 'server': await serverService.deleteByName(name); break;
case 'secret': await secretService.deleteByName(name); break;
case 'project': await projectService.deleteByName(name); break;
case 'user': await userService.deleteByEmail(name); break;
case 'group': await groupService.deleteByName(name); break;
case 'rbac': await rbacDefinitionService.deleteByName(name); break;
case 'prompt': await promptService.deleteByName(name); break;
case 'template': await templateService.deleteByName(name); break;
}
};
gitBackup.setCallbacks(importResource, deleteResource);
// Init async — reads config from backup-ssh secret, don't block startup
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
// Always register backup routes (status shows disabled when no repo configured)
registerGitBackupRoutes(app, gitBackup);
// ── RBAC list filtering hook ──

View File

@@ -20,21 +20,20 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
app.post('/api/v1/audit/events', async (request, reply) => {
const body = request.body;
if (!Array.isArray(body) || body.length === 0) {
reply.code(400).send({ error: 'Request body must be a non-empty array of audit events' });
return;
return reply.code(400).send({ error: 'Request body must be a non-empty array of audit events' });
}
// Basic validation
for (const event of body) {
const e = event as Record<string, unknown>;
if (!e['sessionId'] || !e['projectName'] || !e['eventKind'] || !e['source'] || !e['timestamp']) {
reply.code(400).send({ error: 'Each event requires: timestamp, sessionId, projectName, eventKind, source' });
return;
return reply.code(400).send({ error: 'Each event requires: timestamp, sessionId, projectName, eventKind, source' });
}
}
const count = await service.createBatch(body as AuditEventCreateInput[]);
reply.code(201).send({ inserted: count });
reply.code(201);
return { inserted: count };
});
// GET /api/v1/audit/events — query with filters

View File

@@ -31,8 +31,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
const count = await deps.userService.count();
if (count > 0) {
reply.code(409).send({ error: 'Users already exist. Use login instead.' });
return;
return reply.code(409).send({ error: 'Users already exist. Use login instead.' });
}
const { email, password, name } = request.body as { email: string; password: string; name?: string };

View File

@@ -2,20 +2,11 @@ import type { FastifyInstance } from 'fastify';
import type { GitBackupService } from '../services/backup/git-backup.service.js';
export function registerGitBackupRoutes(app: FastifyInstance, gitBackup: GitBackupService): void {
// GET /api/v1/backup/status — sync status
// GET /api/v1/backup/status — sync status (includes publicKey)
app.get('/api/v1/backup/status', async () => {
return gitBackup.getStatus();
});
// GET /api/v1/backup/key — SSH public key
app.get('/api/v1/backup/key', async (_req, reply) => {
const key = await gitBackup.getPublicKey();
if (!key) {
return reply.code(404).send({ error: 'SSH key not generated yet' });
}
return { publicKey: key };
});
// GET /api/v1/backup/log — commit history
app.get<{ Querystring: { limit?: string } }>('/api/v1/backup/log', async (request) => {
const limit = parseInt(request.query.limit ?? '50', 10);

View File

@@ -6,6 +6,11 @@
*
* Manual commits (not by mcpd) are detected and imported if they don't conflict
* with pending DB changes. Conflicts are resolved in favor of the DB.
*
* Configuration is stored in a secret named "backup-ssh" in the DB.
* The secret data contains: repoUrl, publicKey, privateKey.
* If the secret exists with a repoUrl, backup is enabled.
* SSH keys are auto-generated if missing from the secret.
*/
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
@@ -25,10 +30,12 @@ const REPO_DIR = join(BACKUP_DIR, 'repo');
const SSH_KEY_PATH = join(BACKUP_DIR, 'id_ed25519');
const MCPD_EMAIL = 'mcpd@mcpctl.local';
const SYNC_INTERVAL_MS = 30_000;
const BACKUP_SECRET_NAME = 'backup-ssh';
export interface BackupStatus {
enabled: boolean;
repoUrl: string | null;
publicKey: string | null;
gitReachable: boolean;
lastSyncAt: string | null;
lastPushAt: string | null;
@@ -59,7 +66,8 @@ export type ImportResourceFn = (kind: BackupKind, name: string, doc: Record<stri
export type DeleteResourceFn = (kind: BackupKind, name: string) => Promise<void>;
export class GitBackupService {
private repoUrl: string | null;
private repoUrl: string | null = null;
private publicKey: string | null = null;
private initialized = false;
private gitReachable = false;
private lastSyncAt: Date | null = null;
@@ -73,10 +81,7 @@ export class GitBackupService {
constructor(
private readonly prisma: PrismaClient,
repoUrl?: string,
) {
this.repoUrl = repoUrl || process.env.MCPD_BACKUP_REPO || null;
}
) {}
get enabled(): boolean {
return this.repoUrl !== null;
@@ -88,10 +93,13 @@ export class GitBackupService {
this.deleteResource = deleteFn;
}
/** Initialize: generate SSH key, clone/init repo, initial sync. */
/** Initialize: read config from backup-ssh secret, setup SSH key, clone/init repo. */
async init(): Promise<void> {
// Read config from the backup-ssh secret in the DB
await this.loadConfigFromSecret();
if (!this.enabled) {
console.log('[git-backup] Disabled (no MCPD_BACKUP_REPO configured)');
console.log('[git-backup] Disabled (no backup-ssh secret with repoUrl)');
return;
}
@@ -151,6 +159,7 @@ export class GitBackupService {
return {
enabled: this.enabled,
repoUrl: this.repoUrl,
publicKey: this.publicKey,
gitReachable: this.gitReachable,
lastSyncAt: this.lastSyncAt?.toISOString() ?? null,
lastPushAt: this.lastPushAt?.toISOString() ?? null,
@@ -159,15 +168,6 @@ export class GitBackupService {
};
}
/** Get the SSH public key. */
async getPublicKey(): Promise<string | null> {
try {
return (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
} catch {
return null;
}
}
/** Get commit history. */
async getLog(limit = 50): Promise<BackupLogEntry[]> {
if (!this.initialized) return [];
@@ -318,19 +318,75 @@ export class GitBackupService {
return status.length > 0;
}
// ── Config from Secret ──
/** Load repoUrl and SSH keys from the backup-ssh secret. */
private async loadConfigFromSecret(): Promise<void> {
try {
const secret = await this.prisma.secret.findUnique({ where: { name: BACKUP_SECRET_NAME } });
if (!secret) return;
const data = secret.data as Record<string, string>;
const repoUrl = data['repoUrl'];
if (!repoUrl) return;
this.repoUrl = repoUrl;
this.publicKey = data['publicKey'] ?? null;
} catch (err) {
console.error(`[git-backup] Failed to read ${BACKUP_SECRET_NAME} secret: ${err}`);
}
}
// ── SSH Key ──
/** Ensure SSH key exists on disk (for git operations) and in the secret. */
private async ensureSshKey(): Promise<void> {
// Try to load key from secret first
const secret = await this.prisma.secret.findUnique({ where: { name: BACKUP_SECRET_NAME } });
const data = (secret?.data ?? {}) as Record<string, string>;
if (data['privateKey'] && data['publicKey']) {
// Write keys from secret to disk for git SSH
await writeFile(SSH_KEY_PATH, data['privateKey'] + '\n', { mode: 0o600 });
await writeFile(`${SSH_KEY_PATH}.pub`, data['publicKey'] + '\n', { mode: 0o644 });
this.publicKey = data['publicKey'];
console.log('[git-backup] SSH key loaded from secret');
return;
}
// No keys in secret — check disk (migration from old setup)
try {
await access(SSH_KEY_PATH);
console.log('[git-backup] SSH key exists');
} catch {
console.log('[git-backup] Generating SSH key...');
await execFile('ssh-keygen', ['-t', 'ed25519', '-f', SSH_KEY_PATH, '-N', '', '-C', MCPD_EMAIL], {
timeout: 10_000,
const privateKey = (await readFile(SSH_KEY_PATH, 'utf-8')).trim();
const pubKey = (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
// Store keys back into the secret
await this.prisma.secret.update({
where: { name: BACKUP_SECRET_NAME },
data: { data: { ...data, privateKey, publicKey: pubKey } },
});
console.log('[git-backup] SSH key generated');
this.publicKey = pubKey;
console.log('[git-backup] SSH key migrated from disk to secret');
return;
} catch {
// No key on disk either
}
// Generate new keypair
console.log('[git-backup] Generating SSH key...');
await execFile('ssh-keygen', ['-t', 'ed25519', '-f', SSH_KEY_PATH, '-N', '', '-C', MCPD_EMAIL], {
timeout: 10_000,
});
const privateKey = (await readFile(SSH_KEY_PATH, 'utf-8')).trim();
const pubKey = (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
// Store in secret
await this.prisma.secret.update({
where: { name: BACKUP_SECRET_NAME },
data: { data: { ...data, privateKey, publicKey: pubKey } },
});
this.publicKey = pubKey;
console.log('[git-backup] SSH key generated and stored in secret');
}
// ── Repo Init ──

View File

@@ -32,7 +32,6 @@ services:
MCPD_PYTHON_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-python-runner:latest
MCPD_RATE_LIMIT_MAX: "2000"
MCPD_MCP_NETWORK: mcp-servers
MCPD_BACKUP_REPO: ${MCPD_BACKUP_REPO:-}
depends_on:
postgres:
condition: service_healthy