From f6a7898798b7c2c685663022ec3212a5d38bde80 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 23 Apr 2026 21:22:39 -0400 Subject: [PATCH] refactor(backend): add route tests then extract settings, scheduled-tasks, agents (phase 4b) (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round B of Phase 4. Writes integration tests for three under-covered route groups BEFORE extracting them, then does the extraction once the new tests pass against the monolith. index.ts drops from ~4,206 to ~3,678 lines. New test coverage (42 new assertions): - settings-routes.test.ts (14) — auth, admin gating, private-key stripping, allowlist, single-key write, bulk PATCH validation + partial update - scheduled-tasks-routes.test.ts (18) — list/create/get/toggle/delete/runs, action+target_type matrix, cron validation, tier gating on non-admin - agents-routes.test.ts (10) — GET/POST, admin gating, channel type + HTTPS URL validation, boolean enabled check, upsert semantics Each suite was verified against the inline monolith first, then the route extraction was performed byte-for-byte and all suites re-run to ensure no regression. New route files: - routes/settings.ts — GET/POST/PATCH with PRIVATE_SETTINGS_KEYS strip, ALLOWED_SETTING_KEYS allowlist, and SettingsPatchSchema zod bulk schema - routes/scheduledTasks.ts — 9 endpoints (list, create, get, update, delete, toggle, run-now, runs history, runs CSV export). File-local helpers parseTaskId, validateActionTarget, validateOptionalFields collapse duplication across create+update handlers. Uses shared escapeCsvField from utils/csv.ts. - routes/agents.ts — notification-channel GET/POST. Owns NOTIFICATION_CHANNEL_TYPES and validateHttpsUrl locally because the notification-routes block still inlines identical copies; the helpers will converge once those routes extract in a later slice. --- backend/src/__tests__/agents-routes.test.ts | 128 +++++ .../__tests__/scheduled-tasks-routes.test.ts | 245 ++++++++ backend/src/__tests__/settings-routes.test.ts | 154 +++++ backend/src/index.ts | 544 +----------------- backend/src/routes/agents.ts | 51 ++ backend/src/routes/scheduledTasks.ts | 406 +++++++++++++ backend/src/routes/settings.ts | 95 +++ 7 files changed, 1087 insertions(+), 536 deletions(-) create mode 100644 backend/src/__tests__/agents-routes.test.ts create mode 100644 backend/src/__tests__/scheduled-tasks-routes.test.ts create mode 100644 backend/src/__tests__/settings-routes.test.ts create mode 100644 backend/src/routes/agents.ts create mode 100644 backend/src/routes/scheduledTasks.ts create mode 100644 backend/src/routes/settings.ts diff --git a/backend/src/__tests__/agents-routes.test.ts b/backend/src/__tests__/agents-routes.test.ts new file mode 100644 index 00000000..4bb6e7de --- /dev/null +++ b/backend/src/__tests__/agents-routes.test.ts @@ -0,0 +1,128 @@ +/** + * Integration tests for /api/agents (notification-channel configuration). + * Locks down auth, admin gating, and validation before extraction. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'agents-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'agents-viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM agents').run(); +}); + +describe('GET /api/agents', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get('/api/agents'); + expect(res.status).toBe(401); + }); + + it('returns empty array when no agents configured', async () => { + const res = await request(app).get('/api/agents').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it('lists configured agents for authenticated users', async () => { + const db = DatabaseService.getInstance(); + db.upsertAgent(1, { type: 'discord', url: 'https://discord.com/api/webhooks/abc/def', enabled: true }); + const res = await request(app).get('/api/agents').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + expect(res.body[0].type).toBe('discord'); + }); +}); + +describe('POST /api/agents', () => { + const validPayload = { + type: 'discord', + url: 'https://discord.com/api/webhooks/1/token', + enabled: true, + }; + + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).post('/api/agents').send(validPayload); + expect(res.status).toBe(401); + }); + + it('rejects non-admin users with 403', async () => { + const res = await request(app).post('/api/agents').set('Cookie', viewerCookie).send(validPayload); + expect(res.status).toBe(403); + }); + + it('rejects unsupported channel types with 400', async () => { + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...validPayload, type: 'carrier-pigeon', + }); + expect(res.status).toBe(400); + }); + + it('rejects non-HTTPS urls with 400', async () => { + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...validPayload, url: 'http://example.com/hook', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/url/); + }); + + it('rejects non-boolean enabled with 400', async () => { + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...validPayload, enabled: 'yes', + }); + expect(res.status).toBe(400); + }); + + it('upserts a valid agent', async () => { + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send(validPayload); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const agents = DatabaseService.getInstance().getAgents(1); + expect(agents.length).toBe(1); + expect(agents[0].type).toBe('discord'); + expect(Boolean(agents[0].enabled)).toBe(true); + }); + + it('replaces an existing agent of the same type (upsert)', async () => { + const db = DatabaseService.getInstance(); + db.upsertAgent(1, { type: 'slack', url: 'https://hooks.slack.com/old', enabled: false }); + + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'slack', url: 'https://hooks.slack.com/new', enabled: true, + }); + expect(res.status).toBe(200); + + const agents = db.getAgents(1); + expect(agents.length).toBe(1); + expect(agents[0].url).toBe('https://hooks.slack.com/new'); + expect(Boolean(agents[0].enabled)).toBe(true); + }); +}); diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts new file mode 100644 index 00000000..3c9a3321 --- /dev/null +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -0,0 +1,245 @@ +/** + * Integration tests for /api/scheduled-tasks. Locks down auth, tier gates, + * validation, and the list/create/get/update/toggle/run/delete lifecycle + * before extraction. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'sched-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'sched-viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + // Start each test with an empty scheduled_tasks table. + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM scheduled_tasks').run(); +}); + +describe('GET /api/scheduled-tasks', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get('/api/scheduled-tasks'); + expect(res.status).toBe(401); + }); + + it('rejects non-admin users with 403', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', viewerCookie); + expect(res.status).toBe(403); + }); + + it('returns an empty array when no tasks exist', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it('enriches each task with computed next_runs inside the window', async () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + db.createScheduledTask({ + name: 'nightly-scan', + target_type: 'system', + target_id: null, + node_id: 1, + action: 'scan', + cron_expression: '0 0 * * *', + enabled: 1, + created_by: 'admin', + created_at: now, + updated_at: now, + last_run_at: null, + next_run_at: now + 3600_000, + last_status: null, + last_error: null, + prune_targets: null, + target_services: null, + prune_label_filter: null, + }); + + const res = await request(app).get('/api/scheduled-tasks?window_hours=48').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + expect(res.body[0].name).toBe('nightly-scan'); + expect(Array.isArray(res.body[0].next_runs)).toBe(true); + }); +}); + +describe('POST /api/scheduled-tasks', () => { + const basePayload = { + name: 'daily-update', + target_type: 'stack', + target_id: 'my-stack', + node_id: 1, + action: 'update', + cron_expression: '0 3 * * *', + enabled: true, + }; + + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).post('/api/scheduled-tasks').send(basePayload); + expect(res.status).toBe(401); + }); + + it('rejects non-admin users with 403', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', viewerCookie).send(basePayload); + expect(res.status).toBe(403); + }); + + it('creates a task and returns the new record', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(basePayload); + expect(res.status).toBe(201); + expect(res.body.name).toBe('daily-update'); + expect(res.body.action).toBe('update'); + expect(res.body.enabled).toBe(1); + }); + + it('rejects an invalid cron expression with 400', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + ...basePayload, cron_expression: 'this is not cron', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid cron expression/); + }); + + it('rejects unsupported actions', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + ...basePayload, action: 'nuke', + }); + expect(res.status).toBe(400); + }); + + it('rejects action/target_type mismatches', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + ...basePayload, action: 'snapshot', target_type: 'stack', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Snapshot action requires target_type "fleet"/); + }); + + it('rejects scan without node_id', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'nightly-scan', target_type: 'system', action: 'scan', cron_expression: '0 0 * * *', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Scan action requires node_id/); + }); + + it('rejects target_services with wrong action', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + ...basePayload, action: 'update', target_services: ['web'], + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/target_services can only be used with restart/); + }); +}); + +describe('GET /api/scheduled-tasks/:id', () => { + let taskId: number; + beforeEach(() => { + const now = Date.now(); + taskId = DatabaseService.getInstance().createScheduledTask({ + name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update', + cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + }); + }); + + it('returns 404 for missing task', async () => { + const res = await request(app).get('/api/scheduled-tasks/99999').set('Cookie', adminCookie); + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid id', async () => { + const res = await request(app).get('/api/scheduled-tasks/not-a-number').set('Cookie', adminCookie); + expect(res.status).toBe(400); + }); + + it('returns the task for admin', async () => { + const res = await request(app).get(`/api/scheduled-tasks/${taskId}`).set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.id).toBe(taskId); + }); +}); + +describe('PATCH /api/scheduled-tasks/:id/toggle', () => { + it('flips the enabled flag and recomputes next_run_at', async () => { + const now = Date.now(); + const id = DatabaseService.getInstance().createScheduledTask({ + name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update', + cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now, + last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + }); + + const off = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie); + expect(off.status).toBe(200); + expect(off.body.enabled).toBe(0); + expect(off.body.next_run_at).toBeNull(); + + const on = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie); + expect(on.status).toBe(200); + expect(on.body.enabled).toBe(1); + expect(typeof on.body.next_run_at).toBe('number'); + }); +}); + +describe('DELETE /api/scheduled-tasks/:id', () => { + it('deletes the task and subsequent GET returns 404', async () => { + const now = Date.now(); + const id = DatabaseService.getInstance().createScheduledTask({ + name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update', + cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + }); + + const del = await request(app).delete(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie); + expect(del.status).toBe(200); + expect(del.body.success).toBe(true); + + const get = await request(app).get(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie); + expect(get.status).toBe(404); + }); +}); + +describe('GET /api/scheduled-tasks/:id/runs', () => { + it('returns paginated run history', async () => { + const now = Date.now(); + const id = DatabaseService.getInstance().createScheduledTask({ + name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update', + cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + }); + + const res = await request(app).get(`/api/scheduled-tasks/${id}/runs`).set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('runs'); + }); +}); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts new file mode 100644 index 00000000..9b882180 --- /dev/null +++ b/backend/src/__tests__/settings-routes.test.ts @@ -0,0 +1,154 @@ +/** + * Integration tests for /api/settings (GET/POST/PATCH). These endpoints had + * zero route-layer coverage prior to Phase 4B of the index.ts refactor; this + * file locks down the shape before extraction. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'settings-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'settings-viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('GET /api/settings', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get('/api/settings'); + expect(res.status).toBe(401); + }); + + it('returns settings for authenticated users', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toBeInstanceOf(Object); + }); + + it('strips auth credentials from the response', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.auth_username).toBeUndefined(); + expect(res.body.auth_password_hash).toBeUndefined(); + expect(res.body.auth_jwt_secret).toBeUndefined(); + }); + + it('allows non-admin users to read settings', async () => { + // Settings is read-only for non-admins; write is admin-gated separately. + const res = await request(app).get('/api/settings').set('Cookie', viewerCookie); + expect(res.status).toBe(200); + }); +}); + +describe('POST /api/settings (single-key write)', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).post('/api/settings').send({ key: 'host_cpu_limit', value: '80' }); + expect(res.status).toBe(401); + }); + + it('rejects non-admin users with 403', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', viewerCookie) + .send({ key: 'host_cpu_limit', value: '80' }); + expect(res.status).toBe(403); + }); + + it('rejects disallowed setting keys with 400', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'auth_jwt_secret', value: 'pwned' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid or disallowed setting key/); + }); + + it('rejects missing value with 400', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'host_cpu_limit' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/value is required/); + }); + + it('updates an allowlisted key', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'host_cpu_limit', value: '75' }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const settings = DatabaseService.getInstance().getGlobalSettings(); + expect(settings.host_cpu_limit).toBe('75'); + }); +}); + +describe('PATCH /api/settings (bulk update)', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).patch('/api/settings').send({ host_cpu_limit: 50 }); + expect(res.status).toBe(401); + }); + + it('rejects non-admin users with 403', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', viewerCookie) + .send({ host_cpu_limit: 50 }); + expect(res.status).toBe(403); + }); + + it('rejects invalid values with 400 and returns field-level errors', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', adminCookie) + .send({ host_cpu_limit: 9999, log_retention_days: 'not-a-number' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(res.body.details).toBeInstanceOf(Object); + }); + + it('applies a partial update atomically', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', adminCookie) + .send({ host_cpu_limit: 60, host_ram_limit: 70 }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const settings = DatabaseService.getInstance().getGlobalSettings(); + expect(settings.host_cpu_limit).toBe('60'); + expect(settings.host_ram_limit).toBe('70'); + }); + + it('accepts an empty body and no-ops successfully', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Cookie', adminCookie) + .send({}); + expect(res.status).toBe(200); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index fc7c220e..370cebd9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,7 +6,7 @@ import { ComposeService } from './services/ComposeService'; import crypto from 'crypto'; import si from 'systeminformation'; import path from 'path'; -import { DatabaseService, ScheduledTask, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService'; +import { DatabaseService, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService'; import { NotificationService } from './services/NotificationService'; import { MonitorService } from './services/MonitorService'; import { AutoHealService } from './services/AutoHealService'; @@ -41,7 +41,6 @@ import { requirePaid, requireAdmiral, requireAdmin, - requireScheduledTaskTier, } from './middleware/tierGates'; import { buildPolicyGateOptions, @@ -76,6 +75,9 @@ import { alertsRouter } from './routes/alerts'; import { labelsRouter, stackLabelsRouter } from './routes/labels'; import { apiTokensRouter } from './routes/apiTokens'; import { auditLogRouter } from './routes/auditLog'; +import { settingsRouter } from './routes/settings'; +import { scheduledTasksRouter } from './routes/scheduledTasks'; +import { agentsRouter } from './routes/agents'; import { isDebugEnabled } from './utils/debug'; import { getErrorMessage } from './utils/errors'; @@ -87,7 +89,7 @@ import { enforcePolicyPreDeploy } from './services/PolicyEnforcement'; import { validateImageRef } from './utils/image-ref'; import { applySuppressions } from './utils/suppression-filter'; import { generateSarif } from './services/SarifExporter'; -import { CronExpressionParser } from 'cron-parser'; +import { z } from 'zod'; import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation'; import YAML from 'yaml'; import { promises as fsPromises } from 'fs'; @@ -149,6 +151,9 @@ app.use('/api/webhooks', webhooksRouter); app.use('/api/users', usersRouter); app.use('/api/git-sources', gitSourcesRouter); app.use('/api/stacks', stackGitSourceRouter); +app.use('/api/settings', settingsRouter); +app.use('/api/scheduled-tasks', scheduledTasksRouter); +app.use('/api/agents', agentsRouter); // Symbols still consumed by inline security and node routes still living in // index.ts. These will move with their route groups in a later slice. @@ -1191,130 +1196,6 @@ function validateHttpsUrl(value: unknown): string | null { return null; } -app.get('/api/agents', authMiddleware, async (req: Request, res: Response) => { - try { - const nodeId = req.nodeId ?? 0; - const agents = DatabaseService.getInstance().getAgents(nodeId); - res.json(agents); - } catch (error) { - console.error('Failed to fetch agents:', error); - res.status(500).json({ error: 'Failed to fetch agents' }); - } -}); - -app.post('/api/agents', authMiddleware, async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { type, url, enabled } = req.body; - if (!type || !NOTIFICATION_CHANNEL_TYPES.includes(type)) { - res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); - return; - } - const urlErr = validateHttpsUrl(url); - if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; } - if (typeof enabled !== 'boolean') { - res.status(400).json({ error: 'enabled must be a boolean' }); - return; - } - const nodeId = req.nodeId ?? 0; - DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled }); - console.log(`[Agents] Agent ${type} updated`); - if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`); - res.json({ success: true }); - } catch (error) { - console.error('Failed to update agent:', error); - res.status(500).json({ error: 'Failed to update agent' }); - } -}); - -// Keys that contain auth credentials - never exposed to the frontend or writable via settings API -const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']); - -// Strict allowlist of keys writable via the settings API (prevents overwriting auth credentials) -const ALLOWED_SETTING_KEYS = new Set([ - 'host_cpu_limit', - 'host_ram_limit', - 'host_disk_limit', - 'docker_janitor_gb', - 'global_crash', - 'developer_mode', - 'template_registry_url', - 'metrics_retention_hours', - 'log_retention_days', - 'audit_retention_days', -]); - -// Zod schema for bulk PATCH - all keys optional, present keys fully validated -import { z } from 'zod'; -const SettingsPatchSchema = z.object({ - host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String), - host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String), - host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String), - docker_janitor_gb: z.coerce.number().min(0).transform(String), - global_crash: z.enum(['0', '1']), - developer_mode: z.enum(['0', '1']), - template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }), - metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String), - log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), - audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String), -}).partial(); - -app.get('/api/settings', async (req: Request, res: Response) => { - try { - const settings = DatabaseService.getInstance().getGlobalSettings(); - // Strip auth credentials - these are managed exclusively by /api/auth/* endpoints - for (const key of PRIVATE_SETTINGS_KEYS) { - delete settings[key]; - } - res.json(settings); - } catch (error) { - console.error('Failed to fetch settings:', error); - res.status(500).json({ error: 'Failed to fetch settings' }); - } -}); - -app.post('/api/settings', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { key, value } = req.body; - if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) { - res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` }); - return; - } - if (value === undefined || value === null) { - res.status(400).json({ error: 'Setting value is required' }); - return; - } - DatabaseService.getInstance().updateGlobalSetting(key, String(value)); - res.json({ success: true }); - } catch (error) { - console.error('Failed to update setting:', error); - res.status(500).json({ error: 'Failed to update setting' }); - } -}); - -app.patch('/api/settings', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const parsed = SettingsPatchSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }); - return; - } - const db = DatabaseService.getInstance(); - const updateMany = db.getDb().transaction((entries: [string, string][]) => { - for (const [k, v] of entries) { - db.updateGlobalSetting(k, v); - } - }); - updateMany(Object.entries(parsed.data) as [string, string][]); - res.json({ success: true }); - } catch (error) { - console.error('Failed to bulk update settings:', error); - res.status(500).json({ error: 'Failed to update settings' }); - } -}); - const AutoHealPolicyCreateSchema = z.object({ stack_name: z.string().min(1).max(255), service_name: z.string().min(1).max(255).nullable().optional(), @@ -1798,415 +1679,6 @@ app.post('/api/sso/config/:provider/test', async (req: Request, res: Response): // --- Scheduled Operations Routes (Admiral, admin-only, local-only) --- -app.get('/api/scheduled-tasks', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - let tasks = DatabaseService.getInstance().getScheduledTasks(); - // Skipper users only see 'update' tasks; Admiral sees all - const ls = LicenseService.getInstance(); - if (ls.getVariant() !== 'admiral') { - tasks = tasks.filter(t => t.action === 'update'); - } - // Separate Auto-Update and Scheduled Operations into distinct views - const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined; - const excludeAction = typeof req.query.exclude_action === 'string' ? req.query.exclude_action : undefined; - if (actionFilter) { - tasks = tasks.filter(t => t.action === actionFilter); - } else if (excludeAction) { - tasks = tasks.filter(t => t.action !== excludeAction); - } - - // Timeline view needs every firing inside a rolling window, not just the next run. - const scheduler = SchedulerService.getInstance(); - const windowHours = Math.min(Math.max(Number(req.query.window_hours) || 24, 1), 168); - const from = Date.now(); - const to = from + windowHours * 60 * 60 * 1000; - const enriched = tasks.map(t => ({ - ...t, - next_runs: t.enabled === 1 ? scheduler.calculateRunsWithin(t.cron_expression, from, to) : [], - })); - - res.json(enriched); - } catch (error) { - console.error('[ScheduledTasks] List error:', error); - res.status(500).json({ error: 'Failed to fetch scheduled tasks' }); - } -}); - -app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - try { - const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body; - - if (!name || typeof name !== 'string' || !name.trim()) { - res.status(400).json({ error: 'Name is required' }); return; - } - if (!['stack', 'fleet', 'system'].includes(target_type)) { - res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return; - } - if (!['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) { - res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, or scan.' }); return; - } - // Tier gate based on action type - if (!requireScheduledTaskTier(action, req, res)) return; - // Validate action-target combos - if (action === 'restart' && target_type !== 'stack') { - res.status(400).json({ error: 'Restart action requires target_type "stack".' }); return; - } - if (action === 'update' && target_type !== 'stack') { - res.status(400).json({ error: 'Update action requires target_type "stack".' }); return; - } - if (action === 'snapshot' && target_type !== 'fleet') { - res.status(400).json({ error: 'Snapshot action requires target_type "fleet".' }); return; - } - if (action === 'prune' && target_type !== 'system') { - res.status(400).json({ error: 'Prune action requires target_type "system".' }); return; - } - if (action === 'scan' && target_type !== 'system') { - res.status(400).json({ error: 'Scan action requires target_type "system".' }); return; - } - if (action === 'scan' && !node_id) { - res.status(400).json({ error: 'Scan action requires node_id.' }); return; - } - if (target_type === 'stack' && (!target_id || !node_id)) { - res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return; - } - // Validate prune targets - const validPruneTargets = ['containers', 'images', 'networks', 'volumes']; - if (prune_targets !== undefined && prune_targets !== null) { - if (!Array.isArray(prune_targets) || prune_targets.length === 0 || !prune_targets.every((t: string) => validPruneTargets.includes(t))) { - res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return; - } - } - // Validate target_services - if (target_services !== undefined && target_services !== null) { - if (!Array.isArray(target_services) || target_services.length === 0 || !target_services.every((s: unknown) => typeof s === 'string' && s.length > 0)) { - res.status(400).json({ error: 'target_services must be a non-empty array of service name strings' }); return; - } - if (action !== 'restart' || target_type !== 'stack') { - res.status(400).json({ error: 'target_services can only be used with restart action on stack target' }); return; - } - } - // Validate prune_label_filter - if (prune_label_filter !== undefined && prune_label_filter !== null) { - if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) { - res.status(400).json({ error: 'prune_label_filter must be a non-empty string' }); return; - } - if (action !== 'prune') { - res.status(400).json({ error: 'prune_label_filter can only be used with prune action' }); return; - } - } - // Validate cron expression - try { CronExpressionParser.parse(cron_expression); } catch (e) { - console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, (e as Error).message); - res.status(400).json({ error: 'Invalid cron expression.' }); return; - } - - const scheduler = SchedulerService.getInstance(); - const now = Date.now(); - const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null; - - const id = DatabaseService.getInstance().createScheduledTask({ - name: name.trim(), - target_type, - target_id: target_id || null, - node_id: node_id != null ? Number(node_id) : null, - action, - cron_expression, - enabled: enabled !== false ? 1 : 0, - created_by: req.user?.username || 'admin', - created_at: now, - updated_at: now, - last_run_at: null, - next_run_at: nextRun, - last_status: null, - last_error: null, - prune_targets: prune_targets ? JSON.stringify(prune_targets) : null, - target_services: target_services ? JSON.stringify(target_services) : null, - prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null, - }); - - console.log(`[ScheduledTasks] Created task id=${id} action=${action} target=${target_id || 'none'}`); - const task = DatabaseService.getInstance().getScheduledTask(id); - res.status(201).json(task); - } catch (error) { - console.error('[ScheduledTasks] Create error:', error); - res.status(500).json({ error: 'Failed to create scheduled task' }); - } -}); - -app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } - const task = DatabaseService.getInstance().getScheduledTask(id); - if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(task.action, req, res)) return; - res.json(task); - } catch (error) { - console.error('[ScheduledTasks] Get error:', error); - res.status(500).json({ error: 'Failed to fetch scheduled task' }); - } -}); - -app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } - - const db = DatabaseService.getInstance(); - const existing = db.getScheduledTask(id); - if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; - - const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body; - - if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) { - res.status(400).json({ error: 'Invalid target_type' }); return; - } - if (action && !['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) { - res.status(400).json({ error: 'Invalid action' }); return; - } - - const finalAction = action || existing.action; - const finalTargetType = target_type || existing.target_type; - if (finalAction === 'restart' && finalTargetType !== 'stack') { - res.status(400).json({ error: 'Restart action requires target_type "stack".' }); return; - } - if (finalAction === 'update' && finalTargetType !== 'stack') { - res.status(400).json({ error: 'Update action requires target_type "stack".' }); return; - } - if (finalAction === 'snapshot' && finalTargetType !== 'fleet') { - res.status(400).json({ error: 'Snapshot action requires target_type "fleet".' }); return; - } - if (finalAction === 'prune' && finalTargetType !== 'system') { - res.status(400).json({ error: 'Prune action requires target_type "system".' }); return; - } - if (finalAction === 'scan' && finalTargetType !== 'system') { - res.status(400).json({ error: 'Scan action requires target_type "system".' }); return; - } - if (finalAction === 'scan') { - const finalNodeId = node_id !== undefined ? node_id : existing.node_id; - if (!finalNodeId) { - res.status(400).json({ error: 'Scan action requires node_id.' }); return; - } - } - - // Validate prune targets - const validPruneTargets = ['containers', 'images', 'networks', 'volumes']; - if (prune_targets !== undefined && prune_targets !== null) { - if (!Array.isArray(prune_targets) || prune_targets.length === 0 || !prune_targets.every((t: string) => validPruneTargets.includes(t))) { - res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return; - } - } - // Validate target_services - if (target_services !== undefined && target_services !== null) { - if (!Array.isArray(target_services) || target_services.length === 0 || !target_services.every((s: unknown) => typeof s === 'string' && s.length > 0)) { - res.status(400).json({ error: 'target_services must be a non-empty array of service name strings' }); return; - } - if (finalAction !== 'restart' || finalTargetType !== 'stack') { - res.status(400).json({ error: 'target_services can only be used with restart action on stack target' }); return; - } - } - // Validate prune_label_filter - if (prune_label_filter !== undefined && prune_label_filter !== null) { - if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) { - res.status(400).json({ error: 'prune_label_filter must be a non-empty string' }); return; - } - if (finalAction !== 'prune') { - res.status(400).json({ error: 'prune_label_filter can only be used with prune action' }); return; - } - } - - if (cron_expression) { - try { CronExpressionParser.parse(cron_expression); } catch (e) { - console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, (e as Error).message); - res.status(400).json({ error: 'Invalid cron expression.' }); return; - } - } - - const updates: Record = { updated_at: Date.now() }; - if (name !== undefined) updates.name = typeof name === 'string' ? name.trim() : name; - if (target_type !== undefined) updates.target_type = target_type; - if (target_id !== undefined) updates.target_id = target_id || null; - if (node_id !== undefined) updates.node_id = node_id != null ? Number(node_id) : null; - if (action !== undefined) updates.action = action; - if (cron_expression !== undefined) updates.cron_expression = cron_expression; - if (enabled !== undefined) updates.enabled = enabled ? 1 : 0; - if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null; - if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null; - if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null; - - // Recalculate next_run if cron changed or if enabling - const finalCron = cron_expression || existing.cron_expression; - const finalEnabled = enabled !== undefined ? enabled : existing.enabled; - if (finalEnabled) { - updates.next_run_at = SchedulerService.getInstance().calculateNextRun(finalCron); - } else { - updates.next_run_at = null; - } - - db.updateScheduledTask(id, updates as Partial>); - console.log(`[ScheduledTasks] Updated task id=${id}`); - const task = db.getScheduledTask(id); - res.json(task); - } catch (error) { - console.error('[ScheduledTasks] Update error:', error); - res.status(500).json({ error: 'Failed to update scheduled task' }); - } -}); - -app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } - - const db = DatabaseService.getInstance(); - const existing = db.getScheduledTask(id); - if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; - - db.deleteScheduledTask(id); - console.log(`[ScheduledTasks] Deleted task id=${id}`); - res.json({ success: true }); - } catch (error) { - console.error('[ScheduledTasks] Delete error:', error); - res.status(500).json({ error: 'Failed to delete scheduled task' }); - } -}); - -app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } - - const db = DatabaseService.getInstance(); - const existing = db.getScheduledTask(id); - if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; - - const newEnabled = existing.enabled ? 0 : 1; - const nextRun = newEnabled ? SchedulerService.getInstance().calculateNextRun(existing.cron_expression) : null; - - db.updateScheduledTask(id, { - enabled: newEnabled, - next_run_at: nextRun, - updated_at: Date.now(), - }); - - console.log(`[ScheduledTasks] Toggled task id=${id} enabled=${newEnabled}`); - const task = db.getScheduledTask(id); - res.json(task); - } catch (error) { - console.error('[ScheduledTasks] Toggle error:', error); - res.status(500).json({ error: 'Failed to toggle scheduled task' }); - } -}); - -app.post('/api/scheduled-tasks/:id/run', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } - - const db = DatabaseService.getInstance(); - const existing = db.getScheduledTask(id); - if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; - - const scheduler = SchedulerService.getInstance(); - if (scheduler.isTaskRunning(id)) { - res.status(409).json({ error: 'Task is already running' }); return; - } - - console.log(`[ScheduledTasks] Manual run requested for task id=${id}`); - scheduler.triggerTask(id).catch((err: unknown) => { - const msg = getErrorMessage(err, String(err)); - console.error(`[ScheduledTasks] Background run error for task ${id}:`, msg); - }); - - res.status(202).json({ message: 'Task triggered', task_id: id }); - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Failed to run task'; - console.error('[ScheduledTasks] Run error:', msg); - res.status(500).json({ error: msg }); - } -}); - -app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } - - const db = DatabaseService.getInstance(); - const task = db.getScheduledTask(id); - if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(task.action, req, res)) return; - - const runs = db.getAllScheduledTaskRuns(id); - - const escapeCsv = (val: string): string => { - if (val.includes(',') || val.includes('"') || val.includes('\n')) { - return `"${val.replace(/"/g, '""')}"`; - } - return val; - }; - - const lines = ['Timestamp,Source,Status,Duration (s),Details']; - for (const run of runs) { - const timestamp = new Date(run.started_at).toISOString(); - const source = run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'; - const status = run.status.charAt(0).toUpperCase() + run.status.slice(1); - const duration = run.completed_at && run.started_at - ? ((run.completed_at - run.started_at) / 1000).toFixed(1) - : ''; - const details = run.error || run.output || ''; - lines.push(`${escapeCsv(timestamp)},${escapeCsv(source)},${escapeCsv(status)},${escapeCsv(duration)},${escapeCsv(details)}`); - } - - const safeName = task.name.replace(/[^a-zA-Z0-9_-]/g, '_'); - res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="task-${safeName}-history.csv"`); - res.send(lines.join('\n')); - } catch (error) { - console.error('[ScheduledTasks] Export error:', error); - res.status(500).json({ error: 'Failed to export task runs' }); - } -}); - -app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } - - const db = DatabaseService.getInstance(); - const existing = db.getScheduledTask(id); - if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; - - const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); - const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); - const result = db.getScheduledTaskRuns(id, limit, offset); - res.json(result); - } catch (error) { - console.error('[ScheduledTasks] Runs error:', error); - res.status(500).json({ error: 'Failed to fetch task runs' }); - } -}); // --- Private Registry Routes (Admiral, admin-only, local-only) --- diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts new file mode 100644 index 00000000..4e7e4afd --- /dev/null +++ b/backend/src/routes/agents.ts @@ -0,0 +1,51 @@ +import { Router, type Request, type Response } from 'express'; +import { DatabaseService } from '../services/DatabaseService'; +import { authMiddleware } from '../middleware/auth'; +import { requireAdmin } from '../middleware/tierGates'; +import { isDebugEnabled } from '../utils/debug'; + +const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const; + +function validateHttpsUrl(value: unknown): string | null { + if (!value || typeof value !== 'string' || !value.startsWith('https://')) return 'must be a valid HTTPS URL'; + try { new URL(value); } catch { return 'is not a valid URL'; } + return null; +} + +export const agentsRouter = Router(); + +agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { + try { + const nodeId = req.nodeId ?? 0; + const agents = DatabaseService.getInstance().getAgents(nodeId); + res.json(agents); + } catch (error) { + console.error('Failed to fetch agents:', error); + res.status(500).json({ error: 'Failed to fetch agents' }); + } +}); + +agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const { type, url, enabled } = req.body; + if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) { + res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); + return; + } + const urlErr = validateHttpsUrl(url); + if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; } + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + const nodeId = req.nodeId ?? 0; + DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled }); + console.log(`[Agents] Agent ${type} updated`); + if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`); + res.json({ success: true }); + } catch (error) { + console.error('Failed to update agent:', error); + res.status(500).json({ error: 'Failed to update agent' }); + } +}); diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts new file mode 100644 index 00000000..910c975b --- /dev/null +++ b/backend/src/routes/scheduledTasks.ts @@ -0,0 +1,406 @@ +import { Router, type Request, type Response } from 'express'; +import { CronExpressionParser } from 'cron-parser'; +import { DatabaseService, type ScheduledTask } from '../services/DatabaseService'; +import { LicenseService } from '../services/LicenseService'; +import { SchedulerService } from '../services/SchedulerService'; +import { requirePaid, requireAdmin, requireScheduledTaskTier } from '../middleware/tierGates'; +import { escapeCsvField } from '../utils/csv'; +import { getErrorMessage } from '../utils/errors'; + +const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const; +const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan'] as const; +const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const; + +type TargetType = typeof VALID_TARGET_TYPES[number]; +type ScheduledAction = typeof VALID_ACTIONS[number]; + +function parseTaskId(req: Request, res: Response): number | null { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { + res.status(400).json({ error: 'Invalid task ID' }); + return null; + } + return id; +} + +/** + * Validate that the target_type is compatible with the action. Each action + * has exactly one allowed target_type; the helper returns an error message + * on mismatch and null otherwise. + */ +function validateActionTarget(action: ScheduledAction, targetType: TargetType): string | null { + if (action === 'restart' && targetType !== 'stack') return 'Restart action requires target_type "stack".'; + if (action === 'update' && targetType !== 'stack') return 'Update action requires target_type "stack".'; + if (action === 'snapshot' && targetType !== 'fleet') return 'Snapshot action requires target_type "fleet".'; + if (action === 'prune' && targetType !== 'system') return 'Prune action requires target_type "system".'; + if (action === 'scan' && targetType !== 'system') return 'Scan action requires target_type "system".'; + return null; +} + +/** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */ +function validateOptionalFields( + action: ScheduledAction, + targetType: TargetType, + prune_targets: unknown, + target_services: unknown, + prune_label_filter: unknown, +): string | null { + if (prune_targets !== undefined && prune_targets !== null) { + if (!Array.isArray(prune_targets) || prune_targets.length === 0 + || !prune_targets.every((t: string) => (VALID_PRUNE_TARGETS as readonly string[]).includes(t))) { + return 'prune_targets must be a non-empty array of: containers, images, networks, volumes'; + } + } + if (target_services !== undefined && target_services !== null) { + if (!Array.isArray(target_services) || target_services.length === 0 + || !target_services.every((s: unknown) => typeof s === 'string' && s.length > 0)) { + return 'target_services must be a non-empty array of service name strings'; + } + if (action !== 'restart' || targetType !== 'stack') { + return 'target_services can only be used with restart action on stack target'; + } + } + if (prune_label_filter !== undefined && prune_label_filter !== null) { + if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) { + return 'prune_label_filter must be a non-empty string'; + } + if (action !== 'prune') { + return 'prune_label_filter can only be used with prune action'; + } + } + return null; +} + +export const scheduledTasksRouter = Router(); + +scheduledTasksRouter.get('/', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + let tasks = DatabaseService.getInstance().getScheduledTasks(); + // Skipper users only see 'update' tasks; Admiral sees all. + const ls = LicenseService.getInstance(); + if (ls.getVariant() !== 'admiral') { + tasks = tasks.filter(t => t.action === 'update'); + } + // Split Auto-Update and Scheduled Operations into distinct views. + const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined; + const excludeAction = typeof req.query.exclude_action === 'string' ? req.query.exclude_action : undefined; + if (actionFilter) { + tasks = tasks.filter(t => t.action === actionFilter); + } else if (excludeAction) { + tasks = tasks.filter(t => t.action !== excludeAction); + } + + // Timeline view wants every firing inside a rolling window, not just the next run. + const scheduler = SchedulerService.getInstance(); + const windowHours = Math.min(Math.max(Number(req.query.window_hours) || 24, 1), 168); + const from = Date.now(); + const to = from + windowHours * 60 * 60 * 1000; + const enriched = tasks.map(t => ({ + ...t, + next_runs: t.enabled === 1 ? scheduler.calculateRunsWithin(t.cron_expression, from, to) : [], + })); + + res.json(enriched); + } catch (error) { + console.error('[ScheduledTasks] List error:', error); + res.status(500).json({ error: 'Failed to fetch scheduled tasks' }); + } +}); + +scheduledTasksRouter.post('/', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body; + + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ error: 'Name is required' }); return; + } + if (!(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) { + res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return; + } + if (!(VALID_ACTIONS as readonly string[]).includes(action)) { + res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, or scan.' }); return; + } + if (!requireScheduledTaskTier(action, req, res)) return; + + const targetErr = validateActionTarget(action, target_type); + if (targetErr) { res.status(400).json({ error: targetErr }); return; } + + if (action === 'scan' && !node_id) { + res.status(400).json({ error: 'Scan action requires node_id.' }); return; + } + if (target_type === 'stack' && (!target_id || !node_id)) { + res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return; + } + + const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter); + if (optionalErr) { res.status(400).json({ error: optionalErr }); return; } + + try { CronExpressionParser.parse(cron_expression); } catch (e) { + console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, getErrorMessage(e, 'unknown')); + res.status(400).json({ error: 'Invalid cron expression.' }); return; + } + + const scheduler = SchedulerService.getInstance(); + const now = Date.now(); + const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null; + + const id = DatabaseService.getInstance().createScheduledTask({ + name: name.trim(), + target_type, + target_id: target_id || null, + node_id: node_id != null ? Number(node_id) : null, + action, + cron_expression, + enabled: enabled !== false ? 1 : 0, + created_by: req.user?.username || 'admin', + created_at: now, + updated_at: now, + last_run_at: null, + next_run_at: nextRun, + last_status: null, + last_error: null, + prune_targets: prune_targets ? JSON.stringify(prune_targets) : null, + target_services: target_services ? JSON.stringify(target_services) : null, + prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null, + }); + + console.log(`[ScheduledTasks] Created task id=${id} action=${action} target=${target_id || 'none'}`); + const task = DatabaseService.getInstance().getScheduledTask(id); + res.status(201).json(task); + } catch (error) { + console.error('[ScheduledTasks] Create error:', error); + res.status(500).json({ error: 'Failed to create scheduled task' }); + } +}); + +scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + const id = parseTaskId(req, res); + if (id === null) return; + const task = DatabaseService.getInstance().getScheduledTask(id); + if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireScheduledTaskTier(task.action, req, res)) return; + res.json(task); + } catch (error) { + console.error('[ScheduledTasks] Get error:', error); + res.status(500).json({ error: 'Failed to fetch scheduled task' }); + } +}); + +scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + const id = parseTaskId(req, res); + if (id === null) return; + + const db = DatabaseService.getInstance(); + const existing = db.getScheduledTask(id); + if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireScheduledTaskTier(existing.action, req, res)) return; + + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body; + + if (target_type && !(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) { + res.status(400).json({ error: 'Invalid target_type' }); return; + } + if (action && !(VALID_ACTIONS as readonly string[]).includes(action)) { + res.status(400).json({ error: 'Invalid action' }); return; + } + + const finalAction = (action || existing.action) as ScheduledAction; + const finalTargetType = (target_type || existing.target_type) as TargetType; + const targetErr = validateActionTarget(finalAction, finalTargetType); + if (targetErr) { res.status(400).json({ error: targetErr }); return; } + + if (finalAction === 'scan') { + const finalNodeId = node_id !== undefined ? node_id : existing.node_id; + if (!finalNodeId) { + res.status(400).json({ error: 'Scan action requires node_id.' }); return; + } + } + + const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter); + if (optionalErr) { res.status(400).json({ error: optionalErr }); return; } + + if (cron_expression) { + try { CronExpressionParser.parse(cron_expression); } catch (e) { + console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, getErrorMessage(e, 'unknown')); + res.status(400).json({ error: 'Invalid cron expression.' }); return; + } + } + + const updates: Record = { updated_at: Date.now() }; + if (name !== undefined) updates.name = typeof name === 'string' ? name.trim() : name; + if (target_type !== undefined) updates.target_type = target_type; + if (target_id !== undefined) updates.target_id = target_id || null; + if (node_id !== undefined) updates.node_id = node_id != null ? Number(node_id) : null; + if (action !== undefined) updates.action = action; + if (cron_expression !== undefined) updates.cron_expression = cron_expression; + if (enabled !== undefined) updates.enabled = enabled ? 1 : 0; + if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null; + if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null; + if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null; + + const finalCron = cron_expression || existing.cron_expression; + const finalEnabled = enabled !== undefined ? enabled : existing.enabled; + if (finalEnabled) { + updates.next_run_at = SchedulerService.getInstance().calculateNextRun(finalCron); + } else { + updates.next_run_at = null; + } + + db.updateScheduledTask(id, updates as Partial>); + console.log(`[ScheduledTasks] Updated task id=${id}`); + const task = db.getScheduledTask(id); + res.json(task); + } catch (error) { + console.error('[ScheduledTasks] Update error:', error); + res.status(500).json({ error: 'Failed to update scheduled task' }); + } +}); + +scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + const id = parseTaskId(req, res); + if (id === null) return; + + const db = DatabaseService.getInstance(); + const existing = db.getScheduledTask(id); + if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireScheduledTaskTier(existing.action, req, res)) return; + + db.deleteScheduledTask(id); + console.log(`[ScheduledTasks] Deleted task id=${id}`); + res.json({ success: true }); + } catch (error) { + console.error('[ScheduledTasks] Delete error:', error); + res.status(500).json({ error: 'Failed to delete scheduled task' }); + } +}); + +scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + const id = parseTaskId(req, res); + if (id === null) return; + + const db = DatabaseService.getInstance(); + const existing = db.getScheduledTask(id); + if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireScheduledTaskTier(existing.action, req, res)) return; + + const newEnabled = existing.enabled ? 0 : 1; + const nextRun = newEnabled ? SchedulerService.getInstance().calculateNextRun(existing.cron_expression) : null; + + db.updateScheduledTask(id, { + enabled: newEnabled, + next_run_at: nextRun, + updated_at: Date.now(), + }); + + console.log(`[ScheduledTasks] Toggled task id=${id} enabled=${newEnabled}`); + const task = db.getScheduledTask(id); + res.json(task); + } catch (error) { + console.error('[ScheduledTasks] Toggle error:', error); + res.status(500).json({ error: 'Failed to toggle scheduled task' }); + } +}); + +scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + const id = parseTaskId(req, res); + if (id === null) return; + + const db = DatabaseService.getInstance(); + const existing = db.getScheduledTask(id); + if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireScheduledTaskTier(existing.action, req, res)) return; + + const scheduler = SchedulerService.getInstance(); + if (scheduler.isTaskRunning(id)) { + res.status(409).json({ error: 'Task is already running' }); return; + } + + console.log(`[ScheduledTasks] Manual run requested for task id=${id}`); + scheduler.triggerTask(id).catch((err: unknown) => { + const msg = getErrorMessage(err, String(err)); + console.error(`[ScheduledTasks] Background run error for task ${id}:`, msg); + }); + + res.status(202).json({ message: 'Task triggered', task_id: id }); + } catch (error) { + const msg = getErrorMessage(error, 'Failed to run task'); + console.error('[ScheduledTasks] Run error:', msg); + res.status(500).json({ error: msg }); + } +}); + +scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + const id = parseTaskId(req, res); + if (id === null) return; + + const db = DatabaseService.getInstance(); + const task = db.getScheduledTask(id); + if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireScheduledTaskTier(task.action, req, res)) return; + + const runs = db.getAllScheduledTaskRuns(id); + + const lines = ['Timestamp,Source,Status,Duration (s),Details']; + for (const run of runs) { + const timestamp = new Date(run.started_at).toISOString(); + const source = run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'; + const status = run.status.charAt(0).toUpperCase() + run.status.slice(1); + const duration = run.completed_at && run.started_at + ? ((run.completed_at - run.started_at) / 1000).toFixed(1) + : ''; + const details = run.error || run.output || ''; + lines.push([timestamp, source, status, duration, details].map(escapeCsvField).join(',')); + } + + const safeName = task.name.replace(/[^a-zA-Z0-9_-]/g, '_'); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="task-${safeName}-history.csv"`); + res.send(lines.join('\n')); + } catch (error) { + console.error('[ScheduledTasks] Export error:', error); + res.status(500).json({ error: 'Failed to export task runs' }); + } +}); + +scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + try { + const id = parseTaskId(req, res); + if (id === null) return; + + const db = DatabaseService.getInstance(); + const existing = db.getScheduledTask(id); + if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireScheduledTaskTier(existing.action, req, res)) return; + + const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); + const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); + const result = db.getScheduledTaskRuns(id, limit, offset); + res.json(result); + } catch (error) { + console.error('[ScheduledTasks] Runs error:', error); + res.status(500).json({ error: 'Failed to fetch task runs' }); + } +}); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 00000000..776c2d8a --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,95 @@ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { DatabaseService } from '../services/DatabaseService'; +import { authMiddleware } from '../middleware/auth'; +import { requireAdmin } from '../middleware/tierGates'; + +// Keys that contain auth credentials; never exposed to the frontend or +// writable via the settings API. +const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']); + +// Strict allowlist of keys writable via the settings API. Prevents +// overwriting auth credentials through a misconfigured key. +const ALLOWED_SETTING_KEYS = new Set([ + 'host_cpu_limit', + 'host_ram_limit', + 'host_disk_limit', + 'docker_janitor_gb', + 'global_crash', + 'developer_mode', + 'template_registry_url', + 'metrics_retention_hours', + 'log_retention_days', + 'audit_retention_days', +]); + +// Bulk PATCH schema. All keys optional; present keys are fully validated. +const SettingsPatchSchema = z.object({ + host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String), + docker_janitor_gb: z.coerce.number().min(0).transform(String), + global_crash: z.enum(['0', '1']), + developer_mode: z.enum(['0', '1']), + template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }), + metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String), + log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), + audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String), +}).partial(); + +export const settingsRouter = Router(); + +settingsRouter.get('/', authMiddleware, async (_req: Request, res: Response): Promise => { + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + for (const key of PRIVATE_SETTINGS_KEYS) { + delete settings[key]; + } + res.json(settings); + } catch (error) { + console.error('Failed to fetch settings:', error); + res.status(500).json({ error: 'Failed to fetch settings' }); + } +}); + +settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const { key, value } = req.body; + if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) { + res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` }); + return; + } + if (value === undefined || value === null) { + res.status(400).json({ error: 'Setting value is required' }); + return; + } + DatabaseService.getInstance().updateGlobalSetting(key, String(value)); + res.json({ success: true }); + } catch (error) { + console.error('Failed to update setting:', error); + res.status(500).json({ error: 'Failed to update setting' }); + } +}); + +settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const parsed = SettingsPatchSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }); + return; + } + const db = DatabaseService.getInstance(); + const updateMany = db.getDb().transaction((entries: [string, string][]) => { + for (const [k, v] of entries) { + db.updateGlobalSetting(k, v); + } + }); + updateMany(Object.entries(parsed.data) as [string, string][]); + res.json({ success: true }); + } catch (error) { + console.error('Failed to bulk update settings:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } +});