diff --git a/src/cli/src/commands/backup.ts b/src/cli/src/commands/backup.ts new file mode 100644 index 0000000..3a5aaae --- /dev/null +++ b/src/cli/src/commands/backup.ts @@ -0,0 +1,80 @@ +import { Command } from 'commander'; +import fs from 'node:fs'; +import type { ApiClient } from '../api-client.js'; + +export interface BackupDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createBackupCommand(deps: BackupDeps): Command { + const cmd = new Command('backup') + .description('Backup mcpctl configuration to a JSON file') + .option('-o, --output ', 'output file path', 'mcpctl-backup.json') + .option('-p, --password ', 'encrypt sensitive values with password') + .option('-r, --resources ', 'resource types to backup (comma-separated: servers,profiles,projects)') + .action(async (options: { output: string; password?: string; resources?: string }) => { + const body: Record = {}; + if (options.password) { + body.password = options.password; + } + if (options.resources) { + body.resources = options.resources.split(',').map((s) => s.trim()); + } + + const bundle = await deps.client.post('/api/v1/backup', body); + fs.writeFileSync(options.output, JSON.stringify(bundle, null, 2), 'utf-8'); + deps.log(`Backup saved to ${options.output}`); + }); + + return cmd; +} + +export function createRestoreCommand(deps: BackupDeps): Command { + const cmd = new Command('restore') + .description('Restore mcpctl configuration from a backup file') + .option('-i, --input ', 'backup file path', 'mcpctl-backup.json') + .option('-p, --password ', 'decryption password for encrypted backups') + .option('-c, --conflict ', 'conflict resolution: skip, overwrite, fail', 'skip') + .action(async (options: { input: string; password?: string; conflict: string }) => { + if (!fs.existsSync(options.input)) { + deps.log(`Error: File not found: ${options.input}`); + return; + } + + const raw = fs.readFileSync(options.input, 'utf-8'); + const bundle = JSON.parse(raw) as unknown; + + const body: Record = { + bundle, + conflictStrategy: options.conflict, + }; + if (options.password) { + body.password = options.password; + } + + const result = await deps.client.post<{ + serversCreated: number; + serversSkipped: number; + profilesCreated: number; + profilesSkipped: number; + projectsCreated: number; + projectsSkipped: number; + errors: string[]; + }>('/api/v1/restore', body); + + deps.log('Restore complete:'); + deps.log(` Servers: ${result.serversCreated} created, ${result.serversSkipped} skipped`); + deps.log(` Profiles: ${result.profilesCreated} created, ${result.profilesSkipped} skipped`); + deps.log(` Projects: ${result.projectsCreated} created, ${result.projectsSkipped} skipped`); + + if (result.errors.length > 0) { + deps.log(` Errors:`); + for (const err of result.errors) { + deps.log(` - ${err}`); + } + } + }); + + return cmd; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 16b1f01..15f0b5c 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -10,6 +10,7 @@ import { createApplyCommand } from './commands/apply.js'; import { createSetupCommand } from './commands/setup.js'; import { createClaudeCommand } from './commands/claude.js'; import { createProjectCommand } from './commands/project.js'; +import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { ApiClient } from './api-client.js'; import { loadConfig } from './config/index.js'; @@ -98,6 +99,16 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createBackupCommand({ + client, + log: (...args) => console.log(...args), + })); + + program.addCommand(createRestoreCommand({ + client, + log: (...args) => console.log(...args), + })); + return program; } diff --git a/src/cli/tests/commands/backup.test.ts b/src/cli/tests/commands/backup.test.ts new file mode 100644 index 0000000..6775cd7 --- /dev/null +++ b/src/cli/tests/commands/backup.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import { createBackupCommand, createRestoreCommand } from '../../src/commands/backup.js'; + +const mockClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), +}; + +const log = vi.fn(); + +describe('backup command', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + // Clean up any created files + try { fs.unlinkSync('test-backup.json'); } catch { /* ignore */ } + }); + + it('creates backup command', () => { + const cmd = createBackupCommand({ client: mockClient as never, log }); + expect(cmd.name()).toBe('backup'); + }); + + it('calls API and writes file', async () => { + const bundle = { version: '1', servers: [], profiles: [], projects: [] }; + mockClient.post.mockResolvedValue(bundle); + + const cmd = createBackupCommand({ client: mockClient as never, log }); + await cmd.parseAsync(['-o', 'test-backup.json'], { from: 'user' }); + + expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', {}); + expect(fs.existsSync('test-backup.json')).toBe(true); + expect(log).toHaveBeenCalledWith(expect.stringContaining('test-backup.json')); + }); + + it('passes password when provided', async () => { + mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] }); + + const cmd = createBackupCommand({ client: mockClient as never, log }); + await cmd.parseAsync(['-o', 'test-backup.json', '-p', 'secret'], { from: 'user' }); + + expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', { password: 'secret' }); + }); + + it('passes resource filter', async () => { + mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] }); + + const cmd = createBackupCommand({ client: mockClient as never, log }); + await cmd.parseAsync(['-o', 'test-backup.json', '-r', 'servers,profiles'], { from: 'user' }); + + expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', { + resources: ['servers', 'profiles'], + }); + }); +}); + +describe('restore command', () => { + const testFile = 'test-restore-input.json'; + + beforeEach(() => { + vi.resetAllMocks(); + fs.writeFileSync(testFile, JSON.stringify({ + version: '1', servers: [], profiles: [], projects: [], + })); + }); + + afterEach(() => { + try { fs.unlinkSync(testFile); } catch { /* ignore */ } + }); + + it('creates restore command', () => { + const cmd = createRestoreCommand({ client: mockClient as never, log }); + expect(cmd.name()).toBe('restore'); + }); + + it('reads file and calls API', async () => { + mockClient.post.mockResolvedValue({ + serversCreated: 1, serversSkipped: 0, + profilesCreated: 0, profilesSkipped: 0, + projectsCreated: 0, projectsSkipped: 0, + errors: [], + }); + + const cmd = createRestoreCommand({ client: mockClient as never, log }); + await cmd.parseAsync(['-i', testFile], { from: 'user' }); + + expect(mockClient.post).toHaveBeenCalledWith('/api/v1/restore', expect.objectContaining({ + bundle: expect.objectContaining({ version: '1' }), + conflictStrategy: 'skip', + })); + expect(log).toHaveBeenCalledWith('Restore complete:'); + }); + + it('reports errors from restore', async () => { + mockClient.post.mockResolvedValue({ + serversCreated: 0, serversSkipped: 0, + profilesCreated: 0, profilesSkipped: 0, + projectsCreated: 0, projectsSkipped: 0, + errors: ['Server "x" already exists'], + }); + + const cmd = createRestoreCommand({ client: mockClient as never, log }); + await cmd.parseAsync(['-i', testFile], { from: 'user' }); + + expect(log).toHaveBeenCalledWith(expect.stringContaining('Errors')); + }); + + it('logs error for missing file', async () => { + const cmd = createRestoreCommand({ client: mockClient as never, log }); + await cmd.parseAsync(['-i', 'nonexistent.json'], { from: 'user' }); + + expect(log).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(mockClient.post).not.toHaveBeenCalled(); + }); +}); diff --git a/src/mcpd/src/routes/backup.ts b/src/mcpd/src/routes/backup.ts new file mode 100644 index 0000000..5c5027d --- /dev/null +++ b/src/mcpd/src/routes/backup.ts @@ -0,0 +1,60 @@ +import type { FastifyInstance } from 'fastify'; +import type { BackupService } from '../services/backup/backup-service.js'; +import type { RestoreService } from '../services/backup/restore-service.js'; +import type { BackupBundle, BackupOptions } from '../services/backup/backup-service.js'; +import type { ConflictStrategy, RestoreOptions } from '../services/backup/restore-service.js'; + +export interface BackupDeps { + backupService: BackupService; + restoreService: RestoreService; +} + +export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): void { + app.post<{ + Body: { + password?: string; + resources?: Array<'servers' | 'profiles' | 'projects'>; + }; + }>('/api/v1/backup', async (request) => { + const opts: BackupOptions = {}; + if (request.body?.password) { + opts.password = request.body.password; + } + if (request.body?.resources) { + opts.resources = request.body.resources; + } + const bundle = await deps.backupService.createBackup(opts); + return bundle; + }); + + app.post<{ + Body: { + bundle: BackupBundle; + password?: string; + conflictStrategy?: ConflictStrategy; + }; + }>('/api/v1/restore', async (request, reply) => { + const { bundle, password, conflictStrategy } = request.body; + + if (!deps.restoreService.validateBundle(bundle)) { + reply.code(400); + return { error: 'Invalid backup bundle format', statusCode: 400 }; + } + + const restoreOpts: RestoreOptions = {}; + if (password) { + restoreOpts.password = password; + } + if (conflictStrategy) { + restoreOpts.conflictStrategy = conflictStrategy; + } + + const result = await deps.restoreService.restore(bundle, restoreOpts); + + if (result.errors.length > 0 && result.serversCreated === 0 && result.profilesCreated === 0 && result.projectsCreated === 0) { + reply.code(422); + } + + return result; + }); +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index b402c09..7e76d3a 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -7,3 +7,5 @@ export { registerInstanceRoutes } from './instances.js'; export { registerAuditLogRoutes } from './audit-logs.js'; export { registerHealthMonitoringRoutes } from './health-monitoring.js'; export type { HealthMonitoringDeps } from './health-monitoring.js'; +export { registerBackupRoutes } from './backup.js'; +export type { BackupDeps } from './backup.js'; diff --git a/src/mcpd/src/services/backup/backup-service.ts b/src/mcpd/src/services/backup/backup-service.ts new file mode 100644 index 0000000..7c70345 --- /dev/null +++ b/src/mcpd/src/services/backup/backup-service.ts @@ -0,0 +1,143 @@ +import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js'; +import type { IProjectRepository } from '../../repositories/project.repository.js'; +import { encrypt, isSensitiveKey } from './crypto.js'; +import type { EncryptedPayload } from './crypto.js'; +import { APP_VERSION } from '@mcpctl/shared'; + +export interface BackupBundle { + version: string; + mcpctlVersion: string; + createdAt: string; + encrypted: boolean; + servers: BackupServer[]; + profiles: BackupProfile[]; + projects: BackupProject[]; + encryptedSecrets?: EncryptedPayload; +} + +export interface BackupServer { + name: string; + description: string; + packageName: string | null; + dockerImage: string | null; + transport: string; + repositoryUrl: string | null; + envTemplate: unknown; +} + +export interface BackupProfile { + name: string; + serverName: string; + permissions: unknown; + envOverrides: unknown; +} + +export interface BackupProject { + name: string; + description: string; + profileNames: string[]; +} + +export interface BackupOptions { + password?: string; + resources?: Array<'servers' | 'profiles' | 'projects'>; +} + +export class BackupService { + constructor( + private serverRepo: IMcpServerRepository, + private profileRepo: IMcpProfileRepository, + private projectRepo: IProjectRepository, + ) {} + + async createBackup(options?: BackupOptions): Promise { + const resources = options?.resources ?? ['servers', 'profiles', 'projects']; + + let servers: BackupServer[] = []; + let profiles: BackupProfile[] = []; + let projects: BackupProject[] = []; + + if (resources.includes('servers')) { + const allServers = await this.serverRepo.findAll(); + servers = allServers.map((s) => ({ + name: s.name, + description: s.description, + packageName: s.packageName, + dockerImage: s.dockerImage, + transport: s.transport, + repositoryUrl: s.repositoryUrl, + envTemplate: s.envTemplate, + })); + } + + if (resources.includes('profiles')) { + const allProfiles = await this.profileRepo.findAll(); + const serverMap = new Map(); + const allServers = await this.serverRepo.findAll(); + for (const s of allServers) { + serverMap.set(s.id, s.name); + } + + profiles = allProfiles.map((p) => ({ + name: p.name, + serverName: serverMap.get(p.serverId) ?? p.serverId, + permissions: p.permissions, + envOverrides: p.envOverrides, + })); + } + + if (resources.includes('projects')) { + const allProjects = await this.projectRepo.findAll(); + const allProfiles = await this.profileRepo.findAll(); + const profileMap = new Map(); + for (const p of allProfiles) { + profileMap.set(p.id, p.name); + } + + projects = await Promise.all( + allProjects.map(async (proj) => { + const profileIds = await this.projectRepo.getProfileIds(proj.id); + return { + name: proj.name, + description: proj.description, + profileNames: profileIds.map((id) => profileMap.get(id) ?? id), + }; + }), + ); + } + + const bundle: BackupBundle = { + version: '1', + mcpctlVersion: APP_VERSION, + createdAt: new Date().toISOString(), + encrypted: false, + servers, + profiles, + projects, + }; + + if (options?.password) { + // Collect sensitive values and encrypt them + const secrets: Record = {}; + for (const profile of profiles) { + const overrides = profile.envOverrides as Record | null; + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + if (isSensitiveKey(key)) { + const secretKey = `profile:${profile.name}:${key}`; + secrets[secretKey] = value; + (overrides as Record)[key] = `__ENCRYPTED:${secretKey}__`; + } + } + } + } + + if (Object.keys(secrets).length > 0) { + bundle.encrypted = true; + bundle.encryptedSecrets = encrypt(JSON.stringify(secrets), options.password); + } + } + + return bundle; + } +} diff --git a/src/mcpd/src/services/backup/crypto.ts b/src/mcpd/src/services/backup/crypto.ts new file mode 100644 index 0000000..b6f8740 --- /dev/null +++ b/src/mcpd/src/services/backup/crypto.ts @@ -0,0 +1,68 @@ +import crypto from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; +const SALT_LENGTH = 32; +const AUTH_TAG_LENGTH = 16; +const SCRYPT_COST = 16384; + +export interface EncryptedPayload { + algorithm: string; + salt: string; + iv: string; + authTag: string; + ciphertext: string; +} + +export function deriveKey(password: string, salt: Buffer): Buffer { + return crypto.scryptSync(password, salt, KEY_LENGTH, { N: SCRYPT_COST }); +} + +export function encrypt(data: string, password: string): EncryptedPayload { + const salt = crypto.randomBytes(SALT_LENGTH); + const key = deriveKey(password, salt); + const iv = crypto.randomBytes(IV_LENGTH); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(data, 'utf-8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return { + algorithm: ALGORITHM, + salt: salt.toString('base64'), + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + ciphertext: encrypted.toString('base64'), + }; +} + +export function decrypt(payload: EncryptedPayload, password: string): string { + const salt = Buffer.from(payload.salt, 'base64'); + const iv = Buffer.from(payload.iv, 'base64'); + const authTag = Buffer.from(payload.authTag, 'base64'); + const ciphertext = Buffer.from(payload.ciphertext, 'base64'); + + const key = deriveKey(password, salt); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString('utf-8'); +} + +const SENSITIVE_PATTERNS = [ + /_KEY$/i, + /_SECRET$/i, + /_TOKEN$/i, + /PASSWORD/i, + /^API_KEY$/i, + /^SECRET$/i, + /^TOKEN$/i, + /^CREDENTIALS$/i, +]; + +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_PATTERNS.some((p) => p.test(key)); +} diff --git a/src/mcpd/src/services/backup/index.ts b/src/mcpd/src/services/backup/index.ts new file mode 100644 index 0000000..f70ed2a --- /dev/null +++ b/src/mcpd/src/services/backup/index.ts @@ -0,0 +1,6 @@ +export { BackupService } from './backup-service.js'; +export type { BackupBundle, BackupServer, BackupProfile, BackupProject, BackupOptions } from './backup-service.js'; +export { RestoreService } from './restore-service.js'; +export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js'; +export { encrypt, decrypt, isSensitiveKey } from './crypto.js'; +export type { EncryptedPayload } from './crypto.js'; diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts new file mode 100644 index 0000000..e3e29cd --- /dev/null +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -0,0 +1,224 @@ +import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js'; +import type { IProjectRepository } from '../../repositories/project.repository.js'; +import { decrypt } from './crypto.js'; +import type { BackupBundle } from './backup-service.js'; + +export type ConflictStrategy = 'skip' | 'overwrite' | 'fail'; + +export interface RestoreOptions { + password?: string; + conflictStrategy?: ConflictStrategy; +} + +export interface RestoreResult { + serversCreated: number; + serversSkipped: number; + profilesCreated: number; + profilesSkipped: number; + projectsCreated: number; + projectsSkipped: number; + errors: string[]; +} + +export class RestoreService { + constructor( + private serverRepo: IMcpServerRepository, + private profileRepo: IMcpProfileRepository, + private projectRepo: IProjectRepository, + ) {} + + validateBundle(bundle: unknown): bundle is BackupBundle { + if (typeof bundle !== 'object' || bundle === null) return false; + const b = bundle as Record; + return ( + typeof b['version'] === 'string' && + Array.isArray(b['servers']) && + Array.isArray(b['profiles']) && + Array.isArray(b['projects']) + ); + } + + async restore(bundle: BackupBundle, options?: RestoreOptions): Promise { + const strategy = options?.conflictStrategy ?? 'skip'; + const result: RestoreResult = { + serversCreated: 0, + serversSkipped: 0, + profilesCreated: 0, + profilesSkipped: 0, + projectsCreated: 0, + projectsSkipped: 0, + errors: [], + }; + + // Decrypt secrets if encrypted + let secrets: Record = {}; + if (bundle.encrypted && bundle.encryptedSecrets) { + if (!options?.password) { + result.errors.push('Backup is encrypted but no password provided'); + return result; + } + try { + secrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record; + } catch { + result.errors.push('Failed to decrypt backup - incorrect password or corrupted data'); + return result; + } + } + + // Restore secrets into profile envOverrides + for (const profile of bundle.profiles) { + const overrides = profile.envOverrides as Record | null; + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) { + const secretKey = value.slice(12, -2); + const decrypted = secrets[secretKey]; + if (decrypted !== undefined) { + overrides[key] = decrypted; + } + } + } + } + } + + // Restore servers + const serverNameToId = new Map(); + for (const server of bundle.servers) { + try { + const existing = await this.serverRepo.findByName(server.name); + if (existing) { + if (strategy === 'fail') { + result.errors.push(`Server "${server.name}" already exists`); + return result; + } + if (strategy === 'skip') { + result.serversSkipped++; + serverNameToId.set(server.name, existing.id); + continue; + } + // overwrite + const updateData: Parameters[1] = { + description: server.description, + transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP', + }; + if (server.packageName) updateData.packageName = server.packageName; + if (server.dockerImage) updateData.dockerImage = server.dockerImage; + if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl; + await this.serverRepo.update(existing.id, updateData); + serverNameToId.set(server.name, existing.id); + result.serversCreated++; + continue; + } + + const createData: Parameters[0] = { + name: server.name, + description: server.description, + transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP', + envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>, + }; + if (server.packageName) createData.packageName = server.packageName; + if (server.dockerImage) createData.dockerImage = server.dockerImage; + if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl; + const created = await this.serverRepo.create(createData); + serverNameToId.set(server.name, created.id); + result.serversCreated++; + } catch (err) { + result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Restore profiles + const profileNameToId = new Map(); + for (const profile of bundle.profiles) { + try { + const serverId = serverNameToId.get(profile.serverName); + if (!serverId) { + // Try to find server by name in DB + const server = await this.serverRepo.findByName(profile.serverName); + if (!server) { + result.errors.push(`Profile "${profile.name}" references unknown server "${profile.serverName}"`); + continue; + } + serverNameToId.set(profile.serverName, server.id); + } + + const sid = serverNameToId.get(profile.serverName)!; + const existing = await this.profileRepo.findByServerAndName(sid, profile.name); + if (existing) { + if (strategy === 'fail') { + result.errors.push(`Profile "${profile.name}" already exists for server "${profile.serverName}"`); + return result; + } + if (strategy === 'skip') { + result.profilesSkipped++; + profileNameToId.set(profile.name, existing.id); + continue; + } + // overwrite + await this.profileRepo.update(existing.id, { + permissions: profile.permissions as string[], + envOverrides: profile.envOverrides as Record, + }); + profileNameToId.set(profile.name, existing.id); + result.profilesCreated++; + continue; + } + + const created = await this.profileRepo.create({ + name: profile.name, + serverId: sid, + permissions: profile.permissions as string[], + envOverrides: profile.envOverrides as Record, + }); + profileNameToId.set(profile.name, created.id); + result.profilesCreated++; + } catch (err) { + result.errors.push(`Failed to restore profile "${profile.name}": ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Restore projects + for (const project of bundle.projects) { + try { + const existing = await this.projectRepo.findByName(project.name); + if (existing) { + if (strategy === 'fail') { + result.errors.push(`Project "${project.name}" already exists`); + return result; + } + if (strategy === 'skip') { + result.projectsSkipped++; + continue; + } + // overwrite - update and set profiles + await this.projectRepo.update(existing.id, { description: project.description }); + const profileIds = project.profileNames + .map((name) => profileNameToId.get(name)) + .filter((id): id is string => id !== undefined); + if (profileIds.length > 0) { + await this.projectRepo.setProfiles(existing.id, profileIds); + } + result.projectsCreated++; + continue; + } + + const created = await this.projectRepo.create({ + name: project.name, + description: project.description, + ownerId: 'system', + }); + const profileIds = project.profileNames + .map((name) => profileNameToId.get(name)) + .filter((id): id is string => id !== undefined); + if (profileIds.length > 0) { + await this.projectRepo.setProfiles(created.id, profileIds); + } + result.projectsCreated++; + } catch (err) { + result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`); + } + } + + return result; + } +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index 4933389..fe49f77 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -15,3 +15,7 @@ export { MetricsCollector } from './metrics-collector.js'; export type { InstanceMetrics } from './metrics-collector.js'; export { HealthAggregator } from './health-aggregator.js'; export type { SystemHealth, InstanceHealth } from './health-aggregator.js'; +export { BackupService } from './backup/index.js'; +export type { BackupBundle, BackupOptions } from './backup/index.js'; +export { RestoreService } from './backup/index.js'; +export type { RestoreOptions, RestoreResult, ConflictStrategy } from './backup/index.js'; diff --git a/src/mcpd/tests/backup.test.ts b/src/mcpd/tests/backup.test.ts new file mode 100644 index 0000000..d06734d --- /dev/null +++ b/src/mcpd/tests/backup.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import { BackupService } from '../src/services/backup/backup-service.js'; +import { RestoreService } from '../src/services/backup/restore-service.js'; +import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js'; +import { registerBackupRoutes } from '../src/routes/backup.js'; +import type { IMcpServerRepository, IMcpProfileRepository } from '../src/repositories/interfaces.js'; +import type { IProjectRepository } from '../src/repositories/project.repository.js'; + +// Mock data +const mockServers = [ + { + id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github', + dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null, + envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), + }, + { + id: 's2', name: 'slack', description: 'Slack MCP', packageName: null, + dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null, + envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), + }, +]; + +const mockProfiles = [ + { + id: 'p1', name: 'default', serverId: 's1', permissions: ['read'], + envOverrides: { GITHUB_TOKEN: 'ghp_secret123' }, + version: 1, createdAt: new Date(), updatedAt: new Date(), + }, +]; + +const mockProjects = [ + { + id: 'proj1', name: 'my-project', description: 'Test project', + ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(), + }, +]; + +function mockServerRepo(): IMcpServerRepository { + return { + findAll: vi.fn(async () => [...mockServers]), + findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null), + findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null), + create: vi.fn(async (data) => ({ id: 'new-s', ...data, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])), + update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })), + delete: vi.fn(async () => {}), + }; +} + +function mockProfileRepo(): IMcpProfileRepository { + return { + findAll: vi.fn(async () => [...mockProfiles]), + findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null), + findByServerAndName: vi.fn(async () => null), + create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])), + update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.id === id)!, ...data })), + delete: vi.fn(async () => {}), + }; +} + +function mockProjectRepo(): IProjectRepository { + return { + findAll: vi.fn(async () => [...mockProjects]), + findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])), + update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })), + delete: vi.fn(async () => {}), + setProfiles: vi.fn(async () => {}), + getProfileIds: vi.fn(async () => ['p1']), + }; +} + +describe('Crypto', () => { + it('encrypts and decrypts successfully', () => { + const data = 'hello secret world'; + const password = 'my-password-123'; + const encrypted = encrypt(data, password); + + expect(encrypted.algorithm).toBe('aes-256-gcm'); + expect(encrypted.ciphertext).not.toBe(data); + + const decrypted = decrypt(encrypted, password); + expect(decrypted).toBe(data); + }); + + it('fails with wrong password', () => { + const encrypted = encrypt('secret', 'correct-password'); + expect(() => decrypt(encrypted, 'wrong-password')).toThrow(); + }); + + it('handles large data', () => { + const data = 'x'.repeat(10000); + const encrypted = encrypt(data, 'pass'); + expect(decrypt(encrypted, 'pass')).toBe(data); + }); + + it('detects sensitive keys', () => { + expect(isSensitiveKey('GITHUB_TOKEN')).toBe(true); + expect(isSensitiveKey('API_KEY')).toBe(true); + expect(isSensitiveKey('DATABASE_PASSWORD')).toBe(true); + expect(isSensitiveKey('AWS_SECRET')).toBe(true); + expect(isSensitiveKey('MY_SECRET_KEY')).toBe(true); + expect(isSensitiveKey('CREDENTIALS')).toBe(true); + expect(isSensitiveKey('PORT')).toBe(false); + expect(isSensitiveKey('DATABASE_URL')).toBe(false); + expect(isSensitiveKey('NODE_ENV')).toBe(false); + }); +}); + +describe('BackupService', () => { + let backupService: BackupService; + + beforeEach(() => { + backupService = new BackupService(mockServerRepo(), mockProfileRepo(), mockProjectRepo()); + }); + + it('creates backup with all resources', async () => { + const bundle = await backupService.createBackup(); + + expect(bundle.version).toBe('1'); + expect(bundle.encrypted).toBe(false); + expect(bundle.servers).toHaveLength(2); + expect(bundle.profiles).toHaveLength(1); + expect(bundle.projects).toHaveLength(1); + expect(bundle.servers[0]!.name).toBe('github'); + expect(bundle.profiles[0]!.serverName).toBe('github'); + expect(bundle.projects[0]!.name).toBe('my-project'); + }); + + it('filters resources', async () => { + const bundle = await backupService.createBackup({ resources: ['servers'] }); + expect(bundle.servers).toHaveLength(2); + expect(bundle.profiles).toHaveLength(0); + expect(bundle.projects).toHaveLength(0); + }); + + it('encrypts sensitive env values when password provided', async () => { + const bundle = await backupService.createBackup({ password: 'test-pass' }); + + expect(bundle.encrypted).toBe(true); + expect(bundle.encryptedSecrets).toBeDefined(); + // The GITHUB_TOKEN should be replaced with placeholder + const overrides = bundle.profiles[0]!.envOverrides as Record; + expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:'); + }); + + it('handles empty repositories', async () => { + const emptyServerRepo = mockServerRepo(); + (emptyServerRepo.findAll as ReturnType).mockResolvedValue([]); + const emptyProfileRepo = mockProfileRepo(); + (emptyProfileRepo.findAll as ReturnType).mockResolvedValue([]); + const emptyProjectRepo = mockProjectRepo(); + (emptyProjectRepo.findAll as ReturnType).mockResolvedValue([]); + + const service = new BackupService(emptyServerRepo, emptyProfileRepo, emptyProjectRepo); + const bundle = await service.createBackup(); + + expect(bundle.servers).toHaveLength(0); + expect(bundle.profiles).toHaveLength(0); + expect(bundle.projects).toHaveLength(0); + }); +}); + +describe('RestoreService', () => { + let restoreService: RestoreService; + let serverRepo: IMcpServerRepository; + let profileRepo: IMcpProfileRepository; + let projectRepo: IProjectRepository; + + beforeEach(() => { + serverRepo = mockServerRepo(); + profileRepo = mockProfileRepo(); + projectRepo = mockProjectRepo(); + // Default: nothing exists yet + (serverRepo.findByName as ReturnType).mockResolvedValue(null); + (profileRepo.findByServerAndName as ReturnType).mockResolvedValue(null); + (projectRepo.findByName as ReturnType).mockResolvedValue(null); + restoreService = new RestoreService(serverRepo, profileRepo, projectRepo); + }); + + const validBundle = { + version: '1', + mcpctlVersion: '0.1.0', + createdAt: new Date().toISOString(), + encrypted: false, + servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [] }], + profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }], + projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }], + }; + + it('validates valid bundle', () => { + expect(restoreService.validateBundle(validBundle)).toBe(true); + }); + + it('rejects invalid bundle', () => { + expect(restoreService.validateBundle(null)).toBe(false); + expect(restoreService.validateBundle({})).toBe(false); + expect(restoreService.validateBundle({ version: '1' })).toBe(false); + }); + + it('restores all resources', async () => { + const result = await restoreService.restore(validBundle); + + expect(result.serversCreated).toBe(1); + expect(result.profilesCreated).toBe(1); + expect(result.projectsCreated).toBe(1); + expect(result.errors).toHaveLength(0); + expect(serverRepo.create).toHaveBeenCalled(); + expect(profileRepo.create).toHaveBeenCalled(); + expect(projectRepo.create).toHaveBeenCalled(); + }); + + it('skips existing resources with skip strategy', async () => { + (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); + const result = await restoreService.restore(validBundle, { conflictStrategy: 'skip' }); + + expect(result.serversSkipped).toBe(1); + expect(result.serversCreated).toBe(0); + expect(serverRepo.create).not.toHaveBeenCalled(); + }); + + it('aborts on conflict with fail strategy', async () => { + (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); + const result = await restoreService.restore(validBundle, { conflictStrategy: 'fail' }); + + expect(result.errors).toContain('Server "github" already exists'); + }); + + it('overwrites existing with overwrite strategy', async () => { + (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); + const result = await restoreService.restore(validBundle, { conflictStrategy: 'overwrite' }); + + expect(result.serversCreated).toBe(1); + expect(serverRepo.update).toHaveBeenCalled(); + }); + + it('fails restore with encrypted bundle and no password', async () => { + const encBundle = { ...validBundle, encrypted: true, encryptedSecrets: encrypt('{}', 'pw') }; + const result = await restoreService.restore(encBundle); + expect(result.errors).toContain('Backup is encrypted but no password provided'); + }); + + it('restores encrypted bundle with correct password', async () => { + const secrets = { 'profile:default:API_KEY': 'secret-val' }; + const encBundle = { + ...validBundle, + encrypted: true, + encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'), + profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }], + }; + + const result = await restoreService.restore(encBundle, { password: 'test-pw' }); + expect(result.errors).toHaveLength(0); + expect(result.profilesCreated).toBe(1); + }); + + it('fails with wrong decryption password', async () => { + const encBundle = { + ...validBundle, + encrypted: true, + encryptedSecrets: encrypt('{"key":"val"}', 'correct'), + }; + const result = await restoreService.restore(encBundle, { password: 'wrong' }); + expect(result.errors[0]).toContain('Failed to decrypt'); + }); +}); + +describe('Backup Routes', () => { + let backupService: BackupService; + let restoreService: RestoreService; + + beforeEach(() => { + const sRepo = mockServerRepo(); + const pRepo = mockProfileRepo(); + const prRepo = mockProjectRepo(); + backupService = new BackupService(sRepo, pRepo, prRepo); + + const rSRepo = mockServerRepo(); + (rSRepo.findByName as ReturnType).mockResolvedValue(null); + const rPRepo = mockProfileRepo(); + (rPRepo.findByServerAndName as ReturnType).mockResolvedValue(null); + const rPrRepo = mockProjectRepo(); + (rPrRepo.findByName as ReturnType).mockResolvedValue(null); + restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo); + }); + + async function buildApp() { + const app = Fastify(); + registerBackupRoutes(app, { backupService, restoreService }); + return app; + } + + it('POST /api/v1/backup returns bundle', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/backup', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.version).toBe('1'); + expect(body.servers).toBeDefined(); + expect(body.profiles).toBeDefined(); + expect(body.projects).toBeDefined(); + }); + + it('POST /api/v1/restore imports bundle', async () => { + const app = await buildApp(); + const bundle = await backupService.createBackup(); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/restore', + payload: { bundle, conflictStrategy: 'skip' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.serversCreated).toBeDefined(); + }); + + it('POST /api/v1/restore rejects invalid bundle', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/restore', + payload: { bundle: { invalid: true } }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('Invalid'); + }); +});