diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 9fca0e3..79db36d 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -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")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 3b4fb9c..3f3c869 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -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 diff --git a/src/cli/src/commands/backup.ts b/src/cli/src/commands/backup.ts index 00e6a0c..b792746 100644 --- a/src/cli/src/commands/backup.ts +++ b/src/cli/src/commands/backup.ts @@ -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= 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'); diff --git a/src/cli/tests/commands/backup.test.ts b/src/cli/tests/commands/backup.test.ts index 2872649..54a6c48 100644 --- a/src/cli/tests/commands/backup.test.ts +++ b/src/cli/tests/commands/backup.test.ts @@ -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 () => { diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index dd51b8b..089ad20 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -367,7 +367,7 @@ async function main(): Promise { } } if (!allowed) { - reply.code(403).send({ error: 'Forbidden' }); + return reply.code(403).send({ error: 'Forbidden' }); } }); @@ -395,81 +395,78 @@ async function main(): Promise { // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); - // Hook: enqueue backup after successful mutations - if (gitBackup.enabled) { - const kindFromSegment: Record = { - 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 = { + 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) => { - 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) => { + 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 ── diff --git a/src/mcpd/src/routes/audit-events.ts b/src/mcpd/src/routes/audit-events.ts index 059905a..4b72121 100644 --- a/src/mcpd/src/routes/audit-events.ts +++ b/src/mcpd/src/routes/audit-events.ts @@ -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; 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 diff --git a/src/mcpd/src/routes/auth.ts b/src/mcpd/src/routes/auth.ts index bca91a0..72a24b3 100644 --- a/src/mcpd/src/routes/auth.ts +++ b/src/mcpd/src/routes/auth.ts @@ -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 }; diff --git a/src/mcpd/src/routes/git-backup.ts b/src/mcpd/src/routes/git-backup.ts index f0e04d2..536f018 100644 --- a/src/mcpd/src/routes/git-backup.ts +++ b/src/mcpd/src/routes/git-backup.ts @@ -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); diff --git a/src/mcpd/src/services/backup/git-backup.service.ts b/src/mcpd/src/services/backup/git-backup.service.ts index f489085..dc57541 100644 --- a/src/mcpd/src/services/backup/git-backup.service.ts +++ b/src/mcpd/src/services/backup/git-backup.service.ts @@ -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 Promise; 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 { + // 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 { - try { - return (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim(); - } catch { - return null; - } - } - /** Get commit history. */ async getLog(limit = 50): Promise { 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 { + try { + const secret = await this.prisma.secret.findUnique({ where: { name: BACKUP_SECRET_NAME } }); + if (!secret) return; + + const data = secret.data as Record; + 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 { + // 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; + + 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 ── diff --git a/stack/docker-compose.yml b/stack/docker-compose.yml index f5b2a68..4d70a48 100644 --- a/stack/docker-compose.yml +++ b/stack/docker-compose.yml @@ -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