mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
5bb4b01953
* 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
8748 lines
358 KiB
TypeScript
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'));
|