diff --git a/backend/src/__tests__/secrets.test.ts b/backend/src/__tests__/secrets.test.ts new file mode 100644 index 00000000..22b25ab5 --- /dev/null +++ b/backend/src/__tests__/secrets.test.ts @@ -0,0 +1,401 @@ +/** + * Tests for Fleet Secrets v1 MVP: + * - parseEnv / serializeEnv round-trip + * - applyOverlay / computeDiff + * - encrypt round-trip via CryptoService + * - DatabaseService secret + version + push CRUD + * - SecretsService versioning, importFromStack, executePush aggregation + * - Route guards (requirePaid 403, push lock 409) + * - getAuditSummary patterns for /secrets routes + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import path from 'path'; +import fs from 'fs'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { getAuditSummary } from '../utils/audit-summaries'; +import { parseEnv, serializeEnv, applyOverlay, computeDiff } from '../services/SecretsService'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let SecretsService: typeof import('../services/SecretsService').SecretsService; +let CryptoService: typeof import('../services/CryptoService').CryptoService; + +function authToken(username: string, role: string = 'admin', tv?: number): string { + const payload: Record = { username, role }; + if (tv !== undefined) payload.tv = tv; + return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +function adminToken(): string { + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername(TEST_USERNAME)!; + return authToken(TEST_USERNAME, 'admin', user.token_version); +} + +function clearSecretsTables(): void { + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM secret_pushes').run(); + db.prepare('DELETE FROM secret_versions').run(); + db.prepare('DELETE FROM secrets').run(); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ SecretsService } = await import('../services/SecretsService')); + ({ CryptoService } = await import('../services/CryptoService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + clearSecretsTables(); +}); + +// ---- Pure functions ---- + +describe('parseEnv / serializeEnv', () => { + it('parses simple key=value lines', () => { + const kv = parseEnv('FOO=bar\nBAZ=qux\n'); + expect(kv).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('drops blank lines and # comments', () => { + const kv = parseEnv('# comment\n\nFOO=bar\n# another\n'); + expect(kv).toEqual({ FOO: 'bar' }); + }); + + it('strips matching outer double quotes and decodes escapes', () => { + const kv = parseEnv('FOO="hello world"\nBAR="multi\\nline"\n'); + expect(kv.FOO).toBe('hello world'); + expect(kv.BAR).toBe('multi\nline'); + }); + + it('strips matching outer single quotes literally', () => { + const kv = parseEnv("FOO='raw \\n value'\n"); + expect(kv.FOO).toBe('raw \\n value'); + }); + + it('handles empty values', () => { + const kv = parseEnv('EMPTY=\nNON_EMPTY=x\n'); + expect(kv.EMPTY).toBe(''); + expect(kv.NON_EMPTY).toBe('x'); + }); + + it('strips trailing inline comments on bare values', () => { + const kv = parseEnv('FOO=bar # trailing\n'); + expect(kv.FOO).toBe('bar'); + }); + + it('drops malformed keys', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const kv = parseEnv('1BAD=oops\nGOOD=ok\n'); + expect(kv).toEqual({ GOOD: 'ok' }); + warnSpy.mockRestore(); + }); + + it('round-trips serialize → parse', () => { + const original = { FOO: 'simple', SPACED: 'two words', QUOTED: 'has "quote"', EMPTY: '' }; + const serialized = serializeEnv(original); + const parsed = parseEnv(serialized); + expect(parsed).toEqual(original); + }); + + it('returns empty string for empty map', () => { + expect(serializeEnv({})).toBe(''); + }); +}); + +describe('applyOverlay / computeDiff', () => { + it('preserves keys absent from overlay (overlay merge)', () => { + const merged = applyOverlay({ KEEP: 'x', REPLACE: 'old' }, { REPLACE: 'new', NEW: 'fresh' }); + expect(merged).toEqual({ KEEP: 'x', REPLACE: 'new', NEW: 'fresh' }); + }); + + it('classifies all four diff statuses', () => { + const diff = computeDiff( + { KEEP: 'same', REPLACE: 'old', GONE: 'orphan' }, + { KEEP: 'same', REPLACE: 'new', NEW: 'fresh' }, + ); + const byKey = Object.fromEntries(diff.map(e => [e.key, e])); + expect(byKey.KEEP.status).toBe('unchanged'); + expect(byKey.REPLACE.status).toBe('changed'); + expect(byKey.REPLACE.before).toBe('old'); + expect(byKey.REPLACE.after).toBe('new'); + expect(byKey.NEW.status).toBe('added'); + expect(byKey.GONE.status).toBe('removed'); + expect(byKey.GONE.before).toBe('orphan'); + }); +}); + +// ---- Encryption round-trip ---- + +describe('encryption via CryptoService', () => { + it('round-trips a JSON-encoded KV map', () => { + const kv = { DB_URL: 'postgres://localhost/app', API_KEY: 'abc123' }; + const cipher = CryptoService.getInstance().encrypt(JSON.stringify(kv)); + expect(cipher.startsWith('enc:')).toBe(true); + const plain = CryptoService.getInstance().decrypt(cipher); + expect(JSON.parse(plain)).toEqual(kv); + }); +}); + +// ---- DatabaseService secret accessors ---- + +describe('DatabaseService secret accessors', () => { + it('creates a secret with v1 and bumps to v2 on update', () => { + const db = DatabaseService.getInstance(); + const created = db.createSecretWithVersion({ + name: 'creds', + description: 'app creds', + encryptedPayload: 'enc:dummy', + keyCount: 2, + createdBy: TEST_USERNAME, + note: '', + }); + expect(created.version).toBe(1); + const row = db.getSecret(created.id)!; + expect(row.current_version).toBe(1); + + const updated = db.updateSecretWithVersion({ + secretId: created.id, + description: 'updated', + encryptedPayload: 'enc:dummy2', + keyCount: 3, + createdBy: TEST_USERNAME, + note: 'added DEBUG', + }); + expect(updated.version).toBe(2); + const refreshed = db.getSecret(created.id)!; + expect(refreshed.current_version).toBe(2); + expect(refreshed.description).toBe('updated'); + + const versions = db.listSecretVersions(created.id); + expect(versions.map(v => v.version)).toEqual([2, 1]); + }); + + it('cascades versions and pushes on delete', () => { + const db = DatabaseService.getInstance(); + const created = db.createSecretWithVersion({ + name: 'to-delete', + description: '', + encryptedPayload: 'enc:x', + keyCount: 1, + createdBy: TEST_USERNAME, + note: '', + }); + db.insertSecretPushes([{ + secret_id: created.id, + version: 1, + push_id: 'push-1', + node_id: 1, + stack_name: 'demo', + env_file_basename: '.env', + status: 'ok', + error: '', + added_count: 1, + changed_count: 0, + unchanged_count: 0, + pushed_by: TEST_USERNAME, + pushed_at: Date.now(), + }]); + expect(db.deleteSecret(created.id)).toBe(true); + expect(db.getSecret(created.id)).toBeUndefined(); + expect(db.listSecretVersions(created.id)).toEqual([]); + expect(db.listSecretPushes(created.id)).toEqual([]); + }); +}); + +// ---- SecretsService versioning + decryption ---- + +describe('SecretsService', () => { + it('encrypts on create and decrypts on read', () => { + const svc = SecretsService.getInstance(); + const { id } = svc.create({ + name: 'app-secrets', + description: 'app', + kv: { DB_URL: 'postgres://x', API_KEY: 'abc' }, + user: TEST_USERNAME, + }); + const kv = svc.getDecryptedKv(id); + expect(kv).toEqual({ DB_URL: 'postgres://x', API_KEY: 'abc' }); + // Stored payload must be encrypted, not plaintext. + const versionRow = DatabaseService.getInstance().getCurrentSecretVersion(id)!; + expect(versionRow.encrypted_payload.startsWith('enc:')).toBe(true); + expect(versionRow.encrypted_payload).not.toContain('postgres://x'); + }); + + it('rejects invalid env keys at create', () => { + const svc = SecretsService.getInstance(); + expect(() => svc.create({ + name: 'bad-keys', + kv: { '1BAD': 'value' }, + user: TEST_USERNAME, + })).toThrow(/Invalid env key/); + }); + + it('listVersions returns newest first', () => { + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'v-list', kv: { A: '1' }, user: TEST_USERNAME }); + svc.update(id, { kv: { A: '2', B: '3' }, user: TEST_USERNAME, note: 'add B' }); + svc.update(id, { kv: { A: '4' }, user: TEST_USERNAME }); + const versions = svc.listVersions(id); + expect(versions.map(v => v.version)).toEqual([3, 2, 1]); + expect(versions[1].note).toBe('add B'); + }); +}); + +// ---- importFromStack against the local node ---- + +describe('SecretsService.importFromStack (local node)', () => { + beforeEach(() => { + const composeDir = process.env.COMPOSE_DIR!; + const stackDir = path.join(composeDir, 'demo'); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, '.env'), 'DB_URL=postgres://x\nAPI_KEY=abc\n'); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n'); + }); + + it('reads the local stack .env into a KV map', async () => { + const db = DatabaseService.getInstance(); + const localNode = db.getNodes().find(n => n.type === 'local')!; + const kv = await SecretsService.getInstance().importFromStack(localNode.id, 'demo', '.env'); + expect(kv).toEqual({ DB_URL: 'postgres://x', API_KEY: 'abc' }); + }); +}); + +// ---- executePush against the local node ---- + +describe('SecretsService.executePush (local node)', () => { + beforeEach(() => { + const composeDir = process.env.COMPOSE_DIR!; + const stackDir = path.join(composeDir, 'targetstack'); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\nAPI_KEY=old\n'); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n'); + }); + + it('overlays bundle keys onto the existing .env and records audit rows', async () => { + const db = DatabaseService.getInstance(); + const localNode = db.getNodes().find(n => n.type === 'local')!; + const svc = SecretsService.getInstance(); + + const { id } = svc.create({ + name: 'push-bundle', + kv: { API_KEY: 'new', DEBUG: '1' }, + user: TEST_USERNAME, + }); + + const result = await svc.executePush( + id, + { type: 'nodes', ids: [localNode.id] }, + 'targetstack', + '.env', + TEST_USERNAME, + ); + + expect(result.results).toHaveLength(1); + expect(result.results[0].status).toBe('ok'); + expect(result.results[0].added).toBe(1); // DEBUG + expect(result.results[0].changed).toBe(1); // API_KEY + + const composeDir = process.env.COMPOSE_DIR!; + const envText = fs.readFileSync(path.join(composeDir, 'targetstack', '.env'), 'utf-8'); + const kv = parseEnv(envText); + // Overlay: existing EXISTING preserved, API_KEY replaced, DEBUG added. + expect(kv.EXISTING).toBe('keep'); + expect(kv.API_KEY).toBe('new'); + expect(kv.DEBUG).toBe('1'); + + const pushes = db.listSecretPushes(id); + expect(pushes).toHaveLength(1); + expect(pushes[0].status).toBe('ok'); + expect(pushes[0].push_id).toBe(result.pushId); + }); + + it('marks a node failed when the env file is not declared and continues', async () => { + const db = DatabaseService.getInstance(); + const localNode = db.getNodes().find(n => n.type === 'local')!; + const svc = SecretsService.getInstance(); + const { id } = svc.create({ name: 'push-bad-file', kv: { X: '1' }, user: TEST_USERNAME }); + + const result = await svc.executePush( + id, + { type: 'nodes', ids: [localNode.id] }, + 'targetstack', + 'nonexistent.env', + TEST_USERNAME, + ); + + expect(result.results[0].status).toBe('failed'); + expect(result.results[0].error).toMatch(/not found/); + }); +}); + +// ---- getAuditSummary patterns ---- + +describe('getAuditSummary for secrets routes', () => { + it('resolves create / update / delete', () => { + expect(getAuditSummary('POST', '/secrets')).toBe('Created secret'); + expect(getAuditSummary('PUT', '/secrets/42')).toBe('Updated secret: 42'); + expect(getAuditSummary('DELETE', '/secrets/42')).toBe('Deleted secret: 42'); + }); + + it('resolves push and import wildcards', () => { + expect(getAuditSummary('POST', '/secrets/42/import-from-stack')).toBe('Imported env into secret: 42'); + expect(getAuditSummary('POST', '/secrets/42/push/preview')).toBe('Previewed secret push: 42'); + expect(getAuditSummary('POST', '/secrets/42/push')).toBe('Pushed secret: 42'); + }); +}); + +// ---- Route guards via supertest ---- + +describe('Routes /api/secrets tier gating and lock', () => { + it('returns 403 when license is community', async () => { + const { LicenseService } = await import('../services/LicenseService'); + // Use mockReturnValueOnce so the outer beforeAll spy keeps returning 'paid' for sibling tests. + // requirePaid only consults getTier once per request via effectiveTier(req). + const inst = LicenseService.getInstance(); + const tierSpy = vi.spyOn(inst, 'getTier'); + tierSpy.mockReturnValueOnce('community'); + const res = await request(app) + .get('/api/secrets') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('rejects unauthenticated requests', async () => { + const res = await request(app).get('/api/secrets'); + expect(res.status).toBe(401); + }); + + it('returns 200 when paid', async () => { + const res = await request(app) + .get('/api/secrets') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('rejects malformed body on POST /secrets', async () => { + const res = await request(app) + .post('/api/secrets') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ name: 'x', kv: 'not-an-object' }); + expect(res.status).toBe(400); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index ae7882b2..28ffcd38 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -48,6 +48,7 @@ import { containersRouter, portsRouter } from './routes/containers'; import { nodesRouter } from './routes/nodes'; import { stacksRouter } from './routes/stacks'; import { stackActivityRouter } from './routes/stackActivity'; +import { secretsRouter } from './routes/secrets'; // Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls // util._extend internally. The warning fires at runtime when createProxyServer() is @@ -96,6 +97,7 @@ app.use('/api/convert', convertRouter); app.use('/api/alerts', alertsRouter); app.use('/api/labels', labelsRouter); app.use('/api/stacks', stackLabelsRouter); +app.use('/api/secrets', secretsRouter); app.use('/api/api-tokens', apiTokensRouter); app.use('/api/audit-log', auditLogRouter); app.use('/api/fleet', fleetRouter); diff --git a/backend/src/routes/secrets.ts b/backend/src/routes/secrets.ts new file mode 100644 index 00000000..6919a1f8 --- /dev/null +++ b/backend/src/routes/secrets.ts @@ -0,0 +1,282 @@ +import { Router, type Request, type Response } from 'express'; +import { authMiddleware } from '../middleware/auth'; +import { requirePaid, requireBody } from '../middleware/tierGates'; +import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService'; +import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService'; +import { isValidStackName } from '../utils/validation'; +import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; +import { parseIntParam } from '../utils/parseIntParam'; +import { sanitizeForLog } from '../utils/safeLog'; + +export const secretsRouter = Router(); + +const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,62}[a-zA-Z0-9]$/; + +function isValidName(name: unknown): name is string { + return typeof name === 'string' && NAME_PATTERN.test(name); +} + +function isValidEnvFileBasename(name: unknown): name is string { + if (typeof name !== 'string' || name.length === 0 || name.length > 64) return false; + if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') return false; + return /^[A-Za-z0-9._-]+$/.test(name); +} + +function isValidKv(value: unknown): value is SecretKv { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + for (const [k, v] of Object.entries(value as Record)) { + if (typeof k !== 'string' || typeof v !== 'string') return false; + } + return true; +} + +function isValidSelector(value: unknown): value is BlueprintSelector { + if (!value || typeof value !== 'object') return false; + const sel = value as { type?: unknown; ids?: unknown; any?: unknown; all?: unknown }; + if (sel.type === 'nodes') { + return Array.isArray(sel.ids) && sel.ids.every(id => typeof id === 'number'); + } + if (sel.type === 'labels') { + const isStringArr = (a: unknown) => Array.isArray(a) && a.every(s => typeof s === 'string'); + return isStringArr(sel.any) && isStringArr(sel.all); + } + return false; +} + +function getActor(req: Request): string { + return req.user?.username || 'unknown'; +} + +interface PushBody { selector: BlueprintSelector; stackName: string; envFileBasename: string } + +function parsePushBody(body: unknown): PushBody | { error: string } { + if (!body || typeof body !== 'object') return { error: 'Request body is required' }; + const { selector, stackName, envFileBasename } = body as { selector?: unknown; stackName?: unknown; envFileBasename?: unknown }; + if (!isValidSelector(selector)) return { error: 'selector is invalid' }; + if (typeof stackName !== 'string' || !isValidStackName(stackName)) return { error: 'stackName is invalid' }; + const basename = envFileBasename === undefined ? '.env' : envFileBasename; + if (!isValidEnvFileBasename(basename)) return { error: 'envFileBasename is invalid' }; + return { selector, stackName, envFileBasename: basename }; +} + +secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + try { + const items = SecretsService.getInstance().list(); + res.json(items); + } catch (err) { + console.error('[Secrets] List error:', err); + res.status(500).json({ error: 'Failed to list secrets' }); + } +}); + +secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireBody(req, res)) return; + try { + const { name, description, kv, note } = req.body as { name?: unknown; description?: unknown; kv?: unknown; note?: unknown }; + if (!isValidName(name)) { + res.status(400).json({ error: 'name is required (letters, digits, spaces, dot, dash, underscore; 2-64 chars)' }); + return; + } + if (description !== undefined && typeof description !== 'string') { + res.status(400).json({ error: 'description must be a string' }); + return; + } + if (!isValidKv(kv)) { + res.status(400).json({ error: 'kv must be an object of string values' }); + return; + } + if (note !== undefined && typeof note !== 'string') { + res.status(400).json({ error: 'note must be a string' }); + return; + } + const result = SecretsService.getInstance().create({ + name, + description: typeof description === 'string' ? description : undefined, + kv, + user: getActor(req), + note: typeof note === 'string' ? note : undefined, + }); + res.status(201).json(result); + } catch (err) { + if (isSqliteUniqueViolation(err)) { + res.status(409).json({ error: 'A secret with that name already exists' }); + return; + } + console.error('[Secrets] Create error:', err); + res.status(500).json({ error: getErrorMessage(err, 'Failed to create secret') }); + } +}); + +secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'secret ID'); + if (id === null) return; + const summary = SecretsService.getInstance().getCurrent(id); + if (!summary) { + res.status(404).json({ error: 'Secret not found' }); + return; + } + const kv = SecretsService.getInstance().getDecryptedKv(id); + res.json({ ...summary, kv }); + } catch (err) { + console.error('[Secrets] Get error:', err); + res.status(500).json({ error: 'Failed to read secret' }); + } +}); + +secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireBody(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'secret ID'); + if (id === null) return; + const { description, kv, note } = req.body as { description?: unknown; kv?: unknown; note?: unknown }; + if (description !== undefined && typeof description !== 'string') { + res.status(400).json({ error: 'description must be a string' }); + return; + } + if (!isValidKv(kv)) { + res.status(400).json({ error: 'kv must be an object of string values' }); + return; + } + if (note !== undefined && typeof note !== 'string') { + res.status(400).json({ error: 'note must be a string' }); + return; + } + const existing = DatabaseService.getInstance().getSecret(id); + if (!existing) { + res.status(404).json({ error: 'Secret not found' }); + return; + } + const result = SecretsService.getInstance().update(id, { + description: typeof description === 'string' ? description : undefined, + kv, + user: getActor(req), + note: typeof note === 'string' ? note : undefined, + }); + res.json(result); + } catch (err) { + console.error('[Secrets] Update error:', err); + res.status(500).json({ error: getErrorMessage(err, 'Failed to update secret') }); + } +}); + +secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'secret ID'); + if (id === null) return; + const ok = SecretsService.getInstance().delete(id); + if (!ok) { + res.status(404).json({ error: 'Secret not found' }); + return; + } + res.json({ ok: true }); + } catch (err) { + console.error('[Secrets] Delete error:', err); + res.status(500).json({ error: 'Failed to delete secret' }); + } +}); + +secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'secret ID'); + if (id === null) return; + if (!DatabaseService.getInstance().getSecret(id)) { + res.status(404).json({ error: 'Secret not found' }); + return; + } + res.json(SecretsService.getInstance().listVersions(id)); + } catch (err) { + console.error('[Secrets] Versions error:', err); + res.status(500).json({ error: 'Failed to list versions' }); + } +}); + +secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireBody(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'secret ID'); + if (id === null) return; + const { nodeId, stackName, envFileBasename } = req.body as { nodeId?: unknown; stackName?: unknown; envFileBasename?: unknown }; + if (typeof nodeId !== 'number' || !Number.isFinite(nodeId)) { + res.status(400).json({ error: 'nodeId must be a number' }); + return; + } + if (typeof stackName !== 'string' || !isValidStackName(stackName)) { + res.status(400).json({ error: 'stackName is invalid' }); + return; + } + const basename = envFileBasename === undefined ? '.env' : envFileBasename; + if (!isValidEnvFileBasename(basename)) { + res.status(400).json({ error: 'envFileBasename is invalid' }); + return; + } + const kv = await SecretsService.getInstance().importFromStack(nodeId, stackName, basename); + res.json({ kv }); + } catch (err) { + console.error('[Secrets] Import error:', err); + res.status(500).json({ error: getErrorMessage(err, 'Failed to import env from stack') }); + } +}); + +secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireBody(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'secret ID'); + if (id === null) return; + const parsed = parsePushBody(req.body); + if ('error' in parsed) { + res.status(400).json({ error: parsed.error }); + return; + } + if (!DatabaseService.getInstance().getSecret(id)) { + res.status(404).json({ error: 'Secret not found' }); + return; + } + const plan = await SecretsService.getInstance().previewPushDiff(id, parsed.selector, parsed.stackName, parsed.envFileBasename); + res.json(plan); + } catch (err) { + console.error('[Secrets] Preview error:', err); + res.status(500).json({ error: getErrorMessage(err, 'Failed to preview push') }); + } +}); + +secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireBody(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'secret ID'); + if (id === null) return; + const parsed = parsePushBody(req.body); + if ('error' in parsed) { + res.status(400).json({ error: parsed.error }); + return; + } + const secret = DatabaseService.getInstance().getSecret(id); + if (!secret) { + res.status(404).json({ error: 'Secret not found' }); + return; + } + try { + const result = await SecretsService.getInstance().executePush(id, parsed.selector, parsed.stackName, parsed.envFileBasename, getActor(req)); + console.log(`[Secrets] Push ${sanitizeForLog(secret.name)} v${secret.current_version}: ${result.results.length} nodes`); + res.json(result); + } catch (err) { + if (err instanceof PushBusyError) { + res.status(409).json({ error: 'A push for this secret is already running' }); + return; + } + throw err; + } + } catch (err) { + console.error('[Secrets] Push error:', err); + res.status(500).json({ error: getErrorMessage(err, 'Failed to push secret') }); + } +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 15f5113a..346b6ce3 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -37,7 +37,7 @@ function notifyActionSuccess(category: NotificationCategory, message: string, st .catch(err => console.error('[Stacks] Failed to dispatch activity for %s:', sanitizeForLog(stackName), err)); } -async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise { +export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise { const fsService = FileSystemService.getInstance(nodeId); const stackDir = path.join(fsService.getBaseDir(), stackName); const defaultEnvPath = path.join(stackDir, '.env'); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index fd87810f..ab786ace 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -306,6 +306,46 @@ export interface AuditLogEntry { summary: string; } +export interface SecretRow { + id: number; + name: string; + description: string; + current_version: number; + created_at: number; + created_by: string; + updated_at: number; +} + +export interface SecretVersionRow { + id: number; + secret_id: number; + version: number; + encrypted_payload: string; + key_count: number; + created_at: number; + created_by: string; + note: string; +} + +export type SecretPushStatus = 'ok' | 'failed' | 'skipped'; + +export interface SecretPushRow { + id: number; + secret_id: number; + version: number; + push_id: string; + node_id: number; + stack_name: string; + env_file_basename: string; + status: SecretPushStatus; + error: string; + added_count: number; + changed_count: number; + unchanged_count: number; + pushed_by: string; + pushed_at: number; +} + export type ApiTokenScope = 'read-only' | 'deploy-only' | 'full-admin'; /** Map an API token's scope to the synthesized user role used during request authorization. */ @@ -1030,6 +1070,52 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_auto_heal_history_policy_ts ON auto_heal_history(policy_id, timestamp DESC); + + CREATE TABLE IF NOT EXISTS secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + current_version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + created_by TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name); + + CREATE TABLE IF NOT EXISTS secret_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + secret_id INTEGER NOT NULL, + version INTEGER NOT NULL, + encrypted_payload TEXT NOT NULL, + key_count INTEGER NOT NULL, + created_at INTEGER NOT NULL, + created_by TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + UNIQUE(secret_id, version), + FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_secret_versions_secret ON secret_versions(secret_id); + + CREATE TABLE IF NOT EXISTS secret_pushes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + secret_id INTEGER NOT NULL, + version INTEGER NOT NULL, + push_id TEXT NOT NULL, + node_id INTEGER NOT NULL, + stack_name TEXT NOT NULL, + env_file_basename TEXT NOT NULL DEFAULT '.env', + status TEXT NOT NULL, + error TEXT NOT NULL DEFAULT '', + added_count INTEGER NOT NULL DEFAULT 0, + changed_count INTEGER NOT NULL DEFAULT 0, + unchanged_count INTEGER NOT NULL DEFAULT 0, + pushed_by TEXT NOT NULL, + pushed_at INTEGER NOT NULL, + FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_secret_pushes_push ON secret_pushes(push_id); + CREATE INDEX IF NOT EXISTS idx_secret_pushes_secret_version ON secret_pushes(secret_id, version); + CREATE INDEX IF NOT EXISTS idx_secret_pushes_node ON secret_pushes(node_id, stack_name); `); // Apply migrations safely (ignore if columns already exist) @@ -4077,4 +4163,115 @@ export class DatabaseService { public deleteDeployment(blueprintId: number, nodeId: number): void { this.db.prepare('DELETE FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').run(blueprintId, nodeId); } + + // --- Secrets --- + + public listSecrets(): SecretRow[] { + return this.db.prepare( + 'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets ORDER BY name ASC' + ).all() as SecretRow[]; + } + + public getSecret(id: number): SecretRow | undefined { + return this.db.prepare( + 'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets WHERE id = ?' + ).get(id) as SecretRow | undefined; + } + + public listSecretVersions(secretId: number): SecretVersionRow[] { + return this.db.prepare( + 'SELECT id, secret_id, version, encrypted_payload, key_count, created_at, created_by, note FROM secret_versions WHERE secret_id = ? ORDER BY version DESC' + ).all(secretId) as SecretVersionRow[]; + } + + public getCurrentSecretVersion(secretId: number): SecretVersionRow | undefined { + return this.db.prepare( + `SELECT v.id, v.secret_id, v.version, v.encrypted_payload, v.key_count, v.created_at, v.created_by, v.note + FROM secret_versions v + INNER JOIN secrets s ON s.id = v.secret_id AND s.current_version = v.version + WHERE v.secret_id = ?` + ).get(secretId) as SecretVersionRow | undefined; + } + + public createSecretWithVersion(input: { + name: string; + description: string; + encryptedPayload: string; + keyCount: number; + createdBy: string; + note: string; + }): { id: number; version: number } { + const now = Date.now(); + const txn = this.db.transaction(() => { + const insertSecret = this.db.prepare( + 'INSERT INTO secrets (name, description, current_version, created_at, created_by, updated_at) VALUES (?, ?, 1, ?, ?, ?)' + ); + const result = insertSecret.run(input.name, input.description, now, input.createdBy, now); + const secretId = Number(result.lastInsertRowid); + this.db.prepare( + 'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, 1, ?, ?, ?, ?, ?)' + ).run(secretId, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note); + return { id: secretId, version: 1 }; + }); + return txn(); + } + + public updateSecretWithVersion(input: { + secretId: number; + description: string | null; + encryptedPayload: string; + keyCount: number; + createdBy: string; + note: string; + }): { version: number } { + const now = Date.now(); + const txn = this.db.transaction(() => { + const current = this.db.prepare('SELECT current_version FROM secrets WHERE id = ?').get(input.secretId) as { current_version: number } | undefined; + if (!current) throw new Error('Secret not found'); + const nextVersion = current.current_version + 1; + this.db.prepare( + 'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(input.secretId, nextVersion, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note); + if (input.description === null) { + this.db.prepare('UPDATE secrets SET current_version = ?, updated_at = ? WHERE id = ?').run(nextVersion, now, input.secretId); + } else { + this.db.prepare('UPDATE secrets SET current_version = ?, description = ?, updated_at = ? WHERE id = ?').run(nextVersion, input.description, now, input.secretId); + } + return { version: nextVersion }; + }); + return txn(); + } + + public deleteSecret(id: number): boolean { + const result = this.db.prepare('DELETE FROM secrets WHERE id = ?').run(id); + return result.changes > 0; + } + + public insertSecretPushes(rows: Array>): void { + if (rows.length === 0) return; + const stmt = this.db.prepare( + `INSERT INTO secret_pushes (secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error, added_count, changed_count, unchanged_count, pushed_by, pushed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + const txn = this.db.transaction((entries: Array>) => { + for (const r of entries) { + stmt.run( + r.secret_id, r.version, r.push_id, r.node_id, r.stack_name, r.env_file_basename, + r.status, r.error, r.added_count, r.changed_count, r.unchanged_count, r.pushed_by, r.pushed_at, + ); + } + }); + txn(rows); + } + + public listSecretPushes(secretId: number, limit = 50): SecretPushRow[] { + return this.db.prepare( + `SELECT id, secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error, + added_count, changed_count, unchanged_count, pushed_by, pushed_at + FROM secret_pushes + WHERE secret_id = ? + ORDER BY pushed_at DESC + LIMIT ?` + ).all(secretId, limit) as SecretPushRow[]; + } } diff --git a/backend/src/services/SecretsService.ts b/backend/src/services/SecretsService.ts new file mode 100644 index 00000000..55580b49 --- /dev/null +++ b/backend/src/services/SecretsService.ts @@ -0,0 +1,526 @@ +import path from 'path'; +import crypto from 'crypto'; +import { CryptoService } from './CryptoService'; +import { DatabaseService, type BlueprintSelector, type Node, type SecretRow, type SecretVersionRow, type SecretPushStatus } from './DatabaseService'; +import { FileSystemService } from './FileSystemService'; +import { NodeLabelService } from './NodeLabelService'; +import { resolveAllEnvFilePaths } from '../routes/stacks'; +import { getErrorMessage } from '../utils/errors'; + +export type SecretKv = Record; +export type DiffStatus = 'added' | 'changed' | 'removed' | 'unchanged'; + +export interface SecretDiffEntry { + key: string; + status: DiffStatus; + before?: string; + after?: string; +} + +export interface SecretSummary { + id: number; + name: string; + description: string; + currentVersion: number; + keyCount: number; + createdAt: number; + createdBy: string; + updatedAt: number; +} + +export interface SecretVersionSummary { + version: number; + keyCount: number; + createdAt: number; + createdBy: string; + note: string; +} + +export interface SecretPushPlanEntry { + nodeId: number; + nodeName: string; + stackName: string; + envFileBasename: string; + reachable: boolean; + stackExists: boolean; + error?: string; + diff: SecretDiffEntry[]; + added: number; + changed: number; + unchanged: number; + removedInformational: number; +} + +export interface SecretPushResultEntry { + nodeId: number; + nodeName: string; + stackName: string; + envFileBasename: string; + status: SecretPushStatus; + error?: string; + added: number; + changed: number; + unchanged: number; +} + +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const REQUEST_TIMEOUT_MS = 10_000; + +export function parseEnv(text: string): SecretKv { + const out: SecretKv = {}; + if (!text) return out; + const lines = text.split(/\r?\n/); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq < 0) continue; + const key = line.slice(0, eq).trim(); + if (!ENV_KEY_PATTERN.test(key)) { + console.warn('[Secrets] Dropping invalid env key:', key); + continue; + } + const rawValue = line.slice(eq + 1); + // Strip optional surrounding quotes. + if (rawValue.length >= 2) { + const first = rawValue[0]; + const last = rawValue[rawValue.length - 1]; + if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) { + const inner = rawValue.slice(1, -1); + if (first === '"') { + out[key] = inner + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } else { + out[key] = inner; + } + continue; + } + } + // Bare value: strip trailing inline comment ` #...` + const commentIdx = rawValue.search(/\s#/); + const trimmed = (commentIdx >= 0 ? rawValue.slice(0, commentIdx) : rawValue).trim(); + out[key] = trimmed; + } + return out; +} + +export function serializeEnv(kv: SecretKv): string { + const keys = Object.keys(kv).sort(); + const lines: string[] = []; + for (const k of keys) { + const v = kv[k]; + if (/[\s#=\\"']/.test(v) || v === '') { + const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + lines.push(`${k}="${escaped}"`); + } else { + lines.push(`${k}=${v}`); + } + } + return lines.length === 0 ? '' : `${lines.join('\n')}\n`; +} + +// Bundle wins for keys it defines; keys missing from the overlay are preserved. +export function applyOverlay(existing: SecretKv, overlay: SecretKv): SecretKv { + return { ...existing, ...overlay }; +} + +export function computeDiff(existing: SecretKv, overlay: SecretKv): SecretDiffEntry[] { + const keys = new Set([...Object.keys(existing), ...Object.keys(overlay)]); + const result: SecretDiffEntry[] = []; + for (const key of [...keys].sort()) { + const inE = key in existing; + const inO = key in overlay; + if (inO && !inE) { + result.push({ key, status: 'added', after: overlay[key] }); + } else if (!inO && inE) { + result.push({ key, status: 'removed', before: existing[key] }); + } else if (inO && inE && overlay[key] !== existing[key]) { + result.push({ key, status: 'changed', before: existing[key], after: overlay[key] }); + } else { + result.push({ key, status: 'unchanged' }); + } + } + return result; +} + +function aggregateDiff(diff: SecretDiffEntry[]): { added: number; changed: number; unchanged: number; removedInformational: number } { + const r = { added: 0, changed: 0, unchanged: 0, removedInformational: 0 }; + for (const e of diff) { + if (e.status === 'added') r.added += 1; + else if (e.status === 'changed') r.changed += 1; + else if (e.status === 'unchanged') r.unchanged += 1; + else r.removedInformational += 1; + } + return r; +} + +function isLocalNode(node: Node): boolean { + return node.type === 'local'; +} + +function encryptKv(kv: SecretKv): string { + return CryptoService.getInstance().encrypt(JSON.stringify(kv)); +} + +function decryptKv(payload: string): SecretKv { + if (!payload) return {}; + const plaintext = CryptoService.getInstance().decrypt(payload); + if (!plaintext) return {}; + try { + const parsed: unknown = JSON.parse(plaintext); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: SecretKv = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === 'string') out[k] = v; + } + return out; + } catch (err) { + console.error('[Secrets] Failed to parse decrypted payload:', err); + return {}; + } +} + +function validateKv(kv: SecretKv): { ok: true } | { ok: false; error: string } { + for (const key of Object.keys(kv)) { + if (!ENV_KEY_PATTERN.test(key)) { + return { ok: false, error: `Invalid env key: ${key}` }; + } + if (typeof kv[key] !== 'string') { + return { ok: false, error: `Value for ${key} must be a string` }; + } + } + return { ok: true }; +} + +interface ResolvedEnvFile { absolutePath: string } + +async function resolveEnvFileLocal(nodeId: number, stackName: string, basename: string): Promise { + const paths = await resolveAllEnvFilePaths(nodeId, stackName); + if (paths.length === 0) { + if (basename === '.env') { + const baseDir = FileSystemService.getInstance(nodeId).getBaseDir(); + return { absolutePath: path.join(baseDir, stackName, '.env') }; + } + return null; + } + const match = paths.find(p => path.basename(p) === basename); + return match ? { absolutePath: match } : null; +} + +async function resolveEnvFileRemote(node: Node, stackName: string, basename: string): Promise { + if (!node.api_url || !node.api_token) return null; + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${node.api_token}` }; + const res = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/envs`, { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!res.ok) { + if (res.status === 404 && basename === '.env') { + return { absolutePath: '.env' }; + } + throw new Error(`failed to list env files (HTTP ${res.status})`); + } + const body = await res.json() as { envFiles?: string[] }; + const paths = Array.isArray(body.envFiles) ? body.envFiles : []; + if (paths.length === 0 && basename === '.env') { + return { absolutePath: '.env' }; + } + const match = paths.find(p => path.basename(p) === basename); + return match ? { absolutePath: match } : null; +} + +async function readEnvLocal(nodeId: number, absolutePath: string): Promise { + const fsService = FileSystemService.getInstance(nodeId); + try { + return await fsService.readFile(absolutePath, 'utf-8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') return ''; + throw err; + } +} + +async function writeEnvLocal(nodeId: number, absolutePath: string, content: string): Promise { + const fsService = FileSystemService.getInstance(nodeId); + await fsService.writeFile(absolutePath, content, 'utf-8'); +} + +async function readEnvRemote(node: Node, stackName: string, absolutePath: string): Promise { + if (!node.api_url || !node.api_token) throw new Error('node has no api_url or api_token'); + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${node.api_token}` }; + const url = new URL(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`); + if (absolutePath !== '.env') url.searchParams.set('file', absolutePath); + const res = await fetch(url.toString(), { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (res.status === 404) return ''; + if (!res.ok) throw new Error(`failed to read env (HTTP ${res.status})`); + return await res.text(); +} + +async function writeEnvRemote(node: Node, stackName: string, absolutePath: string, content: string): Promise { + if (!node.api_url || !node.api_token) throw new Error('node has no api_url or api_token'); + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers = { + Authorization: `Bearer ${node.api_token}`, + 'Content-Type': 'application/json', + }; + const url = new URL(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`); + if (absolutePath !== '.env') url.searchParams.set('file', absolutePath); + const res = await fetch(url.toString(), { + method: 'PUT', + headers, + body: JSON.stringify({ content }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`failed to write env (HTTP ${res.status}${body ? ': ' + body.slice(0, 200) : ''})`); + } +} + +interface ResolvedRead { absolutePath: string; text: string } + +async function readExistingEnv(node: Node, stackName: string, basename: string): Promise { + if (isLocalNode(node)) { + const resolved = await resolveEnvFileLocal(node.id, stackName, basename); + if (!resolved) return null; + const text = await readEnvLocal(node.id, resolved.absolutePath); + return { absolutePath: resolved.absolutePath, text }; + } + const resolved = await resolveEnvFileRemote(node, stackName, basename); + if (!resolved) return null; + const text = await readEnvRemote(node, stackName, resolved.absolutePath); + return { absolutePath: resolved.absolutePath, text }; +} + +async function writeEnvForNode(node: Node, stackName: string, absolutePath: string, content: string): Promise { + if (isLocalNode(node)) { + await writeEnvLocal(node.id, absolutePath, content); + } else { + await writeEnvRemote(node, stackName, absolutePath, content); + } +} + +function toSummary(secret: SecretRow, version: SecretVersionRow | undefined): SecretSummary { + return { + id: secret.id, + name: secret.name, + description: secret.description, + currentVersion: secret.current_version, + keyCount: version?.key_count ?? 0, + createdAt: secret.created_at, + createdBy: secret.created_by, + updatedAt: secret.updated_at, + }; +} + +const activePushes = new Set(); + +export class SecretsService { + private static instance: SecretsService | null = null; + + static getInstance(): SecretsService { + if (!SecretsService.instance) { + SecretsService.instance = new SecretsService(); + } + return SecretsService.instance; + } + + private constructor() { /* singleton */ } + + list(): SecretSummary[] { + const db = DatabaseService.getInstance(); + const rows = db.listSecrets(); + return rows.map(row => { + const v = db.getCurrentSecretVersion(row.id); + return toSummary(row, v); + }); + } + + getCurrent(id: number): SecretSummary | null { + const db = DatabaseService.getInstance(); + const row = db.getSecret(id); + if (!row) return null; + const v = db.getCurrentSecretVersion(id); + return toSummary(row, v); + } + + listVersions(id: number): SecretVersionSummary[] { + const db = DatabaseService.getInstance(); + return db.listSecretVersions(id).map(v => ({ + version: v.version, + keyCount: v.key_count, + createdAt: v.created_at, + createdBy: v.created_by, + note: v.note, + })); + } + + create(input: { name: string; description?: string; kv: SecretKv; user: string; note?: string }): { id: number; version: number } { + const validation = validateKv(input.kv); + if (!validation.ok) throw new Error(validation.error); + const db = DatabaseService.getInstance(); + return db.createSecretWithVersion({ + name: input.name, + description: input.description ?? '', + encryptedPayload: encryptKv(input.kv), + keyCount: Object.keys(input.kv).length, + createdBy: input.user, + note: input.note ?? '', + }); + } + + update(id: number, input: { description?: string; kv: SecretKv; user: string; note?: string }): { version: number } { + const validation = validateKv(input.kv); + if (!validation.ok) throw new Error(validation.error); + const db = DatabaseService.getInstance(); + return db.updateSecretWithVersion({ + secretId: id, + description: input.description === undefined ? null : input.description, + encryptedPayload: encryptKv(input.kv), + keyCount: Object.keys(input.kv).length, + createdBy: input.user, + note: input.note ?? '', + }); + } + + delete(id: number): boolean { + return DatabaseService.getInstance().deleteSecret(id); + } + + getDecryptedKv(id: number): SecretKv { + const db = DatabaseService.getInstance(); + const v = db.getCurrentSecretVersion(id); + if (!v) return {}; + return decryptKv(v.encrypted_payload); + } + + async importFromStack(nodeId: number, stackName: string, envFileBasename = '.env'): Promise { + const node = DatabaseService.getInstance().getNodes().find(n => n.id === nodeId); + if (!node) throw new Error('Node not found'); + const read = await readExistingEnv(node, stackName, envFileBasename); + if (!read) throw new Error(`env file '${envFileBasename}' not found on node ${node.name}`); + return parseEnv(read.text); + } + + async previewPushDiff(id: number, selector: BlueprintSelector, stackName: string, envFileBasename: string): Promise { + const db = DatabaseService.getInstance(); + const overlay = this.getDecryptedKv(id); + const matched = NodeLabelService.getInstance().matchSelector(selector, db.getNodes()); + + // Preview is read-only and per-node; fan out in parallel for snappier wizard UX. + return Promise.all(matched.map(async (node): Promise => { + const entry: SecretPushPlanEntry = { + nodeId: node.id, + nodeName: node.name, + stackName, + envFileBasename, + reachable: true, + stackExists: true, + diff: [], + added: 0, + changed: 0, + unchanged: 0, + removedInformational: 0, + }; + try { + const read = await readExistingEnv(node, stackName, envFileBasename); + if (!read) { + entry.stackExists = false; + entry.error = `env file '${envFileBasename}' not found`; + return entry; + } + entry.diff = computeDiff(parseEnv(read.text), overlay); + Object.assign(entry, aggregateDiff(entry.diff)); + } catch (err) { + entry.reachable = false; + entry.error = getErrorMessage(err, 'preview failed'); + } + return entry; + })); + } + + async executePush(id: number, selector: BlueprintSelector, stackName: string, envFileBasename: string, user: string): Promise<{ pushId: string; results: SecretPushResultEntry[] }> { + if (activePushes.has(id)) { + throw new PushBusyError(id); + } + const db = DatabaseService.getInstance(); + const secret = db.getSecret(id); + if (!secret) throw new Error('Secret not found'); + const versionRow = db.getCurrentSecretVersion(id); + if (!versionRow) throw new Error('Secret has no current version'); + const overlay = decryptKv(versionRow.encrypted_payload); + const matched = NodeLabelService.getInstance().matchSelector(selector, db.getNodes()); + const pushId = crypto.randomUUID(); + const results: SecretPushResultEntry[] = []; + const rows: Array[0][number]> = []; + const pushedAt = Date.now(); + + // Sequential write order keeps audit-row ordering deterministic and avoids + // stampeding any single remote that's targeted by multiple labels. + activePushes.add(id); + try { + for (const node of matched) { + const result: SecretPushResultEntry = { + nodeId: node.id, + nodeName: node.name, + stackName, + envFileBasename, + status: 'ok', + added: 0, + changed: 0, + unchanged: 0, + }; + try { + const read = await readExistingEnv(node, stackName, envFileBasename); + if (!read) throw new Error(`env file '${envFileBasename}' not found`); + const existing = parseEnv(read.text); + const agg = aggregateDiff(computeDiff(existing, overlay)); + const newText = serializeEnv(applyOverlay(existing, overlay)); + await writeEnvForNode(node, stackName, read.absolutePath, newText); + result.added = agg.added; + result.changed = agg.changed; + result.unchanged = agg.unchanged; + } catch (err) { + result.status = 'failed'; + result.error = getErrorMessage(err, 'push failed'); + } + results.push(result); + rows.push({ + secret_id: id, + version: versionRow.version, + push_id: pushId, + node_id: node.id, + stack_name: stackName, + env_file_basename: envFileBasename, + status: result.status, + error: result.error ?? '', + added_count: result.added, + changed_count: result.changed, + unchanged_count: result.unchanged, + pushed_by: user, + pushed_at: pushedAt, + }); + } + db.insertSecretPushes(rows); + } finally { + activePushes.delete(id); + } + return { pushId, results }; + } +} + +export class PushBusyError extends Error { + constructor(public readonly secretId: number) { + super(`Push already running for secret ${secretId}`); + this.name = 'PushBusyError'; + } +} diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 3c0930f1..2bd597d4 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -131,6 +131,14 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { // Blueprints (Federation pin) 'PUT /blueprints/*/pin': 'Updated blueprint pin', + + // Fleet secrets + 'POST /secrets': 'Created secret', + 'PUT /secrets': 'Updated secret', + 'DELETE /secrets': 'Deleted secret', + 'POST /secrets/*/import-from-stack': 'Imported env into secret', + 'POST /secrets/*/push/preview': 'Previewed secret push', + 'POST /secrets/*/push': 'Pushed secret', }; // Pre-sorted at module load: most specific patterns (by segment count) first. diff --git a/docs/docs.json b/docs/docs.json index e82288c0..e5c5f388 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -132,6 +132,7 @@ "features/fleet-federation", "features/fleet-actions", "features/fleet-sync", + "features/fleet-secrets", "features/fleet-backups", "features/remote-updates", "features/scheduled-operations" diff --git a/docs/features/fleet-secrets.mdx b/docs/features/fleet-secrets.mdx new file mode 100644 index 00000000..7d67bb3a --- /dev/null +++ b/docs/features/fleet-secrets.mdx @@ -0,0 +1,101 @@ +--- +title: "Fleet Secrets" +description: "Centralized, encrypted, versioned env-var bundles you can push to labeled nodes' stacks." +--- + +Fleet Secrets gives you one place to author the environment variables a stack needs, and one action to push them to every node that runs that stack. Bundles are encrypted at rest, every save bumps a version, and every push is recorded in the audit log with a per-node diff. + +Fleet Secrets is available on **Skipper** and **Admiral** licenses. The tab is hidden on Community. + +## What's in scope + +| Today | Not yet | +|-------|---------| +| Bundles of `KEY=value` env pairs | Certificates, key files, credential JSON blobs | +| Push to nodes by label selector | Pin a bundle to specific node IDs | +| Versioning on every save with a change note | Scheduled or auto-rotation | +| Diff preview before each push | Multi-region or per-environment overlays | + +If a feature is not in the table above, it's deferred to a later release. Fleet Secrets in 1.0 is intentionally focused on env-var drift across copies of the same stack. + +## Create a bundle + +1. Open **Fleet → Secrets**. +2. Click **New bundle**. +3. Give it a name (letters, digits, dot, dash, underscore; 2–64 chars) and an optional description. +4. Add `KEY=value` rows. Keys must match the env-var convention (`A-Z`, digits, underscore; cannot start with a digit). The eye icon toggles value visibility; the copy icon copies a single value to the clipboard. +5. Add a **Change note** (optional but recommended) explaining why this version exists. +6. Click **Save**. The bundle is now version `v1`. + +Saving the bundle encrypts the payload with the instance's data key (AES-256-GCM via the same key that protects MFA secrets and registry credentials). The plaintext only ever exists in memory while you're editing. + +## Edit and version + +Editing a bundle creates a new version. Each version is immutable once saved. Open a bundle and switch to the **Versions** tab to see who saved each version and any change notes attached. + +Saves are last-write-wins. There's no row-level lock; if two operators edit the same bundle concurrently, the later save creates the higher version number and the earlier save remains visible in history. + +## Push to nodes + +A push reads the bundle's current version, computes the diff against each target's existing env file, and writes the merged result. + +1. From the bundle row, click the **Send** action to open the push wizard. +2. **Target tab:** + - Pick one or more node labels. + - Choose **any** (a node matches if it has any of the selected labels) or **all** (a node must have all selected labels). + - Enter the **Stack name** that the bundle should be applied to. The stack must exist on each target node by that name. + - Pick the **Env file** to write. The dropdown lists every file declared by the stack's compose on a representative target node — the canonical `.env` plus any files referenced via `env_file:` directives. Per-node compose files can differ; nodes that do not declare the chosen file are reported as failed for that push. +3. Click **Preview**. +4. **Preview tab:** the table shows one row per matched node. Each row reports a quick header (`+added · ~changed · ·unchanged`) and an expander with the per-key diff. Drift — keys present on the target that are absent from the bundle — is shown as informational `removed` rows. The push will not delete those keys. +5. Click **Push to N nodes**. +6. **Results tab:** per-node status pills (`ok`, `failed`, `skipped`). Hover a failure to see the error. + +Pushes are sequential per bundle to make audit ordering predictable. Only one push for a given bundle can run at a time; a second concurrent push attempt returns `409 Conflict` until the first finishes. + +## Merge semantics: overlay + +Fleet Secrets uses **overlay** merge: + +- A key in the bundle that is missing from the target is **added**. +- A key in both with a different value is **changed** to the bundle's value. +- A key in both with the same value is **unchanged**. +- A key on the target that is **not in the bundle** is preserved on the target. The diff shows it under `removed (drift)` so you can see the divergence, but the push leaves it alone. + +This is deliberately conservative for a v1 push action. If a key truly needs to be removed from a target's env file, do it in the stack editor on that node, then push the bundle to bring the rest of the keys in line. + +## Audit trail + +Every mutating action shows up in **Audit Log**: + +| Action | Audit summary | +|--------|---------------| +| Create bundle | `Created secret` | +| Update bundle (new version) | `Updated secret: ` | +| Delete bundle | `Deleted secret: ` | +| Import env from a stack | `Imported env into secret: ` | +| Preview a push | `Previewed secret push: ` | +| Execute a push | `Pushed secret: ` | + +The execute-push action also writes one row per target node in `secret_pushes`, capturing the bundle id, version, push id (a UUID grouping all rows from the same push), node id, stack name, env file, status, error message, and per-node `added` / `changed` / `unchanged` counts. + +## Troubleshooting + +### "env file '' not found on node " + +The node's compose file for that stack does not declare the env file you asked Fleet Secrets to write to. Either pick a different env file in the wizard's **Env file** dropdown, or update that node's compose to reference the file via `env_file:`. + +### "stack not found" + +The named stack does not exist on that node. Fleet Secrets pushes to existing stacks only — it does not create stack directories. + +### A push partially succeeded + +Per-node failures are independent. The bundle and other targets are unaffected; only the failing node missed the push. Fix the underlying cause (file declared in compose, stack created, node reachable) and re-run the push. Idempotent overlay means re-pushing is safe. + +### "A push for this secret is already running" + +Only one push per bundle runs at a time. Wait for the previous push to finish, then retry. + +### Values look wrong on the target + +Open the bundle and switch to **Versions** — confirm the version you intended to push is the current one. Open the wizard, run **Preview** without pushing, and read the diff. The preview is a faithful representation of what the next push will write. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index d72ec7ae..d93974c2 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -1,4 +1,3 @@ -import { useExperimental } from '@/hooks/useExperimental'; import { RefreshCw, Search, Camera, Network, SlidersHorizontal, @@ -20,11 +19,11 @@ import { useLicense } from '@/context/LicenseContext'; import { AdmiralGate } from './AdmiralGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; -import { FleetSoonPlaceholder } from './fleet/FleetSoonPlaceholder'; import { RoutingTab } from './fleet/RoutingTab'; import { FederationTab } from './fleet/FederationTab'; import { DeploymentsTab } from './blueprints/DeploymentsTab'; import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab'; +import { SecretsTab } from './fleet/secrets/SecretsTab'; interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; @@ -33,7 +32,6 @@ interface FleetViewProps { export function FleetView({ onNavigateToNode }: FleetViewProps) { const { isPaid, license } = useLicense(); const isAdmiral = isPaid && license?.variant === 'admiral'; - const experimental = useExperimental(); const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); @@ -107,15 +105,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { Fleet Actions - {experimental && ( - <> - - - - Secrets - - - + {isPaid && ( + + + Secrets + + )} @@ -198,15 +193,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { - {experimental && ( + {isPaid && ( - } - kicker="Secrets" - title="One source of truth for env, creds and certs" - description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies." - plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']} - /> + )} diff --git a/frontend/src/components/fleet/secrets/SecretBundleSheet.tsx b/frontend/src/components/fleet/secrets/SecretBundleSheet.tsx new file mode 100644 index 00000000..d4b10925 --- /dev/null +++ b/frontend/src/components/fleet/secrets/SecretBundleSheet.tsx @@ -0,0 +1,311 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Eye, EyeOff, Plus, Trash2, Copy, Loader2 } from 'lucide-react'; +import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/toast-store'; +import { + type SecretSummary, + type SecretVersionSummary, + createSecret, + updateSecret, + getSecret, + listSecretVersions, +} from '@/lib/secretsApi'; +import { copyToClipboard } from '@/lib/clipboard'; + +interface KvRow { id: string; key: string; value: string; reveal: boolean } +type Mode = 'create' | 'edit'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + /** When provided, edit existing bundle; when null, create new. */ + secret: SecretSummary | null; + onSaved: () => void; +} + +const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _.-]{0,62}[a-zA-Z0-9]$/; +const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function makeRowId(): string { + return `r-${Math.random().toString(36).slice(2, 10)}`; +} + +function kvToRows(kv: Record): KvRow[] { + return Object.keys(kv) + .sort() + .map((key) => ({ id: makeRowId(), key, value: kv[key], reveal: false })); +} + +function rowsToKv(rows: KvRow[]): { kv: Record } | { error: string } { + const kv: Record = {}; + const seen = new Set(); + for (const r of rows) { + const k = r.key.trim(); + if (!k) continue; + if (!KEY_PATTERN.test(k)) { + return { error: `Invalid key: ${k}` }; + } + if (seen.has(k)) { + return { error: `Duplicate key: ${k}` }; + } + seen.add(k); + kv[k] = r.value; + } + return { kv }; +} + +export function SecretBundleSheet({ open, onOpenChange, secret, onSaved }: Props) { + const mode: Mode = secret ? 'edit' : 'create'; + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [rows, setRows] = useState([]); + const [note, setNote] = useState(''); + const [activeTab, setActiveTab] = useState('keys'); + const [submitting, setSubmitting] = useState(false); + const [loading, setLoading] = useState(false); + const [versions, setVersions] = useState([]); + const [versionsLoading, setVersionsLoading] = useState(false); + + useEffect(() => { + if (!open) return; + let cancelled = false; + if (secret) { + setLoading(true); + (async () => { + try { + const fresh = await getSecret(secret.id); + if (cancelled) return; + setName(fresh.name); + setDescription(fresh.description); + setRows(kvToRows(fresh.kv)); + setNote(''); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load secret'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + } else { + setName(''); + setDescription(''); + setRows([{ id: makeRowId(), key: '', value: '', reveal: true }]); + setNote(''); + setActiveTab('keys'); + } + return () => { cancelled = true; }; + }, [open, secret]); + + useEffect(() => { + if (!open || !secret || activeTab !== 'versions') return; + let cancelled = false; + setVersionsLoading(true); + (async () => { + try { + const list = await listSecretVersions(secret.id); + if (!cancelled) setVersions(list); + } catch (err) { + if (!cancelled) toast.error(err instanceof Error ? err.message : 'Failed to load versions'); + } finally { + if (!cancelled) setVersionsLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [open, secret, activeTab]); + + const tabs: SystemSheetTab[] = mode === 'edit' + ? [{ id: 'keys', label: 'Keys', count: rows.filter(r => r.key.trim().length > 0).length }, { id: 'versions', label: 'Versions' }] + : [{ id: 'keys', label: 'Keys', count: rows.filter(r => r.key.trim().length > 0).length }]; + + const canSubmit = useMemo(() => { + if (!NAME_PATTERN.test(name)) return false; + if (rows.some(r => r.key.trim().length === 0 && r.value.length > 0)) return false; + return true; + }, [name, rows]); + + async function handleSave() { + if (mode === 'create' && !NAME_PATTERN.test(name)) { + toast.error('Name must be 2-64 characters (letters, digits, dot, dash, underscore)'); + return; + } + const built = rowsToKv(rows); + if ('error' in built) { + toast.error(built.error); + return; + } + setSubmitting(true); + try { + if (mode === 'create') { + const result = await createSecret({ name, description: description || undefined, kv: built.kv, note: note || undefined }); + toast.success(`Bundle saved (v${result.version})`); + } else if (secret) { + const result = await updateSecret(secret.id, { description, kv: built.kv, note: note || undefined }); + toast.success(`Bundle saved (v${result.version})`); + } + onSaved(); + onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save bundle'); + } finally { + setSubmitting(false); + } + } + + function addRow() { + setRows((prev) => [...prev, { id: makeRowId(), key: '', value: '', reveal: true }]); + } + + function removeRow(id: string) { + setRows((prev) => prev.filter(r => r.id !== id)); + } + + function updateRow(id: string, patch: Partial) { + setRows((prev) => prev.map(r => r.id === id ? { ...r, ...patch } : r)); + } + + async function handleCopyValue(value: string) { + try { + await copyToClipboard(value); + toast.success('Copied'); + } catch { + toast.error('Copy failed'); + } + } + + return ( + + {activeTab === 'keys' && ( +
+ {loading ? ( +
Loading bundle…
+ ) : ( + <> + +
+
+ + setName(e.target.value)} + placeholder="app-secrets" + disabled={mode === 'edit'} + className="font-mono" + /> +
+
+ + setDescription(e.target.value)} + placeholder="Production database and API credentials" + /> +
+
+
+ + r.key.trim().length > 0).length}`}> +
+ {rows.map((row) => ( +
+ updateRow(row.id, { key: e.target.value })} + placeholder="KEY_NAME" + className="font-mono w-[200px]" + /> + = + updateRow(row.id, { value: e.target.value })} + placeholder="value" + className="font-mono flex-1" + /> + + + +
+ ))} + +
+
+ + + setNote(e.target.value)} + placeholder={mode === 'create' ? 'initial bundle' : 'Why this change?'} + /> + + + )} +
+ )} + + {activeTab === 'versions' && ( +
+ {versionsLoading && ( +
Loading…
+ )} + {!versionsLoading && versions.length === 0 && ( +
No version history yet.
+ )} + {!versionsLoading && versions.map((v) => ( +
+
v{v.version}
+
{v.keyCount} keys
+
{v.note || '(no note)'}
+
{v.createdBy}
+
{new Date(v.createdAt).toLocaleString()}
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/fleet/secrets/SecretPushSheet.tsx b/frontend/src/components/fleet/secrets/SecretPushSheet.tsx new file mode 100644 index 00000000..ec25842a --- /dev/null +++ b/frontend/src/components/fleet/secrets/SecretPushSheet.tsx @@ -0,0 +1,363 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Loader2, Send, ChevronDown, ChevronRight, CheckCircle2, AlertCircle, MinusCircle, type LucideIcon } from 'lucide-react'; +import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet'; +import { Input } from '@/components/ui/input'; +import { MultiSelectCombobox, type MultiSelectOption } from '@/components/ui/multi-select-combobox'; +import { toast } from '@/components/ui/toast-store'; +import { useNodes } from '@/context/NodeContext'; +import { listDistinctLabels, type BlueprintSelector } from '@/lib/blueprintsApi'; +import { + type SecretSummary, + type SecretPushPlanEntry, + type SecretPushResultEntry, + type SecretPushStatus, + type DiffStatus, + previewPush, + executePush, +} from '@/lib/secretsApi'; +import { apiFetch } from '@/lib/api'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + secret: SecretSummary | null; +} + +type Stage = 'target' | 'preview' | 'results'; + +const DIFF_STATUS_COLOR: Record = { + added: 'text-emerald-500', + changed: 'text-amber-500', + removed: 'text-stat-subtitle', + unchanged: 'text-stat-subtitle/60', +}; + +const RESULT_ICON: Record = { + ok: CheckCircle2, + failed: AlertCircle, + skipped: MinusCircle, +}; + +const RESULT_ICON_CLASS: Record = { + ok: 'text-emerald-500', + failed: 'text-destructive', + skipped: 'text-stat-subtitle', +}; + +export function SecretPushSheet({ open, onOpenChange, secret }: Props) { + const { nodes } = useNodes(); + const [stage, setStage] = useState('target'); + const [labelMode, setLabelMode] = useState<'any' | 'all'>('any'); + const [selectedLabels, setSelectedLabels] = useState>(new Set()); + const [allLabels, setAllLabels] = useState([]); + const [stackName, setStackName] = useState(''); + const [envFiles, setEnvFiles] = useState([]); + const [envFile, setEnvFile] = useState('.env'); + const [envFilesLoading, setEnvFilesLoading] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [pushLoading, setPushLoading] = useState(false); + const [plan, setPlan] = useState([]); + const [results, setResults] = useState([]); + const [expanded, setExpanded] = useState>(new Set()); + + useEffect(() => { + if (!open) return; + setStage('target'); + setSelectedLabels(new Set()); + setStackName(''); + setEnvFiles([]); + setEnvFile('.env'); + setPlan([]); + setResults([]); + setExpanded(new Set()); + listDistinctLabels() + .then(setAllLabels) + .catch(() => setAllLabels([])); + }, [open]); + + const labelOptions: MultiSelectOption[] = useMemo( + () => allLabels.map((l) => ({ value: l, label: l })), + [allLabels], + ); + + function buildSelector(): BlueprintSelector { + const labels = Array.from(selectedLabels); + return { type: 'labels', any: labelMode === 'any' ? labels : [], all: labelMode === 'all' ? labels : [] }; + } + + async function loadEnvFiles() { + if (!stackName.trim()) return; + const ref = nodes[0]; + if (!ref) return; + setEnvFilesLoading(true); + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(stackName.trim())}/envs`, { + headers: { 'x-node-id': String(ref.id) }, + }); + if (!res.ok) { + setEnvFiles([]); + setEnvFile('.env'); + return; + } + const body = await res.json() as { envFiles?: string[] }; + const basenames = (body.envFiles ?? []).map((p) => p.split(/[\\/]/).pop() ?? ''); + const unique = Array.from(new Set(basenames.filter(Boolean))); + if (!unique.includes('.env')) unique.unshift('.env'); + setEnvFiles(unique); + if (!unique.includes(envFile)) setEnvFile(unique[0] ?? '.env'); + } catch { + setEnvFiles([]); + } finally { + setEnvFilesLoading(false); + } + } + + useEffect(() => { + if (stage !== 'target') return; + if (!stackName.trim()) { + setEnvFiles([]); + return; + } + const t = setTimeout(() => { void loadEnvFiles(); }, 250); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stage, stackName, nodes.length]); + + async function handlePreview() { + if (!secret) return; + if (selectedLabels.size === 0) { + toast.error('Pick at least one label'); + return; + } + if (!stackName.trim()) { + toast.error('Stack name is required'); + return; + } + setPreviewLoading(true); + try { + const result = await previewPush(secret.id, { + selector: buildSelector(), + stackName: stackName.trim(), + envFileBasename: envFile, + }); + if (result.length === 0) { + toast.error('No nodes match this selector'); + return; + } + setPlan(result); + setStage('preview'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to preview push'); + } finally { + setPreviewLoading(false); + } + } + + async function handlePush() { + if (!secret) return; + setPushLoading(true); + try { + const result = await executePush(secret.id, { + selector: buildSelector(), + stackName: stackName.trim(), + envFileBasename: envFile, + }); + setResults(result.results); + setStage('results'); + const okCount = result.results.filter(r => r.status === 'ok').length; + const failCount = result.results.length - okCount; + if (failCount === 0) toast.success(`Pushed to ${okCount} ${okCount === 1 ? 'node' : 'nodes'}`); + else toast.error(`${okCount} ok · ${failCount} failed`); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to push secret'); + } finally { + setPushLoading(false); + } + } + + function toggleExpanded(nodeId: number) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) next.delete(nodeId); + else next.add(nodeId); + return next; + }); + } + + const tabs: SystemSheetTab[] = [ + { id: 'target', label: 'Target' }, + { id: 'preview', label: 'Preview' }, + { id: 'results', label: 'Results' }, + ]; + + const primaryAction = (() => { + if (stage === 'target') return { + label: previewLoading ? 'Previewing…' : 'Preview', + onClick: handlePreview, + disabled: previewLoading || selectedLabels.size === 0 || !stackName.trim(), + icon: previewLoading ? Loader2 : undefined, + }; + if (stage === 'preview') return { + label: pushLoading ? 'Pushing…' : `Push to ${plan.length} ${plan.length === 1 ? 'node' : 'nodes'}`, + onClick: handlePush, + disabled: pushLoading, + icon: Send, + }; + return { + label: 'Done', + onClick: () => onOpenChange(false), + }; + })(); + + return ( + { + if (id === 'target') setStage('target'); + else if (id === 'preview' && plan.length > 0) setStage('preview'); + else if (id === 'results' && results.length > 0) setStage('results'); + }} + size="xl" + > + {stage === 'target' && ( +
+ +
+
+ Match + + + of these labels +
+ + {selectedLabels.size > 0 && ( +

+ {nodes.length} node{nodes.length === 1 ? '' : 's'} known to fleet · preview will resolve exact matches +

+ )} +
+
+ + +
+
+ + setStackName(e.target.value)} + placeholder="my-app" + className="font-mono" + /> +
+
+ + +

+ Lists files declared by the stack's compose on a representative target. Per-node compose can differ; nodes that don't declare the chosen file are reported as failed. +

+
+
+
+
+ )} + + {stage === 'preview' && ( +
+ {plan.length === 0 && ( +
Run preview from the Target tab.
+ )} + {plan.map((entry) => ( +
+ + {expanded.has(entry.nodeId) && entry.diff.length > 0 && ( +
+ {entry.diff.map((d) => ( +
+ {d.status} + {d.key} + {d.status === 'changed' && old to new} +
+ ))} +
+ )} +
+ ))} +
+ )} + + {stage === 'results' && ( +
+ {results.map((r) => { + const Icon = RESULT_ICON[r.status]; + return ( +
+ + {r.nodeName} + {r.status === 'ok' ? ( + + +{r.added} + ~{r.changed} + ·{r.unchanged} + + ) : ( + {r.error ?? r.status} + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/fleet/secrets/SecretsTab.tsx b/frontend/src/components/fleet/secrets/SecretsTab.tsx new file mode 100644 index 00000000..ead5cdb8 --- /dev/null +++ b/frontend/src/components/fleet/secrets/SecretsTab.tsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useState } from 'react'; +import { KeyRound, Plus, Pencil, Send, Trash2, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/toast-store'; +import { listSecrets, deleteSecret, type SecretSummary } from '@/lib/secretsApi'; +import { SecretBundleSheet } from './SecretBundleSheet'; +import { SecretPushSheet } from './SecretPushSheet'; + +export function SecretsTab() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [pushing, setPushing] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + const list = await listSecrets(); + setItems(list); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load secrets'; + setLoadError(message); + toast.error(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function handleDelete(secret: SecretSummary) { + if (!confirm(`Delete bundle '${secret.name}'? This removes all versions and push history.`)) return; + setDeletingId(secret.id); + try { + await deleteSecret(secret.id); + toast.success('Bundle deleted'); + await refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete bundle'); + } finally { + setDeletingId(null); + } + } + + if (loading) { + return ( +
+ + Loading secrets… +
+ ); + } + + if (loadError) { + return ( +
+
Could not load secrets
+

{loadError}

+ +
+ ); + } + + return ( +
+
+
+

Secret bundles

+

Centralized env-var bundles, encrypted at rest, versioned, pushed to labeled nodes.

+
+ +
+ + {items.length === 0 ? ( + setCreating(true)} /> + ) : ( +
+ + + + + + + + + + + + {items.map((s) => ( + + + + + + + + + ))} + +
NameDescriptionVersionKeysUpdated +
{s.name}{s.description || ·}v{s.currentVersion}{s.keyCount}{new Date(s.updatedAt).toLocaleString()} +
+ + + +
+
+
+ )} + + { + if (!o) { + setCreating(false); + setEditing(null); + } + }} + secret={editing} + onSaved={() => void refresh()} + /> + + { if (!o) setPushing(null); }} + secret={pushing} + /> +
+ ); +} + +function EmptyState({ onCreate }: { onCreate: () => void }) { + return ( +
+ +
+

One source of truth for env

+

+ Build a bundle of key=value pairs, push it to nodes by label, see exactly what changed before you write. +

+
+ +
+ ); +} diff --git a/frontend/src/lib/secretsApi.ts b/frontend/src/lib/secretsApi.ts new file mode 100644 index 00000000..9e8b0b8d --- /dev/null +++ b/frontend/src/lib/secretsApi.ts @@ -0,0 +1,180 @@ +import { apiFetch } from './api'; +import type { BlueprintSelector } from './blueprintsApi'; + +export type DiffStatus = 'added' | 'changed' | 'removed' | 'unchanged'; +export type SecretPushStatus = 'ok' | 'failed' | 'skipped'; + +export interface SecretSummary { + id: number; + name: string; + description: string; + currentVersion: number; + keyCount: number; + createdAt: number; + createdBy: string; + updatedAt: number; +} + +export interface SecretWithKv extends SecretSummary { + kv: Record; +} + +export interface SecretVersionSummary { + version: number; + keyCount: number; + createdAt: number; + createdBy: string; + note: string; +} + +export interface SecretDiffEntry { + key: string; + status: DiffStatus; + before?: string; + after?: string; +} + +export interface SecretPushPlanEntry { + nodeId: number; + nodeName: string; + stackName: string; + envFileBasename: string; + reachable: boolean; + stackExists: boolean; + error?: string; + diff: SecretDiffEntry[]; + added: number; + changed: number; + unchanged: number; + removedInformational: number; +} + +export interface SecretPushResultEntry { + nodeId: number; + nodeName: string; + stackName: string; + envFileBasename: string; + status: SecretPushStatus; + error?: string; + added: number; + changed: number; + unchanged: number; +} + +async function expectJson(res: Response, fallback: string): Promise { + if (!res.ok) { + let detail = fallback; + try { + const body = await res.json(); + if (body && typeof body === 'object' && typeof body.error === 'string') detail = body.error; + } catch { + // body not JSON + } + const err = new Error(detail) as Error & { status: number }; + err.status = res.status; + throw err; + } + return res.json() as Promise; +} + +async function expectOk(res: Response, fallback: string): Promise { + if (!res.ok) { + let detail = fallback; + try { + const body = await res.json(); + if (body && typeof body === 'object' && typeof body.error === 'string') detail = body.error; + } catch { + // body not JSON + } + const err = new Error(detail) as Error & { status: number }; + err.status = res.status; + throw err; + } +} + +export async function listSecrets(): Promise { + const res = await apiFetch('/secrets', { localOnly: true }); + return expectJson(res, 'Failed to load secrets'); +} + +export async function getSecret(id: number): Promise { + const res = await apiFetch(`/secrets/${id}`, { localOnly: true }); + return expectJson(res, 'Failed to load secret'); +} + +export interface CreateSecretInput { + name: string; + description?: string; + kv: Record; + note?: string; +} + +export async function createSecret(input: CreateSecretInput): Promise<{ id: number; version: number }> { + const res = await apiFetch('/secrets', { + method: 'POST', + body: JSON.stringify(input), + localOnly: true, + }); + return expectJson<{ id: number; version: number }>(res, 'Failed to create secret'); +} + +export interface UpdateSecretInput { + description?: string; + kv: Record; + note?: string; +} + +export async function updateSecret(id: number, input: UpdateSecretInput): Promise<{ version: number }> { + const res = await apiFetch(`/secrets/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + localOnly: true, + }); + return expectJson<{ version: number }>(res, 'Failed to update secret'); +} + +export async function deleteSecret(id: number): Promise { + const res = await apiFetch(`/secrets/${id}`, { + method: 'DELETE', + localOnly: true, + }); + await expectOk(res, 'Failed to delete secret'); +} + +export async function listSecretVersions(id: number): Promise { + const res = await apiFetch(`/secrets/${id}/versions`, { localOnly: true }); + return expectJson(res, 'Failed to load versions'); +} + +export async function importFromStack(id: number, input: { nodeId: number; stackName: string; envFileBasename?: string }): Promise<{ kv: Record }> { + const res = await apiFetch(`/secrets/${id}/import-from-stack`, { + method: 'POST', + body: JSON.stringify(input), + localOnly: true, + }); + return expectJson<{ kv: Record }>(res, 'Failed to import env from stack'); +} + +export interface PushInput { + selector: BlueprintSelector; + stackName: string; + envFileBasename: string; +} + +export async function previewPush(id: number, input: PushInput): Promise { + const res = await apiFetch(`/secrets/${id}/push/preview`, { + method: 'POST', + body: JSON.stringify(input), + localOnly: true, + }); + return expectJson(res, 'Failed to preview push'); +} + +export async function executePush(id: number, input: PushInput): Promise<{ pushId: string; results: SecretPushResultEntry[] }> { + const res = await apiFetch(`/secrets/${id}/push`, { + method: 'POST', + body: JSON.stringify(input), + localOnly: true, + }); + return expectJson<{ pushId: string; results: SecretPushResultEntry[] }>(res, 'Failed to push secret'); +}