Files
sencho/backend/src/index.ts
T
Anso 5bb4b01953 feat: auto-heal policies for unhealthy containers (#671)
* feat(db): add auto_heal_policies and auto_heal_history schema and CRUD

Adds two new SQLite tables (auto_heal_policies, auto_heal_history) to
DatabaseService.initSchema() and exposes CRUD methods: getAutoHealPolicies,
getAutoHealPolicy, addAutoHealPolicy, updateAutoHealPolicy,
deleteAutoHealPolicy, recordAutoHealHistory, getAutoHealHistory,
incrementConsecutiveFailures, resetConsecutiveFailures, setPolicyEnabled.
Also adds AutoHealPolicy and AutoHealHistoryEntry TypeScript interfaces.

* feat(events): track health-status duration and expose state accessors

- Add healthStatus and unhealthySince fields to InternalContainerState
- onHealthStatus now records unhealthySince timestamp on first transition
  to unhealthy, and clears it when the container recovers or restarts
- onStart resets both fields so a restarted container begins from 'starting'
- Add listContainerStates() and getContainerState() public accessors for
  use by the upcoming AutoHealService evaluator

* fix(auto-heal): key allowlist in updateAutoHealPolicy, cascade delete, extract ContainerHealthSnapshot

* feat: add AutoHealService evaluator singleton

Polls every 30 s, matches containers to enabled policies via Compose
labels, and restarts containers that have been unhealthy beyond the
configured threshold. Enforces cooldown, per-hour rate cap, and
recent-user-action suppression; auto-disables policies after repeated
consecutive failures. Also adds DockerEventManager.getService() accessor
required by the evaluator.

* fix(auto-heal): prune stale restartTimestamps, guard undefined policy id

- Prune restartTimestamps entries for containers no longer running after
  each container list fetch, preventing unbounded map growth from dead
  container IDs.
- Guard against policies with undefined id at the start of the per-policy
  loop; warn and skip rather than proceed with a non-null assertion.
- Extract handleAutoDisable private helper to bring executeHeal under 30
  lines and isolate the auto-disable side-effect sequence.
- Move ContainerInfo type to module scope.

* feat: add auto-heal API routes and wire AutoHealService lifecycle

Registers five REST endpoints under /api/auto-heal/policies (list, create,
patch, delete, history) with requirePaid + requireAdmin guards and Zod
validation. Wires AutoHealService.start()/stop() into the server startup
and graceful-shutdown blocks alongside MonitorService.

* test: add AutoHealService and DatabaseService auto-heal unit tests

- 15 unit tests for AutoHealService.shouldHeal covering all decision branches
  (healthy state, duration threshold, user-action suppression, cooldown,
  rate limiting, and correct skipReason values)
- 13 integration tests for DatabaseService auto-heal CRUD: policy round-trip,
  stack-name filter, partial update, cascade delete, history ordering/limit,
  consecutive failure counters, and setPolicyEnabled toggle

* fix: log AutoHealService shutdown errors consistently

* fix(api): requireAdmin-first guard order and try/catch on auto-heal routes

* feat(ui): add StackAutoHealSheet component

* feat(ui): add Auto-Heal context menu item to EditorLayout

* fix(ui): StackAutoHealSheet label, token, a11y, and useEffect fixes

- Rename 'All services in stack' to 'All services' in combobox options and placeholder
- Replace text-green-600 with text-success design token in actionColorClass
- Add htmlFor/id pairs to all four numeric form inputs for accessibility
- Inline fetch logic into useEffect, removing stale closure risk and eslint-disable comment
- Remove now-unused fetchPolicies and fetchServices standalone functions
- Update 'Auto-disable after' label to 'Auto-disable after (failures)' for clarity
- Add toast.error in policy fetch failure path; services fetch silently skips as before

* docs: add auto-heal-policies feature documentation

* test(e2e): add auto-heal policies CRUD spec

* fix(docs): correct auto-heal-policies nav position in docs.json
2026-04-17 22:45:06 -04:00

8748 lines
358 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import compression from 'compression';
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
import helmet from 'helmet';
import WebSocket, { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
import DockerController, { globalDockerNetwork, type CreateNetworkOptions, type NetworkDriver } from './services/DockerController';
import type Dockerode from 'dockerode';
import { FileSystemService } from './services/FileSystemService';
import { ComposeService } from './services/ComposeService';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
// @ts-ignore - composerize lacks proper type definitions
import composerize from 'composerize';
import si from 'systeminformation';
import http from 'http';
import httpProxy from 'http-proxy';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'path';
import { HostTerminalService } from './services/HostTerminalService';
import { DatabaseService, Node, AuthProvider, ScheduledTask, UserRole, ResourceType } from './services/DatabaseService';
import { NotificationService } from './services/NotificationService';
import { MonitorService } from './services/MonitorService';
import { AutoHealService } from './services/AutoHealService';
import { DockerEventManager } from './services/DockerEventManager';
import { ImageUpdateService } from './services/ImageUpdateService';
import { templateService } from './services/TemplateService';
import { ErrorParser } from './utils/ErrorParser';
import { NodeRegistry } from './services/NodeRegistry';
import { PilotTunnelManager } from './services/PilotTunnelManager';
import { encodeJsonFrame as encodePilotJsonFrame, PROTOCOL_VERSION as PILOT_PROTOCOL_VERSION, PilotCloseCode } from './pilot/protocol';
import { FleetSyncService } from './services/FleetSyncService';
import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { MfaService } from './services/MfaService';
import { CryptoService } from './services/CryptoService';
import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CacheService } from './services/CacheService';
import { CAPABILITIES, getSenchoVersion, isValidVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry';
import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, repoHost as gitRepoHost } from './services/GitSourceService';
import { sendGitSourceError } from './utils/gitSourceHttp';
// ── Hot-path cache TTLs ────────────────────────────────────────────────
// Short TTLs collapse concurrent polling pressure across browser tabs and
// overlapping service samplers without introducing noticeable UI staleness.
// Keys are per-node: "stats:<nodeId>", "system-stats:<nodeId>", "stack-statuses:<nodeId>".
const STATS_CACHE_TTL_MS = 2_000;
const SYSTEM_STATS_CACHE_TTL_MS = 3_000;
const STACK_STATUSES_CACHE_TTL_MS = 3_000;
/**
* Invalidate the per-node caches affected by a stack/container mutation so
* the next dashboard poll shows fresh state instead of stale reads. Called
* from every endpoint that changes the Docker or filesystem state.
*
* Also drops the global `project-name-map` since stack writes (create, delete,
* rename, compose edits) can reshape the on-disk layout used to build it.
*/
function invalidateNodeCaches(nodeId: number): void {
const cache = CacheService.getInstance();
cache.invalidate(`stats:${nodeId}`);
cache.invalidate(`stack-statuses:${nodeId}`);
cache.invalidate('project-name-map');
}
import { isDebugEnabled } from './utils/debug';
import { getLatestVersion } from './utils/version-check';
import { getErrorMessage } from './utils/errors';
import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from './utils/snapshot-capture';
import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing';
import SelfUpdateService from './services/SelfUpdateService';
import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService';
import TrivyInstaller from './services/TrivyInstaller';
import { severityRank } from './utils/severity';
import { validateImageRef } from './utils/image-ref';
import { applySuppressions } from './utils/suppression-filter';
import { generateSarif } from './services/SarifExporter';
import semver from 'semver';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation';
import YAML from 'yaml';
import { promises as fsPromises } from 'fs';
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
// util._extend internally. The warning fires at runtime when createProxyServer() is
// first invoked (NOT at import time), so intercepting process.emitWarning here -
// before the proxy instances are created below - fully prevents it.
// http-proxy has no compatible update; this suppression is intentional and safe.
const _origEmitWarning = process.emitWarning.bind(process);
(process as any).emitWarning = (warning: any, ...args: any[]) => {
const code = typeof args[0] === 'object' ? args[0]?.code : args[1];
if (code === 'DEP0060') return;
_origEmitWarning(warning, ...args);
};
const MIN_PASSWORD_LENGTH = 8;
const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
const MAX_LABELS_PER_NODE = 50;
const app = express();
const PORT = 3000;
// FileSystemService and ComposeService are instantiated per-request via .getInstance(nodeId)
// Cookie settings
const COOKIE_NAME = 'sencho_token';
const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
const MFA_PENDING_SCOPE = 'mfa_pending';
const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
// Helper to determine if request is secure (HTTPS or behind a proxy that terminates SSL)
const isSecureRequest = (req: Request): boolean => {
return req.secure || req.headers['x-forwarded-proto'] === 'https';
};
// Helper to get cookie options dynamically per-request
const getCookieOptions = (req: Request) => ({
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict' as const,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
});
// Middleware
// Trust the first reverse proxy (nginx, Traefik, etc.) for correct req.protocol,
// req.ip, and secure cookie detection behind a proxy.
app.set('trust proxy', 1);
// Security headers (X-Frame-Options, X-Content-Type-Options, etc.)
// crossOriginEmbedderPolicy: disabled - Monaco editor workers lack COEP headers.
// hsts: disabled - HSTS must only be set when the app is served over HTTPS.
// Enabling it over HTTP permanently breaks browser access for 1 year.
// contentSecurityPolicy.upgradeInsecureRequests: explicitly set to null.
// Helmet 8 merges custom directives with its defaults, which include this
// directive. It tells browsers to silently upgrade all HTTP sub-resource fetches
// to HTTPS. On a plain-HTTP self-hosted deployment (the common case) this causes
// every JS/CSS asset to fail with ERR_SSL_PROTOCOL_ERROR, producing a blank page.
// Setting null is the Helmet 8 API to remove a default directive.
app.use(helmet({
crossOriginEmbedderPolicy: false,
// COOP is only meaningful over HTTPS. Over HTTP the browser logs a warning
// and ignores it, creating noise in the console with no security benefit.
crossOriginOpenerPolicy: false,
// Origin-Agent-Cluster is only meaningful over HTTPS. Over plain HTTP the
// browser logs a warning and ignores it. Disabling removes console noise.
originAgentCluster: false,
hsts: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"],
fontSrc: ["'self'", 'https:', 'data:'],
formAction: ["'self'"],
frameAncestors: ["'self'"],
// img-src: 'https:' is required for App Store template icons hosted on
// external registries (e.g. raw.githubusercontent.com).
imgSrc: ["'self'", 'data:', 'https:'],
objectSrc: ["'none'"],
scriptSrc: ["'self'"],
scriptSrcAttr: ["'none'"],
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
// connect-src: explicit 'self' covers same-origin fetch/XHR/WebSocket.
// ws: and wss: are included for WebSocket connections in any scheme context.
connectSrc: ["'self'", 'ws:', 'wss:'],
// worker-src: Monaco editor creates Web Workers via blob: URLs for language
// services (syntax highlighting, intellisense). Without blob: they silently fail.
workerSrc: ["'self'", 'blob:'],
// Helmet 8 merges custom directives with its defaults, which include
// upgrade-insecure-requests. Setting it to null explicitly removes it.
// On plain-HTTP self-hosted deployments (the common case) this directive
// causes every JS/CSS asset to fail with ERR_SSL_PROTOCOL_ERROR → blank page.
upgradeInsecureRequests: null,
},
},
}));
// CORS - in production restrict to the configured frontend origin.
// In development, mirror the request origin so Vite's dev server works.
const corsOrigin = process.env.NODE_ENV === 'production'
? (process.env.FRONTEND_URL || false)
: true;
app.use(cors({
origin: corsOrigin,
credentials: true,
}));
// Gzip JSON and HTML responses. SSE streams (Content-Type: text/event-stream)
// MUST NOT be compressed because compression buffers output and would delay
// event delivery until a flush, breaking live log and status streams.
app.use(compression({
filter: (req: Request, res: Response) => {
const ct = res.getHeader('Content-Type');
if (typeof ct === 'string' && ct.includes('text/event-stream')) {
return false;
}
return compression.filter(req, res);
},
}));
// Cookie parser must run before rate limiters so the hybrid key generator
// can read req.cookies for per-user rate limit bucketing.
app.use(cookieParser());
// ── Rate Limiting ─────────────────────────────────────────────────────────────
//
// Tiered rate limiting to prevent UX lockouts while maintaining security:
// Tier 0/1 (Polling): High-frequency GET endpoints exempt from global limit,
// with a 300/min safety net to prevent resource exhaustion.
// Tier W (Webhooks): CI/CD webhook triggers at 500/min (shared datacenter IPs).
// Tier 2 (Standard): All other endpoints at 200/min (raised from 100).
// Tier 3 (Auth): Strict brute-force protection (5-10 attempts / 15min).
//
// Enterprise adaptations:
// - Internal node-to-node traffic (node_proxy JWTs) bypasses all rate limiters.
// - Authenticated requests are keyed by user ID (not IP) to prevent shared
// NAT/VPN environments from pooling rate limit budgets.
/** Read-only GET endpoints polled at high frequency by the dashboard/fleet UI. */
const POLLING_EXEMPT_PATHS = new Set([
'/meta', '/health', '/stats', '/system/stats',
'/stacks/statuses', '/metrics/historical',
'/auth/status', '/auth/sso/providers', '/license',
]);
const WEBHOOK_TRIGGER_RE = /^\/webhooks\/\d+\/trigger$/;
/**
* Returns true if the request bears a node_proxy Bearer token.
* Uses jwt.decode() (no signature verification) to avoid crypto overhead on the
* hot path; authMiddleware performs full verification downstream. Worst case for
* a forged token: it skips the rate limiter but is still rejected by auth.
* Result is memoized on the request object so the two sequential limiters
* don't repeat the work.
*/
function isNodeProxyRequest(req: Request): boolean {
const cached = (req as any)._isNodeProxy;
if (cached !== undefined) return cached;
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
(req as any)._isNodeProxy = false;
return false;
}
try {
const decoded = jwt.decode(auth.slice(7)) as { scope?: string } | null;
const result = decoded?.scope === 'node_proxy';
(req as any)._isNodeProxy = result;
return result;
} catch {
(req as any)._isNodeProxy = false;
return false;
}
}
/**
* Hybrid rate limit key: uses the JWT username/sub claim for authenticated
* requests (per-user budgets) and falls back to IP for unauthenticated ones.
* Uses jwt.decode() (no verification) to avoid double-verification cost;
* authMiddleware handles signature checks downstream.
*/
function rateLimitKeyGenerator(req: Request): string {
const cookie = req.cookies?.[COOKIE_NAME];
if (cookie) {
try {
const decoded = jwt.decode(cookie) as { username?: string } | null;
if (decoded?.username) return `user:${decoded.username}`;
} catch { /* fall through to IP */ }
}
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
try {
const decoded = jwt.decode(auth.slice(7)) as { username?: string; sub?: string } | null;
if (decoded?.username) return `user:${decoded.username}`;
if (decoded?.sub) return `user:${decoded.sub}`;
} catch { /* fall through to IP */ }
}
return ipKeyGenerator(req.ip || 'unknown');
}
/** Shared config for all rate limiters (1-minute window, standard headers). */
const rateLimitBase = {
windowMs: 60 * 1000,
standardHeaders: true,
legacyHeaders: false,
} as const;
// Tier 2: Global API rate limiter. Skips polling endpoints (Tier 0/1), webhook
// triggers (Tier W), and internal node-to-node traffic (node_proxy).
const globalApiLimiter = rateLimit({
...rateLimitBase,
max: process.env.NODE_ENV === 'production'
? parseInt(process.env.API_RATE_LIMIT || '200', 10)
: 1000,
keyGenerator: rateLimitKeyGenerator,
message: { error: 'Too many requests. Please try again shortly.' },
skip: (req: Request) => {
if (req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)) return true;
if (req.method === 'POST' && WEBHOOK_TRIGGER_RE.test(req.path)) return true;
if (isNodeProxyRequest(req)) return true;
return false;
},
});
app.use('/api/', globalApiLimiter);
// Tier 0/1: Polling safety net. Applies only to polling-exempt endpoints to
// prevent resource exhaustion from runaway or malicious polling.
const pollingLimiter = rateLimit({
...rateLimitBase,
max: process.env.NODE_ENV === 'production'
? parseInt(process.env.API_POLLING_RATE_LIMIT || '300', 10)
: 3000,
keyGenerator: rateLimitKeyGenerator,
message: { error: 'Too many polling requests. Please try again shortly.' },
skip: (req: Request) => {
if (isNodeProxyRequest(req)) return true;
return !(req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path));
},
});
app.use('/api/', pollingLimiter);
// Tier W: Webhook trigger limiter. Applied inline on the trigger route handler.
// CI/CD platforms (GitHub Actions, GitLab runners) often share datacenter IPs,
// so a higher ceiling prevents dropped deployments during burst activity.
const webhookTriggerLimiter = rateLimit({
...rateLimitBase,
max: process.env.NODE_ENV === 'production' ? 500 : 5000,
message: { error: 'Too many webhook triggers. Please try again shortly.' },
});
// JSON body parser that also captures the raw bytes for HMAC verification.
const jsonParser = express.json({
verify: (req, _res, buf) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
},
});
// Conditionally parse JSON bodies. Remote proxy requests must NOT have their body
// consumed here: express.json() drains the IncomingMessage stream into req.body
// and http-proxy then pipes an already-ended stream to the remote server.
// When Node.js pipes an ended readable it calls process.nextTick(dest.end()),
// which fires *before* the proxyReq socket event, so any attempt to write the
// body inside the proxyReq handler results in "write after end" and the request
// hangs. Solution: skip JSON parsing for remote-targeted /api/ requests so the
// raw stream flows through the proxy intact.
app.use((req: Request, res: Response, next: NextFunction): void => {
const nodeIdHeader = req.headers['x-node-id'];
if (nodeIdHeader) {
const nodeId = parseInt(nodeIdHeader as string, 10);
const node = NodeRegistry.getInstance().getNode(nodeId);
if (
node?.type === 'remote' &&
req.path.startsWith('/api/') &&
!req.path.startsWith('/api/auth/') &&
!req.path.startsWith('/api/nodes') &&
!req.path.startsWith('/api/license') &&
!req.path.startsWith('/api/fleet') &&
!req.path.startsWith('/api/webhooks') &&
!req.path.startsWith('/api/meta')
) {
// Preserve body stream for proxy piping
next();
return;
}
}
jsonParser(req, res, next);
});
// Node Context Middleware
const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) => {
const nodeIdHeader = req.headers['x-node-id'] as string;
const nodeIdQuery = req.query.nodeId as string;
if (nodeIdHeader) {
req.nodeId = parseInt(nodeIdHeader, 10);
} else if (nodeIdQuery) {
req.nodeId = parseInt(nodeIdQuery, 10);
} else {
req.nodeId = NodeRegistry.getInstance().getDefaultNodeId();
}
// Intercept requests to deleted nodes to prevent downstream errors.
// /api/nodes is intentionally exempt: it must always be reachable so the
// frontend can re-sync after a node is deleted (otherwise a stale x-node-id
// in localStorage causes an unrecoverable 404 loop).
if (
req.path.startsWith('/api/') &&
!req.path.startsWith('/api/auth/') &&
!req.path.startsWith('/api/nodes') &&
!req.path.startsWith('/api/license') &&
!req.path.startsWith('/api/fleet') &&
!req.path.startsWith('/api/webhooks') &&
!req.path.startsWith('/api/meta')
) {
const node = DatabaseService.getInstance().getNode(req.nodeId);
if (!node) {
res.status(404).json({ error: `Node with id ${req.nodeId} not found or was deleted.` });
return;
}
}
next();
};
app.use(nodeContextMiddleware);
// Extend Express Request type for user and node
declare global {
namespace Express {
interface Request {
user?: { username: string; role: UserRole; userId: number };
nodeId: number;
apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin';
rawBody?: Buffer;
/** License tier asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
proxyTier?: LicenseTier;
/** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
proxyVariant?: LicenseVariant;
/** User ID carried by a scoped `mfa_pending` token. Only set while the user is completing the MFA challenge. */
mfaPendingUserId?: number;
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
mfaPendingSso?: boolean;
}
}
}
// WebSocket proxy server for forwarding remote node WS connections
const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true });
wsProxyServer.on('error', (err, _req, socket: any) => {
console.error('[WS Proxy] Error:', err.message);
try { socket?.destroy(); } catch { }
});
// Authentication Middleware
// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy).
// Bearer token is evaluated first: node-to-node proxy calls always carry a Bearer token and
// should never be shadowed by a stale or cross-instance cookie.
const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const cookieToken = req.cookies[COOKIE_NAME];
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null;
const token = bearerToken || cookieToken;
if (!token) {
res.status(401).json({ error: 'Authentication required' });
return;
}
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean };
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
// API token path: scope-based programmatic access
if (decoded.scope === 'api_token') {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
if (!apiToken || apiToken.revoked_at) {
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: not found or revoked');
res.status(401).json({ error: 'API token not found or revoked' });
return;
}
if (apiToken.expires_at && apiToken.expires_at < Date.now()) {
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: expired');
res.status(401).json({ error: 'API token has expired' });
return;
}
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
const roleMap: Record<string, UserRole> = {
'read-only': 'viewer',
'deploy-only': 'deployer',
'full-admin': 'admin',
};
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer', userId: apiToken.user_id };
req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin';
if (isDebugEnabled()) console.log('[Auth:diag] API token authenticated:', { scope: apiToken.scope, user: creator?.username, tokenName: apiToken.name });
next();
return;
}
// Partial-auth session: a password/SSO credential has verified, but the
// TOTP second factor is still required. Such a token can only be used to
// complete the MFA challenge or to abort the flow by logging out. Every
// other route must reject it so no privileged action is reachable before
// the second factor clears.
if (decoded.scope === MFA_PENDING_SCOPE) {
const allowedPath = req.path === '/api/auth/login/mfa' || req.path === '/api/auth/logout';
if (!allowedPath) {
res.status(403).json({ error: 'Two-factor authentication required', code: 'MFA_PENDING' });
return;
}
req.mfaPendingUserId = typeof decoded.user_id === 'number' ? decoded.user_id : undefined;
req.mfaPendingSso = decoded.sso === true;
next();
return;
}
// Node proxy tokens: Sencho-to-Sencho communication, not user sessions.
// Handle before user resolution since proxy tokens have no username.
// pilot_tunnel scope is the equivalent credential for pilot-agent-mode
// nodes; it arrives on requests the primary forwarded through a tunnel
// after the primary itself re-signed/trusted them. Same tier-header trust
// rules apply.
if (decoded.scope === 'node_proxy' || decoded.scope === 'pilot_tunnel') {
req.user = { username: 'node-proxy', role: 'admin', userId: 0 };
// Distributed License Enforcement: trust tier headers only from authenticated node proxy requests.
// Browser sessions and API tokens cannot set these; only a valid node_proxy JWT (signed with
// this instance's JWT secret) unlocks the trusted path.
const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
if (isLicenseTier(tierHeader)) {
req.proxyTier = normalizeTier(tierHeader);
}
if (isLicenseVariant(variantHeader)) {
req.proxyVariant = normalizeVariant(variantHeader);
} else if (variantHeader === '') {
req.proxyVariant = null;
}
next();
return;
}
// User session tokens: resolve against the database for up-to-date role and existence checks.
const dbUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
// User must exist in the database (rejects deleted users immediately)
if (!dbUser) {
res.status(401).json({ error: 'User account no longer exists' });
return;
}
// Token version check: rejects sessions after password change, role change, or admin reset.
// Pre-migration tokens (no tv claim) are accepted for backward compat and expire within 24h.
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
if (isDebugEnabled()) console.log('[Auth:diag] Token version mismatch for:', decoded.username, 'jwt:', decoded.tv, 'db:', dbUser.token_version);
console.log('[Auth] Session rejected: token version mismatch for:', decoded.username);
res.status(401).json({ error: 'Session invalidated. Please log in again.' });
return;
}
if (isDebugEnabled()) console.log('[Auth:diag] User resolved:', dbUser.username, 'role:', dbUser.role, 'tv:', dbUser.token_version);
// Use the DB role (not the JWT role) so role changes take effect immediately
req.user = { username: dbUser.username, role: dbUser.role as UserRole, userId: dbUser.id };
next();
} catch (err) {
console.error('[Auth] Token validation failed:', (err as Error).message);
res.status(401).json({ error: 'Invalid or expired token' });
return;
}
};
/** Sign a session JWT and set it as an httpOnly cookie. */
function issueSessionCookie(
res: Response,
req: Request,
user: { username: string; role: string; token_version: number },
jwtSecret: string,
): void {
const token = jwt.sign(
{ username: user.username, role: user.role, tv: user.token_version },
jwtSecret,
{ expiresIn: '24h' },
);
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
}
/**
* Sign a short-lived `mfa_pending` JWT and set it as an httpOnly cookie. This
* represents the partial-auth session that exists between password (or SSO)
* success and TOTP verification. The scope is enforced in `authMiddleware`, so
* this cookie cannot be used to reach any route other than
* `/api/auth/login/mfa` or `/api/auth/logout`.
*/
function issueMfaPendingCookie(
res: Response,
req: Request,
user: { id: number; username: string },
jwtSecret: string,
opts: { sso?: boolean } = {},
): void {
const token = jwt.sign(
{ scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true },
jwtSecret,
{ expiresIn: Math.floor(MFA_PENDING_TTL_MS / 1000) },
);
res.cookie(MFA_PENDING_COOKIE_NAME, token, {
...getCookieOptions(req),
maxAge: MFA_PENDING_TTL_MS,
});
}
/** Clear the partial-auth cookie. Called on successful MFA verification and on logout. */
function clearMfaPendingCookie(res: Response, req: Request): void {
res.clearCookie(MFA_PENDING_COOKIE_NAME, getCookieOptions(req));
}
// Rate limiter for auth endpoints - prevents brute-force attacks.
// Production: 5 attempts per 15-minute window per IP.
// Development: 100 attempts (so E2E tests and local tooling are not blocked).
const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: process.env.NODE_ENV === 'production' ? 5 : 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
});
// Captured at boot. Exposed via /api/health and /api/meta so the Fleet update overlay
// can distinguish a brand-new process from the old one still mid-pull.
const processStartedAt = Date.now();
// Public health endpoint - no auth required (used by Docker HEALTHCHECK and uptime monitors)
app.get('/api/health', (_req: Request, res: Response): void => {
res.json({ status: 'ok', uptime: process.uptime(), startedAt: processStartedAt });
});
// Public meta endpoint - returns this instance's version and supported capabilities.
// No auth required (like /health). Used by remote nodes during connection tests.
app.get('/api/meta', (_req: Request, res: Response): void => {
const updateError = SelfUpdateService.getInstance().getLastError();
res.json({
version: getSenchoVersion(),
capabilities: getActiveCapabilities(),
startedAt: processStartedAt,
...(updateError ? { updateError } : {}),
});
});
// Auth Routes (no authentication required)
// Check if setup is needed, and whether the caller currently holds a valid
// `mfa_pending` partial-auth cookie (so the frontend can route to the
// challenge screen on a page reload mid-flow, for example after an OIDC
// redirect).
app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
let mfaPending = false;
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
if (mfaCookie && settings.auth_jwt_secret) {
try {
const decoded = jwt.verify(mfaCookie, settings.auth_jwt_secret) as { scope?: string };
mfaPending = decoded.scope === MFA_PENDING_SCOPE;
} catch {
// Expired or invalid cookie; treat as no pending challenge.
}
}
res.json({ needsSetup, mfaPending });
} catch (error) {
console.error('Error checking setup status:', error);
res.json({ needsSetup: true, mfaPending: false });
}
});
// Initial setup endpoint
app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const dbSvc = DatabaseService.getInstance();
const settings = dbSvc.getGlobalSettings();
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
if (!needsSetup) {
res.status(400).json({ error: 'Setup has already been completed' });
return;
}
const { username, password, confirmPassword } = req.body;
// Validation
if (!username || !password || !confirmPassword) {
res.status(400).json({ error: 'All fields are required' });
return;
}
if (username.length < 3) {
res.status(400).json({ error: 'Username must be at least 3 characters' });
return;
}
if (password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
if (password !== confirmPassword) {
res.status(400).json({ error: 'Passwords do not match' });
return;
}
// Save credentials (this also generates the JWT secret)
const passwordHash = await bcrypt.hash(password, 10);
const jwtSecret = crypto.randomBytes(64).toString('hex');
dbSvc.updateGlobalSetting('auth_username', username);
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
// Create admin user in users table
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
// Issue JWT and log user in
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
res.json({ success: true, message: 'Setup completed successfully' });
} catch (error) {
console.error('Setup error:', error);
res.status(500).json({ error: 'Failed to complete setup' });
}
});
// Login endpoint
app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
try {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(username);
if (user) {
const isValid = await bcrypt.compare(password, user.password_hash);
if (isValid) {
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret missing from DB');
// If MFA is enabled for this user, issue only the partial-auth cookie
// and signal the client to complete the TOTP challenge. No session
// cookie is set until the second factor is verified.
const mfa = db.getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
}
if (mfa?.enabled) {
issueMfaPendingCookie(res, req, user, jwtSecret);
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] Login successful:', user.username);
res.json({ success: true, message: 'Login successful' });
return;
}
}
console.warn('[Auth] Login failed for username:', username);
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Update password endpoint - any authenticated user can change their own password
app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot change passwords.', code: 'SCOPE_DENIED' });
return;
}
try {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
res.status(400).json({ error: 'Old password and new password are required' });
return;
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
const dbSvc = DatabaseService.getInstance();
const user = dbSvc.getUserByUsername(req.user!.username);
if (!user) {
res.status(400).json({ error: 'User not found' });
return;
}
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
if (!isValid) {
res.status(401).json({ error: 'Invalid old password' });
return;
}
const newHash = await bcrypt.hash(newPassword, 10);
dbSvc.updateUser(user.id, { password_hash: newHash });
// Keep global_settings in sync for backward compat
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
// Invalidate all other sessions for this user
dbSvc.bumpTokenVersion(user.id);
// Re-issue cookie with new token version so the current session survives
const settings = dbSvc.getGlobalSettings();
const updatedUser = dbSvc.getUserById(user.id);
if (settings.auth_jwt_secret && updatedUser) {
issueSessionCookie(res, req, updatedUser, settings.auth_jwt_secret);
}
console.log('[Auth] Password changed by:', req.user!.username);
res.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
console.error('[Auth] Password update error:', error);
res.status(500).json({ error: 'Failed to update password' });
}
});
app.post('/api/auth/logout', (req: Request, res: Response): void => {
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict',
});
// Also clear any partial-auth cookie so a user aborting the MFA challenge
// is returned to a fully unauthenticated state.
clearMfaPendingCookie(res, req);
res.json({ success: true, message: 'Logged out successfully' });
});
// Check authentication status
app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void => {
res.json({ authenticated: true, user: req.user });
});
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication
app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot generate node tokens.', code: 'SCOPE_DENIED' });
return;
}
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
return;
}
// Default 1-year expiry — admin should rotate tokens periodically
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' });
res.json({ token });
} catch (error: any) {
res.status(500).json({ error: error.message || 'Failed to generate node token' });
}
});
// --- SSO Auth Routes (public, under /api/auth/sso/*) ---
// Seed SSO config from environment variables on startup
SSOService.getInstance().seedFromEnv();
const ssoRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: process.env.NODE_ENV === 'production' ? 10 : 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many SSO attempts. Please try again later.' },
});
/** Derive the OAuth callback base URL from SSO_CALLBACK_URL or the request Host header, with injection validation. */
function getSSOBaseUrl(req: Request, res: Response): string | null {
const host = req.get('host') || '';
if (!process.env.SSO_CALLBACK_URL && /[\s<>\r\n]/.test(host)) {
console.error('[SSO] Rejected suspicious Host header');
res.redirect('/?sso_error=Invalid+request');
return null;
}
if (!process.env.SSO_CALLBACK_URL && isDebugEnabled()) {
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', host);
}
return process.env.SSO_CALLBACK_URL || `${req.protocol}://${host}`;
}
// List enabled SSO providers (for login page)
app.get('/api/auth/sso/providers', (_req: Request, res: Response): void => {
try {
const providers = SSOService.getInstance().getEnabledProviders();
res.json(providers);
} catch (e) {
console.warn('[SSO] Failed to list enabled providers, returning empty list:', (e as Error).message);
res.json([]);
}
});
// LDAP login
app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
const result = await SSOService.getInstance().authenticateLDAP(username, password);
if (!result.success || !result.user) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
// Provision or find existing user
const user = SSOService.getInstance().provisionUser({
authProvider: 'ldap',
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
// Issue JWT (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
// If MFA is enabled AND the user has opted into SSO enforcement, route
// through the TOTP challenge. Otherwise SSO bypasses MFA (default).
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
console.log(`[SSO] LDAP login successful: ${user.username}`);
res.json({ success: true, message: 'Login successful' });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'LDAP login failed';
console.error('[SSO] LDAP login error:', msg);
res.status(500).json({ error: msg });
}
});
// OIDC: Initiate authorization flow
app.get('/api/auth/sso/oidc/:provider/authorize', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
if (!validProviders.includes(provider)) {
res.status(400).json({ error: 'Invalid SSO provider' });
return;
}
const baseUrl = getSSOBaseUrl(req, res);
if (!baseUrl) return;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const { url, state, codeVerifier } = await SSOService.getInstance().getOIDCAuthorizationUrl(provider, callbackUrl);
// Store state + codeVerifier in an encrypted short-lived cookie
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
const statePayload = JSON.stringify({ state, codeVerifier, provider });
res.cookie('sencho_sso_state', cryptoSvc.encrypt(statePayload), {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'lax', // Must be lax for cross-site IdP redirect
maxAge: 5 * 60 * 1000, // 5 minutes
});
res.redirect(url);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'SSO initialization failed';
console.error('[SSO] OIDC authorize error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});
// OIDC: Callback from identity provider
app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const code = String(req.query.code || '');
const state = String(req.query.state || '');
const oidcError = req.query.error ? String(req.query.error) : '';
const error_description = req.query.error_description ? String(req.query.error_description) : '';
if (oidcError) {
res.redirect(`/?sso_error=${encodeURIComponent(error_description || oidcError)}`);
return;
}
if (!code || !state) {
res.redirect('/?sso_error=Missing+authorization+code');
return;
}
// Read and validate state cookie
const stateCookie = req.cookies?.sencho_sso_state;
// Always clear the one-time state cookie, regardless of outcome
res.clearCookie('sencho_sso_state', { httpOnly: true, secure: isSecureRequest(req), sameSite: 'lax' });
if (!stateCookie) {
res.redirect('/?sso_error=SSO+session+expired.+Please+try+again.');
return;
}
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
let statePayload: { state: string; codeVerifier: string; provider: string };
try {
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
} catch (e) {
console.error('[SSO] Failed to decrypt SSO state cookie:', (e as Error).message);
res.redirect('/?sso_error=Invalid+SSO+session');
return;
}
if (statePayload.provider !== provider) {
res.redirect(`/?sso_error=${encodeURIComponent(`Provider mismatch: expected ${statePayload.provider}, got ${provider}`)}`);
return;
}
const baseUrl = getSSOBaseUrl(req, res);
if (!baseUrl) return;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const result = await SSOService.getInstance().handleOIDCCallback(
provider, callbackUrl,
{ code, state },
statePayload.state,
statePayload.codeVerifier
);
if (!result.success || !result.user) {
res.redirect(`/?sso_error=${encodeURIComponent(result.error || 'Authentication failed')}`);
return;
}
// Provision or find existing user
const user = SSOService.getInstance().provisionUser({
authProvider: provider as AuthProvider,
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
// Issue JWT + cookie (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
// If MFA is enabled AND the user has opted into SSO enforcement, set only
// the partial-auth cookie. The frontend surfaces the challenge screen
// based on `/api/auth/status` after the redirect lands.
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=oidc provider=', provider, 'user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] OIDC login password OK, MFA challenge pending: ${user.username} via ${provider}`);
res.redirect('/');
return;
}
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
console.log(`[SSO] OIDC login successful: ${user.username} via ${provider}`);
res.redirect('/');
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'SSO callback failed';
console.error('[SSO] OIDC callback error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});
// --- MFA (TOTP) Routes ---
const MFA_MAX_FAILED = 5;
const MFA_LOCKOUT_MS = 15 * 60 * 1000;
const MFA_REPLAY_TTL_MS = 120 * 1000;
const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000;
/**
* Complete the second factor of login. Consumes the short-lived
* `sencho_mfa_pending` cookie and, on success, clears it and issues a full
* session cookie. Accepts either a 6-digit TOTP or one of the user's backup
* codes (single-use). Enforces per-user failure counter and lockout.
*/
app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const startedAt = Date.now();
try {
const db = DatabaseService.getInstance();
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'Server is not configured' });
return;
}
const pendingCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
if (!pendingCookie) {
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: no pending cookie');
res.status(401).json({ error: 'No pending two-factor challenge. Please sign in again.' });
return;
}
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean };
try {
decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded;
} catch {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: pending cookie expired or invalid');
res.status(401).json({ error: 'Two-factor challenge expired. Please sign in again.' });
return;
}
if (decoded.scope !== MFA_PENDING_SCOPE || typeof decoded.user_id !== 'number') {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: bad cookie scope=', decoded.scope, 'userId=', decoded.user_id);
res.status(401).json({ error: 'Invalid two-factor challenge' });
return;
}
const user = db.getUserById(decoded.user_id);
const mfa = db.getUserMfa(decoded.user_id);
if (!user || !mfa?.enabled || !mfa.totp_secret_encrypted) {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: mfa not configured for userId=', decoded.user_id);
res.status(401).json({ error: 'Two-factor authentication is not configured' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] login/mfa: entry user=', user.username, 'sso=', !!decoded.sso, 'failedAttempts=', mfa.failed_attempts, 'lockedUntil=', mfa.locked_until ?? null, 'lockedRemainingMs=', mfa.locked_until ? Math.max(0, mfa.locked_until - Date.now()) : 0);
}
// Lockout check
if (mfa.locked_until && mfa.locked_until > Date.now()) {
const retryAfter = Math.ceil((mfa.locked_until - Date.now()) / 1000);
res.setHeader('Retry-After', String(retryAfter));
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: rejected (locked) user=', user.username, 'retryAfter=', retryAfter);
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter });
return;
}
const rawCode = typeof req.body?.code === 'string' ? req.body.code : '';
const isBackup = req.body?.isBackupCode === true;
if (!rawCode) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const cryptoSvc = CryptoService.getInstance();
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
let verified = false;
if (isBackup) {
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
const bcryptStart = Date.now();
const result = await MfaService.verifyBackupCode(hashes, rawCode);
const bcryptMs = Date.now() - bcryptStart;
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=backup user=', user.username, 'matched=', result.matched, 'bcryptMs=', bcryptMs, 'hashesChecked=', hashes.length);
if (bcryptMs > 500) console.warn('[MFA] Slow backup-code verify for user=', user.username, 'durationMs=', bcryptMs);
if (result.matched) {
db.upsertUserMfa(decoded.user_id, { backup_codes_json: JSON.stringify(result.remainingHashes) });
verified = true;
}
} else {
const trimmed = rawCode.trim().replace(/\s+/g, '');
const totpOk = MfaService.verifyTotp(secret, trimmed);
const window = MfaService.currentWindow();
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=totp user=', user.username, 'formatOk=', /^\d{6}$/.test(trimmed), 'totpOk=', totpOk, 'window=', window);
if (totpOk) {
if (db.isMfaCodeUsed(decoded.user_id, trimmed, window)) {
db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: replay rejected user=', user.username, 'window=', window);
res.status(401).json({ error: 'This code was already used. Please wait for the next one.', code: 'OTP_REPLAY' });
return;
}
db.markMfaCodeUsed(decoded.user_id, trimmed, window);
verified = true;
}
}
if (!verified) {
const failedCount = db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: verify failed user=', user.username, 'failedCount=', failedCount, 'lockoutThreshold=', MFA_MAX_FAILED);
if (failedCount >= MFA_MAX_FAILED) {
const lockedUntil = Date.now() + MFA_LOCKOUT_MS;
db.lockMfa(decoded.user_id, lockedUntil);
res.setHeader('Retry-After', String(Math.ceil(MFA_LOCKOUT_MS / 1000)));
console.warn('[MFA] Lockout engaged: user=', user.username, 'lockedUntil=', new Date(lockedUntil).toISOString());
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter: Math.ceil(MFA_LOCKOUT_MS / 1000) });
return;
}
res.status(401).json({ error: 'Invalid verification code' });
return;
}
db.clearMfaFailures(decoded.user_id);
clearMfaPendingCookie(res, req);
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] MFA challenge cleared:', user.username);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt);
res.json({ success: true });
} catch (error: unknown) {
console.error('[Auth] MFA verification error:', (error as Error).message);
res.status(500).json({ error: 'Two-factor verification failed' });
}
});
/** Report the current user's MFA state. Used by the Account settings UI. */
app.get('/api/auth/mfa/status', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
const hashes: string[] = mfa?.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
res.json({
enabled: mfa?.enabled === 1,
backupCodesRemaining: hashes.length,
sso_enforce_mfa: mfa?.sso_enforce_mfa === 1,
});
} catch (error: unknown) {
console.error('[MFA] status error:', (error as Error).message);
res.status(500).json({ error: 'Failed to load MFA status' });
}
});
/**
* Begin enrolment: generate a fresh TOTP secret, store it encrypted with
* `enabled=0`, and return the otpauth URI plus the raw base32 secret so the
* frontend can render a QR code and the manual-entry fallback.
*/
app.post('/api/auth/mfa/enroll/start', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const db = DatabaseService.getInstance();
const existing = db.getUserMfa(req.user.userId);
if (existing?.enabled) {
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/start user=', req.user.username, 'hadPendingSecret=', Boolean(existing?.totp_secret_encrypted));
}
const secret = MfaService.generateSecret();
const cryptoSvc = CryptoService.getInstance();
db.upsertUserMfa(req.user.userId, {
enabled: false,
totp_secret_encrypted: cryptoSvc.encrypt(secret),
backup_codes_json: null,
failed_attempts: 0,
locked_until: null,
});
const otpauthUri = MfaService.buildOtpauthUri(secret, req.user.username);
res.json({ otpauthUri, secret });
} catch (error: unknown) {
console.error('[MFA] enroll start error:', (error as Error).message);
res.status(500).json({ error: 'Failed to start enrolment' });
}
});
/**
* Finalise enrolment: verify the user's first TOTP against the pending
* secret, flip `enabled=1`, generate + hash + return the backup codes ONCE,
* and bump `token_version` so any other sessions re-authenticate.
*/
app.post('/api/auth/mfa/enroll/confirm', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const code = typeof req.body?.code === 'string' ? req.body.code : '';
if (!code) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.totp_secret_encrypted) {
res.status(400).json({ error: 'No enrolment in progress. Start enrolment first.' });
return;
}
if (mfa.enabled) {
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
return;
}
const cryptoSvc = CryptoService.getInstance();
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
if (!MfaService.verifyTotp(secret, code)) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
const backupCodes = MfaService.generateBackupCodes();
const hashes = await MfaService.hashBackupCodes(backupCodes);
db.upsertUserMfa(req.user.userId, {
enabled: true,
backup_codes_json: JSON.stringify(hashes),
failed_attempts: 0,
locked_until: null,
});
db.bumpTokenVersion(req.user.userId);
// The token_version bump invalidates the caller's current session cookie.
// Since the user has just proven possession of the TOTP secret, re-issue a
// session cookie that carries the new token_version so they stay signed in
// long enough to see and save the backup codes.
const refreshed = db.getUserById(req.user.userId);
const settings = db.getGlobalSettings();
if (refreshed && settings.auth_jwt_secret) {
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
}
console.log('[MFA] Enrolment completed:', req.user.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/confirm backupCodesIssued=', backupCodes.length, 'user=', req.user.username);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error: unknown) {
console.error('[MFA] enroll confirm error:', (error as Error).message);
res.status(500).json({ error: 'Failed to confirm enrolment' });
}
});
/**
* Disable MFA for the current user. Requires a valid TOTP or backup code to
* prove possession, so a stolen session cookie alone cannot turn off the
* second factor. Bumps `token_version` on success.
*/
app.post('/api/auth/mfa/disable', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const code = typeof req.body?.code === 'string' ? req.body.code : '';
const isBackup = req.body?.isBackupCode === true;
if (!code) {
res.status(400).json({ error: 'A verification code is required to disable two-factor authentication' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
return;
}
let ok = false;
if (isBackup) {
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
ok = (await MfaService.verifyBackupCode(hashes, code)).matched;
} else {
const cryptoSvc = CryptoService.getInstance();
ok = MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code);
}
if (isDebugEnabled()) {
console.log('[MFA:diag] disable user=', req.user.username, 'codeType=', isBackup ? 'backup' : 'totp', 'verified=', ok);
}
if (!ok) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
db.deleteUserMfa(req.user.userId);
db.bumpTokenVersion(req.user.userId);
// Re-issue the session cookie so the user stays signed in after the bump.
// They just proved possession of a current factor, so granting them the
// new token_version is safe and avoids a surprising forced re-login.
const refreshed = db.getUserById(req.user.userId);
const settings = db.getGlobalSettings();
if (refreshed && settings.auth_jwt_secret) {
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
}
console.log('[MFA] Disabled by user:', req.user.username);
res.json({ success: true });
} catch (error: unknown) {
console.error('[MFA] disable error:', (error as Error).message);
res.status(500).json({ error: 'Failed to disable two-factor authentication' });
}
});
/**
* Regenerate backup codes. Requires a valid TOTP so a stolen session alone
* cannot print new codes. The old set is invalidated immediately; the new
* set is returned in cleartext ONCE.
*/
app.post('/api/auth/mfa/backup-codes/regenerate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const code = typeof req.body?.code === 'string' ? req.body.code : '';
if (!code) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
return;
}
const cryptoSvc = CryptoService.getInstance();
if (!MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code)) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
const backupCodes = MfaService.generateBackupCodes();
const hashes = await MfaService.hashBackupCodes(backupCodes);
db.upsertUserMfa(req.user.userId, { backup_codes_json: JSON.stringify(hashes) });
console.log('[MFA] Backup codes regenerated:', req.user.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] backup-codes/regenerate user=', req.user.username, 'codesIssued=', backupCodes.length);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error: unknown) {
console.error('[MFA] regenerate backup codes error:', (error as Error).message);
res.status(500).json({ error: 'Failed to regenerate backup codes' });
}
});
/** Toggle whether SSO logins must also complete the TOTP challenge. */
app.put('/api/auth/mfa/sso-bypass', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const enforce = req.body?.enforce === true;
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.enabled) {
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
return;
}
if ((mfa.sso_enforce_mfa === 1) !== enforce) {
db.upsertUserMfa(req.user.userId, { sso_enforce_mfa: enforce });
console.log('[MFA] SSO bypass toggled:', req.user.username, 'enforce=', enforce);
}
res.json({ success: true, sso_enforce_mfa: enforce });
} catch (error: unknown) {
console.error('[MFA] sso-bypass error:', (error as Error).message);
res.status(500).json({ error: 'Failed to update SSO enforcement' });
}
});
// Apply authentication middleware to all /api/* routes except /api/auth/*
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) {
next();
return;
}
authMiddleware(req, res, next);
});
// Audit logging middleware - records all mutating API actions for Admiral accountability.
// Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code.
import { getAuditSummary } from './utils/audit-summaries';
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
next();
return;
}
const username = req.user?.username || 'unknown';
const nodeId = req.nodeId ?? null;
const forwarded = req.headers['x-forwarded-for'];
const xff = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : '';
const ip = req.ip || xff || '';
const apiPath = req.path;
res.on('finish', () => {
try {
if (isDebugEnabled()) {
console.log(`[Audit:diag] ${req.method} /api${apiPath} by=${username} status=${res.statusCode} node=${nodeId ?? 'local'} ip=${ip}`);
}
DatabaseService.getInstance().insertAuditLog({
timestamp: Date.now(),
username,
method: req.method,
path: `/api${apiPath}`,
status_code: res.statusCode,
node_id: nodeId,
ip_address: ip,
summary: getAuditSummary(req.method, apiPath),
});
} catch (err) {
console.error('[Audit] Failed to write audit log:', err);
}
});
next();
});
// --- License Routes (local-only, never proxied) ---
// Paid feature guard: returns false and sends 403 if not on a paid tier (Skipper or Admiral).
// Checks req.proxyTier first (set by authMiddleware for trusted node proxy requests),
// falling back to the local LicenseService tier for direct access.
const requirePaid = (req: Request, res: Response): boolean => {
const tier = req.proxyTier !== undefined ? req.proxyTier : LicenseService.getInstance().getTier();
if (tier !== 'paid') {
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
return false;
}
return true;
};
// Admiral feature guard: requires paid tier with team variant.
// Checks req.proxyTier/proxyVariant first (set by authMiddleware for trusted node proxy
// requests), falling back to the local LicenseService for direct access.
const requireAdmiral = (req: Request, res: Response): boolean => {
const ls = LicenseService.getInstance();
const tier = req.proxyTier !== undefined ? req.proxyTier : ls.getTier();
const variant = req.proxyVariant !== undefined ? req.proxyVariant : ls.getVariant();
if (tier !== 'paid') {
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
return false;
}
if (variant !== 'admiral') {
res.status(403).json({ error: 'This feature requires a Sencho Admiral license.', code: 'ADMIRAL_REQUIRED' });
return false;
}
return true;
};
const requireAdmin = (req: Request, res: Response): boolean => {
if (req.user?.role !== 'admin') {
res.status(403).json({ error: 'Admin access required.', code: 'ADMIN_REQUIRED' });
return false;
}
return true;
};
// Only accept calls from a sibling Sencho using its node_proxy Bearer token.
// Browser sessions, API tokens, and console tokens are all rejected.
const requireNodeProxy = (req: Request, res: Response): boolean => {
if (req.user?.username !== 'node-proxy') {
res.status(403).json({ error: 'Node proxy authentication required.', code: 'NODE_PROXY_REQUIRED' });
return false;
}
return true;
};
const requireBody = (req: Request, res: Response): boolean => {
if (!req.body || typeof req.body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
return false;
}
return true;
};
function isSqliteUniqueViolation(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE';
}
// Tier gate for scheduled tasks: 'update' and 'scan' actions require Skipper+, everything else requires Admiral.
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
if (action === 'update' || action === 'scan') return requirePaid(req, res);
return requireAdmiral(req, res);
};
async function triggerPostDeployScan(
stackName: string,
nodeId: number,
): Promise<void> {
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) return;
try {
const docker = DockerController.getInstance(nodeId).getDocker();
const containers = await docker.listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${stackName}`] },
});
const imageRefs = new Set<string>();
for (const c of containers as Array<{ Image?: string }>) {
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
}
if (imageRefs.size === 0) return;
const db = DatabaseService.getInstance();
const policy = db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
for (const imageRef of imageRefs) {
try {
const digest = await svc.getImageDigest(imageRef, nodeId);
if (digest) {
const cached = db.getLatestScanByDigest(digest, 'vuln');
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
}
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
if (scan.critical_count > 0 || scan.high_count > 0) {
NotificationService.getInstance().dispatchAlert(
scan.critical_count > 0 ? 'error' : 'warning',
`Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`,
stackName,
);
}
if (
policy &&
severityRank(scan.highest_severity) >= severityRank(policy.max_severity)
) {
NotificationService.getInstance().dispatchAlert(
policy.block_on_deploy ? 'error' : 'warning',
`Policy "${policy.name}" triggered for ${imageRef}: ${scan.highest_severity} exceeds ${policy.max_severity}`,
stackName,
);
}
} catch (err) {
const message = (err as Error).message;
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
NotificationService.getInstance().dispatchAlert(
'warning',
`Post-deploy scan failed for ${imageRef} (${stackName}): ${message}`,
stackName,
);
}
}
} catch (err) {
console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, (err as Error).message);
}
}
// --- Scoped RBAC Permission Engine (Admiral) ---
type PermissionAction =
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
| 'node:read' | 'node:manage'
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
admin: [
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
'node:read', 'node:manage',
'system:settings', 'system:users', 'system:license', 'system:webhooks',
'system:tokens', 'system:console', 'system:audit', 'system:registries',
],
'node-admin': [
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
'node:read', 'node:manage',
],
deployer: [
'stack:read', 'stack:deploy',
],
viewer: [
'stack:read', 'node:read',
],
auditor: [
'stack:read', 'node:read', 'system:audit',
],
};
/**
* Core permission resolver. Checks if the current user can perform `action` on an optional resource.
* 1. Admin → always true (backward compat)
* 2. Check global role permissions
* 3. If resource specified AND Admiral → check scoped role_assignments
*/
function checkPermission(
req: Request,
action: PermissionAction,
resourceType?: ResourceType,
resourceId?: string,
): boolean {
if (!req.user) return false;
const globalRole = req.user.role;
if (isDebugEnabled()) console.log('[RBAC:diag] checkPermission:', action, 'user:', req.user.username, 'globalRole:', globalRole, 'resource:', resourceType, resourceId);
// Admins always have full access
if (globalRole === 'admin') return true;
// Check if the user's global role grants this action
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
// Scoped assignments only apply when a resource is specified and license is Admiral
if (!resourceType || !resourceId) return false;
const variant = req.proxyVariant !== undefined ? req.proxyVariant : LicenseService.getInstance().getVariant();
if (variant !== 'admiral') return false;
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
for (const assignment of assignments) {
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
}
return false;
}
/** Generic permission guard — sends 403 if denied. */
function requirePermission(
req: Request,
res: Response,
action: PermissionAction,
resourceType?: ResourceType,
resourceId?: string,
): boolean {
if (checkPermission(req, action, resourceType, resourceId)) return true;
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return false;
}
// Scope enforcement for API tokens - restricts which endpoints a token can reach.
const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
/^\/api\/stacks\/[^/]+\/deploy$/,
/^\/api\/stacks\/[^/]+\/down$/,
/^\/api\/stacks\/[^/]+\/restart$/,
/^\/api\/stacks\/[^/]+\/stop$/,
/^\/api\/stacks\/[^/]+\/start$/,
/^\/api\/stacks\/[^/]+\/update$/,
];
const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => {
const scope = req.apiTokenScope;
if (!scope) { next(); return; } // Not an API token request
if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', req.method, req.path, 'scope:', scope);
if (scope === 'full-admin') { next(); return; }
if (scope === 'read-only') {
if (req.method !== 'GET') {
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope);
res.status(403).json({ error: 'API token scope "read-only" only allows GET requests.', code: 'SCOPE_DENIED' });
return;
}
next();
return;
}
if (scope === 'deploy-only') {
if (req.method === 'GET') { next(); return; }
const fullPath = `/api${req.path}`;
if (req.method === 'POST' && DEPLOY_ALLOWED_PATTERNS.some(p => p.test(fullPath))) {
next();
return;
}
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope);
res.status(403).json({ error: 'API token scope "deploy-only" does not allow this action.', code: 'SCOPE_DENIED' });
return;
}
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied: unknown scope', req.method, req.path, 'scope:', scope);
res.status(403).json({ error: 'Unknown API token scope.', code: 'SCOPE_DENIED' });
};
app.use('/api', enforceApiTokenScope);
app.get('/api/license', (_req: Request, res: Response): void => {
try {
const info = LicenseService.getInstance().getLicenseInfo();
res.json(info);
} catch (error) {
console.error('[License] Error getting license info:', error);
res.status(500).json({ error: 'Failed to retrieve license information' });
}
});
app.post('/api/license/activate', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage licenses.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const { license_key } = req.body;
if (!license_key || typeof license_key !== 'string') {
res.status(400).json({ error: 'A valid license key is required' });
return;
}
const result = await LicenseService.getInstance().activate(license_key.trim());
if (result.success) {
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
} else {
res.status(400).json({ error: result.error });
}
} catch (error) {
console.error('[License] Activation error:', error);
res.status(500).json({ error: 'License activation failed' });
}
});
app.post('/api/license/deactivate', async (_req: Request, res: Response): Promise<void> => {
if (_req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage licenses.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(_req, res)) return;
try {
const result = await LicenseService.getInstance().deactivate();
if (result.success) {
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
} else {
res.status(500).json({ error: result.error });
}
} catch (error) {
console.error('[License] Deactivation error:', error);
res.status(500).json({ error: 'License deactivation failed' });
}
});
app.post('/api/license/validate', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await LicenseService.getInstance().validate();
res.json({ ...result, license: LicenseService.getInstance().getLicenseInfo() });
} catch (error) {
console.error('[License] Validation error:', error);
res.status(500).json({ error: 'License validation failed' });
}
});
app.get('/api/license/billing-portal', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await LicenseService.getInstance().getBillingPortalUrl();
if ('error' in result) {
res.status(404).json({ error: result.error });
return;
}
res.json({ url: result.url });
} catch (error) {
console.error('[License] Billing portal error:', error);
res.status(500).json({ error: 'Failed to retrieve billing portal URL' });
}
});
// --- Self-Update ---
/** Respond 202 and trigger the "last breath" self-update after the response flushes. */
function scheduleLocalUpdate(res: Response, message: string): void {
res.status(202).json({ message });
res.on('finish', () => {
setTimeout(() => {
// Defense in depth: triggerUpdate records its own errors into lastUpdateError,
// but guard against an unexpected throw becoming an unhandled rejection.
SelfUpdateService.getInstance().triggerUpdate().catch((err) => {
console.error('[SelfUpdate] Unexpected error during triggerUpdate:', err);
});
}, 500);
});
}
app.post('/api/system/update', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!SelfUpdateService.getInstance().isAvailable()) {
res.status(503).json({ error: 'Self-update unavailable. Sencho must be deployed via Docker Compose.' });
return;
}
scheduleLocalUpdate(res, 'Update initiated. The server will restart shortly.');
});
// --- Fleet Overview (local-only, aggregates all nodes) ---
// In-memory tracker for remote node updates (transient — lost on gateway restart)
interface UpdateTracker {
status: 'updating' | 'completed' | 'timeout' | 'failed';
startedAt: number;
previousVersion: string | null;
error?: string;
/** Process start time of the remote node before the update was triggered. */
previousProcessStart: number | null;
/** True when the node became unreachable at least once during the update window. */
wasOffline: boolean;
/** Timestamp when the tracker transitioned to a terminal state (completed/failed/timeout). */
resolvedAt?: number;
}
const updateTracker = new Map<number, UpdateTracker>();
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const UPDATE_TIMEOUT_MSG = 'Node did not come back online within 5 minutes.';
const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure
// Latest Sencho version lookup and caching live in utils/version-check.ts
// (shared with MonitorService). Fleet compares the gateway version against
// whatever getLatestVersion() returns from GitHub or Docker Hub.
/** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */
async function getCompareTarget(gatewayVersion: string | null) {
const latestVersion = await getLatestVersion();
const latestValid = latestVersion !== null && isValidVersion(latestVersion);
const result = {
latestVersion,
latestValid,
compareVersion: latestValid ? latestVersion : gatewayVersion,
compareValid: latestValid || isValidVersion(gatewayVersion),
};
if (isDebugEnabled()) {
console.debug('[Fleet:debug] Compare target resolved:', { gatewayVersion, latestVersion, using: result.compareVersion, valid: result.compareValid });
}
return result;
}
function createTracker(
status: UpdateTracker['status'],
previousVersion: string | null,
previousProcessStart: number | null,
error?: string,
): UpdateTracker {
const now = Date.now();
return {
status, startedAt: now, previousVersion, previousProcessStart, wasOffline: false, error,
resolvedAt: status !== 'updating' ? now : undefined,
};
}
/** Transition a tracker to a terminal state, setting resolvedAt automatically. */
function resolveTracker(tracker: UpdateTracker, status: 'completed' | 'failed' | 'timeout', error?: string): UpdateTracker {
return { ...tracker, status, resolvedAt: Date.now(), error };
}
interface FleetNodeOverview {
id: number;
name: string;
type: 'local' | 'remote';
status: 'online' | 'offline' | 'unknown';
stats: {
active: number;
managed: number;
unmanaged: number;
exited: number;
total: number;
} | null;
systemStats: {
cpu: { usage: string; cores: number };
memory: { total: number; used: number; free: number; usagePercent: string };
disk: { total: number; used: number; free: number; usagePercent: string } | null;
} | null;
stacks: string[] | null;
}
// Fleet role: tells the frontend whether this Sencho is the control or a replica.
// The control serves read+write for security rules. Replicas are read-only and managed upstream.
app.get('/api/fleet/role', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
res.json({ role: FleetSyncService.getRole() });
});
const MAX_SYNC_ROWS = 5000;
const VALID_SEVERITY = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
const isIntFlag = (v: unknown): v is 0 | 1 => v === 0 || v === 1;
function validateScanPolicyRow(row: unknown): string | null {
if (!row || typeof row !== 'object') return 'row must be an object';
const r = row as Record<string, unknown>;
if (typeof r.name !== 'string' || r.name.length === 0 || r.name.length > 200) return 'name must be a non-empty string';
if (typeof r.max_severity !== 'string' || !VALID_SEVERITY.has(r.max_severity)) return 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW';
if (r.stack_pattern !== null && typeof r.stack_pattern !== 'string') return 'stack_pattern must be a string or null';
if (typeof r.stack_pattern === 'string' && r.stack_pattern.length > 200) return 'stack_pattern is too long';
if (typeof r.node_identity !== 'string') return 'node_identity must be a string';
if (r.node_identity.length > 500) return 'node_identity is too long';
if (!isIntFlag(r.block_on_deploy)) return 'block_on_deploy must be 0 or 1';
if (!isIntFlag(r.enabled)) return 'enabled must be 0 or 1';
return null;
}
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
// Returns a normalized scanners array, undefined when no input was provided,
// or null when the input is present but invalid.
function parseScannersInput(raw: unknown): readonly ('vuln' | 'secret')[] | undefined | null {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.length === 0) return null;
const out = new Set<'vuln' | 'secret'>();
for (const item of raw) {
if (item !== 'vuln' && item !== 'secret') return null;
out.add(item);
}
return Array.from(out) as readonly ('vuln' | 'secret')[];
}
function validateCveSuppressionRow(row: unknown): string | null {
if (!row || typeof row !== 'object') return 'row must be an object';
const r = row as Record<string, unknown>;
if (typeof r.cve_id !== 'string' || !CVE_ID_RE.test(r.cve_id)) return 'cve_id must be a valid CVE or GHSA identifier';
if (r.pkg_name !== null && typeof r.pkg_name !== 'string') return 'pkg_name must be a string or null';
if (typeof r.pkg_name === 'string' && r.pkg_name.length > 200) return 'pkg_name is too long';
if (r.image_pattern !== null && typeof r.image_pattern !== 'string') return 'image_pattern must be a string or null';
if (typeof r.image_pattern === 'string' && r.image_pattern.length > 300) return 'image_pattern is too long';
if (typeof r.reason !== 'string') return 'reason must be a string';
if (r.reason.length > 2000) return 'reason is too long';
if (typeof r.created_by !== 'string' || r.created_by.length > 200) return 'created_by must be a string';
if (typeof r.created_at !== 'number') return 'created_at must be a number';
if (r.expires_at !== null && typeof r.expires_at !== 'number') return 'expires_at must be a number or null';
return null;
}
// Fleet sync: receive a full replacement of a replicated resource from the control.
// Restricted to node_proxy Bearer tokens so only a sibling Sencho can push.
app.post('/api/fleet/sync/:resource', authMiddleware, (req: Request, res: Response): void => {
if (!requireNodeProxy(req, res)) return;
const resource = req.params.resource;
if (resource !== 'scan_policies' && resource !== 'cve_suppressions') {
res.status(400).json({ error: `Unsupported sync resource: ${resource}` });
return;
}
const body = req.body ?? {};
const rows = Array.isArray(body.rows) ? body.rows : null;
const targetIdentity = typeof body.targetIdentity === 'string' ? body.targetIdentity : '';
if (!rows) {
res.status(400).json({ error: 'rows array is required' });
return;
}
if (rows.length > MAX_SYNC_ROWS) {
res.status(413).json({ error: `Too many rows (max ${MAX_SYNC_ROWS})` });
return;
}
const validator = resource === 'scan_policies' ? validateScanPolicyRow : validateCveSuppressionRow;
for (let i = 0; i < rows.length; i++) {
const err = validator(rows[i]);
if (err) {
res.status(400).json({ error: `Invalid row at index ${i}: ${err}` });
return;
}
}
try {
FleetSyncService.getInstance().applyIncomingSync(resource, rows, targetIdentity);
res.json({ success: true, applied: rows.length });
} catch (error) {
console.error('[FleetSync] Failed to apply incoming sync:', error);
res.status(500).json({ error: 'Failed to apply sync' });
}
});
// Fleet sync status: surfaces per-node replication results so operators can spot stale replicas.
app.get('/api/fleet/sync-status', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
res.json(DatabaseService.getInstance().getFleetSyncStatuses());
});
app.get('/api/fleet/overview', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
try {
const debug = isDebugEnabled();
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
if (debug) console.debug('[Fleet:debug] Overview requested, fetching', nodes.length, 'nodes');
const results = await Promise.allSettled(
nodes.map(async (node): Promise<FleetNodeOverview> => {
if (node.type === 'remote') {
return fetchRemoteNodeOverview(node);
}
return fetchLocalNodeOverview(node);
})
);
const overview: FleetNodeOverview[] = results.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
console.error(`[Fleet] Failed to fetch node ${nodes[i].name}:`, result.reason);
return {
id: nodes[i].id,
name: nodes[i].name,
type: nodes[i].type,
status: 'offline' as const,
stats: null,
systemStats: null,
stacks: null,
};
});
if (debug) {
const online = overview.filter(n => n.status === 'online').length;
console.debug('[Fleet:debug] Overview complete:', online, 'online,', overview.length - online, 'offline');
}
res.json(overview);
} catch (error) {
console.error('[Fleet] Overview error:', error);
res.status(500).json({ error: 'Failed to fetch fleet overview' });
}
});
// Paid-gated: detailed stack info per node
app.get('/api/fleet/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
if (node.type === 'remote') {
if (!node.api_url || !node.api_token) {
res.status(503).json({ error: 'Remote node not configured' });
return;
}
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/stacks`, {
headers: { Authorization: `Bearer ${node.api_token}` },
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
res.status(502).json({ error: 'Failed to fetch stacks from remote node' });
return;
}
const stacks = await response.json();
if (isDebugEnabled()) console.debug('[Fleet:debug] Node stacks:', nodeId, node.type, Array.isArray(stacks) ? stacks.length : 0, 'stacks');
res.json(stacks);
return;
}
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
if (isDebugEnabled()) console.debug('[Fleet:debug] Node stacks:', nodeId, node.type, stacks.length, 'stacks');
res.json(stacks);
} catch (error) {
console.error('[Fleet] Node stacks error:', error);
res.status(500).json({ error: 'Failed to fetch node stacks' });
}
});
// Paid-gated: container details for a specific stack on a specific node
app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
if (node.type === 'remote') {
if (!node.api_url || !node.api_token) {
res.status(503).json({ error: 'Remote node not configured' });
return;
}
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(stackName)}/containers`, {
headers: { Authorization: `Bearer ${node.api_token}` },
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
res.status(502).json({ error: 'Failed to fetch containers from remote node' });
return;
}
const containers = await response.json();
res.json(containers);
return;
}
const dockerController = DockerController.getInstance(nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (isDebugEnabled()) console.debug('[Fleet:debug] Stack containers:', nodeId, stackName, containers.length, 'containers');
res.json(containers);
} catch (error) {
console.error('[Fleet] Node stack containers error:', error);
res.status(500).json({ error: 'Failed to fetch stack containers' });
}
});
// Fleet Update Status — returns version comparison and active update status for all nodes
app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
if (!requirePaid(_req, res)) return;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const gatewayVersion = getSenchoVersion();
const gatewayValid = isValidVersion(gatewayVersion);
const { latestVersion, latestValid, compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
const debug = isDebugEnabled();
const results = await Promise.allSettled(
nodes.map(async (node) => {
const tracker = updateTracker.get(node.id);
let version: string | null = null;
let remoteStartedAt: number | null = null;
let remoteUpdateError: string | null = null;
let remoteOnline = false;
if (node.type === 'local') {
version = gatewayVersion;
} else if (node.api_url && node.api_token) {
const meta = await fetchRemoteMeta(node.api_url, node.api_token);
version = meta.version;
remoteStartedAt = meta.startedAt;
remoteUpdateError = meta.updateError;
remoteOnline = meta.online;
}
// For nodes actively updating, check if they've come back
if (tracker?.status === 'updating') {
const elapsed = Date.now() - tracker.startedAt;
if (debug) {
console.debug('[Fleet:debug] Polling update status for node', node.id, node.name, '- elapsed:', Math.round(elapsed / 1000) + 's', 'version:', version, 'wasOffline:', tracker.wasOffline, 'remoteOnline:', remoteOnline);
}
if (elapsed > UPDATE_TIMEOUT_MS) {
// Final timeout (5 min)
if (debug) console.debug('[Fleet:debug] Node', node.id, 'timed out after', Math.round(elapsed / 1000) + 's');
updateTracker.set(node.id, resolveTracker(tracker, 'timeout', UPDATE_TIMEOUT_MSG));
} else if (node.type === 'remote') {
if (remoteUpdateError) {
// Remote reported a pull failure via /api/meta
if (debug) console.debug('[Fleet:debug] Node', node.id, 'reported pull failure:', remoteUpdateError);
updateTracker.set(node.id, resolveTracker(tracker, 'failed', remoteUpdateError));
} else if (!remoteOnline) {
// Node is unreachable (restarting); record that it went offline
if (!tracker.wasOffline) {
if (debug) console.debug('[Fleet:debug] Node', node.id, 'went offline (restarting)');
updateTracker.set(node.id, { ...tracker, wasOffline: true });
}
} else if (version !== tracker.previousVersion) {
// Signal 1: Version changed (or version now resolvable after being unknown)
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 1 (version changed):', tracker.previousVersion, '->', version);
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
} else if (
remoteStartedAt !== null &&
tracker.previousProcessStart !== null &&
remoteStartedAt !== tracker.previousProcessStart
) {
// Signal 2: Process restarted (startedAt changed)
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 2 (process restarted):', tracker.previousProcessStart, '->', remoteStartedAt);
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
} else if (tracker.wasOffline && remoteOnline) {
// Signal 3: Node went offline and is back online (container was recreated)
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online)');
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
} else if (
elapsed > 15_000 &&
isValidVersion(version) &&
gatewayValid &&
!semver.lt(version, compareVersion!)
) {
// Signal 4: Remote is now at or above gateway version (after minimum processing time).
// Catches fast restarts where the 5s polling interval misses the offline window
// and startedAt hasn't been observed to change yet.
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 4 (version >= compare target):', version, '>=', compareVersion);
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
} else if (elapsed > EARLY_FAIL_MS) {
// Heuristic: node never went offline and nothing changed after 3 min
if (debug) console.debug('[Fleet:debug] Node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's - no signals detected');
updateTracker.set(node.id, resolveTracker(tracker, 'failed', 'Update may have failed. The node is still running and its version has not changed.'));
}
} else if (node.type === 'local') {
// Local node has only two failure signals: an explicit pull/spawn error,
// or the early-fail heuristic. Success is observed by the frontend overlay
// (it reloads the page when /api/health reports a new startedAt), at which
// point the new process starts with an empty tracker map.
const selfUpdate = SelfUpdateService.getInstance();
const localError = selfUpdate.getLastError();
if (localError) {
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'update failed:', localError);
updateTracker.set(node.id, resolveTracker(tracker, 'failed', localError));
selfUpdate.clearLastError();
} else if (elapsed > EARLY_FAIL_MS) {
// Helper container likely failed silently. Surface failure before the 5 min timeout.
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's');
updateTracker.set(node.id, resolveTracker(tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.'));
}
}
}
// Auto-expire completed entries 60s after they resolved so the badge is visible
if (tracker?.status === 'completed' && tracker.resolvedAt && Date.now() - tracker.resolvedAt > 60_000) {
updateTracker.delete(node.id);
}
// Assume remote nodes are outdated when their version is unresolvable
let updateAvailable = false;
if (!isValidVersion(version)) {
updateAvailable = node.type === 'remote';
} else if (compareValid) {
updateAvailable = semver.lt(version, compareVersion!);
}
const currentTracker = updateTracker.get(node.id);
return {
nodeId: node.id,
name: node.name,
type: node.type,
version,
latestVersion: latestValid ? latestVersion : gatewayVersion,
updateAvailable,
updateStatus: currentTracker?.status ?? null,
error: currentTracker?.error ?? null,
};
})
);
const nodeStatuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return {
nodeId: nodes[i].id,
name: nodes[i].name,
type: nodes[i].type,
version: null,
latestVersion: latestValid ? latestVersion : gatewayVersion,
updateAvailable: false,
updateStatus: null,
error: null,
};
});
if (isDebugEnabled()) {
const trackerStates = Array.from(updateTracker.entries()).map(([nid, t]) => `${nid}:${t.status}`);
console.debug('[Fleet:debug] Update status:', nodeStatuses.length, 'nodes, trackers:', trackerStates.join(', ') || 'none');
}
res.json({ nodes: nodeStatuses });
} catch (error) {
console.error('[Fleet] Update status error:', error);
res.status(500).json({ error: 'Failed to fetch update status' });
}
});
// Trigger update on a specific node
app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
const existing = updateTracker.get(nodeId);
if (existing?.status === 'updating') {
if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) {
updateTracker.set(nodeId, resolveTracker(existing, 'timeout', UPDATE_TIMEOUT_MSG));
} else {
res.status(409).json({ error: 'Update already in progress for this node.' });
return;
}
}
// Clear terminal states to allow retry
if (existing && (existing.status === 'timeout' || existing.status === 'failed' || existing.status === 'completed')) {
updateTracker.delete(nodeId);
}
console.log('[Fleet] Update triggered for node', node.name, node.type);
if (isDebugEnabled()) {
console.debug('[Fleet:debug] Update trigger details:', { nodeId, name: node.name, type: node.type, hasUrl: !!node.api_url, hasToken: !!node.api_token });
}
if (node.type === 'local') {
if (!SelfUpdateService.getInstance().isAvailable()) {
res.status(503).json({ error: 'Self-update unavailable on the local node.' });
return;
}
updateTracker.set(nodeId, createTracker('updating', getSenchoVersion(), null));
scheduleLocalUpdate(res, 'Update initiated on local node. The server will restart shortly.');
return;
}
// Remote node
if (!node.api_url || !node.api_token) {
res.status(503).json({ error: 'Remote node not configured.' });
return;
}
// Check remote availability and capabilities
const meta = await fetchRemoteMeta(node.api_url, node.api_token);
if (isDebugEnabled()) {
console.debug('[Fleet:debug] Remote meta for update:', { nodeId, online: meta.online, version: meta.version, capabilities: meta.capabilities, startedAt: meta.startedAt });
}
if (!meta.online) {
res.status(503).json({ error: 'Remote node is unreachable. Verify the node is running and the API URL is correct.' });
return;
}
if (!meta.capabilities.includes('self-update')) {
res.status(503).json({ error: 'Remote node does not support self-update. It may need to be updated manually first.' });
return;
}
// Trigger remote update
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/update`, {
method: 'POST',
headers: {
Authorization: `Bearer ${node.api_token}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
const errorMsg = (err as Record<string, string>)?.error || 'Remote node rejected update request.';
updateTracker.set(nodeId, createTracker('failed', meta.version, meta.startedAt, errorMsg));
res.status(502).json({ error: errorMsg });
return;
}
updateTracker.set(nodeId, createTracker('updating', meta.version, meta.startedAt));
res.status(202).json({ message: `Update initiated on ${node.name}.` });
} catch (error) {
console.error('[Fleet] Node update error:', error);
const errorMsg = (error as Error)?.message || 'Failed to trigger node update.';
const failedNodeId = parseInt(req.params.nodeId as string, 10);
if (!isNaN(failedNodeId)) {
updateTracker.set(failedNodeId, createTracker('failed', null, null, errorMsg));
}
res.status(500).json({ error: 'Failed to trigger node update.' });
}
});
// Trigger update on all outdated nodes
app.post('/api/fleet/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const gatewayVersion = getSenchoVersion();
const { compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
const debug = isDebugEnabled();
console.log('[Fleet] Update-all triggered,', nodes.length, 'nodes registered');
if (debug) console.debug('[Fleet:debug] Update-all compare target:', { gatewayVersion, compareVersion, compareValid });
// Filter to eligible candidates, then trigger all in parallel
const candidates = nodes.filter(node => {
if (node.type === 'local') return false;
const tracker = updateTracker.get(node.id);
if (tracker?.status === 'updating') return false;
if (!node.api_url || !node.api_token) return false;
// Clear terminal states so they can be re-triggered
if (tracker && (tracker.status === 'timeout' || tracker.status === 'failed' || tracker.status === 'completed')) {
updateTracker.delete(node.id);
}
return true;
});
const results = await Promise.allSettled(candidates.map(async (node) => {
const meta = await fetchRemoteMeta(node.api_url!, node.api_token!);
if (!meta.online) {
return { name: node.name, triggered: false };
}
if (!meta.capabilities.includes('self-update')) {
return { name: node.name, triggered: false };
}
if (isValidVersion(meta.version) && compareValid && !semver.lt(meta.version, compareVersion!)) {
return { name: node.name, triggered: false };
}
const response = await fetch(`${node.api_url!.replace(/\/$/, '')}/api/system/update`, {
method: 'POST',
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (response.ok) {
updateTracker.set(node.id, createTracker('updating', meta.version, meta.startedAt));
return { name: node.name, triggered: true };
}
return { name: node.name, triggered: false };
}));
const updating: string[] = [];
const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name);
for (let i = 0; i < results.length; i++) {
const r = results[i];
const val = r.status === 'fulfilled' ? r.value : { name: candidates[i].name, triggered: false };
(val.triggered ? updating : skipped).push(val.name);
}
if (debug) console.debug('[Fleet:debug] Update-all results:', { updating, skippedCount: skipped.length, candidateCount: candidates.length });
res.status(202).json({ updating, skipped });
} catch (error) {
console.error('[Fleet] Update all error:', error);
res.status(500).json({ error: 'Failed to trigger fleet update.' });
}
});
// Clear update tracker entry for a specific node (dismiss or before retry)
app.delete('/api/fleet/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = parseInt(req.params.nodeId as string, 10);
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
updateTracker.delete(nodeId);
res.status(204).send();
} catch (error) {
console.error('[Fleet] Clear update status error:', error);
res.status(500).json({ error: 'Failed to clear update status.' });
}
});
// Clear all terminal (timed-out, failed, completed) tracker entries at once
app.delete('/api/fleet/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
// Pre-fetch fresh latest version so the next GET has up-to-date data
if (req.query.recheck === 'true') {
await getLatestVersion(true);
}
for (const [nodeId, tracker] of updateTracker) {
if (tracker.status === 'timeout' || tracker.status === 'failed' || tracker.status === 'completed') {
updateTracker.delete(nodeId);
}
}
res.status(204).send();
});
async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
try {
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(node.id));
const [allContainers, stacks, currentLoad, mem, fsSize] = await Promise.all([
DockerController.getInstance(node.id).getAllContainers(),
FileSystemService.getInstance(node.id).getStacks(),
si.currentLoad(),
si.mem(),
si.fsSize(),
]);
const isManagedByComposeDir = (c: Dockerode.ContainerInfo): boolean => {
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
if (!workingDir) return false;
const resolved = path.resolve(workingDir);
return resolved === composeDir || resolved.startsWith(composeDir + path.sep);
};
const containers = allContainers as Dockerode.ContainerInfo[];
const active = containers.filter(c => c.State === 'running').length;
const exited = containers.filter(c => c.State === 'exited').length;
const total = containers.length;
const managed = containers.filter(c => c.State === 'running' && isManagedByComposeDir(c)).length;
const unmanaged = containers.filter(c => c.State === 'running' && !isManagedByComposeDir(c)).length;
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
return {
id: node.id,
name: node.name,
type: node.type,
status: 'online',
stats: { active, managed, unmanaged, exited, total },
systemStats: {
cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length },
memory: {
total: mem.total,
used: mem.used,
free: mem.free,
usagePercent: ((mem.used / mem.total) * 100).toFixed(1),
},
disk: mainDisk ? {
total: mainDisk.size,
used: mainDisk.used,
free: mainDisk.available,
usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0',
} : null,
},
stacks,
};
} catch (error) {
console.error(`[Fleet] Local node ${node.name} error:`, error);
return {
id: node.id, name: node.name, type: node.type, status: 'offline',
stats: null, systemStats: null, stacks: null,
};
}
}
async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
if (!node.api_url || !node.api_token) {
return {
id: node.id, name: node.name, type: node.type, status: 'offline',
stats: null, systemStats: null, stacks: null,
};
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
try {
const [statsRes, systemStatsRes, stacksRes] = await Promise.allSettled([
fetch(`${baseUrl}/api/stats`, { headers, signal: AbortSignal.timeout(10000) }),
fetch(`${baseUrl}/api/system/stats`, { headers, signal: AbortSignal.timeout(10000) }),
fetch(`${baseUrl}/api/stacks`, { headers, signal: AbortSignal.timeout(10000) }),
]);
interface RemoteSystemStats {
cpu: { usage: string; cores: number };
memory: { total: number; used: number; free: number; usagePercent: string };
disk?: { total: number; used: number; free: number; usagePercent: string } | null;
}
const stats: FleetNodeOverview['stats'] | null = statsRes.status === 'fulfilled' && statsRes.value.ok
? await statsRes.value.json() as FleetNodeOverview['stats'] : null;
const systemStatsRaw: RemoteSystemStats | null = systemStatsRes.status === 'fulfilled' && systemStatsRes.value.ok
? await systemStatsRes.value.json() as RemoteSystemStats : null;
const stacks: string[] | null = stacksRes.status === 'fulfilled' && stacksRes.value.ok
? await stacksRes.value.json() as string[] : null;
const systemStats: FleetNodeOverview['systemStats'] | null = systemStatsRaw ? {
cpu: systemStatsRaw.cpu,
memory: systemStatsRaw.memory,
disk: systemStatsRaw.disk ? {
total: systemStatsRaw.disk.total,
used: systemStatsRaw.disk.used,
free: systemStatsRaw.disk.free,
usagePercent: systemStatsRaw.disk.usagePercent,
} : null,
} : null;
return {
id: node.id,
name: node.name,
type: node.type,
status: stats || systemStats ? 'online' : 'offline',
stats,
systemStats,
stacks,
};
} catch (error) {
console.error(`[Fleet] Remote node ${node.name} error:`, error);
return {
id: node.id, name: node.name, type: node.type, status: 'offline',
stats: null, systemStats: null, stacks: null,
};
}
}
// ─── Fleet Snapshots (Skipper+) ───
// Create fleet snapshot
app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const { description = '' } = req.body;
if (typeof description === 'string' && description.length > 500) {
res.status(400).json({ error: 'Description must be 500 characters or less' });
return;
}
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const username = req.user?.username || 'admin';
const captureStart = Date.now();
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
return captureRemoteNodeFiles(node);
}
return captureLocalNodeFiles(node);
})
);
const capturedNodes: SnapshotNodeData[] = [];
const skippedNodes: Array<{ nodeId: number; nodeName: string; reason: string }> = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
capturedNodes.push(result.value);
} else {
console.error(`[Fleet Snapshot] Failed to capture node ${nodes[i].name}:`, result.reason);
skippedNodes.push({
nodeId: nodes[i].id,
nodeName: nodes[i].name,
reason: result.reason instanceof Error ? result.reason.message : 'Unknown error',
});
}
});
let totalStacks = 0;
const allFiles: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }> = [];
for (const nodeData of capturedNodes) {
totalStacks += nodeData.stacks.length;
for (const stack of nodeData.stacks) {
for (const file of stack.files) {
allFiles.push({
nodeId: nodeData.nodeId,
nodeName: nodeData.nodeName,
stackName: stack.stackName,
filename: file.filename,
content: file.content,
});
}
}
}
const snapshotId = db.createSnapshot(
description,
username,
capturedNodes.length,
totalStacks,
JSON.stringify(skippedNodes),
);
if (allFiles.length > 0) {
db.insertSnapshotFiles(snapshotId, allFiles);
}
console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks');
if (isDebugEnabled()) {
console.debug(`[Fleet:debug] Snapshot ${snapshotId} capture completed in ${Date.now() - captureStart}ms, ${allFiles.length} file(s) stored`);
for (const skip of skippedNodes) {
console.debug(`[Fleet:debug] Skipped node "${skip.nodeName}" (id=${skip.nodeId}): ${skip.reason}`);
}
}
const snapshot = db.getSnapshot(snapshotId);
res.status(201).json(snapshot);
} catch (error) {
console.error('[Fleet Snapshot] Create error:', error);
res.status(500).json({ error: 'Failed to create fleet snapshot' });
}
});
// List fleet snapshots
app.get('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const offset = parseInt(req.query.offset as string, 10) || 0;
const db = DatabaseService.getInstance();
const snapshots = db.getSnapshots(limit, offset);
const total = db.getSnapshotCount();
if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshots list: limit=', limit, 'offset=', offset, 'total=', total);
res.json({ snapshots, total });
} catch (error) {
console.error('[Fleet Snapshot] List error:', error);
res.status(500).json({ error: 'Failed to list fleet snapshots' });
}
});
// Get snapshot detail
app.get('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; }
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(id);
if (!snapshot) {
res.status(404).json({ error: 'Snapshot not found' });
return;
}
const files = db.getSnapshotFiles(id);
// Group files by node and stack
const nodesMap = new Map<number, { nodeId: number; nodeName: string; stacks: Map<string, Array<{ filename: string; content: string }>> }>();
for (const file of files) {
if (!nodesMap.has(file.node_id)) {
nodesMap.set(file.node_id, { nodeId: file.node_id, nodeName: file.node_name, stacks: new Map() });
}
const nodeEntry = nodesMap.get(file.node_id)!;
if (!nodeEntry.stacks.has(file.stack_name)) {
nodeEntry.stacks.set(file.stack_name, []);
}
nodeEntry.stacks.get(file.stack_name)!.push({ filename: file.filename, content: file.content });
}
const nodes = Array.from(nodesMap.values()).map(n => ({
nodeId: n.nodeId,
nodeName: n.nodeName,
stacks: Array.from(n.stacks.entries()).map(([stackName, stackFiles]) => ({
stackName,
files: stackFiles,
})),
}));
if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshot detail:', id, files.length, 'files');
res.json({ ...snapshot, nodes });
} catch (error) {
console.error('[Fleet Snapshot] Detail error:', error);
res.status(500).json({ error: 'Failed to fetch snapshot details' });
}
});
// Restore a stack from snapshot
app.post('/api/fleet/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const snapshotId = parseInt(req.params.id as string, 10);
if (isNaN(snapshotId)) { res.status(400).json({ error: 'Invalid snapshot ID' }); return; }
const { nodeId, stackName, redeploy = false } = req.body;
if (!nodeId || !stackName) {
res.status(400).json({ error: 'nodeId and stackName are required' });
return;
}
if (!isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(snapshotId);
if (!snapshot) {
res.status(404).json({ error: 'Snapshot not found' });
return;
}
const files = db.getSnapshotStackFiles(snapshotId, nodeId, stackName);
if (files.length === 0) {
res.status(404).json({ error: 'No files found for this stack in the snapshot' });
return;
}
if (isDebugEnabled()) {
const fileNames = files.map(f => f.filename).join(', ');
console.debug(`[Fleet:debug] Restore: snapshot=${snapshotId}, node=${nodeId}, stack="${stackName}", files=[${fileNames}], redeploy=${redeploy}`);
}
const node = db.getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Target node no longer exists' });
return;
}
if (node.type === 'local') {
const fsService = FileSystemService.getInstance(node.id);
// Backup current files before restore
try {
await fsService.backupStackFiles(stackName);
} catch (e) {
// Stack may not exist yet before first restore — that's ok
console.warn(`[Fleet Snapshot] Pre-restore backup failed for stack "${stackName}" (may not exist yet):`, (e as Error).message);
}
for (const file of files) {
if (file.filename === 'compose.yaml') {
await fsService.saveStackContent(stackName, file.content);
} else if (file.filename === '.env') {
await fsService.saveEnvContent(stackName, file.content);
}
}
if (redeploy) {
const composeService = ComposeService.getInstance(node.id);
await composeService.deployStack(stackName);
}
} else {
// Remote node
if (!node.api_url || !node.api_token) {
res.status(503).json({ error: 'Remote node not configured' });
return;
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers: Record<string, string> = {
Authorization: `Bearer ${node.api_token}`,
'Content-Type': 'application/json',
};
for (const file of files) {
if (file.filename === 'compose.yaml') {
const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
method: 'PUT',
headers,
body: JSON.stringify({ content: file.content }),
signal: AbortSignal.timeout(15000),
});
if (!putRes.ok) throw new Error('Failed to restore compose file on remote node');
} else if (file.filename === '.env') {
const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
method: 'PUT',
headers,
body: JSON.stringify({ content: file.content }),
signal: AbortSignal.timeout(15000),
});
if (!putRes.ok) throw new Error('Failed to restore env file on remote node');
}
}
if (redeploy) {
await fetch(`${baseUrl}/api/compose/${encodeURIComponent(stackName)}/up`, {
method: 'POST',
headers,
signal: AbortSignal.timeout(30000),
});
}
}
console.log('[Fleet] Snapshot restore:', snapshotId, 'node=', nodeId, 'stack=', stackName);
res.json({ message: 'Stack restored successfully', redeployed: redeploy });
} catch (error) {
console.error('[Fleet Snapshot] Restore error:', error);
res.status(500).json({ error: 'Failed to restore stack from snapshot' });
}
});
// Delete snapshot
app.delete('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<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 snapshot ID' }); return; }
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(id);
if (!snapshot) {
res.status(404).json({ error: 'Snapshot not found' });
return;
}
if (isDebugEnabled()) {
console.debug(`[Fleet:debug] Deleting snapshot ${id} (${snapshot.node_count} node(s), ${snapshot.stack_count} stack(s))`);
}
db.deleteSnapshot(id);
console.log('[Fleet] Snapshot deleted:', id);
res.json({ message: 'Snapshot deleted' });
} catch (error) {
console.error('[Fleet Snapshot] Delete error:', error);
res.status(500).json({ error: 'Failed to delete snapshot' });
}
});
// ─── Webhooks (Skipper+) ─── CRUD requires auth + paid tier, trigger is public with HMAC ───
// Webhook CRUD (auth + paid tier required)
app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
if (!requirePaid(_req, res)) return;
try {
const webhooks = DatabaseService.getInstance().getWebhooks();
const svc = WebhookService.getInstance();
res.json(webhooks.map(w => ({ ...w, secret: svc.maskSecret(w.secret) })));
} catch (error) {
console.error('[Webhooks] List error:', error);
res.status(500).json({ error: 'Failed to list webhooks' });
}
});
app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const { name, stack_name, action, enabled } = req.body;
if (!name || !stack_name || !action) {
res.status(400).json({ error: 'name, stack_name, and action are required' });
return;
}
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
if (!validActions.includes(action)) {
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
return;
}
if (action === 'git-pull' && !GitSourceService.getInstance().get(stack_name)) {
res.status(400).json({ error: 'Configure a Git source for this stack before creating a git-pull webhook' });
return;
}
const svc = WebhookService.getInstance();
const secret = svc.generateSecret();
const id = DatabaseService.getInstance().addWebhook({
name, stack_name, action, secret, enabled: enabled !== false,
});
// Return the full secret only on creation
res.status(201).json({ id, secret });
} catch (error) {
console.error('[Webhooks] Create error:', error);
res.status(500).json({ error: 'Failed to create webhook' });
}
});
app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const webhook = DatabaseService.getInstance().getWebhook(id);
if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; }
const { name, stack_name, action, enabled } = req.body;
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
if (action && !validActions.includes(action)) {
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
return;
}
if (action === 'git-pull') {
const targetStack = stack_name || webhook.stack_name;
if (!GitSourceService.getInstance().get(targetStack)) {
res.status(400).json({ error: 'Configure a Git source for this stack before enabling a git-pull webhook' });
return;
}
}
DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled });
res.json({ success: true });
} catch (error) {
console.error('[Webhooks] Update error:', error);
res.status(500).json({ error: 'Failed to update webhook' });
}
});
app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
DatabaseService.getInstance().deleteWebhook(id);
res.json({ success: true });
} catch (error) {
console.error('[Webhooks] Delete error:', error);
res.status(500).json({ error: 'Failed to delete webhook' });
}
});
app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const executions = DatabaseService.getInstance().getWebhookExecutions(id);
res.json(executions);
} catch (error) {
console.error('[Webhooks] History error:', error);
res.status(500).json({ error: 'Failed to fetch webhook history' });
}
});
// Webhook trigger - public endpoint, authenticated via HMAC signature
app.post('/api/webhooks/:id/trigger', webhookTriggerLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
const webhook = db.getWebhook(id);
if (!webhook || !webhook.enabled) {
res.status(404).json({ error: 'Webhook not found or disabled' });
return;
}
// Paid tier gate - trigger only works with an active Skipper or Admiral license
if (LicenseService.getInstance().getTier() !== 'paid') {
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
return;
}
// Validate HMAC signature
const signature = req.headers['x-webhook-signature'] as string;
if (!signature) {
res.status(401).json({ error: 'Missing X-Webhook-Signature header' });
return;
}
const rawBody = req.rawBody?.toString('utf-8') ?? JSON.stringify(req.body ?? {});
const svc = WebhookService.getInstance();
if (!svc.validateSignature(rawBody, webhook.secret, signature)) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
// Use action from body if provided, otherwise use webhook default
const action = req.body?.action || webhook.action;
const triggerSource = req.headers['user-agent'] || req.ip || null;
// Execute asynchronously - return 202 immediately
res.status(202).json({ message: 'Webhook accepted', action });
const atomic = LicenseService.getInstance().getTier() === 'paid';
svc.execute(id, action, triggerSource, atomic).catch(err => {
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
});
} catch (error) {
console.error('[Webhooks] Trigger error:', error);
res.status(500).json({ error: 'Failed to process webhook' });
}
});
// --- User Management (local-only, admin + paid tier gated for creation) ---
app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const db = DatabaseService.getInstance();
const users = db.getUsers();
const mfaUserIds = db.getUsersWithMfaEnabled();
const enriched = users.map((u) => ({
...u,
mfaEnabled: mfaUserIds.has(u.id),
}));
res.json(enriched);
} catch (error) {
console.error('[Users] List error:', error);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
app.post('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
const { username, password, role } = req.body;
if (!username || !password || !role) {
res.status(400).json({ error: 'Username, password, and role are required' });
return;
}
if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) {
res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' });
return;
}
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
return;
}
if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return;
const db = DatabaseService.getInstance();
const existing = db.getUserByUsername(username);
if (existing) {
res.status(409).json({ error: 'A user with this username already exists' });
return;
}
// Enforce seat limits based on license variant
const seatLimits = LicenseService.getInstance().getSeatLimits();
if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) {
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.` });
return;
}
if (role !== 'admin' && seatLimits.maxViewers !== null && db.getNonAdminCount() >= seatLimits.maxViewers) {
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.` });
return;
}
const passwordHash = await bcrypt.hash(password, 10);
const id = db.addUser({ username, password_hash: passwordHash, role });
console.log('[Users] Created:', username, 'role:', role, 'by:', req.user!.username);
res.status(201).json({ id, username, role });
} catch (error) {
console.error('[Users] Create error:', error);
res.status(500).json({ error: 'Failed to create user' });
}
});
// Note: requirePaid is intentionally not enforced on PUT/DELETE user endpoints.
// Admins must be able to manage existing users even if their license lapses (security consideration).
app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
const user = db.getUser(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
const { username, password, role } = req.body;
const updates: Partial<{ username: string; password_hash: string; role: string }> = {};
if (username !== undefined) {
if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) {
res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' });
return;
}
const existing = db.getUserByUsername(username);
if (existing && existing.id !== id) {
res.status(409).json({ error: 'A user with this username already exists' });
return;
}
updates.username = username;
}
if (role !== undefined) {
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
return;
}
if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return;
// Prevent demoting yourself
if (user.username === req.user!.username && role !== user.role) {
res.status(400).json({ error: 'Cannot change your own role' });
return;
}
// Prevent removing the last admin
if (user.role === 'admin' && role !== 'admin' && db.getAdminCount() <= 1) {
res.status(400).json({ error: 'Cannot demote the only admin user' });
return;
}
updates.role = role;
}
if (password !== undefined) {
// Prevent setting passwords on SSO-provisioned users (would enable local login bypass)
if (user.auth_provider !== 'local') {
res.status(400).json({ error: 'Cannot set a password on an SSO-provisioned user.' });
return;
}
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
updates.password_hash = await bcrypt.hash(password, 10);
}
db.updateUser(id, updates);
// Invalidate the user's active sessions when their role or password changes
if (updates.role || updates.password_hash) {
db.bumpTokenVersion(id);
}
console.log('[Users] Updated user', id, 'fields:', Object.keys(updates).join(', '), 'by:', req.user!.username);
res.json({ success: true });
} catch (error) {
console.error('[Users] Update error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
const user = db.getUser(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
// Cannot delete yourself
if (user.username === req.user!.username) {
res.status(400).json({ error: 'Cannot delete your own account' });
return;
}
// Cannot delete the last admin
if (user.role === 'admin' && db.getAdminCount() <= 1) {
res.status(400).json({ error: 'Cannot delete the only admin user' });
return;
}
db.deleteUser(id);
console.log('[Users] Deleted:', user.username, '(id:', id, ') by:', req.user!.username);
res.json({ success: true });
} catch (error) {
console.error('[Users] Delete error:', error);
res.status(500).json({ error: 'Failed to delete user' });
}
});
/**
* Admin reset: clear a target user's MFA enrolment and force re-auth. Used
* when a user has lost their authenticator AND exhausted their backup codes,
* and another admin is available. For total lockout (including sole admin),
* see the CLI `reset-mfa` command.
*/
app.post('/api/users/:id/mfa/reset', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid user id' });
return;
}
const db = DatabaseService.getInstance();
const target = db.getUser(id);
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
}
db.deleteUserMfa(id);
db.bumpTokenVersion(id);
try {
db.insertAuditLog({
timestamp: Date.now(),
username: req.user!.username,
method: 'POST',
path: req.originalUrl,
status_code: 200,
node_id: null,
ip_address: req.ip || 'unknown',
summary: `Admin reset two-factor authentication for ${target.username}`,
});
} catch (err) {
console.warn('[MFA] Admin reset audit log write failed:', (err as Error).message);
}
console.log('[MFA] Admin reset: target=', target.username, 'by=', req.user!.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] admin-reset target=', target.username, 'actor=', req.user!.username);
}
res.json({ success: true });
} catch (error: unknown) {
console.error('[MFA] Admin reset error:', (error as Error).message);
res.status(500).json({ error: 'Failed to reset two-factor authentication' });
}
});
// --- Scoped Role Assignments (Admiral) ---
app.get('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
if (!db.getUser(userId)) {
res.status(404).json({ error: 'User not found' });
return;
}
const assignments = db.getAllRoleAssignments(userId);
res.json(assignments);
} catch (error) {
console.error('[Roles] List error:', error);
res.status(500).json({ error: 'Failed to fetch role assignments' });
}
});
app.post('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
const { role, resource_type, resource_id } = req.body;
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Invalid role' });
return;
}
const validResourceTypes: ResourceType[] = ['stack', 'node'];
if (!validResourceTypes.includes(resource_type)) {
res.status(400).json({ error: 'Invalid resource type' });
return;
}
if (!resource_id || typeof resource_id !== 'string') {
res.status(400).json({ error: 'resource_id is required' });
return;
}
const db = DatabaseService.getInstance();
if (!db.getUser(userId)) {
res.status(404).json({ error: 'User not found' });
return;
}
try {
const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id });
console.log('[Roles] Assigned', role, 'on', resource_type, resource_id, 'to user', userId, 'by:', req.user!.username);
res.status(201).json({ id, user_id: userId, role, resource_type, resource_id });
} catch (err: unknown) {
if ((err as Error).message?.includes('UNIQUE constraint')) {
res.status(409).json({ error: 'This role assignment already exists' });
return;
}
throw err;
}
} catch (error) {
console.error('[Roles] Create error:', error);
res.status(500).json({ error: 'Failed to add role assignment' });
}
});
app.delete('/api/users/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
const assignId = parseInt(req.params.assignId as string, 10);
const db = DatabaseService.getInstance();
const assignment = db.getRoleAssignmentById(assignId);
if (!assignment || assignment.user_id !== userId) {
res.status(404).json({ error: 'Role assignment not found' });
return;
}
db.deleteRoleAssignment(assignId);
console.log('[Roles] Removed assignment', assignId, 'from user', userId, 'by:', req.user!.username);
res.json({ success: true });
} catch (error) {
console.error('[Roles] Delete error:', error);
res.status(500).json({ error: 'Failed to delete role assignment' });
}
});
// Return the current user's effective permissions (any authenticated user)
app.get('/api/permissions/me', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
const db = DatabaseService.getInstance();
const globalRole = req.user.role;
const globalPermissions = ROLE_PERMISSIONS[globalRole] || [];
const assignments = db.getAllRoleAssignments(req.user.userId);
const scopedPermissions: Record<string, PermissionAction[]> = {};
for (const a of assignments) {
const key = `${a.resource_type}:${a.resource_id}`;
const perms = ROLE_PERMISSIONS[a.role] || [];
const existing = scopedPermissions[key] || [];
scopedPermissions[key] = [...new Set([...existing, ...perms])];
}
res.json({
globalRole,
globalPermissions,
scopedPermissions,
isAdmiral: LicenseService.getInstance().getVariant() === 'admiral',
});
} catch (error) {
console.error('[Permissions] Error:', error);
res.status(500).json({ error: 'Failed to fetch permissions' });
}
});
// Remote Node HTTP Proxy - single global instance.
// Previously, createProxyMiddleware was called inside the request handler on every API
// call, spawning a new proxy instance (and http-proxy server) each time. This caused:
// - MaxListenersExceededWarning: repeated 'close' listeners added to [Server]
// - DEP0060: util._extend called on every http-proxy initialisation
// Fix: create ONE instance at startup; use the router option to resolve the
// target URL dynamically per request without constructing new listeners.
const remoteNodeProxy = createProxyMiddleware<Request, Response>({
target: 'http://localhost:0', // placeholder - overridden per-request by router
changeOrigin: true,
router: (req) => {
const node = NodeRegistry.getInstance().getNode(req.nodeId);
return node?.api_url?.replace(/\/$/, '');
},
// When mounted at app.use('/api/', ...), Express strips the '/api/' prefix from
// req.url before the middleware sees it. Re-add it so the remote Sencho instance
// receives the full path (e.g. '/stats' becomes '/api/stats').
pathRewrite: (path) => '/api' + path,
on: {
proxyReq: (proxyReq, req) => {
const node = NodeRegistry.getInstance().getNode(req.nodeId);
// Strip headers that must not reach the remote instance:
// - x-node-id: remote Sencho treats all requests as local
// - cookie: the browser's sencho_token is signed with THIS instance's JWT secret;
// the remote would try to verify it with its own secret and return 401.
// Authentication is handled exclusively via the Bearer token below.
proxyReq.removeHeader('x-node-id');
proxyReq.removeHeader('cookie');
if (node?.api_token) {
proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`);
}
// Distributed License Enforcement: assert the main instance's license tier to the
// remote node so tier-gated routes honor the main's license instead of the node's local
// (likely Community) tier. The remote's authMiddleware only trusts these headers when the
// request carries a valid node_proxy JWT.
const proxyLs = LicenseService.getInstance();
proxyReq.setHeader(PROXY_TIER_HEADER, proxyLs.getTier());
proxyReq.setHeader(PROXY_VARIANT_HEADER, proxyLs.getVariant() || '');
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
// doesn't reject the request with 404 ("Node X not found") - the remote
// has no record of the gateway's node IDs and should treat the request
// as local. This affects endpoints like EventSource /api/containers/:id/logs
// that pass nodeId as a query param rather than the x-node-id header.
if (proxyReq.path.includes('nodeId=')) {
const [pathname, qs] = proxyReq.path.split('?');
const params = new URLSearchParams(qs || '');
params.delete('nodeId');
const newQs = params.toString();
proxyReq.path = pathname + (newQs ? `?${newQs}` : '');
}
// Body forwarding: the conditional json parser (see top of file) skips
// parsing for remote requests, so req's raw stream is intact and
// http-proxy's req.pipe(proxyReq) forwards the body automatically.
// No manual body rewriting needed here.
},
proxyRes: (proxyRes) => {
// Mark every response forwarded from a remote node with a sentinel header.
// The frontend (apiFetch / fetchForNode) checks this before firing the
// global 'sencho-unauthorized' event: a 401 from a remote means the stored
// api_token for that node is invalid - not that the user's own session
// expired. Without this distinction, any node with a bad token causes an
// immediate logout loop.
proxyRes.headers['x-sencho-proxy'] = '1';
},
error: (err, _req, proxyRes) => {
console.error('[Proxy] Remote node error:', (err as Error).message);
// proxyRes can be either a ServerResponse (HTTP) or a raw Socket (WS/TCP errors).
// Only attempt to send an HTTP 502 if it is a proper ServerResponse with a
// headersSent flag - otherwise silently drop (the socket will be destroyed).
const res = proxyRes as any;
if (typeof res?.headersSent === 'boolean' && !res.headersSent && typeof res.status === 'function') {
res.status(502).json({
error: 'Remote node is unreachable. Check the API URL and ensure Sencho is running on that host.'
});
}
},
},
});
// Intercepts all /api/ requests for remote Distributed API nodes and forwards them
// to the target Sencho instance. Node management and auth routes always execute locally.
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet') || req.path.startsWith('/webhooks') || req.path.startsWith('/meta')) {
next();
return;
}
const node = NodeRegistry.getInstance().getNode(req.nodeId);
if (!node || node.type !== 'remote') {
next();
return;
}
if (!node.api_url || !node.api_token) {
res.status(503).json({
error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.`
});
return;
}
remoteNodeProxy(req, res, next);
});
// Create HTTP server for WebSocket upgrade handling
const server = http.createServer(app);
// WebSocket server with authentication
const wss = new WebSocketServer({ noServer: true });
// Dedicated WS server for accepting inbound pilot-agent tunnels. Agents dial
// /api/pilot/tunnel; the handshake verifies a pilot_enroll or pilot_tunnel
// JWT, then hands the socket off to PilotTunnelManager.
const pilotTunnelWss = new WebSocketServer({ noServer: true });
let terminalWs: WebSocket | null = null;
// Notification push - set of authenticated browser clients subscribed to real-time alerts
const notificationSubscribers = new Set<WebSocket>();
NotificationService.getInstance().setBroadcaster((notification) => {
if (notificationSubscribers.size === 0) return;
const msg = JSON.stringify({ type: 'notification', payload: notification });
for (const ws of notificationSubscribers) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(msg);
}
}
});
/**
* Handle a pilot-agent tunnel upgrade. Accepts either:
* - pilot_enroll (15m, one-time): consume the enrollment row, mint a
* long-lived pilot_tunnel token, send it back in a ctrl enroll_ack frame.
* - pilot_tunnel (365d): accept the socket directly.
* In both cases, the accepted WebSocket is handed to PilotTunnelManager.
*/
async function handlePilotTunnelUpgrade(
req: import('http').IncomingMessage,
socket: import('stream').Duplex,
head: Buffer,
): Promise<void> {
const reject = (status: number, message: string) => {
try { socket.write(`HTTP/1.1 ${status} ${message}\r\n\r\n`); } catch { /* ignore */ }
try { socket.destroy(); } catch { /* ignore */ }
};
const authHeader = req.headers['authorization'];
const header = Array.isArray(authHeader) ? authHeader[0] : authHeader;
const token = header?.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return reject(401, 'Unauthorized');
const db = DatabaseService.getInstance();
const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
if (!jwtSecret) return reject(500, 'Internal Server Error');
let decoded: { scope?: string; nodeId?: number; enrollNonce?: string };
try {
decoded = jwt.verify(token, jwtSecret) as typeof decoded;
} catch {
return reject(401, 'Unauthorized');
}
if (decoded.scope !== 'pilot_enroll' && decoded.scope !== 'pilot_tunnel') {
return reject(403, 'Forbidden');
}
if (typeof decoded.nodeId !== 'number') return reject(400, 'Bad Request');
const node = db.getNode(decoded.nodeId);
if (!node || node.type !== 'remote' || node.mode !== 'pilot_agent') {
return reject(404, 'Not Found');
}
let mintedTunnelToken: string | null = null;
if (decoded.scope === 'pilot_enroll') {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const row = db.consumePilotEnrollment(tokenHash);
if (!row || row.node_id !== decoded.nodeId) {
return reject(401, 'Unauthorized');
}
mintedTunnelToken = jwt.sign(
{ scope: 'pilot_tunnel', nodeId: decoded.nodeId },
jwtSecret,
{ expiresIn: '365d' },
);
}
const agentVersionHeader = req.headers['x-sencho-agent-version'];
const agentVersion = Array.isArray(agentVersionHeader) ? agentVersionHeader[0] : agentVersionHeader;
pilotTunnelWss.handleUpgrade(req, socket, head, async (ws) => {
try {
ws.send(encodePilotJsonFrame({
t: 'hello',
version: PILOT_PROTOCOL_VERSION,
role: 'primary',
}));
if (mintedTunnelToken) {
ws.send(encodePilotJsonFrame({
t: 'ctrl',
op: 'enroll_ack',
payload: { token: mintedTunnelToken, nodeId: decoded.nodeId },
}));
}
} catch {
try { ws.close(1011, 'hello failed'); } catch { /* ignore */ }
return;
}
try {
await PilotTunnelManager.getInstance().registerTunnel(decoded.nodeId!, ws, agentVersion);
} catch (err) {
console.error('[Pilot] Failed to register tunnel:', (err as Error).message);
try { ws.close(1011, 'registration failed'); } catch { /* ignore */ }
}
});
}
// Handle WebSocket upgrade with JWT authentication
server.on('upgrade', async (req, socket, head) => {
// Pilot-agent tunnel ingress: agents dial /api/pilot/tunnel and present
// either a short-lived pilot_enroll token (consumed once) or a long-lived
// pilot_tunnel token. Handled independently of user/session auth because
// these are machine credentials and carry no cookies.
try {
const reqUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (reqUrl.pathname === '/api/pilot/tunnel') {
await handlePilotTunnelUpgrade(req, socket, head);
return;
}
} catch {
// URL parse error falls through to the normal flow, which will reject.
}
// Parse cookies from the upgrade request
const cookieHeader = req.headers.cookie || '';
const cookies = Object.fromEntries(
cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v)
);
// Accept either cookie auth (browser sessions) or Bearer token auth (node-to-node WS proxy)
const cookieToken = cookies[COOKIE_NAME];
const authHeader = req.headers['authorization'] as string | undefined;
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
// Prefer Bearer over cookie: node-to-node proxy upgrades carry a Bearer token and must
// not be shadowed by a browser cookie signed with a different instance's JWT secret.
const token = bearerToken || cookieToken;
if (!token) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number };
// Node proxy tokens are machine-to-machine credentials and must never be granted
// interactive terminal access (host console or container exec).
const isProxyToken = decoded.scope === 'node_proxy';
// API token scope enforcement for WebSocket connections
let wsApiTokenScope: string | null = null;
if (decoded.scope === 'api_token') {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
if (!apiToken || apiToken.revoked_at) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (apiToken.expires_at && apiToken.expires_at < Date.now()) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
wsApiTokenScope = apiToken.scope;
}
// For user session tokens (no scope), resolve against DB for up-to-date role and
// token_version checks. This mirrors what authMiddleware does for HTTP requests.
// Scoped tokens (api_token, node_proxy, console_session) skip this: they are
// validated by their own logic above or by the gateway that issued them.
let wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
if (!decoded.scope && decoded.username) {
const dbUser = DatabaseService.getInstance().getUserByUsername(decoded.username);
if (!dbUser) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
console.log('[Auth] WS session rejected: token version mismatch for:', decoded.username);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wsResolvedUser = { username: dbUser.username, role: dbUser.role as UserRole, token_version: dbUser.token_version };
}
const url = req.url || '';
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
// Gate WebSocket paths by API token scope
if (wsApiTokenScope) {
const isLogPath = /^\/api\/stacks\/[^/]+\/logs$/.test(pathname);
const isNotifPath = pathname === '/ws/notifications';
if (wsApiTokenScope === 'read-only' || wsApiTokenScope === 'deploy-only') {
if (!isLogPath && !isNotifPath) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
}
}
// Resolve node context from query param
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(nodeId);
// Notification push channel - local only when no remote nodeId is specified.
// When a nodeId pointing to a remote node is provided, fall through to the
// proxy block below so the browser subscribes to that remote node's push stream.
if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) {
const notifWss = new WebSocketServer({ noServer: true });
notifWss.handleUpgrade(req, socket, head, (ws) => {
notifWss.close();
notificationSubscribers.add(ws);
ws.on('close', () => notificationSubscribers.delete(ws));
ws.on('error', () => { notificationSubscribers.delete(ws); ws.terminate(); });
});
return;
}
// Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance
if (node && node.type === 'remote' && node.api_url && node.api_token) {
const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
// Interactive console paths (host console / container exec) are guarded on the remote by
// an isProxyToken check that rejects the long-lived api_token (scope: 'node_proxy').
// Exchange it for a short-lived console_session token before forwarding so the remote
// allows the connection while keeping the guard intact for direct api_token access.
const isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws';
let bearerTokenForProxy = node.api_token;
if (isInteractiveConsolePath) {
try {
const ls = LicenseService.getInstance();
const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${node.api_token}`,
[PROXY_TIER_HEADER]: ls.getTier(),
[PROXY_VARIANT_HEADER]: ls.getVariant() || '',
},
});
if (tokenRes.ok) {
const data = await tokenRes.json() as { token?: string };
if (typeof data.token === 'string') bearerTokenForProxy = data.token;
} else {
console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`);
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
socket.destroy();
return;
}
} catch (e) {
console.error('[WS Proxy] Failed to fetch remote console token:', (e as Error).message);
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
socket.destroy();
return;
}
}
req.headers['authorization'] = `Bearer ${bearerTokenForProxy}`;
delete req.headers['x-node-id'];
// Strip the browser's session cookie - it is signed by this instance's JWT secret and
// would fail verification on the remote. Auth is handled exclusively via the Bearer token.
delete req.headers['cookie'];
// Distributed License Enforcement: assert the main's license tier on proxied WS connections.
const wsLs = LicenseService.getInstance();
req.headers[PROXY_TIER_HEADER] = wsLs.getTier();
req.headers[PROXY_VARIANT_HEADER] = wsLs.getVariant() || '';
// Strip nodeId from the forwarded URL so the remote treats the request as a local one.
// The remote has no record of the gateway's nodeId, so leaving it would cause unnecessary
// fallback logic. Removing it lets the remote default cleanly to its own local node.
const fwdUrl = new URL(req.url!, `http://${req.headers.host || 'localhost'}`);
fwdUrl.searchParams.delete('nodeId');
req.url = fwdUrl.pathname + (fwdUrl.searchParams.toString() ? `?${fwdUrl.searchParams.toString()}` : '');
wsProxyServer.ws(req, socket, head, { target: wsTarget });
return;
}
// Local node handling
const logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/);
const hostConsoleMatch = pathname.match(/^\/api\/system\/host-console/);
if (logsMatch) {
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
const logsWss = new WebSocketServer({ noServer: true });
logsWss.handleUpgrade(req, socket, head, (ws) => {
// Close the per-connection server immediately after the upgrade is complete.
// The wss instance is only needed to negotiate the handshake; keeping it open
// would accumulate listeners and allocate memory for every connection.
logsWss.close();
const stackName = decodeURIComponent(logsMatch[1]);
if (!isValidStackName(stackName)) {
ws.send('Error: Invalid stack name\r\n');
ws.close();
return;
}
try {
if (isDebugEnabled()) console.debug('[Stacks:debug] WS log stream opened', { stackName, nodeId });
ws.on('close', () => {
if (isDebugEnabled()) console.debug('[Stacks:debug] WS log stream closed', { stackName, nodeId });
});
ComposeService.getInstance(nodeId).streamLogs(stackName, ws);
} catch (error) {
console.error('[Stacks] Failed to stream logs:', error);
if (ws.readyState === WebSocket.OPEN) {
ws.send(`Error streaming logs: ${(error as Error).message}\n`);
}
}
});
} else if (hostConsoleMatch) {
// Node proxy tokens must not access interactive host terminals
if (isProxyToken) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// RBAC gate: only users with 'system:console' permission may access the host console.
// Console_session tokens are pre-validated by the gateway's requireAdmin() middleware,
// so they skip this check. API tokens are already blocked by the scope gate above.
const isConsoleSession = decoded.scope === 'console_session';
if (!isConsoleSession) {
const userRole = wsResolvedUser?.role;
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
console.log('[HostConsole] Access denied: insufficient permissions', { username: wsResolvedUser?.username || decoded.username, role: userRole });
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
}
// Admiral license gate: host console requires Admiral (paid + team variant).
// For proxied connections (console_session tokens), trust the tier headers sent by the gateway;
// for direct connections, check the local LicenseService.
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const consoleVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
const ls = LicenseService.getInstance();
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
? normalizeTier(consoleTierHeader)
: ls.getTier();
const consoleVariant = (isConsoleSession && consoleVariantHeader !== undefined && isLicenseVariant(consoleVariantHeader))
? normalizeVariant(consoleVariantHeader)
: ls.getVariant();
if (consoleTier !== 'paid' || consoleVariant !== 'admiral') {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
const stackParam = parsedUrl.searchParams.get('stack');
console.log('[HostConsole] WebSocket upgrade accepted', { username: consoleUsername, nodeId, stack: stackParam || '(root)' });
const hostConsoleWss = new WebSocketServer({ noServer: true });
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
hostConsoleWss.close();
let targetDirectory = '';
try {
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
if (stackParam) {
const resolved = path.resolve(baseDir, stackParam);
if (!resolved.startsWith(path.resolve(baseDir))) {
ws.send('Error: Invalid stack path\r\n');
ws.close();
return;
}
targetDirectory = resolved;
} else {
targetDirectory = baseDir;
}
} catch (e) {
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
}
try {
HostTerminalService.spawnTerminal(ws, targetDirectory, consoleUsername);
} catch (error) {
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: (error as Error).message });
if (ws.readyState === WebSocket.OPEN) {
ws.send('Error: Failed to start terminal session.\r\n');
ws.close();
}
}
});
} else {
// Generic terminal WebSocket (container exec)
// Node proxy tokens must not access interactive container terminals
if (isProxyToken) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// Admin enforcement: container exec requires admin role.
// console_session tokens are already admin-gated at creation time.
// API tokens reaching this point have full-admin scope (read-only/deploy-only blocked above).
if (!decoded.scope) {
// User session token: verify admin role against the database (not the JWT)
// so role changes take effect immediately, matching authMiddleware behavior.
const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
if (!execUser) {
console.warn('[Exec] User account not found:', decoded.username);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (decoded.tv !== undefined && execUser.token_version !== decoded.tv) {
console.warn('[Exec] Session invalidated (token version mismatch):', decoded.username);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (execUser.role !== 'admin') {
console.warn('[Exec] Non-admin user rejected:', decoded.username);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
}
if (isDebugEnabled()) console.debug('[Exec:diag] WS upgrade for exec path', { nodeId, username: decoded.username, scope: decoded.scope || 'user-session' });
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
}
} catch (error) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
});
wss.on('connection', (ws) => {
console.log('WebSocket connected');
ws.on('message', (message) => {
try {
const data = JSON.parse(message.toString());
// Only handle 'action'-based messages at the global level.
// 'type'-based messages (input, resize, ping) are handled by the
// per-session listener registered inside execContainer's closure.
if (!data.action) return;
if (data.action === 'connectTerminal') {
terminalWs = ws;
} else if (data.action === 'streamStats') {
const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId();
// When a WS is proxied from a gateway to this remote instance, the nodeId in the
// message belongs to the gateway's DB and won't resolve locally. Fall back to local.
let nodeId = requestedId;
try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); }
DockerController.getInstance(nodeId).streamStats(data.containerId, ws).catch((err: Error) => {
console.error('[WS] streamStats error:', err.message);
if (ws.readyState === WebSocket.OPEN) ws.close();
});
} else if (data.action === 'execContainer') {
// Handle container exec for bash access
// Input, resize, and cleanup are handled inside execContainer's closure
const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId();
let nodeId = requestedId;
try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); }
DockerController.getInstance(nodeId).execContainer(data.containerId, ws).catch((err: Error) => {
console.error('[WS] execContainer error:', err.message);
if (ws.readyState === WebSocket.OPEN) ws.close();
});
}
} catch (error) {
// Malformed JSON - ignore silently
}
});
});
// API Routes (all protected by authMiddleware)
app.get('/api/containers', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getRunningContainers();
res.json(containers);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch containers' });
}
});
app.get('/api/ports/in-use', async (req: Request, res: Response) => {
try {
const fsService = FileSystemService.getInstance(req.nodeId);
const stacks = await fsService.getStacks();
const dockerController = DockerController.getInstance(req.nodeId);
const portsInUse = await dockerController.getPortsInUse(stacks);
res.json(portsInUse);
} catch (error) {
console.error('[Ports] Failed to fetch ports in use:', error);
res.status(500).json({ error: 'Failed to fetch ports in use' });
}
});
// --- Label Routes (Skipper+) ---
app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = req.nodeId ?? 0;
const labels = DatabaseService.getInstance().getLabels(nodeId);
if (isDebugEnabled()) console.debug('[Labels:debug] List labels: nodeId=', nodeId, 'count=', labels.length);
res.json(labels);
} catch (error) {
console.error('[Labels] List error:', error);
res.status(500).json({ error: 'Failed to list labels' });
}
});
app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const nodeId = req.nodeId ?? 0;
const { name, color } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
res.status(400).json({ error: 'name is required and must be 1-30 characters' });
return;
}
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' });
return;
}
if (!color || !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` });
return;
}
const db = DatabaseService.getInstance();
if (db.getLabelCount(nodeId) >= MAX_LABELS_PER_NODE) {
res.status(409).json({ error: `Maximum of ${MAX_LABELS_PER_NODE} labels per node reached` });
return;
}
if (isDebugEnabled()) console.debug('[Labels:debug] Create label:', { nodeId, name: name.trim(), color });
const label = db.createLabel(nodeId, name.trim(), color);
if (isDebugEnabled()) console.debug('[Labels:debug] Created label:', label.id);
res.status(201).json(label);
} catch (error: unknown) {
if (isSqliteUniqueViolation(error)) {
res.status(409).json({ error: 'A label with that name already exists' });
return;
}
console.error('[Labels] Create error:', error);
res.status(500).json({ error: 'Failed to create label' });
}
});
app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const nodeId = req.nodeId ?? 0;
const db = DatabaseService.getInstance();
const assignments = db.getLabelsForStacks(nodeId);
// Opportunistic cleanup: only scan the filesystem when there are assignments to validate
const assignedStacks = Object.keys(assignments);
if (assignedStacks.length > 0) {
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
const fsSet = new Set(fsStacks);
const staleNames = assignedStacks.filter(name => !fsSet.has(name));
if (staleNames.length > 0) {
db.cleanupStaleAssignments(nodeId, fsStacks);
for (const name of staleNames) {
delete assignments[name];
}
if (isDebugEnabled()) console.debug('[Labels:debug] Cleaned up stale assignments:', staleNames);
}
}
if (isDebugEnabled()) console.debug('[Labels:debug] Assignments: nodeId=', nodeId, 'stacks=', Object.keys(assignments).length);
res.json(assignments);
} catch (error) {
console.error('[Labels] Assignments error:', error);
res.status(500).json({ error: 'Failed to fetch label assignments' });
}
});
app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
const nodeId = req.nodeId ?? 0;
const { name, color } = req.body;
if (name !== undefined) {
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
res.status(400).json({ error: 'name must be 1-30 characters' });
return;
}
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' });
return;
}
}
if (color !== undefined && !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` });
return;
}
if (isDebugEnabled()) console.debug('[Labels:debug] Update label:', { id, nodeId, name: name?.trim(), color });
const updated = DatabaseService.getInstance().updateLabel(id, nodeId, {
name: name?.trim(),
color,
});
if (!updated) {
res.status(404).json({ error: 'Label not found' });
return;
}
res.json(updated);
} catch (error: unknown) {
if (isSqliteUniqueViolation(error)) {
res.status(409).json({ error: 'A label with that name already exists' });
return;
}
console.error('[Labels] Update error:', error);
res.status(500).json({ error: 'Failed to update label' });
}
});
app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
const nodeId = req.nodeId ?? 0;
if (isDebugEnabled()) console.debug('[Labels:debug] Delete label:', { id, nodeId });
DatabaseService.getInstance().deleteLabel(id, nodeId);
res.json({ success: true });
} catch (error) {
console.error('[Labels] Delete error:', error);
res.status(500).json({ error: 'Failed to delete label' });
}
});
app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
const nodeId = req.nodeId ?? 0;
const { labelIds } = req.body;
if (!Array.isArray(labelIds) || !labelIds.every((id: unknown) => typeof id === 'number')) {
res.status(400).json({ error: 'labelIds must be an array of numbers' });
return;
}
if (isDebugEnabled()) console.debug('[Labels:debug] Set stack labels:', { stackName, nodeId, labelIds });
DatabaseService.getInstance().setStackLabels(stackName, nodeId, labelIds);
res.json({ success: true });
} catch (error) {
console.error('[Labels] Set stack labels error:', error);
res.status(500).json({ error: 'Failed to set stack labels' });
}
});
const activeBulkActions = new Set<string>();
app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
const { action } = req.body;
const validActions = ['deploy', 'stop', 'restart'];
if (!action || !validActions.includes(action)) {
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
return;
}
const nodeId = req.nodeId ?? 0;
const label = DatabaseService.getInstance().getLabel(id, nodeId);
if (!label) {
res.status(404).json({ error: 'Label not found' });
return;
}
const lockKey = `bulk:${nodeId}`;
if (activeBulkActions.has(lockKey)) {
res.status(429).json({ error: 'A bulk action is already running for this node. Please wait.' });
return;
}
activeBulkActions.add(lockKey);
try {
const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId);
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
const fsStackNames = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackNames.has(name));
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length });
const results: { stackName: string; success: boolean; error?: string }[] = [];
for (const stackName of validStacks) {
try {
if (action === 'deploy') {
await ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false);
} else {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (action === 'stop') {
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
} else {
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
}
}
results.push({ stackName, success: true });
} catch (err: unknown) {
results.push({ stackName, success: false, error: (err as Error)?.message || 'Unknown error' });
}
}
const succeeded = results.filter(r => r.success).length;
const failed = results.length - succeeded;
console.log(`[Labels] Bulk ${action} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed });
if (succeeded > 0) {
invalidateNodeCaches(req.nodeId);
}
res.json({ results });
} finally {
activeBulkActions.delete(lockKey);
}
} catch (error) {
console.error('[Labels] Bulk action error:', error);
res.status(500).json({ error: 'Failed to execute bulk action' });
}
});
// Stack Routes - Updated to use stackName (directory name) instead of filename
app.get('/api/stacks', async (req: Request, res: Response) => {
try {
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
res.json(stacks);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stacks' });
}
});
app.get('/api/stacks/statuses', async (req: Request, res: Response) => {
try {
const result = await CacheService.getInstance().getOrFetch(
`stack-statuses:${req.nodeId}`,
STACK_STATUSES_CACHE_TTL_MS,
async () => {
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
const dockerController = DockerController.getInstance(req.nodeId);
const bulkInfo = await dockerController.getBulkStackStatuses(stackNames);
// Map back to filenames to match frontend expectations
const data: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number }> = {};
for (const stack of stacks) {
const name = stack.replace(/\.(yml|yaml)$/, '');
data[stack] = bulkInfo[name] ?? { status: 'unknown' };
}
return data;
},
);
res.json(result);
} catch (error) {
console.error('Failed to fetch stack statuses:', error);
res.status(500).json({ error: 'Failed to fetch stack statuses' });
}
});
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
res.send(content);
} catch (error) {
res.status(500).json({ error: 'Failed to read stack' });
}
});
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const { content } = req.body;
if (typeof content !== 'string') {
console.error('Content is not a string:', content);
return res.status(400).json({ error: 'Content must be a string' });
}
await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Compose file saved: ${stackName}`);
res.json({ message: 'Stack saved successfully' });
} catch (error) {
console.error('Failed to save stack:', error);
res.status(500).json({ error: 'Failed to save stack' });
}
});
// Helper: resolve all env file paths dynamically from compose.yaml's env_file field
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
const fsService = FileSystemService.getInstance(nodeId);
const stackDir = path.join(fsService.getBaseDir(), stackName);
const defaultEnvPath = path.join(stackDir, '.env');
try {
// Try to read and parse the compose file
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
let composeContent: string | null = null;
for (const file of composeFiles) {
try {
composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8');
break;
} catch {
// Try next file
}
}
if (!composeContent) return [defaultEnvPath];
const parsed = YAML.parse(composeContent);
if (!parsed?.services) return [defaultEnvPath];
const envFiles = new Set<string>();
// Iterate through all services and collect every env_file declaration
for (const serviceName of Object.keys(parsed.services)) {
const service = parsed.services[serviceName];
if (!service?.env_file) continue;
const addEnvPath = (rawPath: string) => {
const resolved = path.resolve(stackDir, rawPath);
if (!isPathWithinBase(resolved, stackDir)) return;
envFiles.add(resolved);
};
if (typeof service.env_file === 'string') {
addEnvPath(service.env_file);
} else if (Array.isArray(service.env_file)) {
for (const entry of service.env_file) {
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
if (entryPath) addEnvPath(entryPath);
}
}
}
if (envFiles.size === 0) {
envFiles.add(defaultEnvPath);
}
// Filter to only include files that actually exist on disk
const existing: string[] = [];
for (const f of envFiles) {
try {
await fsService.access(f);
existing.push(f);
} catch {
// File does not exist - skip
}
}
return existing;
} catch (error) {
console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, error);
}
// Fallback: return default only if it exists
try {
await fsService.access(defaultEnvPath);
return [defaultEnvPath];
} catch {
return [];
}
}
app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
res.json({ envFiles: envPaths });
} catch (error) {
res.status(500).json({ error: 'Failed to resolve env files' });
}
});
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const requestedFile = req.query.file as string | undefined;
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
let envPath = envPaths[0]; // Fallback to the first
if (requestedFile) {
// Validate that the requested file exists in the allowed resolved list
if (envPaths.includes(requestedFile)) {
envPath = requestedFile;
} else {
return res.status(400).json({ error: 'Requested env file not allowed' });
}
}
const fsService = FileSystemService.getInstance(req.nodeId);
try {
await fsService.access(envPath);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.error('[Sencho] Unexpected error checking env file existence:', (e as Error).message);
}
return res.status(404).json({ error: 'Env file not found' });
}
const content = await fsService.readFile(envPath, 'utf-8');
res.send(content);
} catch (error) {
console.error('Failed to read env file:', error);
res.status(500).json({ error: 'Failed to read env file' });
}
});
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const { content } = req.body;
if (typeof content !== 'string') {
return res.status(400).json({ error: 'Content must be a string' });
}
const requestedFile = req.query.file as string | undefined;
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
let envPath = envPaths[0]; // Fallback
if (requestedFile) {
if (envPaths.includes(requestedFile)) {
envPath = requestedFile;
} else {
return res.status(400).json({ error: 'Requested env file not allowed' });
}
}
const fsService = FileSystemService.getInstance(req.nodeId);
await fsService.writeFile(envPath, content, 'utf-8');
invalidateNodeCaches(req.nodeId);
const envFileName = path.basename(envPath);
console.log(`[Stacks] Env file saved: ${stackName}/${envFileName}`);
res.json({ message: 'Env file saved successfully' });
} catch (error) {
console.error('[Stacks] Failed to save env file:', error);
res.status(500).json({ error: 'Failed to save env file' });
}
});
// ── Git sources ────────────────────────────────────────────────────────
// Status mapping and error helper live in utils/gitSourceHttp so the
// mapping can be unit-tested without spinning up the full app.
app.get('/api/git-sources', async (req: Request, res: Response) => {
try {
const all = GitSourceService.getInstance().list();
// Filter to the subset of stacks the caller can read. Keeps scoped
// Admiral roles from discovering git config for stacks outside their grant.
const visible = all.filter(src => checkPermission(req, 'stack:read', 'stack', src.stack_name));
res.json(visible);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.get('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
try {
const source = GitSourceService.getInstance().get(stackName);
if (!source) return res.status(404).json({ error: 'No Git source configured for this stack' });
res.json(source);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.put('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const {
repo_url,
branch,
compose_path,
sync_env,
env_path,
auth_type,
token,
auto_apply_on_webhook,
auto_deploy_on_apply,
} = req.body ?? {};
if (typeof repo_url !== 'string' || !repo_url.trim()) {
return res.status(400).json({ error: 'repo_url is required' });
}
if (typeof branch !== 'string' || !branch.trim()) {
return res.status(400).json({ error: 'branch is required' });
}
if (typeof compose_path !== 'string' || !compose_path.trim()) {
return res.status(400).json({ error: 'compose_path is required' });
}
if (auth_type !== 'none' && auth_type !== 'token') {
return res.status(400).json({ error: 'auth_type must be "none" or "token"' });
}
if (!/^https:\/\//i.test(repo_url)) {
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
}
// Bound each field so a caller cannot flood the service with huge
// payloads. These limits are generous compared to anything a real Git
// provider would produce.
if (repo_url.length > 2048) {
return res.status(400).json({ error: 'repo_url is too long' });
}
if (branch.length > 256) {
return res.status(400).json({ error: 'branch is too long' });
}
if (compose_path.length > 1024) {
return res.status(400).json({ error: 'compose_path is too long' });
}
if (typeof env_path === 'string' && env_path.length > 1024) {
return res.status(400).json({ error: 'env_path is too long' });
}
if (typeof token === 'string' && token.length > 8192) {
return res.status(400).json({ error: 'token is too long' });
}
// Confirm the stack actually exists on the active node. Without this
// guard, a caller can stash a git-source row for a name that does not
// exist yet and have it auto-link when a stack with that name is
// later created.
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
if (!stacks.includes(stackName)) {
return res.status(404).json({ error: 'Stack not found' });
}
const syncEnv = Boolean(sync_env);
const resolvedEnvPath = syncEnv
? (typeof env_path === 'string' && env_path.trim()
? env_path
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
: null;
const source = await GitSourceService.getInstance().upsert({
stackName,
repoUrl: repo_url.trim(),
branch: branch.trim(),
composePath: compose_path.trim(),
syncEnv,
envPath: resolvedEnvPath,
authType: auth_type,
token: typeof token === 'string' ? token : undefined,
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
autoDeployOnApply: Boolean(auto_deploy_on_apply),
});
console.log(`[GitSource] Configured git source for ${stackName}`);
res.json(source);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.delete('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
GitSourceService.getInstance().delete(stackName);
console.log(`[GitSource] Removed git source for ${stackName}`);
res.json({ success: true });
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks/:stackName/git-source/pull', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const result = await GitSourceService.getInstance().pull(stackName);
res.json(result);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const { commitSha, deploy } = req.body ?? {};
if (typeof commitSha !== 'string' || !commitSha.trim()) {
return res.status(400).json({ error: 'commitSha is required' });
}
const result = await GitSourceService.getInstance().apply(
stackName,
commitSha.trim(),
{ deploy: typeof deploy === 'boolean' ? deploy : undefined }
);
invalidateNodeCaches(req.nodeId);
const shortSha = commitSha.trim().slice(0, 7);
if (result.deployed) {
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName} (deployed)`);
} else if (result.deployError) {
console.warn(`[GitSource] Applied commit ${shortSha} to ${stackName}, deploy failed: ${result.deployError}`);
} else {
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName}`);
}
res.json(result);
if (result.deployed) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
);
}
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks/:stackName/git-source/dismiss-pending', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
GitSourceService.getInstance().dismissPending(stackName);
res.json({ success: true });
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:create')) return;
try {
const { stackName } = req.body;
if (!stackName || typeof stackName !== 'string') {
return res.status(400).json({ error: 'Stack name is required and must be a string' });
}
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
}
await FileSystemService.getInstance(req.nodeId).createStack(stackName);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack created: ${stackName}`);
res.json({ message: 'Stack created successfully', name: stackName });
} catch (error: unknown) {
const message = getErrorMessage(error, '');
if (message.includes('already exists')) {
return res.status(409).json({ error: 'Stack already exists' });
}
console.error('Failed to create stack:', error);
res.status(500).json({ error: 'Failed to create stack' });
}
});
app.post('/api/stacks/from-git', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:create')) return;
const fromGitStartedAt = Date.now();
const fromGitDiag = isDebugEnabled();
let fromGitStackName = '';
try {
const {
stack_name,
repo_url,
branch,
compose_path,
sync_env,
env_path,
auth_type,
token,
auto_apply_on_webhook,
auto_deploy_on_apply,
deploy_now,
} = req.body ?? {};
fromGitStackName = typeof stack_name === 'string' ? stack_name : '';
if (typeof stack_name !== 'string' || !stack_name.trim()) {
return res.status(400).json({ error: 'stack_name is required' });
}
if (!isValidStackName(stack_name)) {
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
}
if (typeof repo_url !== 'string' || !repo_url.trim()) {
return res.status(400).json({ error: 'repo_url is required' });
}
if (typeof branch !== 'string' || !branch.trim()) {
return res.status(400).json({ error: 'branch is required' });
}
if (typeof compose_path !== 'string' || !compose_path.trim()) {
return res.status(400).json({ error: 'compose_path is required' });
}
const resolvedAuthType = auth_type === 'token' ? 'token' : 'none';
if (!/^https:\/\//i.test(repo_url)) {
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
}
if (repo_url.length > 2048) {
return res.status(400).json({ error: 'repo_url is too long' });
}
if (branch.length > 256) {
return res.status(400).json({ error: 'branch is too long' });
}
if (compose_path.length > 1024) {
return res.status(400).json({ error: 'compose_path is too long' });
}
if (typeof env_path === 'string' && env_path.length > 1024) {
return res.status(400).json({ error: 'env_path is too long' });
}
if (typeof token === 'string' && token.length > 8192) {
return res.status(400).json({ error: 'token is too long' });
}
// Reject if a stack with this name already exists on disk. Without this
// the service would catch it at createStack() time, but erroring early
// avoids spinning up a temp clone we will not use.
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
if (stacks.includes(stack_name)) {
return res.status(409).json({ error: 'Stack already exists' });
}
const syncEnv = Boolean(sync_env);
const resolvedEnvPath = syncEnv
? (typeof env_path === 'string' && env_path.trim()
? env_path
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
: null;
if (fromGitDiag) {
console.log(
`[Stacks:diag] from-git start stack=${stack_name} nodeId=${req.nodeId ?? 'local'} host=${gitRepoHost(repo_url)} branch=${branch} composePath=${compose_path} envPath=${resolvedEnvPath ?? 'none'} authType=${resolvedAuthType} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}`
);
}
const result = await GitSourceService.getInstance().createStackFromGit({
stackName: stack_name.trim(),
repoUrl: repo_url.trim(),
branch: branch.trim(),
composePath: compose_path.trim(),
syncEnv,
envPath: resolvedEnvPath,
authType: resolvedAuthType,
token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null,
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
autoDeployOnApply: Boolean(auto_deploy_on_apply),
});
invalidateNodeCaches(req.nodeId);
// Deploy is best-effort. The compose file is already on disk and the
// git source is linked, so a deploy failure does not roll back the
// stack; the user can retry the deploy from the editor. This mirrors
// the apply-then-deploy behavior in GitSourceService.apply().
let deployed = false;
let deployError: string | undefined;
if (deploy_now === true) {
try {
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
deployed = true;
invalidateNodeCaches(req.nodeId);
} catch (e) {
deployError = getErrorMessage(e, 'Deploy failed');
console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError);
}
}
console.log(`[Stacks] Stack created from Git: ${stack_name} at ${result.commitSha.slice(0, 7)}`);
if (fromGitDiag) {
console.log(
`[Stacks:diag] from-git ok stack=${stack_name} sha=${result.commitSha.slice(0, 7)} deployed=${deployed} envWritten=${result.envWritten} warnings=${result.warnings.length} elapsedMs=${Date.now() - fromGitStartedAt}`
);
}
res.json({
name: stack_name,
source: result.source,
commitSha: result.commitSha,
envWritten: result.envWritten,
warnings: result.warnings,
deployed,
deployError,
});
if (deployed) {
triggerPostDeployScan(stack_name, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stack_name}:`, err),
);
}
} catch (error) {
if (fromGitDiag) {
const code = error instanceof GitSourceError ? error.code : 'UNKNOWN';
console.log(
`[Stacks:diag] from-git fail stack=${fromGitStackName} code=${code} elapsedMs=${Date.now() - fromGitStartedAt}`
);
}
sendGitSourceError(res, error);
}
});
app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
// Stage 1: Tell Docker to clean up ghost networks/containers
try {
await ComposeService.getInstance(req.nodeId).downStack(stackName);
} catch (downErr) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}
// Stage 2: Obliterate the files. Capture failure so Stage 3 still runs.
let fsErr: unknown = null;
try {
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
} catch (err) {
fsErr = err;
console.error(`[Stacks] File deletion failed for ${stackName}, continuing with DB cleanup:`, err);
}
// Stage 3: DB cleanup. Runs unconditionally because an orphan git_source
// row would silently auto-link to a future stack with the same name, and
// stale scoped role assignments / update badges confuse the dashboard.
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
DatabaseService.getInstance().deleteGitSource(stackName);
if (fsErr) throw fsErr;
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack deleted: ${stackName}`);
res.json({ success: true });
} catch (error: unknown) {
console.error(`[Stacks] Failed to delete stack ${stackName}:`, error);
const message = getErrorMessage(error, 'Failed to delete stack');
res.status(500).json({ error: message });
}
});
app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
res.json(containers);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch containers' });
}
});
app.get('/api/stacks/:stackName/services', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
const parsed = YAML.parse(content);
const services = parsed?.services ? Object.keys(parsed.services) : [];
res.json(services);
} catch (error) {
console.error('[Stacks] Failed to fetch services:', error);
res.status(500).json({ error: 'Failed to fetch services' });
}
});
app.get('/api/containers/:id/logs', async (req: Request, res: Response) => {
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
// Pass both req and res so we can listen for the client disconnect
await dockerController.streamContainerLogs(id, req, res);
} catch (error) {
res.status(500).json({ error: 'Failed to initialize log stream' });
}
});
app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.startContainer(id);
invalidateNodeCaches(req.nodeId);
res.json({ message: 'Container started' });
} catch (error) {
res.status(500).json({ error: 'Failed to start container' });
}
});
app.post('/api/containers/:id/stop', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.stopContainer(id);
invalidateNodeCaches(req.nodeId);
res.json({ message: 'Container stopped' });
} catch (error) {
res.status(500).json({ error: 'Failed to stop container' });
}
});
app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.restartContainer(id);
invalidateNodeCaches(req.nodeId);
res.json({ message: 'Container restarted' });
} catch (error) {
res.status(500).json({ error: 'Failed to restart container' });
}
});
// End of legacy container routes
app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const debug = isDebugEnabled();
const atomic = LicenseService.getInstance().getTier() === 'paid';
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
const t0 = Date.now();
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Deploy completed: ${stackName}`);
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
res.json({ message: 'Deployed successfully' });
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
);
} catch (error: unknown) {
console.error(`[Stacks] Deploy failed: ${stackName}`, error);
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`);
const message = getErrorMessage(error, 'Failed to deploy stack');
res.status(500).json({ error: message, rolledBack });
}
});
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Down completed: ${stackName}`);
res.json({ status: 'Command started' });
} catch (error) {
console.error(`[Stacks] Down failed: ${stackName}`, error);
res.status(500).json({ error: 'Failed to start command' });
}
});
app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
return res.status(404).json({ error: 'No containers found for this stack.' });
}
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Restart completed: ${stackName} (${containers.length} containers)`);
res.json({ success: true, message: 'Restart completed via Engine API.' });
} catch (error: unknown) {
console.error(`[Stacks] Restart failed: ${stackName}`, error);
const message = getErrorMessage(error, 'Failed to restart containers');
res.status(500).json({ error: message });
}
});
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
return res.status(404).json({ error: 'No containers found for this stack.' });
}
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stop completed: ${stackName} (${containers.length} containers)`);
res.json({ success: true, message: 'Stop completed via Engine API.' });
} catch (error: unknown) {
console.error(`[Stacks] Stop failed: ${stackName}`, error);
const message = getErrorMessage(error, 'Failed to stop containers');
res.status(500).json({ error: message });
}
});
app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
return res.status(404).json({ error: 'No containers found for this stack.' });
}
await Promise.all(containers.map(c => dockerController.startContainer(c.Id)));
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Start completed: ${stackName} (${containers.length} containers)`);
res.json({ success: true, message: 'Start completed via Engine API.' });
} catch (error: unknown) {
console.error(`[Stacks] Start failed: ${stackName}`, error);
const message = getErrorMessage(error, 'Failed to start containers');
res.status(500).json({ error: message });
}
});
// Update stack: pull images and recreate containers
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const debug = isDebugEnabled();
const atomic = LicenseService.getInstance().getTier() === 'paid';
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
const t0 = Date.now();
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Update completed: ${stackName}`);
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
res.json({ status: 'Update completed' });
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
);
} catch (error) {
console.error(`[Stacks] Update failed: ${stackName}`, error);
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`);
res.status(500).json({ error: 'Failed to update', rolledBack });
}
});
// Manual rollback endpoint (Skipper+ and Admin)
app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!requirePaid(req, res)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const fsSvc = FileSystemService.getInstance(req.nodeId);
const backupInfo = await fsSvc.getBackupInfo(stackName);
if (!backupInfo.exists) {
return res.status(404).json({ error: 'No backup available for this stack.' });
}
console.log(`[Stacks] Rollback initiated: ${stackName}`);
await fsSvc.restoreStackFiles(stackName);
// Re-deploy with restored files (non-atomic to avoid loops)
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, false);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Rollback completed: ${stackName}`);
res.json({ message: 'Stack rolled back successfully.' });
} catch (error: unknown) {
console.error(`[Stacks] Rollback failed: ${stackName}`, error);
const message = getErrorMessage(error, 'Rollback failed.');
res.status(500).json({ error: message });
}
});
// Backup info endpoint (read-only)
app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const fsSvc = FileSystemService.getInstance(req.nodeId);
const info = await fsSvc.getBackupInfo(stackName);
res.json(info);
} catch (error: unknown) {
console.error('Failed to get backup info:', error);
const message = getErrorMessage(error, 'Failed to get backup info.');
res.status(500).json({ error: message });
}
});
// Docker Run to Compose converter endpoint.
// Accepts a raw `docker run ...` command and returns the equivalent compose
// YAML as a string. Authenticated, input-validated, and resilient to
// composerize throws / malformed output.
const MAX_DOCKER_RUN_LENGTH = 8192;
app.post('/api/convert', authMiddleware, async (req: Request, res: Response): Promise<void> => {
const { dockerRun } = req.body ?? {};
if (typeof dockerRun !== 'string') {
res.status(400).json({ error: 'dockerRun must be a string' });
return;
}
const trimmed = dockerRun.trim();
if (trimmed.length === 0) {
res.status(400).json({ error: 'dockerRun command is required' });
return;
}
if (trimmed.length > MAX_DOCKER_RUN_LENGTH) {
res.status(400).json({ error: `dockerRun command is too long (max ${MAX_DOCKER_RUN_LENGTH} characters)` });
return;
}
if (trimmed.includes('\0')) {
res.status(400).json({ error: 'dockerRun command contains invalid characters' });
return;
}
let yaml: unknown;
try {
yaml = composerize(trimmed);
} catch (error) {
console.error('Conversion error:', error);
res.status(422).json({ error: 'Could not parse command. Check syntax and supported flags.' });
return;
}
if (typeof yaml !== 'string' || !yaml.includes('services:')) {
console.warn('Converter produced unexpected output for input:', trimmed.slice(0, 200));
res.status(422).json({ error: 'Could not parse command. Check syntax and supported flags.' });
return;
}
res.json({ yaml });
});
// Get all containers stats for dashboard.
// Cached per-node for 2s to collapse multi-tab polling pressure. Invalidated
// by stack/container write endpoints (deploy, down, start, stop, restart, etc).
app.get('/api/stats', async (req: Request, res: Response) => {
try {
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(req.nodeId));
const result = await CacheService.getInstance().getOrFetch(
`stats:${req.nodeId}`,
STATS_CACHE_TTL_MS,
async () => {
const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers();
// A container is "managed" if Docker started it from within COMPOSE_DIR.
// We use com.docker.compose.project.working_dir rather than project name because
// stacks launched from the COMPOSE_DIR root (not a subdirectory) all share the
// project name of the root folder, causing false "external" classification.
const isManagedByComposeDir = (c: any): boolean => {
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
if (!workingDir) return false;
const resolved = path.resolve(workingDir);
return resolved === composeDir || resolved.startsWith(composeDir + path.sep);
};
const active = allContainers.filter((c: any) => c.State === 'running').length;
const exited = allContainers.filter((c: any) => c.State === 'exited').length;
const total = allContainers.length;
const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length;
const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length;
return { active, managed, unmanaged, exited, total };
},
);
res.json(result);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stats' });
}
});
app.get('/api/metrics/historical', async (req: Request, res: Response) => {
try {
const metrics = DatabaseService.getInstance().getContainerMetrics(24);
res.json(metrics);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch metrics' });
}
});
app.get('/api/logs/global', async (req: Request, res: Response) => {
try {
const debug = isDebugEnabled();
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getRunningContainers();
const allLogs: GlobalLogEntry[] = [];
if (debug) console.debug('[GlobalLogs:debug] Polling snapshot starting', { containerCount: containers.length, nodeId: req.nodeId });
await Promise.all(containers.map(async (c) => {
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
const containerName = normalizeContainerName(rawName, stackName);
try {
const container = dockerController.getDocker().getContainer(c.Id);
const inspect = await container.inspect();
const isTty = inspect.Config.Tty;
const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true }) as Buffer;
demuxDockerLog(logsBuffer, isTty, (line, source) => {
if (!line.trim()) return;
const { timestampMs, cleanMessage } = parseLogTimestamp(line);
const level = detectLogLevel(cleanMessage, source);
allLogs.push({ stackName, containerName, source, level, message: cleanMessage, timestampMs });
});
} catch (err) {
console.warn(`[GlobalLogs] Failed to fetch/parse logs for container ${containerName} (${c.Id.substring(0, 12)}):`, (err as Error).message);
}
}));
// Sort globally by timestamp ascending (newest bottom).
// Limit to 500 lines; the client renders at most 300 rows at once.
allLogs.sort((a, b) => a.timestampMs - b.timestampMs);
if (debug) console.debug('[GlobalLogs:debug] Polling snapshot complete', { totalLines: allLogs.length });
res.json(allLogs.slice(-500));
} catch (error) {
console.error('[GlobalLogs] Snapshot fetch failed:', (error as Error).message);
res.status(500).json({ error: 'Failed to fetch global logs' });
}
});
app.get('/api/logs/global/stream', async (req: Request, res: Response) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Prevent nginx from buffering SSE events (would cause burst delivery).
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const debug = isDebugEnabled();
const dockerController = DockerController.getInstance(req.nodeId);
const streams: NodeJS.ReadableStream[] = [];
// Send a heartbeat comment every 30s to keep reverse proxies from closing
// idle connections. SSE comments (lines starting with ':') are silently
// discarded by the browser's EventSource API.
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(':heartbeat\n\n');
}, 30_000);
try {
const containers = await dockerController.getRunningContainers();
if (debug) console.debug('[GlobalLogs:debug] SSE stream opened', { containerCount: containers.length, nodeId: req.nodeId });
await Promise.all(containers.map(async (c) => {
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
const containerName = normalizeContainerName(rawName, stackName);
try {
const container = dockerController.getDocker().getContainer(c.Id);
const inspect = await container.inspect();
const isTty = inspect.Config.Tty;
const stream = await container.logs({ follow: true, stdout: true, stderr: true, tail: 500, timestamps: true });
streams.push(stream);
stream.on('data', (chunk: Buffer) => {
demuxDockerLog(chunk, isTty, (line, source) => {
if (!line.trim()) return;
const { timestampMs, cleanMessage } = parseLogTimestamp(line);
const level = detectLogLevel(cleanMessage, source);
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ stackName, containerName, source, level, message: cleanMessage, timestampMs })}\n\n`);
}
});
});
} catch (err) {
console.warn(`[GlobalLogs] Failed to attach stream for container ${containerName} (${c.Id.substring(0, 12)}):`, (err as Error).message);
}
}));
req.on('close', () => {
clearInterval(heartbeat);
if (debug) console.debug('[GlobalLogs:debug] SSE stream closed, cleaning up', { streamCount: streams.length });
streams.forEach(s => {
try { (s as NodeJS.ReadableStream & { destroy(): void }).destroy(); } catch { /* stream already ended */ }
});
});
} catch (error) {
clearInterval(heartbeat);
console.error('[GlobalLogs] SSE stream attachment failed:', (error as Error).message);
res.write(`data: ${JSON.stringify({ level: 'ERROR', message: '[Sencho] Failed to attach global log stream.', timestampMs: Date.now(), stackName: 'system', containerName: 'backend', source: 'STDERR' })}\n\n`);
res.end();
}
});
// Get host system stats.
// Cached for 3s to collapse overlapping samplers: the dashboard polls every 5s,
// MonitorService samples every 30s, and si.currentLoad() blocks for ~200ms per
// call. A short TTL makes concurrent polls share one sample without noticeable
// UX staleness. No write-path invalidation: these are pure host metrics.
app.get('/api/system/stats', async (req: Request, res: Response) => {
try {
// Network is read outside the cache because it is cheap and per-request.
const rxSec = Math.max(0, globalDockerNetwork.rxSec);
const txSec = Math.max(0, globalDockerNetwork.txSec);
const sample = await CacheService.getInstance().getOrFetch(
`system-stats:${req.nodeId}`,
SYSTEM_STATS_CACHE_TTL_MS,
async () => {
// Remote node requests are intercepted and proxied by remoteNodeProxy
// before reaching here. This fetcher only runs for local nodes.
const [currentLoad, mem, fsSize] = await Promise.all([
si.currentLoad(),
si.mem(),
si.fsSize(),
]);
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
return {
cpu: {
usage: currentLoad.currentLoad.toFixed(1),
cores: currentLoad.cpus.length,
},
memory: {
total: mem.total,
used: mem.used,
free: mem.free,
usagePercent: ((mem.used / mem.total) * 100).toFixed(1),
},
disk: mainDisk ? {
fs: mainDisk.fs,
mount: mainDisk.mount,
total: mainDisk.size,
used: mainDisk.used,
free: mainDisk.available,
usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0',
} : null,
};
},
);
res.json({ ...sample, network: { rxBytes: 0, txBytes: 0, rxSec, txSec } });
} catch (error) {
console.error('Failed to fetch system stats:', error);
res.status(500).json({ error: 'Failed to fetch system stats' });
}
});
// Admin-only cache observability: per-namespace hit/miss/stale counters and
// live entry counts for the unified CacheService. Used by Settings → About and
// for post-deployment verification that cache hit rates look healthy.
app.get('/api/system/cache-stats', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
res.json(CacheService.getInstance().getStats());
} catch (error) {
console.error('Failed to fetch cache stats:', error);
res.status(500).json({ error: 'Failed to fetch cache stats' });
}
});
// --- Notification & Alerting Routes ---
const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const;
/** Trim, deduplicate, and drop empty entries from a stack_patterns array. */
const cleanStackPatterns = (patterns: string[]): string[] =>
[...new Set(patterns.map(p => p.trim()).filter(Boolean))];
/** Validate that a string is a well-formed HTTPS URL. Returns an error string or null. */
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;
}
app.get('/api/agents', authMiddleware, async (req: Request, res: Response) => {
try {
const agents = DatabaseService.getInstance().getAgents();
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;
}
DatabaseService.getInstance().upsertAgent({ 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',
'global_logs_refresh',
'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']),
global_logs_refresh: z.enum(['1', '3', '5', '10']),
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' });
}
});
app.get('/api/alerts', authMiddleware, async (req: Request, res: Response) => {
try {
let stackName = req.query.stackName as string | undefined;
if (Array.isArray(stackName)) stackName = stackName[0] as string;
const alerts = DatabaseService.getInstance().getStackAlerts(stackName);
res.json(alerts);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch alerts' });
}
});
const AlertCreateSchema = z.object({
stack_name: z.string().min(1).max(255),
metric: z.enum(['cpu_percent', 'memory_percent', 'memory_mb', 'net_rx', 'net_tx', 'restart_count']),
operator: z.enum(['>', '>=', '<', '<=', '==']),
threshold: z.number().min(0),
duration_mins: z.coerce.number().int().min(0).max(1440),
cooldown_mins: z.coerce.number().int().min(0).max(10080),
});
const AutoHealPolicyCreateSchema = z.object({
stack_name: z.string().min(1).max(255),
service_name: z.string().min(1).max(255).nullable().optional(),
unhealthy_duration_mins: z.coerce.number().int().min(1).max(1440),
cooldown_mins: z.coerce.number().int().min(1).max(1440).default(5),
max_restarts_per_hour: z.coerce.number().int().min(1).max(60).default(3),
auto_disable_after_failures: z.coerce.number().int().min(1).max(100).default(5),
});
const AutoHealPolicyUpdateSchema = AutoHealPolicyCreateSchema.partial().omit({ stack_name: true });
app.post('/api/alerts', authMiddleware, async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const parsed = AlertCreateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors });
return;
}
try {
const created = DatabaseService.getInstance().addStackAlert(parsed.data);
res.status(201).json(created);
} catch (error) {
console.error('Failed to add alert:', error);
res.status(500).json({ error: 'Failed to add alert' });
}
});
app.delete('/api/alerts/:id', authMiddleware, async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
DatabaseService.getInstance().deleteStackAlert(id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete alert' });
}
});
// ─── Auto-Heal Policies ───────────────────────────────────────────────────────
app.get('/api/auto-heal/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const stackName = typeof req.query.stackName === 'string' ? req.query.stackName : undefined;
try {
res.json(DatabaseService.getInstance().getAutoHealPolicies(stackName));
} catch (err) {
console.error('[AutoHeal] Failed to list policies:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/api/auto-heal/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const parsed = AutoHealPolicyCreateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
return;
}
const { stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures } = parsed.data;
const now = Date.now();
try {
const policy = DatabaseService.getInstance().addAutoHealPolicy({
stack_name,
service_name: service_name ?? null,
unhealthy_duration_mins,
cooldown_mins,
max_restarts_per_hour,
auto_disable_after_failures,
enabled: 1,
consecutive_failures: 0,
last_fired_at: 0,
created_at: now,
updated_at: now,
});
res.status(201).json(policy);
} catch (err) {
console.error('[AutoHeal] Failed to create policy:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.patch('/api/auto-heal/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid id' }); return; }
const parsed = AutoHealPolicyUpdateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
return;
}
try {
const db = DatabaseService.getInstance();
if (!db.getAutoHealPolicy(id)) { res.status(404).json({ error: 'Policy not found' }); return; }
db.updateAutoHealPolicy(id, parsed.data);
res.json(db.getAutoHealPolicy(id));
} catch (err) {
console.error('[AutoHeal] Failed to update policy:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.delete('/api/auto-heal/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid id' }); return; }
try {
const db = DatabaseService.getInstance();
if (!db.getAutoHealPolicy(id)) { res.status(404).json({ error: 'Policy not found' }); return; }
db.deleteAutoHealPolicy(id);
res.json({ success: true });
} catch (err) {
console.error('[AutoHeal] Failed to delete policy:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/api/auto-heal/policies/:id/history', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid id' }); return; }
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 100);
try {
res.json(DatabaseService.getInstance().getAutoHealHistory(id, limit));
} catch (err) {
console.error('[AutoHeal] Failed to fetch history:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
try {
const history = DatabaseService.getInstance().getNotificationHistory();
res.json(history);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch notifications' });
}
});
app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Response) => {
try {
DatabaseService.getInstance().markAllNotificationsRead();
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to mark notifications read' });
}
});
app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; }
DatabaseService.getInstance().deleteNotification(id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete notification' });
}
});
app.delete('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
try {
DatabaseService.getInstance().deleteAllNotifications();
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to clear notifications' });
}
});
app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { type, url } = 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; }
await NotificationService.getInstance().testDispatch(type, url);
res.json({ success: true });
} catch (error: unknown) {
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
}
});
// --- Notification Routes (Admiral) ---
app.get('/api/notification-routes', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const routes = DatabaseService.getInstance().getNotificationRoutes();
res.json(routes);
} catch (error) {
console.error('Failed to fetch notification routes:', error);
res.status(500).json({ error: 'Failed to fetch notification routes' });
}
});
app.post('/api/notification-routes', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Name is required' });
return;
}
if (name.trim().length > 100) {
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
return;
}
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
return;
}
const cleanedPatterns = cleanStackPatterns(stack_patterns);
if (cleanedPatterns.length === 0) {
res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' });
return;
}
if (!NOTIFICATION_CHANNEL_TYPES.includes(channel_type)) {
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
const channelUrlErr = validateHttpsUrl(channel_url);
if (channelUrlErr) { res.status(400).json({ error: `channel_url ${channelUrlErr}` }); return; }
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
res.status(400).json({ error: 'priority must be a finite number' });
return;
}
const now = Date.now();
const route = DatabaseService.getInstance().createNotificationRoute({
name: name.trim(),
stack_patterns: cleanedPatterns,
channel_type,
channel_url: channel_url.trim(),
priority: typeof priority === 'number' ? priority : 0,
enabled: enabled !== false,
created_at: now,
updated_at: now,
});
console.log(`[Routes] Route "${route.name}" created (id=${route.id})`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${route.name}" created with patterns=[${cleanedPatterns}], channel=${channel_type}`);
res.status(201).json(route);
} catch (error) {
console.error('Failed to create notification route:', error);
res.status(500).json({ error: 'Failed to create notification route' });
}
});
app.put('/api/notification-routes/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid route ID' }); return; }
const existing = DatabaseService.getInstance().getNotificationRoute(id);
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
res.status(400).json({ error: 'Name must be a non-empty string' });
return;
}
if (name !== undefined && name.trim().length > 100) {
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
return;
}
let cleanedPatterns: string[] | undefined;
if (stack_patterns !== undefined) {
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
return;
}
cleanedPatterns = cleanStackPatterns(stack_patterns);
if (cleanedPatterns.length === 0) {
res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' });
return;
}
}
if (channel_type !== undefined && !NOTIFICATION_CHANNEL_TYPES.includes(channel_type)) {
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
if (channel_url !== undefined) {
const urlErr = validateHttpsUrl(channel_url);
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
}
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
res.status(400).json({ error: 'priority must be a finite number' });
return;
}
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
const updates: Record<string, unknown> = { updated_at: Date.now() };
if (name !== undefined) updates.name = name.trim();
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
if (channel_type !== undefined) updates.channel_type = channel_type;
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
if (priority !== undefined) updates.priority = priority;
if (enabled !== undefined) updates.enabled = enabled;
DatabaseService.getInstance().updateNotificationRoute(id, updates);
const updated = DatabaseService.getInstance().getNotificationRoute(id);
console.log(`[Routes] Route ${id} updated`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route ${id} update fields: ${Object.keys(updates).filter(k => k !== 'updated_at')}`);
res.json(updated);
} catch (error) {
console.error('Failed to update notification route:', error);
res.status(500).json({ error: 'Failed to update notification route' });
}
});
app.delete('/api/notification-routes/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid route ID' }); return; }
const changes = DatabaseService.getInstance().deleteNotificationRoute(id);
if (changes === 0) { res.status(404).json({ error: 'Route not found' }); return; }
console.log(`[Routes] Route ${id} deleted`);
res.json({ success: true });
} catch (error) {
console.error('Failed to delete notification route:', error);
res.status(500).json({ error: 'Failed to delete notification route' });
}
});
app.post('/api/notification-routes/:id/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid route ID' }); return; }
const route = DatabaseService.getInstance().getNotificationRoute(id);
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${route.channel_url})`);
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
res.json({ success: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
res.status(500).json({ error: 'Test failed', details: msg });
}
});
// Issue a short-lived console session token for WebSocket proxy delegation.
// When the gateway needs to proxy an interactive terminal (host console or container exec)
// to a remote node, it calls this endpoint (authenticated with the long-lived api_token)
// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session'
// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths.
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot generate console tokens.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured' });
return;
}
const consoleToken = jwt.sign({ scope: 'console_session' }, jwtSecret, { expiresIn: '60s' });
res.json({ token: consoleToken });
} catch (error) {
console.error('Failed to issue console token:', error);
res.status(500).json({ error: 'Failed to issue console token' });
}
});
// --- SSO Config Routes (admin, local-only) ---
app.get('/api/sso/config', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const configs = DatabaseService.getInstance().getSSOConfigs();
const result = configs.map(c => {
const parsed = JSON.parse(c.config_json);
// Strip encrypted secrets from response
delete parsed.ldapBindPassword;
delete parsed.oidcClientSecret;
return { ...parsed, provider: c.provider, enabled: c.enabled === 1 };
});
res.json(result);
} catch (error) {
console.error('[SSO] Failed to fetch SSO configs:', error);
res.status(500).json({ error: 'Failed to fetch SSO configuration' });
}
});
app.get('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const config = SSOService.getInstance().getProviderConfig(String(req.params.provider));
if (!config) {
res.status(404).json({ error: 'Provider not configured' });
return;
}
// Strip encrypted secrets
const result = { ...config };
delete result.ldapBindPassword;
delete result.oidcClientSecret;
res.json(result);
} catch (error) {
console.error('[SSO] Failed to fetch SSO config:', error);
res.status(500).json({ error: 'Failed to fetch SSO configuration' });
}
});
app.put('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const provider = String(req.params.provider);
const validProviders = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
if (!validProviders.includes(provider)) {
res.status(400).json({ error: 'Invalid SSO provider' });
return;
}
const config = { ...req.body, provider } as import('./services/SSOService').SSOProviderConfig;
// Validate required fields when enabling a provider
if (config.enabled) {
const missing: string[] = [];
if (provider === 'ldap') {
if (!config.ldapUrl?.trim()) missing.push('Server URL');
if (!config.ldapSearchBase?.trim()) missing.push('Search Base');
} else {
if (!config.oidcClientId?.trim()) missing.push('Client ID');
if ((provider === 'oidc_okta' || provider === 'oidc_custom') && !config.oidcIssuerUrl?.trim()) missing.push('Issuer URL');
}
if (missing.length > 0) {
res.status(400).json({ error: `Missing required fields: ${missing.join(', ')}` });
return;
}
}
SSOService.getInstance().saveProviderConfig(config);
console.log(`[SSO] Config updated: ${provider} ${config.enabled ? 'enabled' : 'disabled'}`);
res.json({ success: true, message: 'SSO configuration saved' });
} catch (error) {
console.error('[SSO] Failed to save SSO config:', error);
res.status(500).json({ error: 'Failed to save SSO configuration' });
}
});
app.delete('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const deletedProvider = String(req.params.provider);
SSOService.getInstance().deleteProviderConfig(deletedProvider);
console.log(`[SSO] Config deleted: ${deletedProvider}`);
res.json({ success: true, message: 'SSO configuration deleted' });
} catch (error) {
console.error('[SSO] Failed to delete SSO config:', error);
res.status(500).json({ error: 'Failed to delete SSO configuration' });
}
});
app.post('/api/sso/config/:provider/test', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const provider = String(req.params.provider);
if (provider === 'ldap') {
const result = await SSOService.getInstance().testLdapConnection();
res.json(result);
} else {
const result = await SSOService.getInstance().testOidcDiscovery(provider);
res.json(result);
}
} catch (error) {
console.error('[SSO] Connection test failed:', error);
res.status(500).json({ success: false, error: 'Connection test failed' });
}
});
// --- Audit Log Routes (Admiral, local-only) ---
app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const username = req.query.username as string | undefined;
const method = req.query.method as string | undefined;
const search = req.query.search as string | undefined;
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
if (isDebugEnabled()) {
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${username || '-'} method=${method || '-'} search=${search || '-'}`);
}
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search });
res.json(result);
} catch (error) {
console.error('[AuditLog] Failed to fetch audit log:', error);
res.status(500).json({ error: 'Failed to fetch audit log' });
}
});
app.get('/api/audit-log/export', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const format = (req.query.format as string) === 'csv' ? 'csv' : 'json';
const username = req.query.username as string | undefined;
const method = req.query.method as string | undefined;
const search = req.query.search as string | undefined;
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
if (isDebugEnabled()) {
console.log(`[Audit:diag] Export: format=${format} filters=${JSON.stringify({ username, method, search, from, to })}`);
}
const result = DatabaseService.getInstance().getAuditLogs({ page: 1, limit: 10000, username, method, from, to, search });
const timestamp = new Date().toISOString().slice(0, 10);
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.json"`);
res.json(result.entries);
} else {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`);
const csvEscape = (val: string | number | null): string => {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const rows = result.entries.map(e =>
headers.map(h => csvEscape(e[h as keyof typeof e])).join(',')
);
res.send([headers.join(','), ...rows].join('\n'));
}
} catch (error) {
console.error('[AuditLog] Export failed:', error);
res.status(500).json({ error: 'Failed to export audit log' });
}
});
// --- API Token Routes (Admiral, admin-only, local-only) ---
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage other API tokens.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const { name, scope, expires_in } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Token name is required.' });
return;
}
if (name.trim().length > 100) {
res.status(400).json({ error: 'Token name must be 100 characters or fewer.' });
return;
}
const validScopes = ['read-only', 'deploy-only', 'full-admin'];
if (!scope || !validScopes.includes(scope)) {
res.status(400).json({ error: `Scope must be one of: ${validScopes.join(', ')}` });
return;
}
const validExpiry = [30, 60, 90, 365];
if (expires_in !== undefined && expires_in !== null && !validExpiry.includes(expires_in)) {
res.status(400).json({ error: `expires_in must be one of: ${validExpiry.join(', ')} (days), or null for no expiry.` });
return;
}
const expiresAt = typeof expires_in === 'number' ? Date.now() + expires_in * 24 * 60 * 60 * 1000 : null;
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured.' });
return;
}
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(req.user!.username);
if (!user) {
res.status(500).json({ error: 'User not found.' });
return;
}
const activeCount = db.getActiveApiTokenCountByUser(user.id);
if (activeCount >= 25) {
res.status(400).json({ error: 'Maximum of 25 active API tokens per user.' });
return;
}
if (db.getActiveApiTokenByNameAndUser(name.trim(), user.id)) {
res.status(409).json({ error: 'An active token with this name already exists.' });
return;
}
// JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB check is always tighter
const API_TOKEN_JWT_CEILING = '400d';
const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret, { expiresIn: API_TOKEN_JWT_CEILING });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const id = db.addApiToken({
token_hash: tokenHash,
name: name.trim(),
scope: scope as 'read-only' | 'deploy-only' | 'full-admin',
user_id: user.id,
created_at: Date.now(),
expires_at: expiresAt,
});
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token created:', { name: name.trim(), scope, expires_in, user: req.user!.username });
res.status(201).json({ id, token: rawToken });
} catch (error) {
console.error('[ApiTokens] Create error:', error);
res.status(500).json({ error: 'Failed to create API token' });
}
});
app.get('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage other API tokens.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
if (!user) { res.status(500).json({ error: 'User not found.' }); return; }
const tokens = DatabaseService.getInstance().getApiTokensByUser(user.id);
// Never expose token hashes to the client
const sanitized = tokens.map(({ token_hash: _hash, ...rest }) => rest);
res.json(sanitized);
} catch (error) {
console.error('[ApiTokens] List error:', error);
res.status(500).json({ error: 'Failed to list API tokens' });
}
});
app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage other API tokens.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid token ID.' }); return; }
const apiToken = DatabaseService.getInstance().getApiTokenById(id);
if (!apiToken) { res.status(404).json({ error: 'API token not found.' }); return; }
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
if (!user || apiToken.user_id !== user.id) {
res.status(403).json({ error: 'You can only revoke your own tokens.' });
return;
}
DatabaseService.getInstance().revokeApiToken(id);
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token revoked:', { id, name: apiToken.name, user: req.user!.username });
res.json({ success: true });
} catch (error) {
console.error('[ApiTokens] Revoke error:', error);
res.status(500).json({ error: 'Failed to revoke API token' });
}
});
// --- 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);
}
res.json(tasks);
} 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) ---
const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const;
function isValidRegistryUrl(url: string, type: string): boolean {
// Docker Hub is fixed server-side to the legacy URL; no validation needed.
if (type === 'dockerhub') return true;
const trimmed = url.trim();
if (!trimmed) return false;
// Reject any non-http(s) scheme (file://, ftp://, javascript:, etc.).
const lower = trimmed.toLowerCase();
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('file:') || lower.startsWith('ftp:')) {
return false;
}
// Parse with a default https:// prefix so bare hosts validate.
try {
const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
if (!parsed.hostname) return false;
} catch {
return false;
}
return true;
}
app.get('/api/registries', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
res.json(RegistryService.getInstance().getAll());
} catch (error) {
console.error('[Registries] List error:', error);
res.status(500).json({ error: 'Failed to fetch registries' });
}
});
app.post('/api/registries', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const { name, url, type, username, secret, aws_region } = req.body;
if (!name || typeof name !== 'string' || name.length > 100) {
res.status(400).json({ error: 'Name is required (max 100 characters).' }); return;
}
if (!url || typeof url !== 'string' || url.length > 500) {
res.status(400).json({ error: 'URL is required (max 500 characters).' }); return;
}
if (!type || !VALID_REGISTRY_TYPES.includes(type)) {
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
}
if (!isValidRegistryUrl(url, type)) {
res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return;
}
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'Username is required.' }); return;
}
if (!secret || typeof secret !== 'string') {
res.status(400).json({ error: 'Secret/token is required.' }); return;
}
if (type === 'ecr' && (!aws_region || typeof aws_region !== 'string')) {
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
}
const id = RegistryService.getInstance().create({ name, url, type, username, secret, aws_region: aws_region ?? null });
res.status(201).json({ id });
} catch (error) {
console.error('[Registries] Create error:', error);
res.status(500).json({ error: 'Failed to create registry' });
}
});
app.put('/api/registries/:id', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const existing = RegistryService.getInstance().getById(id);
if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; }
const { name, url, type, username, secret, aws_region } = req.body;
if (name !== undefined && (typeof name !== 'string' || name.length > 100)) {
res.status(400).json({ error: 'Name must be a string (max 100 characters).' }); return;
}
if (url !== undefined && (typeof url !== 'string' || url.length > 500)) {
res.status(400).json({ error: 'URL must be a string (max 500 characters).' }); return;
}
if (type !== undefined && !VALID_REGISTRY_TYPES.includes(type)) {
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
}
const effectiveType = type ?? existing.type;
if (url !== undefined && !isValidRegistryUrl(url, effectiveType)) {
res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return;
}
if (effectiveType === 'ecr' && aws_region !== undefined && (typeof aws_region !== 'string' || !aws_region)) {
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
}
RegistryService.getInstance().update(id, { name, url, type, username, secret, aws_region });
res.json({ success: true });
} catch (error) {
console.error('[Registries] Update error:', error);
res.status(500).json({ error: 'Failed to update registry' });
}
});
app.delete('/api/registries/:id', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const existing = RegistryService.getInstance().getById(id);
if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; }
RegistryService.getInstance().delete(id);
res.json({ success: true });
} catch (error) {
console.error('[Registries] Delete error:', error);
res.status(500).json({ error: 'Failed to delete registry' });
}
});
app.post('/api/registries/:id/test', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const result = await RegistryService.getInstance().testConnection(id);
res.json(result);
} catch (error) {
console.error('[Registries] Test error:', error);
res.status(500).json({ error: 'Failed to test registry connection' });
}
});
// Stateless test: validate credentials without persisting. Powers the
// "Test connection" button inside the create/edit form so users can verify
// creds before saving.
app.post('/api/registries/test', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const { type, url, username, secret, aws_region } = req.body;
if (!type || !VALID_REGISTRY_TYPES.includes(type)) {
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
}
if (typeof url !== 'string' || url.length === 0 || url.length > 500) {
res.status(400).json({ error: 'URL is required (max 500 characters).' }); return;
}
if (!isValidRegistryUrl(url, type)) {
res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return;
}
if (typeof username !== 'string' || username.length === 0) {
res.status(400).json({ error: 'Username is required.' }); return;
}
if (typeof secret !== 'string' || secret.length === 0) {
res.status(400).json({ error: 'Secret/token is required.' }); return;
}
if (type === 'ecr' && (typeof aws_region !== 'string' || !aws_region)) {
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
}
const result = await RegistryService.getInstance().testWithCredentials({
type,
url,
username,
secret,
aws_region: aws_region ?? null,
});
res.json(result);
} catch (error) {
console.error('[Registries] Stateless test error:', error);
res.status(500).json({ error: 'Failed to test registry connection' });
}
});
// --- System Maintenance Routes (The System Janitor) ---
app.get('/api/system/orphans', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const dockerController = DockerController.getInstance(req.nodeId);
const orphans = await dockerController.getOrphanContainers(knownStacks);
res.json(orphans);
} catch (error) {
console.error('Failed to fetch orphan containers:', error);
res.status(500).json({ error: 'Failed to fetch orphan containers' });
}
});
app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { containerIds } = req.body;
if (!Array.isArray(containerIds)) {
return res.status(400).json({ error: 'containerIds must be an array' });
}
const invalidIds = containerIds.filter((id: unknown) => typeof id !== 'string' || !isValidDockerResourceId(id));
if (invalidIds.length > 0) {
return res.status(400).json({ error: 'One or more container IDs have an invalid format' });
}
console.log(`[Resources] Prune orphans: ${containerIds.length} container(s) requested`);
const dockerController = DockerController.getInstance(req.nodeId);
const results = await dockerController.removeContainers(containerIds);
const succeeded = results.filter((r: { success: boolean }) => r.success).length;
console.log(`[Resources] Prune orphans completed: ${succeeded}/${containerIds.length} removed`);
invalidateNodeCaches(req.nodeId);
res.json({ results });
} catch (error) {
console.error('Failed to prune orphan containers:', error);
res.status(500).json({ error: 'Failed to prune orphan containers' });
}
});
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { target, scope } = req.body as { target: string; scope?: string };
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
return res.status(400).json({ error: 'Invalid prune target' });
}
const pruneScope = scope === 'managed' ? 'managed' : 'all';
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
const dockerController = DockerController.getInstance(req.nodeId);
let result: { success: boolean; reclaimedBytes: number };
if (pruneScope === 'managed' && target !== 'containers') {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
result = await dockerController.pruneManagedOnly(
target as 'images' | 'volumes' | 'networks',
knownStacks
);
} else {
result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes');
}
console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`);
if (target === 'containers') {
invalidateNodeCaches(req.nodeId);
}
res.json({ message: 'Prune completed', ...result });
} catch (error: unknown) {
console.error('System prune error:', error);
res.status(500).json({ error: 'System prune failed' });
}
});
app.get('/api/system/docker-df', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks);
res.json(df);
} catch (error) {
console.error('Failed to fetch docker disk usage:', error);
res.status(500).json({ error: 'Failed to fetch docker disk usage' });
}
});
// Single endpoint returning classified images, volumes, and networks in one call
app.get('/api/system/resources', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(result);
} catch (error) {
console.error('Failed to fetch classified resources:', error);
res.status(500).json({ error: 'Failed to fetch resources' });
}
});
// Keep legacy endpoints for backward compat with remote proxy routing
app.get('/api/system/images', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(images);
} catch (error) {
console.error('Failed to fetch images:', error);
res.status(500).json({ error: 'Failed to fetch images' });
}
});
app.get('/api/system/volumes', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(volumes);
} catch (error) {
console.error('Failed to fetch volumes:', error);
res.status(500).json({ error: 'Failed to fetch volumes' });
}
});
app.get('/api/system/networks', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
res.json(networks);
} catch (error) {
console.error('Failed to fetch networks:', error);
res.status(500).json({ error: 'Failed to fetch networks' });
}
});
app.post('/api/system/images/delete', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID is required' });
if (typeof id !== 'string' || !isValidDockerResourceId(id)) {
return res.status(400).json({ error: 'Invalid image ID format' });
}
console.log(`[Resources] Delete image: ${id.substring(0, 12)}`);
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.removeImage(id);
invalidateNodeCaches(req.nodeId);
res.json({ success: true, message: 'Image deleted' });
} catch (error: unknown) {
console.error('Failed to delete image:', error);
res.status(500).json({ error: 'Failed to delete image' });
}
});
app.post('/api/system/volumes/delete', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { id } = req.body;
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'Volume name is required' });
console.log(`[Resources] Delete volume: ${id}`);
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.removeVolume(id);
invalidateNodeCaches(req.nodeId);
res.json({ success: true, message: 'Volume deleted' });
} catch (error: unknown) {
console.error('Failed to delete volume:', error);
res.status(500).json({ error: 'Failed to delete volume' });
}
});
app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID is required' });
if (typeof id !== 'string' || !isValidDockerResourceId(id)) {
return res.status(400).json({ error: 'Invalid network ID format' });
}
console.log(`[Resources] Delete network: ${id.substring(0, 12)}`);
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.removeNetwork(id);
invalidateNodeCaches(req.nodeId);
res.json({ success: true, message: 'Network deleted' });
} catch (error: unknown) {
console.error('Failed to delete network:', error);
res.status(500).json({ error: 'Failed to delete network' });
}
});
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
try {
const includeSystem = req.query.includeSystem === 'true';
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const dockerController = DockerController.getInstance(req.nodeId);
const topology = await dockerController.getTopologyData(knownStacks, includeSystem);
console.log(`[Resources] Topology fetched: ${topology.length} networks, includeSystem=${includeSystem}`);
if (isDebugEnabled()) console.debug('[Resources:debug] Topology fetched', { networkCount: topology.length, includeSystem });
res.json(topology);
} catch (error: unknown) {
console.error('Failed to fetch network topology:', error);
res.status(500).json({ error: 'Failed to fetch network topology' });
}
});
app.get('/api/system/networks/:id', async (req: Request, res: Response) => {
try {
const id = req.params.id as string;
if (!id) return res.status(400).json({ error: 'Network ID is required' });
const dockerController = DockerController.getInstance(req.nodeId);
const networkInfo = await dockerController.inspectNetwork(id);
res.json(networkInfo);
} catch (error: unknown) {
console.error('Failed to inspect network:', error);
const err = error as Record<string, unknown>;
const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404)
|| (error instanceof Error && error.message.includes('404'));
res.status(is404 ? 404 : 500).json({ error: is404 ? 'Network not found' : 'Failed to inspect network' });
}
});
app.post('/api/system/networks', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { name, driver, subnet, gateway, labels, internal, attachable } = req.body;
if (!name) return res.status(400).json({ error: 'Network name is required' });
const options: CreateNetworkOptions = { Name: name };
const VALID_DRIVERS: NetworkDriver[] = ['bridge', 'overlay', 'macvlan', 'host', 'none'];
if (driver) {
if (!VALID_DRIVERS.includes(driver)) return res.status(400).json({ error: 'Invalid network driver' });
options.Driver = driver;
}
if (subnet || gateway) {
if (subnet && !isValidCidr(subnet)) return res.status(400).json({ error: 'Invalid subnet CIDR notation (e.g. 172.20.0.0/16)' });
if (gateway && !isValidIPv4(gateway)) return res.status(400).json({ error: 'Invalid gateway IP address (e.g. 172.20.0.1)' });
options.IPAM = { Config: [{}] };
if (subnet) options.IPAM.Config[0].Subnet = subnet;
if (gateway) options.IPAM.Config[0].Gateway = gateway;
}
if (labels && typeof labels === 'object' && !Array.isArray(labels)) options.Labels = labels;
if (internal) options.Internal = true;
if (attachable) options.Attachable = true;
const dockerController = DockerController.getInstance(req.nodeId);
const network = await dockerController.createNetwork(options);
console.log(`[Resources] Network created: ${name}`);
invalidateNodeCaches(req.nodeId);
res.status(201).json({ success: true, message: 'Network created', id: network.id });
} catch (error: unknown) {
console.error('Failed to create network:', error);
const msg = getErrorMessage(error, '');
const safePatterns = ['already exists', 'name is invalid', 'invalid network name'];
const lowerMsg = msg.toLowerCase();
const isSafe = safePatterns.some(p => lowerMsg.includes(p));
res.status(isSafe ? 409 : 500).json({ error: isSafe ? msg : 'Failed to create network' });
}
});
// --- App Templates Routes ---
app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => {
try {
const templates = await templateService.getTemplates();
res.json(templates);
} catch (error) {
console.error('[Templates] Failed to fetch:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
templateService.clearCache();
console.log('[Templates] Cache cleared by', req.user?.username || 'unknown');
res.json({ success: true });
});
app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { stackName, template, envVars, skip_scan } = req.body;
if (!stackName || !template) {
return res.status(400).json({ error: 'stackName and template are required' });
}
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
}
const fsService = FileSystemService.getInstance(req.nodeId);
const baseDir = fsService.getBaseDir();
const stackPath = path.join(baseDir, stackName);
if (!isPathWithinBase(stackPath, baseDir)) {
return res.status(400).json({ error: 'Invalid stack path' });
}
try {
await fsPromises.access(stackPath);
if (await fsService.hasComposeFile(stackPath)) {
return res.status(409).json({
error: `A stack directory named '${stackName}' already exists. Please choose a different Stack Name.`,
rolledBack: false
});
}
// Orphaned directory left by external deletion (e.g. Docker Desktop).
console.log(`[Templates] Cleaned up orphaned stack directory: ${stackName}`);
await fsService.deleteStack(stackName);
} catch {
// Directory does not exist; proceed with deploy
}
const debug = isDebugEnabled();
console.log(`[Templates] Deploy started: ${stackName}`);
if (debug) console.debug('[Templates:debug] Deploy payload', { stackName, templateTitle: template.title, envVarCount: envVars ? Object.keys(envVars).length : 0 });
// 1. Create stack directory
await fsService.createStack(stackName);
// 2. Generate compose YAML and save
const composeYaml = templateService.generateComposeFromTemplate(template);
await fsService.saveStackContent(stackName, composeYaml);
// 3. Generate env string and save to default .env
if (envVars && Object.keys(envVars).length > 0) {
const envString = templateService.generateEnvString(envVars);
const defaultEnvPath = path.join(stackPath, '.env');
await fsPromises.writeFile(defaultEnvPath, envString, 'utf-8');
}
// 4. Deploy the stack with atomic rollback
try {
const atomic = LicenseService.getInstance().getTier() === 'paid';
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
invalidateNodeCaches(req.nodeId);
console.log(`[Templates] Deploy completed: ${stackName}`);
res.json({ success: true, message: 'Template deployed successfully' });
if (!skip_scan) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
);
}
} catch (deployError: unknown) {
const rawError = getErrorMessage(deployError, String(deployError));
console.error(`[Templates] Deploy failed: ${stackName} -`, rawError);
const parsed = ErrorParser.parse(rawError);
const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true;
if (shouldRollback) {
try {
// Stage 1: Tell Docker to clean up ghost networks/containers
await ComposeService.getInstance(req.nodeId).downStack(stackName);
} catch (downErr) {
console.error("[Templates] Rollback Stage 1 (Docker down) failed:", downErr);
}
try {
// Stage 2: Remove the stack files
await fsService.deleteStack(stackName);
} catch (fsErr) {
console.error("[Templates] Rollback Stage 2 (File deletion) failed:", fsErr);
}
}
// Partial state may linger (directory created, deploy failed, rollback
// may or may not have cleaned up). Drop node caches either way.
invalidateNodeCaches(req.nodeId);
res.status(500).json({
error: parsed.message,
rolledBack: shouldRollback,
ruleId: parsed.rule?.id || 'UNKNOWN'
});
}
} catch (error: unknown) {
const message = getErrorMessage(error, 'Failed to deploy template');
console.error('[Templates] Deploy error:', message);
res.status(500).json({ error: message });
}
});
// =========================
// Image Update Checker API
// =========================
app.get('/api/image-updates', authMiddleware, (req: Request, res: Response) => {
try {
const updates = DatabaseService.getInstance().getStackUpdateStatus(req.nodeId);
res.json(updates);
} catch (error) {
console.error('Failed to fetch image update status:', error);
res.status(500).json({ error: 'Failed to fetch image update status' });
}
});
app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Response) => {
if (!requireAdmin(_req, res)) return;
try {
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
if (!triggered) {
const mins = ImageUpdateService.manualCooldownMinutes;
res.status(429).json({ error: `Rate limited. Please wait at least ${mins} minute${mins !== 1 ? 's' : ''} between manual refreshes.` });
return;
}
res.json({ success: true, message: 'Image update check started in background.' });
} catch (error) {
console.error('Failed to trigger image update refresh:', error);
res.status(500).json({ error: 'Failed to trigger refresh' });
}
});
app.get('/api/image-updates/status', authMiddleware, (_req: Request, res: Response) => {
res.json({ checking: ImageUpdateService.getInstance().isChecking() });
});
// Fleet-wide image update aggregation (local DB + remote node APIs)
const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
const FLEET_CACHE_TTL = 120_000; // 2 minutes
app.get('/api/image-updates/fleet', authMiddleware, async (_req: Request, res: Response) => {
try {
const result = await CacheService.getInstance().getOrFetch<Record<number, Record<string, boolean>>>(
FLEET_UPDATE_CACHE_KEY,
FLEET_CACHE_TTL,
async () => {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const nr = NodeRegistry.getInstance();
const data: Record<number, Record<string, boolean>> = {};
// Local nodes: synchronous DB reads
for (const node of nodes) {
if (node.type === 'local') {
data[node.id] = db.getStackUpdateStatus(node.id);
}
}
// Remote nodes: parallel fetches with individual timeouts
const remoteNodes = nodes.filter(n => n.type === 'remote' && n.status === 'online' && n.api_url);
const remoteResults = await Promise.allSettled(
remoteNodes.map(async (node) => {
const proxyTarget = nr.getProxyTarget(node.id);
const baseUrl = node.api_url!.replace(/\/$/, '');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const resp = await fetch(`${baseUrl}/api/image-updates`, {
headers: proxyTarget?.apiToken
? { Authorization: `Bearer ${proxyTarget.apiToken}` }
: {},
signal: controller.signal,
});
clearTimeout(timeout);
if (resp.ok) return { nodeId: node.id, data: await resp.json() as Record<string, boolean> };
} catch {
clearTimeout(timeout);
}
return null;
})
);
for (const entry of remoteResults) {
if (entry.status === 'fulfilled' && entry.value) {
data[entry.value.nodeId] = entry.value.data;
}
}
return data;
},
);
res.json(result);
} catch (error) {
console.error('Failed to aggregate fleet update status:', error);
res.status(500).json({ error: 'Failed to aggregate fleet update status' });
}
});
// =========================
// Auto-Update Execution API
// =========================
// Execute auto-update for a single stack (or all stacks with target "*").
// This runs locally on whichever Sencho instance receives the request.
// The gateway scheduler proxies this to remote nodes via HTTP.
app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { target } = req.body as { target?: string };
console.log(`[AutoUpdate] Execute requested: target="${target || ''}"`);
if (!target || typeof target !== 'string') {
return res.status(400).json({ error: 'Missing "target" (stack name or "*" for all)' });
}
let stackNames: string[];
if (target === '*') {
stackNames = await FileSystemService.getInstance(req.nodeId).getStacks();
if (stackNames.length === 0) {
return res.json({ result: 'No stacks found on node; skipped.' });
}
} else {
if (!isValidStackName(target)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
stackNames = [target];
}
const docker = DockerController.getInstance(req.nodeId);
const imageUpdateService = ImageUpdateService.getInstance();
const compose = ComposeService.getInstance(req.nodeId);
const db = DatabaseService.getInstance();
const atomic = LicenseService.getInstance().getTier() === 'paid';
const results: string[] = [];
for (const stackName of stackNames) {
try {
const containers = await docker.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
results.push(`Stack "${stackName}": no containers found; skipped.`);
continue;
}
const imageRefs = [...new Set(
containers
.map((c: { Image?: string }) => c.Image)
.filter((img): img is string => !!img && !img.startsWith('sha256:'))
)];
if (imageRefs.length === 0) {
results.push(`Stack "${stackName}": no pullable images; skipped.`);
continue;
}
let hasUpdate = false;
const updatedImages: string[] = [];
const checkErrors: string[] = [];
for (const imageRef of imageRefs) {
try {
const result = await imageUpdateService.checkImage(docker, imageRef);
if (result.error) {
checkErrors.push(result.error);
} else if (result.hasUpdate) {
hasUpdate = true;
updatedImages.push(imageRef);
}
} catch (e) {
const errMsg = getErrorMessage(e, String(e));
checkErrors.push(errMsg);
console.warn(`[AutoUpdate] Failed to check image ${imageRef}:`, e);
}
}
if (!hasUpdate) {
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
results.push(`Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`);
} else if (checkErrors.length > 0) {
results.push(`Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`);
} else {
results.push(`Stack "${stackName}": all images up to date.`);
}
continue;
}
await compose.updateStack(stackName, undefined, atomic);
db.clearStackUpdateStatus(req.nodeId, stackName);
NotificationService.getInstance().dispatchAlert(
'info',
`Auto-update: stack "${stackName}" updated with new images`,
stackName
);
results.push(`Stack "${stackName}": updated (${updatedImages.join(', ')}).`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
results.push(`Stack "${stackName}" failed: ${msg}`);
console.error(`[AutoUpdate] Failed for stack "${stackName}":`, e);
}
}
res.json({ result: results.join('\n') });
} catch (error) {
const msg = error instanceof Error ? error.message : 'Auto-update execution failed';
console.error('[AutoUpdate] Execute error:', msg);
res.status(500).json({ error: msg });
}
});
// =========================
// Vulnerability Scanning Routes
// =========================
const trivyInstallLimiter = rateLimit({
...rateLimitBase,
windowMs: 10 * 60 * 1000,
max: process.env.NODE_ENV === 'production' ? 5 : 50,
keyGenerator: rateLimitKeyGenerator,
message: { error: 'Too many install requests. Try again later.' },
});
app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => {
const svc = TrivyService.getInstance();
const installer = TrivyInstaller.getInstance();
const settings = DatabaseService.getInstance().getGlobalSettings();
res.json({
available: svc.isTrivyAvailable(),
version: svc.getVersion(),
source: svc.getSource(),
autoUpdate: settings.trivy_auto_update === '1',
busy: installer.isBusy(),
});
});
app.post('/api/security/trivy-install', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() === 'host') {
res.status(409).json({ error: 'Trivy is already installed on the host PATH. Remove the host binary before managing it from Sencho.' });
return;
}
if (svc.getSource() === 'managed') {
res.status(409).json({ error: 'Trivy is already installed. Use the update endpoint instead.' });
return;
}
try {
const { version } = await TrivyInstaller.getInstance().install();
await svc.detectTrivy();
res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() });
} catch (err) {
const msg = getErrorMessage(err, 'Install failed');
console.error('[Security] Trivy install failed:', msg);
res.status(500).json({ error: msg });
}
});
app.delete('/api/security/trivy-install', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'No managed Trivy install to remove' });
return;
}
try {
await TrivyInstaller.getInstance().uninstall();
await svc.detectTrivy();
res.json({ available: svc.isTrivyAvailable(), source: svc.getSource() });
} catch (err) {
const msg = getErrorMessage(err, 'Uninstall failed');
console.error('[Security] Trivy uninstall failed:', msg);
res.status(500).json({ error: msg });
}
});
app.get('/api/security/trivy-update-check', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'Update checks only apply to managed installs' });
return;
}
try {
const result = await TrivyInstaller.getInstance().checkForUpdate(svc.getVersion(), svc.getSource());
res.json(result);
} catch (err) {
const msg = getErrorMessage(err, 'Update check failed');
console.error('[Security] Trivy update check failed:', msg);
res.status(502).json({ error: msg });
}
});
app.post('/api/security/trivy-update', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'Update only applies to managed installs' });
return;
}
try {
const { version } = await TrivyInstaller.getInstance().update();
await svc.detectTrivy();
res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() });
} catch (err) {
const msg = getErrorMessage(err, 'Update failed');
console.error('[Security] Trivy update failed:', msg);
res.status(500).json({ error: msg });
}
});
app.put('/api/security/trivy-auto-update', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmiral(req, res)) return;
const enabled = req.body?.enabled === true;
try {
DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', enabled ? '1' : '0');
res.json({ autoUpdate: enabled });
} catch (err) {
const msg = getErrorMessage(err, 'Failed to update setting');
console.error('[Security] Trivy auto-update toggle failed:', msg);
res.status(500).json({ error: msg });
}
});
app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' });
return;
}
const rawImageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
if (!rawImageRef) {
res.status(400).json({ error: 'imageRef is required' });
return;
}
if (!validateImageRef(rawImageRef)) {
res.status(400).json({ error: 'Invalid imageRef format' });
return;
}
const imageRef = rawImageRef;
const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null;
const force = req.body?.force === true;
const scanners = parseScannersInput(req.body?.scanners);
if (scanners === null) {
res.status(400).json({ error: 'scanners must be an array of "vuln" or "secret"' });
return;
}
if (scanners?.includes('secret') && !requirePaid(req, res)) return;
const nodeId = req.nodeId;
if (svc.isScanning(nodeId, imageRef)) {
res.status(409).json({ error: 'Already scanning this image' });
return;
}
const scanId = svc.beginScan(imageRef, nodeId, 'manual', stackContext, scanners);
res.status(202).json({ scanId });
svc.finishScan(scanId, imageRef, nodeId, { useCache: !force, scanners }).catch((err) => {
console.error(`[Security] Scan failed for ${imageRef}:`, (err as Error).message);
});
});
app.post('/api/security/scan/stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' }); return;
}
const stackName = typeof req.body?.stackName === 'string' ? req.body.stackName.trim() : '';
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
res.status(400).json({ error: 'Invalid stack name' }); return;
}
try {
const scan = await svc.scanComposeStack(req.nodeId, stackName, 'manual');
res.status(201).json(scan);
} catch (error) {
const message = (error as Error).message || '';
if (message === 'Invalid stack path' || message.startsWith('No compose file found')) {
res.status(404).json({ error: message }); return;
}
console.error('[Security] Stack config scan failed:', error);
res.status(500).json({ error: message || 'Failed to scan stack' });
}
});
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
try {
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
const imageRefLike =
typeof req.query.imageRefLike === 'string' && req.query.imageRefLike.trim()
? req.query.imageRefLike.trim()
: undefined;
const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined;
const status =
statusParam === 'completed' || statusParam === 'in_progress' || statusParam === 'failed'
? statusParam
: undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const offset = req.query.offset ? Number(req.query.offset) : undefined;
const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, {
imageRef,
imageRefLike,
status,
limit,
offset,
});
res.json(result);
} catch (error) {
console.error('[Security] Failed to list scans:', error);
res.status(500).json({ error: 'Failed to list scans' });
}
});
app.get('/api/security/scans/:scanId', authMiddleware, (req: Request, res: Response): void => {
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
}
const scan = DatabaseService.getInstance().getVulnerabilityScan(scanId);
if (!scan || scan.node_id !== req.nodeId) {
res.status(404).json({ error: 'Scan not found' }); return;
}
res.json(scan);
});
app.get(
'/api/security/scans/:scanId/vulnerabilities',
authMiddleware,
(req: Request, res: Response): void => {
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
}
const db = DatabaseService.getInstance();
const scan = db.getVulnerabilityScan(scanId);
if (!scan || scan.node_id !== req.nodeId) {
res.status(404).json({ error: 'Scan not found' }); return;
}
const severity = typeof req.query.severity === 'string'
? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN')
: undefined;
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']);
if (severity && !validSeverities.has(severity)) {
res.status(400).json({ error: 'Invalid severity filter' }); return;
}
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const offset = req.query.offset ? Number(req.query.offset) : undefined;
const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset });
const suppressions = db.getCveSuppressions();
const enriched = applySuppressions(result.items, scan.image_ref, suppressions);
res.json({ ...result, items: enriched });
},
);
app.get(
'/api/security/scans/:scanId/secrets',
authMiddleware,
(req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
}
const db = DatabaseService.getInstance();
const scan = db.getVulnerabilityScan(scanId);
if (!scan || scan.node_id !== req.nodeId) {
res.status(404).json({ error: 'Scan not found' }); return;
}
const severity = typeof req.query.severity === 'string'
? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN')
: undefined;
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']);
if (severity && !validSeverities.has(severity)) {
res.status(400).json({ error: 'Invalid severity filter' }); return;
}
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const offset = req.query.offset ? Number(req.query.offset) : undefined;
res.json(db.getSecretFindings(scanId, { severity, limit, offset }));
},
);
app.get(
'/api/security/scans/:scanId/misconfigs',
authMiddleware,
(req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
}
const db = DatabaseService.getInstance();
const scan = db.getVulnerabilityScan(scanId);
if (!scan || scan.node_id !== req.nodeId) {
res.status(404).json({ error: 'Scan not found' }); return;
}
const severity = typeof req.query.severity === 'string'
? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN')
: undefined;
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']);
if (severity && !validSeverities.has(severity)) {
res.status(400).json({ error: 'Invalid severity filter' }); return;
}
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const offset = req.query.offset ? Number(req.query.offset) : undefined;
res.json(db.getMisconfigFindings(scanId, { severity, limit, offset }));
},
);
app.get('/api/security/image-summaries', authMiddleware, (req: Request, res: Response) => {
try {
const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId);
res.json(summaries);
} catch (error) {
console.error('[Security] Failed to fetch image summaries:', error);
res.status(500).json({ error: 'Failed to fetch image summaries' });
}
});
app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' }); return;
}
const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
const formatRaw = typeof req.body?.format === 'string' ? req.body.format : 'spdx-json';
if (!imageRef) {
res.status(400).json({ error: 'imageRef is required' }); return;
}
if (!validateImageRef(imageRef)) {
res.status(400).json({ error: 'Invalid imageRef format' }); return;
}
if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') {
res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return;
}
try {
const sbom = await svc.generateSBOM(imageRef, formatRaw as SbomFormat);
const safeName = imageRef.replace(/[^a-zA-Z0-9._-]/g, '_');
const ext = formatRaw === 'spdx-json' ? 'spdx.json' : 'cdx.json';
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.${ext}"`);
res.send(sbom);
} catch (error) {
console.error('[Security] SBOM generation failed:', error);
res.status(500).json({ error: (error as Error).message || 'Failed to generate SBOM' });
}
});
app.get(
'/api/security/scans/:scanId/sarif',
authMiddleware,
(req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return;
}
const db = DatabaseService.getInstance();
const scan = db.getVulnerabilityScan(scanId);
if (!scan || scan.node_id !== req.nodeId) {
res.status(404).json({ error: 'Scan not found' }); return;
}
if (scan.status !== 'completed') {
res.status(409).json({ error: 'Scan not complete' }); return;
}
const fetchAll = <T,>(
q: (opts: { limit?: number; offset?: number }) => { items: T[]; total: number },
): T[] => {
const pageSize = 1000;
const collected: T[] = [];
let offset = 0;
while (true) {
const page = q({ limit: pageSize, offset });
collected.push(...page.items);
if (collected.length >= page.total || page.items.length === 0) break;
offset += page.items.length;
}
return collected;
};
try {
const details = fetchAll((opts) => db.getVulnerabilityDetails(scanId, opts));
const secrets = fetchAll((opts) => db.getSecretFindings(scanId, opts));
const misconfigs = fetchAll((opts) => db.getMisconfigFindings(scanId, opts));
const suppressed = applySuppressions(details, scan.image_ref, db.getCveSuppressions());
const sarif = generateSarif(scan, suppressed, secrets, misconfigs);
const safeName = scan.image_ref.replace(/[^a-zA-Z0-9._-]/g, '_') || `scan-${scanId}`;
res.setHeader('Content-Type', 'application/sarif+json');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.sarif.json"`);
res.send(JSON.stringify(sarif));
} catch (error) {
console.error('[Security] SARIF export failed:', error);
res.status(500).json({ error: (error as Error).message || 'Failed to generate SARIF' });
}
},
);
app.get('/api/security/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
res.json(DatabaseService.getInstance().getScanPolicies());
});
app.post('/api/security/policies', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'Security policies are managed from the control node.' });
return;
}
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Policy name is required' }); return;
}
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
if (!validSeverities.has(max_severity)) {
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
}
try {
const resolvedNodeId = node_id != null ? Number(node_id) : null;
const policy = DatabaseService.getInstance().createScanPolicy({
name: name.trim(),
node_id: resolvedNodeId,
node_identity: FleetSyncService.resolveIdentityForNodeId(resolvedNodeId),
stack_pattern: stack_pattern ? String(stack_pattern) : null,
max_severity,
block_on_deploy: block_on_deploy ? 1 : 0,
enabled: enabled === false ? 0 : 1,
replicated_from_control: 0,
});
FleetSyncService.getInstance().pushResourceAsync('scan_policies');
res.status(201).json(policy);
} catch (error) {
console.error('[Security] Failed to create policy:', error);
res.status(500).json({ error: 'Failed to create policy' });
}
});
app.put('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'Security policies are managed from the control node.' });
return;
}
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid policy id' }); return;
}
const body = req.body ?? {};
const updates: Record<string, unknown> = {};
if (body.name !== undefined) updates.name = String(body.name).trim();
if (body.node_id !== undefined) {
const resolvedNodeId = body.node_id != null ? Number(body.node_id) : null;
updates.node_id = resolvedNodeId;
updates.node_identity = FleetSyncService.resolveIdentityForNodeId(resolvedNodeId);
}
if (body.stack_pattern !== undefined) updates.stack_pattern = body.stack_pattern ? String(body.stack_pattern) : null;
if (body.max_severity !== undefined) {
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
if (!validSeverities.has(body.max_severity)) {
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
}
updates.max_severity = body.max_severity;
}
if (body.block_on_deploy !== undefined) updates.block_on_deploy = body.block_on_deploy ? 1 : 0;
if (body.enabled !== undefined) updates.enabled = body.enabled ? 1 : 0;
const policy = DatabaseService.getInstance().updateScanPolicy(id, updates);
if (!policy) {
res.status(404).json({ error: 'Policy not found' }); return;
}
FleetSyncService.getInstance().pushResourceAsync('scan_policies');
res.json(policy);
});
app.delete('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'Security policies are managed from the control node.' });
return;
}
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid policy id' }); return;
}
DatabaseService.getInstance().deleteScanPolicy(id);
FleetSyncService.getInstance().pushResourceAsync('scan_policies');
res.json({ success: true });
});
// CVE suppressions. Rules live on the control instance and replicate fleet-wide.
// Reads are open to any authenticated user so operators on replicas can audit; writes
// are admin-only and rejected on replicas.
app.get('/api/security/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const now = Date.now();
const rows = DatabaseService.getInstance().getCveSuppressions().map((s) => ({
...s,
active: s.expires_at === null || s.expires_at > now,
}));
res.json(rows);
});
app.post('/api/security/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'CVE suppressions are managed from the control node.' });
return;
}
const body = req.body ?? {};
const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : '';
if (!CVE_ID_RE.test(cveId)) {
res.status(400).json({ error: 'cve_id must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx' });
return;
}
const pkgName = body.pkg_name == null || body.pkg_name === '' ? null : String(body.pkg_name).trim();
if (pkgName !== null && pkgName.length > 200) {
res.status(400).json({ error: 'pkg_name is too long' }); return;
}
const imagePattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim();
if (imagePattern !== null && imagePattern.length > 300) {
res.status(400).json({ error: 'image_pattern is too long' }); return;
}
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (!reason) {
res.status(400).json({ error: 'reason is required' }); return;
}
if (reason.length > 2000) {
res.status(400).json({ error: 'reason is too long' }); return;
}
const expiresAt = body.expires_at == null ? null : Number(body.expires_at);
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
}
try {
const suppression = DatabaseService.getInstance().createCveSuppression({
cve_id: cveId,
pkg_name: pkgName,
image_pattern: imagePattern,
reason,
created_by: req.user?.username || 'unknown',
created_at: Date.now(),
expires_at: expiresAt,
replicated_from_control: 0,
});
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.status(201).json(suppression);
} catch (error) {
const message = (error as Error).message || '';
if (message.includes('UNIQUE')) {
res.status(409).json({ error: 'A suppression already exists for this CVE, package, and image pattern.' });
return;
}
console.error('[Security] Failed to create suppression:', error);
res.status(500).json({ error: 'Failed to create suppression' });
}
});
app.put('/api/security/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'CVE suppressions are managed from the control node.' });
return;
}
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid suppression id' }); return;
}
const body = req.body ?? {};
const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null }> = {};
if (body.reason !== undefined) {
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
if (reason.length > 2000) { res.status(400).json({ error: 'reason is too long' }); return; }
updates.reason = reason;
}
if (body.image_pattern !== undefined) {
const pattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim();
if (pattern !== null && pattern.length > 300) {
res.status(400).json({ error: 'image_pattern is too long' }); return;
}
updates.image_pattern = pattern;
}
if (body.expires_at !== undefined) {
const expiresAt = body.expires_at == null ? null : Number(body.expires_at);
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
}
updates.expires_at = expiresAt;
}
const suppression = DatabaseService.getInstance().updateCveSuppression(id, updates);
if (!suppression) {
res.status(404).json({ error: 'Suppression not found' }); return;
}
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.json(suppression);
});
app.delete('/api/security/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'CVE suppressions are managed from the control node.' });
return;
}
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid suppression id' }); return;
}
DatabaseService.getInstance().deleteCveSuppression(id);
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.json({ success: true });
});
app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId1 = Number(req.query.scanId1);
const scanId2 = Number(req.query.scanId2);
if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) {
res.status(400).json({ error: 'scanId1 and scanId2 are required' }); return;
}
const db = DatabaseService.getInstance();
const a = db.getVulnerabilityScan(scanId1);
const b = db.getVulnerabilityScan(scanId2);
if (!a || !b || a.node_id !== req.nodeId || b.node_id !== req.nodeId) {
res.status(404).json({ error: 'One or both scans not found' }); return;
}
const COMPARE_ROW_LIMIT = 1000;
const aVulns = db.getVulnerabilityDetails(scanId1, { limit: COMPARE_ROW_LIMIT }).items;
const bVulns = db.getVulnerabilityDetails(scanId2, { limit: COMPARE_ROW_LIMIT }).items;
const truncated =
a.total_vulnerabilities > COMPARE_ROW_LIMIT || b.total_vulnerabilities > COMPARE_ROW_LIMIT;
if (truncated) {
console.warn(
`[Compare] scan(s) exceed ${COMPARE_ROW_LIMIT}-row cap: scanA=${a.id}(${a.total_vulnerabilities}) scanB=${b.id}(${b.total_vulnerabilities})`,
);
}
const keyOf = (v: { vulnerability_id: string; pkg_name: string }) =>
`${v.vulnerability_id}::${v.pkg_name}`;
const aMap = new Map(aVulns.map((v) => [keyOf(v), v]));
const bMap = new Map(bVulns.map((v) => [keyOf(v), v]));
const addedRaw = bVulns.filter((v) => !aMap.has(keyOf(v)));
const removedRaw = aVulns.filter((v) => !bMap.has(keyOf(v)));
const unchangedRaw = aVulns.filter((v) => bMap.has(keyOf(v)));
const suppressions = db.getCveSuppressions();
const added = applySuppressions(addedRaw, b.image_ref, suppressions);
const removed = applySuppressions(removedRaw, a.image_ref, suppressions);
const unchanged = applySuppressions(unchangedRaw, b.image_ref, suppressions);
if (isDebugEnabled()) {
console.log('[Compare:diag]', {
scanId1,
scanId2,
reqNodeId: req.nodeId,
tier: req.proxyTier ?? LicenseService.getInstance().getTier(),
aVulns: aVulns.length,
bVulns: bVulns.length,
added: added.length,
removed: removed.length,
unchanged: unchanged.length,
suppressions: suppressions.length,
truncated,
});
}
res.json({
scanA: {
id: a.id,
scanned_at: a.scanned_at,
image_ref: a.image_ref,
total_vulnerabilities: a.total_vulnerabilities,
},
scanB: {
id: b.id,
scanned_at: b.scanned_at,
image_ref: b.image_ref,
total_vulnerabilities: b.total_vulnerabilities,
},
added,
removed,
unchanged,
truncated,
row_limit: COMPARE_ROW_LIMIT,
});
});
// =========================
// Node Management API
// =========================
// List all nodes
app.get('/api/nodes', async (req: Request, res: Response) => {
try {
const nodes = DatabaseService.getInstance().getNodes();
res.json(nodes);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch nodes' });
}
});
// Per-node scheduling + update summary (must be before :id route)
app.get('/api/nodes/scheduling-summary', authMiddleware, (_req: Request, res: Response) => {
try {
const db = DatabaseService.getInstance();
const scheduleSummary = db.getNodeSchedulingSummary();
const updateSummary = db.getNodeUpdateSummary();
const result: Record<number, {
active_tasks: number;
auto_update_enabled: boolean;
next_run_at: number | null;
stacks_with_updates: number;
}> = {};
for (const s of scheduleSummary) {
result[s.node_id] = {
active_tasks: s.active_tasks,
auto_update_enabled: s.auto_update_enabled === 1,
next_run_at: s.next_run_at,
stacks_with_updates: 0,
};
}
for (const u of updateSummary) {
if (result[u.node_id]) {
result[u.node_id].stacks_with_updates = u.stacks_with_updates;
} else {
result[u.node_id] = {
active_tasks: 0,
auto_update_enabled: false,
next_run_at: null,
stacks_with_updates: u.stacks_with_updates,
};
}
}
res.json(result);
} catch (error) {
console.error('Failed to fetch node scheduling summary:', error);
res.status(500).json({ error: 'Failed to fetch node scheduling summary' });
}
});
// Get a specific node
app.get('/api/nodes/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const node = DatabaseService.getInstance().getNode(id);
if (!node) {
return res.status(404).json({ error: 'Node not found' });
}
res.json(node);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch node' });
}
});
// Create a new node
app.post('/api/nodes', async (req: Request, res: Response) => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
if (!requirePermission(req, res, 'node:manage')) return;
try {
const { name, type, compose_dir, is_default, api_url, api_token, mode } = req.body;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Node name is required' });
}
if (!type || !['local', 'remote'].includes(type)) {
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
}
const resolvedMode: 'proxy' | 'pilot_agent' = type === 'remote' && mode === 'pilot_agent' ? 'pilot_agent' : 'proxy';
if (type === 'remote' && resolvedMode === 'proxy') {
if (!api_url || typeof api_url !== 'string') {
return res.status(400).json({ error: 'API URL is required for proxy-mode remote nodes' });
}
const urlCheck = isValidRemoteUrl(api_url);
if (!urlCheck.valid) {
return res.status(400).json({ error: urlCheck.reason });
}
}
const id = DatabaseService.getInstance().addNode({
name,
type,
compose_dir: compose_dir || '/app/compose',
is_default: is_default || false,
api_url: resolvedMode === 'pilot_agent' ? '' : (api_url || ''),
api_token: resolvedMode === 'pilot_agent' ? '' : (api_token || ''),
mode: resolvedMode,
});
// Notify subscribers (e.g. DockerEventManager) so a new local node gets
// its event stream spun up immediately, not on next restart.
NodeRegistry.getInstance().notifyNodeAdded(id);
let enrollment: ReturnType<typeof mintPilotEnrollment> | null = null;
if (resolvedMode === 'pilot_agent') {
enrollment = mintPilotEnrollment(id, req);
}
const isPlainHttp = resolvedMode === 'proxy' && type === 'remote' && api_url && api_url.startsWith('http://');
res.json({
success: true,
id,
...(enrollment && { enrollment }),
...(isPlainHttp && {
warning: 'This node uses plain HTTP. Use HTTPS or a VPN for connections over the public internet.'
})
});
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'A node with that name already exists' });
}
console.error('Failed to create node:', error);
res.status(500).json({ error: error.message || 'Failed to create node' });
}
});
/**
* Mint a fresh 15-minute, single-use enrollment token for a pilot-mode node.
* Stores a sha256 hash in pilot_enrollments so the token itself is never
* recoverable from the DB. The returned `dockerRun` string is a copy-paste
* starter command the admin runs on the remote host.
*/
function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; dockerRun: string } {
const db = DatabaseService.getInstance();
const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret not configured');
const ttlSeconds = 15 * 60;
const expiresAt = Date.now() + ttlSeconds * 1000;
const enrollNonce = crypto.randomUUID();
const token = jwt.sign(
{ scope: 'pilot_enroll', nodeId, enrollNonce },
jwtSecret,
{ expiresIn: ttlSeconds },
);
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
db.createPilotEnrollment(nodeId, tokenHash, expiresAt);
const forwardedProto = req.headers['x-forwarded-proto'];
const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
const protocol = protoHeader || req.protocol || 'http';
const host = req.get('host') || 'localhost:3000';
const primaryUrl = `${protocol}://${host}`;
const dockerRun =
`docker run -d --restart=unless-stopped --name sencho-agent ` +
`-v /var/run/docker.sock:/var/run/docker.sock ` +
`-v sencho-agent-data:/app/data ` +
`-v /opt/docker/sencho:/app/compose ` +
`-e SENCHO_MODE=pilot ` +
`-e SENCHO_PRIMARY_URL=${primaryUrl} ` +
`-e SENCHO_ENROLL_TOKEN=${token} ` +
`saelix/sencho:latest`;
return { token, expiresAt, dockerRun };
}
// Regenerate an enrollment token for an existing pilot-mode node. Used when
// the first token expired before the agent was started, or when the agent
// container was lost and needs to re-enroll from scratch.
app.post('/api/nodes/:id/pilot/enroll', async (req: Request, res: Response) => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
const nodeIdStr = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdStr)) return;
try {
const nodeId = parseInt(nodeIdStr, 10);
if (!Number.isFinite(nodeId)) {
return res.status(400).json({ error: 'Invalid node id' });
}
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) return res.status(404).json({ error: 'Node not found' });
if (node.type !== 'remote' || node.mode !== 'pilot_agent') {
return res.status(400).json({ error: 'Enrollment only applies to pilot-agent nodes' });
}
// Close any existing tunnel so the re-enrolling agent cleanly replaces it.
PilotTunnelManager.getInstance().closeTunnel(nodeId, PilotCloseCode.EnrollmentRegenerated, 'enrollment regenerated');
const enrollment = mintPilotEnrollment(nodeId, req);
res.json({ success: true, enrollment });
} catch (error: any) {
console.error('Failed to regenerate pilot enrollment:', error);
res.status(500).json({ error: error.message || 'Failed to regenerate enrollment' });
}
});
// Update a node
app.put('/api/nodes/:id', async (req: Request, res: Response) => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
const nodeId = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeId)) return;
try {
const id = parseInt(nodeId);
const updates = req.body;
if (updates.api_url !== undefined && updates.api_url !== '') {
const urlCheck = isValidRemoteUrl(updates.api_url);
if (!urlCheck.valid) {
return res.status(400).json({ error: urlCheck.reason });
}
}
DatabaseService.getInstance().updateNode(id, updates);
// Evict cached Docker connection so it reconnects with new config
NodeRegistry.getInstance().evictConnection(id);
NodeRegistry.getInstance().notifyNodeUpdated(id);
const isPlainHttp = updates.api_url && updates.api_url.startsWith('http://');
res.json({
success: true,
...(isPlainHttp && {
warning: 'This node uses plain HTTP. Use HTTPS or a VPN for connections over the public internet.'
})
});
} catch (error: any) {
console.error('Failed to update node:', error);
res.status(500).json({ error: error.message || 'Failed to update node' });
}
});
// Delete a node
app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
const nodeIdParam = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
try {
const id = parseInt(nodeIdParam);
DatabaseService.getInstance().deleteNode(id);
NodeRegistry.getInstance().evictConnection(id);
NodeRegistry.getInstance().notifyNodeRemoved(id);
CacheService.getInstance().invalidate(`${REMOTE_META_NAMESPACE}:${id}`);
updateTracker.delete(id);
res.json({ success: true });
} catch (error: unknown) {
console.error('Failed to delete node:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to delete node' });
}
});
// Test connection to a node
app.post('/api/nodes/:id/test', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const result = await NodeRegistry.getInstance().testConnection(id);
res.json(result);
} catch (error: any) {
res.status(500).json({ success: false, error: error.message || 'Connection test failed' });
}
});
// Fetch capability metadata for a specific node. For local nodes, returns this
// instance's capabilities directly. For remote nodes, relays GET /api/meta from
// the remote Sencho instance. Backend-side cache (via CacheService) shields
// against rate limit contention on the remote and serves stale data on
// transient failures. Keys are "remote-meta:<nodeId>" so we can invalidate by
// namespace when a node is deleted.
const REMOTE_META_NAMESPACE = 'remote-meta';
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const node = DatabaseService.getInstance().getNode(id);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
if (node.type === 'local') {
res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES });
return;
}
const baseUrl = node.api_url?.replace(/\/$/, '');
if (!baseUrl || !node.api_token) {
res.json({ version: null, capabilities: [] });
return;
}
const cacheKey = `${REMOTE_META_NAMESPACE}:${id}`;
const meta = await CacheService.getInstance().getOrFetch<RemoteMeta>(
cacheKey,
REMOTE_META_CACHE_TTL,
async () => {
const fetched = await fetchRemoteMeta(baseUrl, node.api_token!);
// A successful fetch always includes a version; null version means the
// remote was unreachable. Throw so CacheService serves stale on error
// instead of caching an empty result.
if (fetched.version === null) {
throw new Error('Remote meta fetch returned null version');
}
return fetched;
},
);
res.json(meta);
} catch (error: unknown) {
console.error('Failed to fetch node meta:', error);
const message = getErrorMessage(error, 'Failed to fetch node metadata');
res.status(500).json({ error: message });
}
});
// Serve static files in production (for Docker deployment)
if (process.env.NODE_ENV === 'production') {
app.use(express.static('public'));
// Handle SPA routing - serve index.html for non-API routes
// Using app.use middleware instead of app.get('*') for path-to-regexp compatibility
app.use((req: Request, res: Response) => {
if (!req.path.startsWith('/api')) {
res.sendFile('index.html', { root: 'public' });
} else {
res.status(404).json({ error: 'API endpoint not found' });
}
});
} else {
// In development, still need to catch 404s for API to prevent hangs
app.use((req: Request, res: Response) => {
if (req.path.startsWith('/api')) {
res.status(404).json({ error: 'API endpoint not found' });
}
});
}
// Start server with migration
let mfaReplayPurgeTimer: NodeJS.Timeout | null = null;
async function startServer() {
try {
// Run migration before starting server
console.log('Running stack migration check...');
const defaultFsService = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId());
await defaultFsService.migrateFlatToDirectory();
console.log('Migration check completed');
} catch (error) {
console.error('Migration failed:', error);
// Continue starting server even if migration fails
}
// Initialize License Service (starts trial on first boot, periodic validation)
LicenseService.getInstance().initialize();
// Detect whether this instance can self-update (Docker Compose container inspection)
await SelfUpdateService.getInstance().initialize();
// Start Background Watchdog
MonitorService.getInstance().start();
AutoHealService.getInstance().start();
// Start Docker Event Stream (causal crash/OOM/health detection per local node)
await DockerEventManager.getInstance().start();
// Detect Trivy binary so the vulnerability-scanning capability reflects
// reality before any request hits and so the first scan does not pay
// detection latency.
await TrivyService.getInstance().initialize();
// Start Background Image Update Checker
ImageUpdateService.getInstance().start();
// Start Scheduled Operations Service
SchedulerService.getInstance().start();
// Sweep any leftover git-source temp clones from a crashed prior run
sweepStaleGitTempDirs().catch((err) => {
console.warn('[GitSource] Temp dir sweep failed:', (err as Error).message);
});
// Periodic purge of used-MFA-code rows so the replay blacklist stays
// bounded even without verification traffic. The table holds (user, code,
// window) tuples for the last ~2 minutes; older rows are safe to drop.
mfaReplayPurgeTimer = setInterval(() => {
try {
const deleted = DatabaseService.getInstance().purgeOldMfaCodes(Date.now() - MFA_REPLAY_TTL_MS);
if (isDebugEnabled() && deleted > 0) {
console.log('[MFA:diag] replay purge deleted=', deleted);
}
} catch (err) {
console.warn('[MFA] Replay purge failed:', (err as Error).message);
}
}, MFA_REPLAY_PURGE_INTERVAL_MS);
mfaReplayPurgeTimer.unref();
// Pilot-agent mode: bind only to loopback so no external port is exposed.
// All traffic is demultiplexed from the primary via the pilot tunnel.
const isPilotAgent = process.env.SENCHO_MODE === 'pilot';
const listenHost = isPilotAgent ? '127.0.0.1' : undefined;
server.listen(PORT, listenHost, () => {
console.log(`Server running on ${listenHost || '0.0.0.0'}:${PORT}${isPilotAgent ? ' (pilot-agent mode)' : ''}`);
if (isPilotAgent) {
// Start the outbound tunnel client once the local HTTP server is ready
// to accept loopback traffic from the tunnel.
import('./pilot/agent').then((m) => m.startPilotAgent(PORT)).catch((err) => {
console.error('[Pilot] Agent startup failed:', err);
});
}
});
}
// Only start the server when this file is the entry point (not when imported by tests).
if (require.main === module) {
startServer();
}
// Exports used by tests (supertest requires the http.Server instance).
export { app, server };
// Graceful shutdown - allows in-flight requests to finish, then cleanly stops
// background services and closes the SQLite connection before the process exits.
// Docker sends SIGTERM when the container stops; Ctrl-C sends SIGINT in dev.
const gracefulShutdown = (signal: string) => {
console.log(`[Shutdown] ${signal} received - shutting down gracefully…`);
server.close(() => {
console.log('[Shutdown] HTTP server closed');
try { LicenseService.getInstance().destroy(); } catch (e) {
console.warn('[Shutdown] LicenseService cleanup failed:', (e as Error).message);
}
try { MonitorService.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] MonitorService cleanup failed:', (e as Error).message);
}
try { AutoHealService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] AutoHealService cleanup failed:', (e as Error).message); }
try { DockerEventManager.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] DockerEventManager cleanup failed:', (e as Error).message);
}
try { ImageUpdateService.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] ImageUpdateService cleanup failed:', (e as Error).message);
}
try { SchedulerService.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] SchedulerService cleanup failed:', (e as Error).message);
}
if (mfaReplayPurgeTimer) {
clearInterval(mfaReplayPurgeTimer);
mfaReplayPurgeTimer = null;
}
try { DatabaseService.getInstance().getDb().close(); } catch (e) {
console.warn('[Shutdown] Database close failed:', (e as Error).message);
}
console.log('[Shutdown] Done - exiting');
process.exit(0);
});
// Force-exit after 10 s if connections refuse to drain
setTimeout(() => {
console.error('[Shutdown] Timed out waiting for connections - forcing exit');
process.exit(1);
}, 10_000).unref();
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));