refactor(backend): add route tests then extract settings, scheduled-tasks, agents (phase 4b) (#737)

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.
This commit is contained in:
Anso
2026-04-23 21:22:39 -04:00
committed by GitHub
parent 90eae03922
commit f6a7898798
7 changed files with 1087 additions and 536 deletions
+128
View File
@@ -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);
});
});
@@ -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');
});
});
@@ -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);
});
});
+8 -536
View File
@@ -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<string, unknown> = { 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<Omit<ScheduledTask, 'id'>>);
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) ---
+51
View File
@@ -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<void> => {
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<void> => {
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' });
}
});
+406
View File
@@ -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<string, unknown> = { 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<Omit<ScheduledTask, 'id'>>);
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' });
}
});
+95
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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' });
}
});