import { Router, type Request, type Response } from 'express'; import path from 'path'; import semver from 'semver'; import si from 'systeminformation'; import type Dockerode from 'dockerode'; import { DatabaseService, type Node, type StackDossierFields } from '../services/DatabaseService'; import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } from '../services/FleetSyncService'; import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants'; import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPDATE_TIMEOUT_MS, UPDATE_TIMEOUT_MSG, TERMINAL_TTL_MS } from '../services/FleetUpdateTrackerService'; import { NodeRegistry } from '../services/NodeRegistry'; import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary'; import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; import { StackOpLockService } from '../services/StackOpLockService'; import SelfUpdateService from '../services/SelfUpdateService'; import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates'; import { requirePermission } from '../middleware/permissions'; import { scheduleLocalUpdate } from './license'; import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate'; import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities'; import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture'; import { getLatestVersion, getLatestRelease } from '../utils/version-check'; import { isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { withTimeout, TimeoutError } from '../utils/withTimeout'; // Mirror the system-maintenance route timeout so fleet's local-node prune // paths cap the slow `docker system df` call at the same 8s budget (F-6). const FLEET_DF_TIMEOUT_MS = 8_000; import { POLICY_SEVERITIES } from '../utils/severity'; import { isNoOpBlockingPolicy } from '../utils/policy-risk'; import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog'; import { formatNoTargetError } from '../utils/remoteTarget'; import { CloudBackupService } from '../services/CloudBackupService'; import { NotificationService } from '../services/NotificationService'; import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation'; import { activeBulkActions } from './labels'; import { runLocalLabelStop, isLabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop'; import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary'; import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign'; import { MAX_ASSIGNMENTS } from '../helpers/constants'; import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard'; import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService'; import { PROXY_TIER_HEADER } from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; const updateTracker = FleetUpdateTrackerService.getInstance(); const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure // Throttle the forced latest-version refresh so a caller cannot loop the recheck // endpoint to hammer GitHub / Docker Hub. The 30-minute cache still serves reads // between forced refreshes; this only bounds how often we bypass it. const FORCED_RECHECK_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes let lastForcedRecheckAt = 0; /** Test-only: reset the forced-recheck throttle clock so suites do not depend * on cross-test ordering of the module-scope timestamp. */ export function _resetForcedRecheckThrottleForTests(): void { lastForcedRecheckAt = 0; } /** * Atomically resolve an in-flight update tracker to a terminal state and store * it. Re-reads the live entry and transitions only if it is still 'updating' * with the same startedAt; because there is no await between the read and the * set, a concurrent /update-status poll that already resolved this node cannot * be clobbered or cause a duplicate WARN. For failure-class outcomes (failed / * timeout) it emits one operator-visible WARN so a failed fleet update is * observable without enabling developer mode. The error text is secret-redacted * and control-stripped before logging; no tokens or meta dumps. */ function resolveTerminal( node: { id: number; name: string }, tracker: UpdateTracker, status: TerminalStatus, error?: string, ): void { const live = updateTracker.get(node.id); if (!live || live.status !== 'updating' || live.startedAt !== tracker.startedAt) return; if (status !== 'completed') { const elapsedSec = Math.round((Date.now() - live.startedAt) / 1000); const detail = error ? `: ${sanitizeForLog(redactSensitiveText(error))}` : ''; console.warn(`[Fleet] Node update ${status} for "${sanitizeForLog(node.name)}" (id ${node.id}) after ${elapsedSec}s${detail}`); } updateTracker.set(node.id, updateTracker.resolve(live, status, error)); } const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/; 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; 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' || !POLICY_SEVERITIES.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') { const patternError = validateStackPatternForRedos(r.stack_pattern); if (patternError) return patternError; } 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'; // Risk inputs are optional for back-compat: a legacy control omits them and // the receiver defaults such rows to severity-only. Reject only bad values. if (r.block_on_severity !== undefined && !isIntFlag(r.block_on_severity)) return 'block_on_severity must be 0 or 1'; if (r.block_on_kev !== undefined && !isIntFlag(r.block_on_kev)) return 'block_on_kev must be 0 or 1'; if (r.block_on_fixable !== undefined && !isIntFlag(r.block_on_fixable)) return 'block_on_fixable must be 0 or 1'; // Cross-field invariant at the trust boundary: a replicated blocking policy // must have at least one active input, or it would persist as a silent no-op // gate. Absent fields default to severity-only (same as the receiver apply). const sev = r.block_on_severity === undefined ? 1 : (r.block_on_severity as number); const kev = r.block_on_kev === undefined ? 0 : (r.block_on_kev as number); const fix = r.block_on_fixable === undefined ? 0 : (r.block_on_fixable as number); if (isNoOpBlockingPolicy(r.block_on_deploy, sev, kev, fix)) { return 'a blocking policy must enable at least one of severity, KEV, or fixable'; } return null; } /** * Reject `stack_pattern` inputs that would compile to a backtracking-prone * regex. The matcher in `getMatchingPolicy` substitutes `*` with `.*`, so a * pattern like `***...` becomes a chain of adjacent `.*` runs that exhibit * catastrophic backtracking on long inputs. * * Caps mirror the limit in routes/security.ts so a control creating a policy * sees the same error as a replica receiving one. Length is gated at 200 by * the surrounding row validator. */ export function validateStackPatternForRedos(pattern: string): string | null { if (pattern.length > 200) return 'stack_pattern is too long'; const stars = (pattern.match(/\*/g) ?? []).length; if (stars > 8) return 'stack_pattern has too many wildcards (max 8)'; if (/\*{4,}/.test(pattern)) return 'stack_pattern must not contain 4+ consecutive wildcards'; return null; } function validateCveSuppressionRow(row: unknown): string | null { if (!row || typeof row !== 'object') return 'row must be an object'; const r = row as Record; 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; } function validateMisconfigAcknowledgementRow(row: unknown): string | null { if (!row || typeof row !== 'object') return 'row must be an object'; const r = row as Record; if (typeof r.rule_id !== 'string' || r.rule_id.length === 0 || r.rule_id.length > 200) return 'rule_id must be a non-empty string up to 200 chars'; 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') { if (r.stack_pattern.length > 300) return 'stack_pattern is too long'; const patternError = validateStackPatternForRedos(r.stack_pattern); if (patternError) return patternError; } 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; } interface FleetNodeOverview { id: number; name: string; type: 'local' | 'remote'; mode?: string; 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; latency_ms?: number; last_successful_contact?: number | null; pilot_last_seen?: number | null; cordoned: boolean; cordoned_at: number | null; cordoned_reason: string | null; } /** 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; } async function fetchLocalNodeOverview(node: Node): Promise { 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, // Percentage is keyed off mem.active (the real working set), not mem.used: // on Linux/BSD/macOS mem.used counts reclaimable page cache and reads ~99% // on a busy host. mem.available is total - active, so used + free = total. used: mem.active, free: mem.available, usagePercent: ((mem.active / 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, last_successful_contact: node.last_successful_contact ?? null, cordoned: node.cordoned, cordoned_at: node.cordoned_at, cordoned_reason: node.cordoned_reason, }; } 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, last_successful_contact: node.last_successful_contact ?? null, cordoned: node.cordoned, cordoned_at: node.cordoned_at, cordoned_reason: node.cordoned_reason, }; } } function pilotLastSeenSeconds(node: Node): number | null { return node.mode === 'pilot_agent' && node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null; } function offlineRemoteOverview(node: Node, status: 'online' | 'offline'): FleetNodeOverview { const pilotSeen = pilotLastSeenSeconds(node); // For pilot-agent rows the tunnel heartbeat is the contact signal. Mirror // it into last_successful_contact so the Fleet "last seen" cell renders // the recent tunnel timestamp instead of a stale HTTP-success time. const lastContact = pilotSeen ?? node.last_successful_contact ?? null; return { id: node.id, name: node.name, type: node.type, mode: node.mode, status, stats: null, systemStats: null, stacks: null, last_successful_contact: lastContact, pilot_last_seen: pilotSeen, cordoned: node.cordoned, cordoned_at: node.cordoned_at, cordoned_reason: node.cordoned_reason, }; } async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise { const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { // Soft-online keeps the Fleet card from flapping during a brief pilot // tunnel reconnect: a recent pilot_last_seen still counts as reachable. const status: 'online' | 'offline' = node.mode === 'pilot_agent' && node.pilot_last_seen ? 'online' : 'offline'; return offlineRemoteOverview(node, status); } const baseUrl = target.apiUrl.replace(/\/$/, ''); const headers: Record = target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}; const t0 = Date.now(); 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; const completedAt = Date.now(); const latency_ms = completedAt - t0; const isOnline = !!(stats || systemStats); if (isOnline) { db.updateNodeLastContact(node.id); } return { id: node.id, name: node.name, type: node.type, mode: node.mode, status: isOnline ? 'online' : 'offline', stats, systemStats, stacks, latency_ms, last_successful_contact: isOnline ? Math.floor(completedAt / 1000) : node.last_successful_contact ?? null, pilot_last_seen: pilotLastSeenSeconds(node), cordoned: node.cordoned, cordoned_at: node.cordoned_at, cordoned_reason: node.cordoned_reason, }; } catch (error) { console.error(`[Fleet] Remote node ${node.name} error:`, error); return offlineRemoteOverview(node, 'offline'); } } export const fleetRouter = Router(); // 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. fleetRouter.get('/role', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; res.json({ role: FleetSyncService.getRole() }); }); // Receive a full replacement of a replicated resource from the control. // Restricted to node_proxy Bearer tokens so only a sibling Sencho can push. // // No requirePaid here: the control instance has already enforced its tier // before issuing the push. The replica trusts a valid node_proxy bearer // signed against THIS instance's secret and applies the payload regardless // of the replica's own tier. fleetRouter.post('/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' && resource !== 'misconfig_acknowledgements' ) { 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 : ''; // pushedAt is optional for back-compat with older controls that predate the // versioning protocol. When present and strictly older than the most recent // applied push for this resource, reject with 409 STALE_SYNC_PUSH so the // control's retry logic can fall back to the next write. Negative or zero // values are treated as absent: the sender always uses Date.now(). const pushedAt = typeof body.pushedAt === 'number' && Number.isFinite(body.pushedAt) && body.pushedAt > 0 ? body.pushedAt : null; // controlIdentity is optional for back-compat. The receiver anchors to the // first non-empty fingerprint it sees and rejects mismatches afterward. const controlIdentity = typeof body.controlIdentity === 'string' ? body.controlIdentity : ''; 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 : resource === 'cve_suppressions' ? validateCveSuppressionRow : validateMisconfigAcknowledgementRow; 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, pushedAt ?? undefined, controlIdentity || undefined, ); res.json({ success: true, applied: rows.length }); } catch (error) { if (error instanceof StaleSyncPushError) { res.status(409).json({ error: error.message, code: SYNC_ERROR_CODES.staleSyncPush, }); return; } if (error instanceof ControlIdentityMismatchError) { res.status(409).json({ error: error.message, code: SYNC_ERROR_CODES.controlIdentityMismatch, expected: error.expected, got: error.got, }); return; } console.error('[FleetSync] Failed to apply incoming sync:', error); res.status(500).json({ error: 'Failed to apply sync' }); } }); // Demote this replica back to a standalone control. Wipes all replicated // security rules and the cached fingerprint, then flips `fleet_role` to // 'control'. The local UI's write controls become available again. // `{confirm: true}` body is required so a misclick cannot destroy mirrored // state. fleetRouter.post('/role/demote', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; const body = req.body ?? {}; if (body.confirm !== true) { res.status(400).json({ error: 'Demote requires explicit confirmation. Send { "confirm": true } to proceed.', }); return; } try { const demoted = FleetSyncService.getInstance().demote(); if (!demoted) { res.status(409).json({ error: 'This instance is already a control; nothing to demote.', code: 'ALREADY_CONTROL', }); return; } res.json({ success: true, role: 'control' }); } catch (error) { console.error('[FleetSync] Demote failed:', error); res.status(500).json({ error: 'Failed to demote replica' }); } }); // Reset the control anchor on this replica. An admin must opt in explicitly // with `{override: true}` because reanchor wipes all replicated rows; the // next push from a different control will re-populate them. Used when a // control is permanently rebuilt or replaced and must be re-bound to its // existing replicas. fleetRouter.post('/role/reanchor', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; const body = req.body ?? {}; if (body.override !== true) { res.status(400).json({ error: 'Reanchor requires explicit override. Send { "override": true } to confirm.', }); return; } try { FleetSyncService.getInstance().reanchor(); res.json({ success: true }); } catch (error) { console.error('[FleetSync] Reanchor failed:', error); res.status(500).json({ error: 'Failed to reset control anchor' }); } }); fleetRouter.get('/sync-status', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; res.json(DatabaseService.getInstance().getFleetSyncStatuses()); }); fleetRouter.get('/overview', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'node:read')) return; 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 => { if (node.type === 'remote') { return fetchRemoteNodeOverview(node, db); } 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, cordoned: nodes[i].cordoned, cordoned_at: nodes[i].cordoned_at, cordoned_reason: nodes[i].cordoned_reason, }; }); 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' }); } }); interface FleetNodeConfiguration { id: number; name: string; type: 'local' | 'remote'; status: 'online' | 'offline'; configuration: ConfigurationStatus | null; } fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'node:read')) return; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const userId = req.user?.userId ?? 0; const ls = LicenseService.getInstance(); const localTier = ls.getTier(); const results = await Promise.allSettled( nodes.map(async (node: Node): Promise => { if (node.type === 'local') { return { id: node.id, name: node.name, type: 'local', status: 'online', configuration: buildLocalConfigurationStatus(node.id, userId, localTier), }; } const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null }; } try { const resp = await fetch( `${target.apiUrl.replace(/\/$/, '')}/api/dashboard/configuration`, { headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}), [PROXY_TIER_HEADER]: localTier, }, signal: AbortSignal.timeout(10000), }, ); const configuration = resp.ok ? (await resp.json() as ConfigurationStatus) : null; return { id: node.id, name: node.name, type: 'remote', status: configuration ? 'online' : 'offline', configuration, }; } catch { return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null }; } }), ); const fleet: FleetNodeConfiguration[] = results.map((result, i) => { if (result.status === 'fulfilled') return result.value; console.error(`[Fleet] Configuration fetch failed for node ${nodes[i].name}:`, result.reason); return { id: nodes[i].id, name: nodes[i].name, type: nodes[i].type, status: 'offline', configuration: null }; }); res.json(fleet); } catch (error) { console.error('[Fleet] Configuration overview error:', error); res.status(500).json({ error: 'Failed to fetch fleet configuration' }); } }); /** * Fleet-wide dependency map. Auth-only (read-only visibility, Community). Fans * out to every node, building each node's local graph in-process for the hub * and via its auth-only per-node route for remotes, then merges with per-node * attribution. Unreachable nodes degrade to nodeErrors so the rest still draws. */ fleetRouter.get('/dependency-map', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'node:read')) return; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const results = await Promise.allSettled( nodes.map(async (node: Node): Promise => { if (node.type === 'local') { const graph = await buildLocalGraph(node.id, node.name); return { nodeId: node.id, nodeName: node.name, status: 'ok', graph, error: null }; } const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: formatNoTargetError(node) }; } const resp = await fetch( `${target.apiUrl.replace(/\/$/, '')}/api/dependency-map/node-graph`, { headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, signal: AbortSignal.timeout(15000), }, ); if (!resp.ok) { const errBody = await resp.json().catch(() => null) as { error?: string } | null; return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: errBody?.error ?? `Remote returned ${resp.status}` }; } const graph = await resp.json().catch(() => null); // Shape-guard a reachable-but-malformed payload (proxy HTML, version // drift) so one bad node degrades to a nodeError instead of crashing // mergeFleetGraph and 500-ing the whole fleet map. if (!isLocalDependencyGraph(graph)) { console.error(`[Fleet] Dependency map: node ${sanitizeForLog(node.name)} returned a payload that failed shape validation (status ${resp.status})`); return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: 'Remote returned an unexpected dependency-graph payload' }; } return { nodeId: node.id, nodeName: node.name, status: 'ok', graph, error: null }; }), ); const perNode: FleetNodeGraphResult[] = results.map((result, i) => { if (result.status === 'fulfilled') return result.value; console.error(`[Fleet] Dependency map fetch failed for node ${nodes[i].name}:`, result.reason); return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', graph: null, error: getErrorMessage(result.reason, 'Failed to reach node') }; }); res.json(mergeFleetGraph(perNode)); } catch (error) { console.error('[Fleet] Dependency map error:', error); res.status(500).json({ error: 'Failed to build fleet dependency map' }); } }); interface FleetNetworkingSummaryNode { nodeId: number; nodeName: string; status: 'ok' | 'error'; summary: NodeNetworkingSummary | null; error: string | null; } function isNodeNetworkingSummary(v: unknown): v is NodeNetworkingSummary { if (!v || typeof v !== 'object') return false; const o = v as Record; return (['exposed', 'unknownExposure', 'networkDrift'] as const).every(k => { const b = o[k] as { count?: unknown; stacks?: unknown } | undefined; return !!b && typeof b.count === 'number' && Array.isArray(b.stacks); }); } /** * Fleet-wide networking summary for the overview filter. Auth-only (read-only, * Community). Hub-exempt under /api/fleet, so it is never proxied: it builds the * hub's summary in-process and reaches each remote through its node-local * /api/networking/summary route. A remote on an older version (no route) returns * 404 and degrades to a skip, so one unreachable or unsupported node never fails * the filter for the rest. */ fleetRouter.get('/networking-summary', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'node:read')) return; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const results = await Promise.allSettled( nodes.map(async (node: Node): Promise => { if (node.type === 'local') { const summary = await computeNodeNetworkingSummary(node.id); return { nodeId: node.id, nodeName: node.name, status: 'ok', summary, error: null }; } const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: formatNoTargetError(node) }; } const resp = await fetch( `${target.apiUrl.replace(/\/$/, '')}/api/networking/summary`, { headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, signal: AbortSignal.timeout(15000) }, ); if (!resp.ok) { return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: `Remote returned ${resp.status}` }; } const summary = await resp.json().catch(() => null); if (!isNodeNetworkingSummary(summary)) { console.error(`[Fleet] Networking summary: node ${sanitizeForLog(node.name)} returned an unexpected payload (status ${resp.status})`); return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: 'Remote returned an unexpected summary payload' }; } return { nodeId: node.id, nodeName: node.name, status: 'ok', summary, error: null }; }), ); const perNode: FleetNetworkingSummaryNode[] = results.map((result, i) => { if (result.status === 'fulfilled') return result.value; console.error(`[Fleet] Networking summary fetch failed for node ${nodes[i].name}:`, result.reason); return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', summary: null, error: getErrorMessage(result.reason, 'Failed to reach node') }; }); res.json({ nodes: perNode }); } catch (error) { console.error('[Fleet] Networking summary error:', error); res.status(500).json({ error: 'Failed to build fleet networking summary' }); } }); // Read guard uses the unscoped stack:read (no resource): a fleet node view is a // cross-node aggregate with no single control-DB stack to scope a per-stack // assignment against, so it requires the global stack:read every shipped role // holds. The per-stack scoped form is correct only on the local stacks router. fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'stack:read')) return; try { const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) return; const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); return; } if (node.type === 'remote') { const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { res.status(503).json({ error: formatNoTargetError(node) }); return; } const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks`, { headers: target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}, 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' }); } }); // Unscoped stack:read for the same reason as /node/:nodeId/stacks above: a // fleet-routed read has no local control-DB stack resource to scope against. fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'stack:read')) return; try { const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) 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') { const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { res.status(503).json({ error: formatNoTargetError(node) }); return; } const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(stackName)}/containers`, { headers: target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}, 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' }); } }); fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'node:read')) 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); const statusBeforeResolve = tracker?.status; 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 { const meta = await NodeRegistry.getInstance().fetchMetaForNode(node.id); version = meta.version; remoteStartedAt = meta.startedAt; remoteUpdateError = meta.updateError; remoteOnline = meta.online; } 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) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'timed out after', Math.round(elapsed / 1000) + 's'); resolveTerminal(node, tracker, 'timeout', UPDATE_TIMEOUT_MSG); } else if (node.type === 'remote') { if (remoteUpdateError) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'reported pull failure:', remoteUpdateError); resolveTerminal(node, tracker, 'failed', remoteUpdateError); } else if (!remoteOnline) { if (!tracker.wasOffline) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'went offline (restarting)'); updateTracker.set(node.id, { ...tracker, wasOffline: true }); } } else if (isValidVersion(version) && version !== tracker.previousVersion) { // Signal 1: a valid, different version. A null/unparseable version // from a transient /api/meta blip is NOT a version change, so it // must not complete a still-running, same-process node here. if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 1 (version changed):', tracker.previousVersion, '->', version); updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed')); } else if ( remoteStartedAt !== null && tracker.previousProcessStart !== null && remoteStartedAt !== tracker.previousProcessStart ) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 2 (process restarted):', tracker.previousProcessStart, '->', remoteStartedAt); updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed')); } else if ( tracker.wasOffline && remoteOnline && (remoteStartedAt === null || tracker.previousProcessStart === null) ) { // Signal 3: offline-then-online is only trustworthy as a completion // signal when we cannot read the remote process start time. When // startedAt IS known and unchanged (signal 2 above did not fire), // the process never restarted, so a brief unreachable blip on the // same version must not be reported as a completed update; it falls // through to the early-fail / timeout heuristics instead. if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online, startedAt unavailable)'); updateTracker.set(node.id, updateTracker.resolve(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, updateTracker.resolve(tracker, 'completed')); } else if (elapsed > EARLY_FAIL_MS) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's - no signals detected'); resolveTerminal(node, 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 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); resolveTerminal(node, tracker, 'failed', localError); selfUpdate.clearLastError(); } else if (elapsed > EARLY_FAIL_MS) { if (debug) console.debug('[Fleet:debug] Local node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's'); resolveTerminal(node, tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.'); } } } // Auto-expire completed entries after their visibility window so the // badge is visible briefly after completion. if (tracker?.status === 'completed' && tracker.resolvedAt && Date.now() - tracker.resolvedAt > TERMINAL_TTL_MS) { updateTracker.delete(node.id); } let updateAvailable = false; if (!isValidVersion(version)) { // Assume remote nodes are outdated when their version is unresolvable. updateAvailable = node.type === 'remote'; } else if (compareValid) { updateAvailable = semver.lt(version, compareVersion!); } const currentTracker = updateTracker.get(node.id); // A node that just finished updating runs new code with a possibly // different version and capability set. Drop its cached /api/meta on // the completion transition so the dashboard reflects the new state // immediately instead of waiting out the remote-meta TTL. if ( node.type === 'remote' && statusBeforeResolve !== 'completed' && currentTracker?.status === 'completed' ) { invalidateRemoteMetaCache(node.id); } // Apply skip-version: suppress updateAvailable when the node has skipped // the effective compare target (which may be the gateway fallback, not // just the raw GitHub latest). const skipRow = db.getNodeUpdateSkip(node.id); let skipActive = false; let skippedVersion: string | null = null; if (skipRow && compareValid && skipRow.skippedVersion === compareVersion) { updateAvailable = false; skipActive = true; skippedVersion = skipRow.skippedVersion; } return { nodeId: node.id, name: node.name, type: node.type, version, latestVersion: latestValid ? latestVersion : gatewayVersion, updateAvailable, updateStatus: currentTracker?.status ?? null, error: currentTracker?.error ?? null, skipActive, skippedVersion, }; }), ); const nodeStatuses = results.map((r, i) => { if (r.status === 'fulfilled') return r.value; console.warn(`[Fleet] Update-status poll failed for node ${nodes[i].name}:`, r.reason); 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, skipActive: false, skippedVersion: 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' }); } }); // Release notes for the Changelog tab in the Node Updates sheet. fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Request, res: Response): Promise => { try { const forceRefresh = req.query.recheck === 'true'; const release = await getLatestRelease(forceRefresh); res.json({ // Bind the notes to the release they belong to so the frontend can tell // when the advertised latest version has moved past the loaded notes. version: release ? release.tag_name.replace(/^v/, '') : null, releaseNotes: release?.body ?? null, htmlUrl: release?.html_url ?? null, }); } catch (error) { console.error('[Fleet] Release notes error:', error); res.status(500).json({ error: 'Failed to fetch release notes' }); } }); // Pilot loopback targets carry an empty apiToken because the tunnel bridge // re-injects admin auth; sending a malformed `Bearer ` header would 401 on // the pilot's local Express. Omit the header in that case. function postSystemUpdate(target: { apiUrl: string; apiToken: string }) { const headers: Record = { 'Content-Type': 'application/json' }; if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; return fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/update`, { method: 'POST', headers, signal: AbortSignal.timeout(10000), }); } // --- Skip-version endpoints --- fleetRouter.post('/nodes/:nodeId/skip-version', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) { return; } const db = DatabaseService.getInstance(); const node = db.getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); return; } const { version } = req.body ?? {}; const normalized = typeof version === 'string' ? semver.valid(version) : null; if (!normalized || version.length > 64) { res.status(400).json({ error: 'Invalid version' }); return; } const username = req.user?.username ?? 'unknown'; db.setNodeUpdateSkip(nodeId, normalized, username); res.status(204).end(); } catch (error) { console.error('[Fleet] Skip-version error:', error); res.status(500).json({ error: 'Failed to skip version' }); } }); fleetRouter.delete('/nodes/:nodeId/skip-version', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const nodeId = parseIntParam(req, res, 'nodeId'); if (nodeId === null) { return; } const db = DatabaseService.getInstance(); const node = db.getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); return; } db.deleteNodeUpdateSkip(nodeId); res.status(204).end(); } catch (error) { console.error('[Fleet] Unskip-version error:', error); res.status(500).json({ error: 'Failed to unskip version' }); } }); fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) 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, updateTracker.resolve(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, mode: node.mode }); } if (node.type === 'local') { if (!SelfUpdateService.getInstance().isAvailable()) { res.status(503).json({ error: 'Self-update unavailable on the local node.' }); return; } updateTracker.set(nodeId, updateTracker.create('updating', getSenchoVersion(), null)); scheduleLocalUpdate(res, 'Update initiated on local node. The server will restart shortly.'); return; } const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { res.status(503).json({ error: formatNoTargetError(node) }); return; } const meta = await NodeRegistry.getInstance().fetchMetaForNode(node.id); 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; } const response = await postSystemUpdate(target); if (!response.ok) { const err = await response.json().catch(() => ({})); const errorMsg = (err as Record)?.error || 'Remote node rejected update request.'; updateTracker.set(nodeId, updateTracker.create('failed', meta.version, meta.startedAt, errorMsg)); res.status(502).json({ error: errorMsg }); return; } updateTracker.set(nodeId, updateTracker.create('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 = getErrorMessage(error, 'Failed to trigger node update.'); const failedNodeId = parseInt(req.params.nodeId as string, 10); if (!isNaN(failedNodeId)) { updateTracker.set(failedNodeId, updateTracker.create('failed', null, null, errorMsg)); } res.status(500).json({ error: 'Failed to trigger node update.' }); } }); fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Response): Promise => { 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 }); const registry = NodeRegistry.getInstance(); const candidates = nodes.filter(node => { if (node.type === 'local') return false; const tracker = updateTracker.get(node.id); if (tracker?.status === 'updating') return false; if (registry.getProxyTarget(node.id) === null) 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); } // Skip nodes that have skipped the current compare target version. const skipRow = db.getNodeUpdateSkip(node.id); if (skipRow && compareValid && skipRow.skippedVersion === compareVersion) { if (debug) console.debug('[Fleet:debug] Update-all skipping', node.name, '(version', compareVersion, 'skipped)'); return false; } return true; }); const results = await Promise.allSettled(candidates.map(async (node) => { const target = registry.getProxyTarget(node.id); if (!target) return { name: node.name, triggered: false }; const meta = await registry.fetchMetaForNode(node.id); 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 postSystemUpdate(target); if (response.ok) { updateTracker.set(node.id, updateTracker.create('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]; if (r.status === 'rejected') { console.warn(`[Fleet] Update-all failed for node ${candidates[i].name}:`, r.reason); } 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.' }); } }); fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) 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.' }); } }); fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; // Optionally pre-fetch a fresh latest version so the next GET compares against // it. Throttled so a caller cannot loop this to hammer the upstream registries; // `rechecked` tells the client whether the forced refresh actually ran. let rechecked = false; if (req.query.recheck === 'true') { const now = Date.now(); if (now - lastForcedRecheckAt >= FORCED_RECHECK_COOLDOWN_MS) { lastForcedRecheckAt = now; await getLatestVersion(true); rechecked = true; } } for (const [nodeId, tracker] of updateTracker.entries()) { if (tracker.status === 'timeout' || tracker.status === 'failed' || tracker.status === 'completed') { updateTracker.delete(nodeId); } } res.status(200).json({ rechecked }); }); // ─── Fleet Actions: gateway-orchestrated endpoints (multi-node) ─── // // Per-node fleet-action endpoints (run on the target node via the proxy) live // in `routes/fleetActions.ts`. The endpoint below is gateway-orchestrated and // lives here so it sits behind the `/api/fleet/` proxy-exempt prefix. // Attribute one error to every stack a node was supposed to act on. Used for // the local-node exception path, where the control DB authoritatively knows the // local stack list, so each stack carries the same node-level cause. const failAllStacks = (stacks: string[], error: string): StackStopResult[] => stacks.map(stackName => ({ stackName, success: false, error })); type FleetStopNodeResult = { nodeId: number; nodeName: string; reachable: boolean; matched: boolean; stackResults: StackStopResult[]; error?: string; }; // Fleet-wide stop by label name. Each node is asked authoritatively: the local // node matches against its own DB in-process; each remote runs the match + stop // on its own Docker via its local-stop receiver. Remote label rows are never // mirrored to the control, so there is no central pre-check; unreachable nodes // are reported at the node level and never block the reachable ones. // Tier: requireAdmin (admin-only fleet plumbing; available on every license). fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; const body = req.body as { labelName?: unknown; dryRun?: unknown; targets?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); return; } const { labelName, dryRun, targets } = body; if (typeof labelName !== 'string' || labelName.trim().length === 0) { res.status(400).json({ error: 'labelName is required' }); return; } // Optional per-node allowlist binding execution to exactly the preview the // operator confirmed: each entry names a node and the stacks to stop on it. // This binds both axes of drift between preview and execution: a node that was // unreachable during preview and reconnects cannot enter the stop (it is not // in the map), and a stack that gained the label after preview is never // stopped (the executor intersects the live label match against this set). // Absent (e.g. a dry run) the fan-out scans the whole fleet as before. let confirmedStacksByNode: Map> | null = null; if (targets !== undefined) { if (!Array.isArray(targets)) { res.status(400).json({ error: 'targets must be an array' }); return; } confirmedStacksByNode = new Map(); for (const raw of targets) { if (!raw || typeof raw !== 'object') { res.status(400).json({ error: 'each target must be an object' }); return; } const { nodeId, stackNames } = raw as { nodeId?: unknown; stackNames?: unknown }; if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) { res.status(400).json({ error: 'target.nodeId must be an integer' }); return; } if (!Array.isArray(stackNames) || !stackNames.every(s => typeof s === 'string')) { res.status(400).json({ error: 'target.stackNames must be an array of strings' }); return; } confirmedStacksByNode.set(nodeId, new Set(stackNames as string[])); } } const trimmed = labelName.trim(); const isDryRun = dryRun === true; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const targetNodes = confirmedStacksByNode ? nodes.filter(n => confirmedStacksByNode.has(n.id)) : nodes; if (isDebugEnabled()) console.debug('[Fleet:debug] fleet-stop:', { labelName: trimmed, dryRun: isDryRun, nodes: targetNodes.length, scoped: confirmedStacksByNode !== null }); const results = await Promise.all(targetNodes.map(async (node): Promise => { const allowedStacks = confirmedStacksByNode?.get(node.id); if (node.type === 'local') { // Match + stop runs in-process against the control's own Docker. The // helper shares the per-node `bulk:` lock with the per-label action // route so the two cannot double-stop the same containers. A control-side // failure (e.g. the compose dir is unreadable) degrades to a per-stack // error for this node only; the node is reachable, the stop just failed. try { const outcome = await runLocalLabelStop(node.id, trimmed, isDryRun, allowedStacks); return { nodeId: node.id, nodeName: node.name, reachable: true, matched: outcome.matched, stackResults: outcome.stackResults }; } catch (err) { const errorMsg = getErrorMessage(err, 'Failed to stop local stacks'); const localLabel = db.getLabels(node.id).find(l => l.name === trimmed); const localStacks = (localLabel ? db.getStacksForLabel(localLabel.id, node.id) : []) .filter(s => !allowedStacks || allowedStacks.has(s)); return { nodeId: node.id, nodeName: node.name, reachable: true, matched: !!localLabel, stackResults: failAllStacks(localStacks, errorMsg), }; } } // Remote node. Ask the remote authoritatively via its admin-only local-stop // receiver, which name-matches under the remote's own bulk lock. There is no // control-side pre-check: remote label rows are never mirrored to the // control, so a mirror lookup would skip every remote. A node we cannot // reach is reported at the node level and never blocks the reachable nodes. const target = NodeRegistry.getInstance().getProxyTarget(node.id); if (!target) { return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: formatNoTargetError(node) }; } // A real stop bound to a confirmed stack set must only go to a remote that // honors the allowlist (cross-node-rbac). An older remote ignores // `stackNames` and would stop every label-matched stack, including ones // labelled after the preview. Refuse to send and report the node as // needing an upgrade instead. Dry runs carry no allowlist and only // preview, so they are safe to send to any version. if (!isDryRun && allowedStacks) { const supported = await remoteSupportsCrossNodeRbac(node.id); if (!supported) { return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: failAllStacks([...allowedStacks], 'Node must be upgraded to honor an exact-stack stop'), error: 'Node is running a version that cannot limit the stop to the confirmed stacks; upgrade it and retry.', }; } } try { const headers: Record = { 'Content-Type': 'application/json' }; if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; const response = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-stop`, { method: 'POST', headers, body: JSON.stringify({ labelName: trimmed, dryRun: isDryRun, ...(allowedStacks ? { stackNames: [...allowedStacks] } : {}), }), signal: AbortSignal.timeout(60000), }); if (!response.ok) { const err = (await response.json().catch(() => ({}))) as { error?: string }; return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: err.error || `Remote returned ${response.status}` }; } // A 200 with a body that isn't the local-stop contract is a remote // failure, not an empty stop. Fail the node (matching the non-ok and // unreachable paths above) instead of defaulting matched to true and // results to [], which the UI renders as a successful zero-stack node // and hides the remote contract break. const remote = await response.json().catch(() => null); if (!isLabelLocalStopResponse(remote)) { return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: 'Remote returned a malformed response' }; } // Defense in depth behind the capability gate: if a confirmed allowlist // was sent, the remote must report only stacks from it. A result naming // a stack outside the confirmed set means the remote ignored the // allowlist (an over-broad stop), so fail the node rather than render // the extra stops as a clean result. if (allowedStacks && !remote.results.every(r => allowedStacks.has(r.stackName))) { return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: 'Remote stopped stacks outside the confirmed set' }; } return { nodeId: node.id, nodeName: node.name, reachable: true, matched: remote.matched, stackResults: remote.results, }; } catch (err) { return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: getErrorMessage(err, 'Failed to reach remote node') }; } })); // A confirmed node that was deleted or unregistered between preview and // execution would otherwise vanish from the response (the node-list filter // above drops it), letting the remaining successes read as a clean stop. // Surface each missing confirmed node as an unreachable failure, with its // confirmed stacks marked failed so the run is reported as partial, not // clean. const missingResults: FleetStopNodeResult[] = []; if (confirmedStacksByNode) { const present = new Set(nodes.map(n => n.id)); for (const [nodeId, stacks] of confirmedStacksByNode) { if (!present.has(nodeId)) { missingResults.push({ nodeId, nodeName: `Node ${nodeId}`, reachable: false, matched: false, stackResults: failAllStacks([...stacks], 'Node no longer exists'), error: 'Node no longer exists', }); } } } const allResults = [...results, ...missingResults]; if (isDebugEnabled()) { const matched = allResults.filter(r => r.matched).length; const stopped = allResults.reduce((n, r) => n + r.stackResults.filter(s => s.success).length, 0); const failed = allResults.reduce((n, r) => n + r.stackResults.filter(s => !s.success).length, 0); console.debug('[Fleet:debug] fleet-stop complete:', { matched, stopped, failed, missing: missingResults.length }); } res.json({ results: allResults }); } catch (error) { console.error('[Fleet] fleet-stop error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to run fleet stop') }); } }); // Fleet-wide bulk label assign. Propagates a label template (name + color) to // stacks across one or more nodes: for each target node the label is resolved or // created by name on that node, then assigned to the given stacks preserving // their existing labels (add semantics). Labels are node-local, so the control // never reuses a local label id on a remote; the local node runs in-process and // each remote runs its own `/api/fleet-actions/labels/local-assign` receiver. // Per-node failures (unknown node, no proxy target, unreachable, mixed-version // remote, malformed response) degrade that node only and never discard the rest // of the fan-out. // Tier: requireAdmin (admin-only fleet plumbing; available on every license). fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; const body = req.body as { label?: unknown; targets?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); return; } const validated = validateLabelTemplate(body.label); if (!validated.ok) { res.status(400).json({ error: validated.error }); return; } if (!Array.isArray(body.targets) || body.targets.length === 0) { res.status(400).json({ error: 'targets must be a non-empty array' }); return; } // Normalize targets: each must name a node and carry a string array of stacks. // Stack names are deduped per node and empty groups are dropped, so the cap // measures real assignments rather than padded input. const targets: { nodeId: number; stackNames: string[] }[] = []; let totalStacks = 0; for (const raw of body.targets as unknown[]) { if (!raw || typeof raw !== 'object') { res.status(400).json({ error: 'each target must be an object' }); return; } const { nodeId, stackNames } = raw as { nodeId?: unknown; stackNames?: unknown }; if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) { res.status(400).json({ error: 'target.nodeId must be an integer' }); return; } if (!Array.isArray(stackNames) || !stackNames.every(s => typeof s === 'string')) { res.status(400).json({ error: 'target.stackNames must be an array of strings' }); return; } const unique = Array.from(new Set(stackNames as string[])); if (unique.length === 0) continue; totalStacks += unique.length; targets.push({ nodeId, stackNames: unique }); } if (targets.length === 0) { res.status(400).json({ error: 'no target stacks provided' }); return; } if (totalStacks > MAX_ASSIGNMENTS) { res.status(400).json({ error: `targets may not exceed ${MAX_ASSIGNMENTS} stack assignments` }); return; } const { template } = validated; try { const db = DatabaseService.getInstance(); const nodesById = new Map(db.getNodes().map(n => [n.id, n])); if (isDebugEnabled()) console.debug('[Fleet:debug] bulk-assign:', { label: template.name, targets: targets.length, totalStacks }); const results: AssignNodeResult[] = await Promise.all(targets.map(async (target): Promise => { const node = nodesById.get(target.nodeId); if (!node) { return { nodeId: target.nodeId, nodeName: `Node ${target.nodeId}`, reachable: false, created: false, error: 'Unknown node', stackResults: failAllAssign(target.stackNames, 'Unknown node'), }; } if (node.type === 'local') { try { const outcome = await runLocalLabelAssign(node.id, template, target.stackNames); return { nodeId: node.id, nodeName: node.name, reachable: true, created: outcome.created, stackResults: outcome.stackResults }; } catch (err) { return { nodeId: node.id, nodeName: node.name, reachable: true, created: false, stackResults: failAllAssign(target.stackNames, getErrorMessage(err, 'Failed to assign labels')), }; } } const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); if (!proxyTarget) { const error = formatNoTargetError(node); return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error, stackResults: failAllAssign(target.stackNames, error) }; } try { const headers: Record = { 'Content-Type': 'application/json' }; if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-assign`, { method: 'POST', headers, body: JSON.stringify({ label: template, stackNames: target.stackNames }), signal: AbortSignal.timeout(60000), }); if (!response.ok) { const err = (await response.json().catch(() => ({}))) as { error?: string }; const message = err.error || `Remote returned ${response.status}`; return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) }; } // A 200 whose body is not the expected { created, results } shape, or // whose results do not cover exactly the stacks this node was asked to // label, is a degraded node, not a clean no-op: report it as a per-node // failure so a malformed or partial remote cannot read as a successful // zero-stack assign. const remote = validateRemoteAssignResults(target.stackNames, await response.json().catch(() => null)); if (!remote.ok) { const message = 'Remote returned a malformed response'; return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) }; } return { nodeId: node.id, nodeName: node.name, reachable: true, created: remote.created, stackResults: remote.results, }; } catch (err) { const errorMsg = getErrorMessage(err, 'Failed to reach remote node'); return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: errorMsg, stackResults: failAllAssign(target.stackNames, errorMsg) }; } })); res.json({ results }); } catch (error) { console.error('[Fleet] bulk-assign error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to run bulk label assign') }); } }); // Fleet-wide Docker prune. Fans out to every node, running per-target prune // (images/volumes/networks) under the chosen scope. Local nodes call // DockerController directly under a per-node bulk-prune lock; remote nodes // receive one POST /api/system/prune/system per target via the standard // Bearer-token path. Concurrent execution against the per-node prune route in // systemMaintenance.ts is safe because Docker's prune API is internally // serialized and idempotent (the worst case is a duplicate call returning 0 // reclaimed bytes). // Tier: requireAdmin (admin-only fleet plumbing; available on every license). const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const; type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number]; fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); return; } const rawTargets = Array.isArray(body.targets) ? body.targets : null; if (!rawTargets || rawTargets.length === 0) { res.status(400).json({ error: 'targets must be a non-empty array' }); return; } const dedup = new Set(); for (const t of rawTargets) { if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) { res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` }); return; } dedup.add(t as FleetPruneTarget); } const targets: FleetPruneTarget[] = Array.from(dedup); const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; const isDryRun = body.dryRun === true; type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean }; type NodeResult = { nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; }; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); if (isDebugEnabled()) console.debug('[Fleet:debug] fleet-prune:', { targets, scope, dryRun: isDryRun, nodes: nodes.length }); const results: NodeResult[] = await Promise.all(nodes.map(async (node): Promise => { if (node.type === 'local') { const lockKey = `bulk-prune:${node.id}`; if (activeBulkActions.has(lockKey)) { return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error: 'A prune is already running on this node' })), }; } activeBulkActions.add(lockKey); try { const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : []; const dockerController = DockerController.getInstance(node.id); const targetResults: TargetResult[] = []; let anySuccess = false; for (const target of targets) { try { if (isDryRun) { // estimateSystemReclaim hits `docker system df`; bound it // so a slow local daemon doesn't hang the fleet admin tab // (F-6). estimateManagedReclaim is fast (no df) and stays // unwrapped. const estimate = scope === 'managed' ? await dockerController.estimateManagedReclaim(target, knownStacks) : await withTimeout( dockerController.estimateSystemReclaim(target, knownStacks), FLEET_DF_TIMEOUT_MS, 'docker disk usage', ); targetResults.push({ target, success: true, reclaimedBytes: estimate.reclaimableBytes, dryRun: true }); continue; } const result = scope === 'managed' ? await dockerController.pruneManagedOnly(target, knownStacks) : await dockerController.pruneSystem(target); targetResults.push({ target, success: true, reclaimedBytes: result.reclaimedBytes }); if (result.reclaimedBytes > 0 || result.success) anySuccess = true; } catch (err) { const error = err instanceof TimeoutError ? 'Docker daemon is busy. Please try again in a moment.' : getErrorMessage(err, 'Prune failed'); targetResults.push({ target, success: false, reclaimedBytes: 0, error }); } } if (anySuccess && !isDryRun) invalidateNodeCaches(node.id); return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults }; } finally { activeBulkActions.delete(lockKey); } } // Remote node: POST /api/system/prune/system per target, short-circuiting // on the first transport-level failure so we don't hammer a dead node. const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); if (!proxyTarget) { const error = formatNoTargetError(node); return { nodeId: node.id, nodeName: node.name, reachable: false, error, targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error })), }; } const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); const remoteHeaders: Record = { 'Content-Type': 'application/json' }; if (proxyTarget.apiToken) remoteHeaders.Authorization = `Bearer ${proxyTarget.apiToken}`; const targetResults: TargetResult[] = []; let nodeUnreachable: string | null = null; for (const target of targets) { if (nodeUnreachable) { targetResults.push({ target, success: false, reclaimedBytes: 0, error: nodeUnreachable }); continue; } try { const response = await fetch(`${baseUrl}/api/system/prune/system`, { method: 'POST', headers: remoteHeaders, body: JSON.stringify({ target, scope, dryRun: isDryRun }), signal: AbortSignal.timeout(120000), }); if (!response.ok) { const errBody = (await response.json().catch(() => ({}))) as { error?: string }; const message = errBody.error || `Remote returned ${response.status}`; nodeUnreachable = message; targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); continue; } const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number; dryRun?: boolean } | null; if (!remote || typeof remote.reclaimedBytes !== 'number') { targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' }); continue; } const entry: TargetResult = { target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes }; if (remote.dryRun) entry.dryRun = true; targetResults.push(entry); } catch (err) { const message = getErrorMessage(err, 'Failed to reach remote node'); nodeUnreachable = message; targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); } } return { nodeId: node.id, nodeName: node.name, reachable: nodeUnreachable === null, error: nodeUnreachable ?? undefined, targets: targetResults, }; })); if (isDebugEnabled()) { const reachable = results.filter(r => r.reachable).length; const reclaimed = results.reduce((n, r) => n + r.targets.reduce((m, t) => m + t.reclaimedBytes, 0), 0); console.debug('[Fleet:debug] fleet-prune complete:', { reachable, unreachable: results.length - reachable, reclaimedBytes: reclaimed }); } res.json({ results }); } catch (error) { console.error('[Fleet] fleet-prune error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to run fleet prune') }); } }); // ─── Fleet Actions: blast-radius preview endpoints (non-destructive) ─── // // Power the live readouts in the Fleet Action cards. Same auth gates as the // destructive endpoints above so the surface stays uniform: an operator who // can fire `fleet-stop` is also the operator who can ask how big it would be. // Per-label fleet preview. Fans out to every node authoritatively (local DB + // live remote reads via collectFleetLabelSummaries) and reports, per node, the // matching stacks, whether the label exists at all, and reachability. The card // uses these to distinguish "0 matching stacks" from "label exists but no // stacks assigned" from "remote unavailable". fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; const body = req.body as { labelName?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); return; } const { labelName } = body; if (typeof labelName !== 'string' || labelName.trim().length === 0) { res.status(400).json({ error: 'labelName is required' }); return; } const trimmed = labelName.trim(); try { const summaries = await collectFleetLabelSummaries(); let matchedStacks = 0; const perNode = summaries.map((node) => { const entry = node.labels.find(l => l.name === trimmed); const stackNames = entry?.stackNames ?? []; matchedStacks += stackNames.length; return { nodeId: node.nodeId, nodeName: node.nodeName, reachable: node.reachable, labelExists: !!entry, stackCount: stackNames.length, stackNames, ...(node.error ? { error: node.error } : {}), }; }); const matchedNodes = perNode.filter(n => n.stackCount > 0).length; const unreachableNodes = perNode.filter(n => !n.reachable).length; res.json({ matchedNodes, matchedStacks, unreachableNodes, perNode }); } catch (error) { console.error('[Fleet] match-preview error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to compute match preview') }); } }); // Stack-label suggestions for the Stop-by-label target picker. Fans out to every // node authoritatively (local DB + live remote reads via collectFleetLabelSummaries) // and aggregates the per-node stack labels into one name-keyed list with node and // stack counts plus the carrying node names. Node labels (the separate // `/api/node-labels` namespace) are deliberately never folded in, because // fleet-stop targets stack labels only; the `scope: 'stack'` tag makes that // explicit. Labels with zero matching stacks fleet-wide are dropped (stopping // them is a no-op). `unreachableNodes`/`partial` tell the card the counts cover // only the nodes it could reach. fleetRouter.get('/labels/suggestions', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const summaries = await collectFleetLabelSummaries(); const agg = new Map(); for (const node of summaries) { for (const label of node.labels) { const entry = agg.get(label.name) ?? { nodeCount: 0, stackCount: 0, nodes: [] }; entry.nodeCount += 1; entry.stackCount += label.stackNames.length; entry.nodes.push(node.nodeName); agg.set(label.name, entry); } } const suggestions = Array.from(agg.entries()) .filter(([, counts]) => counts.stackCount > 0) .map(([name, counts]) => ({ name, scope: 'stack' as const, nodeCount: counts.nodeCount, stackCount: counts.stackCount, nodes: counts.nodes })) .sort((a, b) => a.name.localeCompare(b.name)); const unreachableNodes = summaries.filter(n => !n.reachable).length; res.json({ suggestions, unreachableNodes, partial: unreachableNodes > 0 }); } catch (error) { console.error('[Fleet] label-suggestions error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to load stack-label suggestions') }); } }); // Fleet-wide prune size estimate. Local node uses the controller estimate // helper; remote nodes hit `/api/system/prune/estimate` per target. Same // fan-out shape as `/labels/fleet-prune` minus the locks (estimation is read // only). fleetRouter.post('/prune/estimate', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; const body = req.body as { targets?: unknown; scope?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); return; } const rawTargets = Array.isArray(body.targets) ? body.targets : null; if (!rawTargets || rawTargets.length === 0) { res.status(400).json({ error: 'targets must be a non-empty array' }); return; } const dedup = new Set(); for (const t of rawTargets) { if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) { res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` }); return; } dedup.add(t as FleetPruneTarget); } const targets: FleetPruneTarget[] = Array.from(dedup); const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; type NodeEstimate = { nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string; }; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const perNode: NodeEstimate[] = await Promise.all(nodes.map(async (node): Promise => { if (node.type === 'local') { try { const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : []; const dockerController = DockerController.getInstance(node.id); let nodeBytes = 0; for (const target of targets) { // estimateSystemReclaim hits `docker system df`; bound it so a // slow local daemon doesn't hang the fleet estimate (F-6). const result = scope === 'managed' ? await dockerController.estimateManagedReclaim(target, knownStacks) : await withTimeout( dockerController.estimateSystemReclaim(target, knownStacks), FLEET_DF_TIMEOUT_MS, 'docker disk usage', ); nodeBytes += result.reclaimableBytes; } return { nodeId: node.id, nodeName: node.name, reclaimableBytes: nodeBytes, reachable: true }; } catch (err) { const error = err instanceof TimeoutError ? 'Docker daemon is busy. Please try again in a moment.' : getErrorMessage(err, 'Failed to estimate locally'); return { nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false, error, }; } } const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); if (!proxyTarget) { return { nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false, error: formatNoTargetError(node), }; } const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); const estimateHeaders: Record = { 'Content-Type': 'application/json' }; if (proxyTarget.apiToken) estimateHeaders.Authorization = `Bearer ${proxyTarget.apiToken}`; // Estimate is a live readout; fan out the per-target fetches in parallel // so wall time matches the slowest single call rather than the sum. // (The destructive sibling stays serial because Docker prune is internally // serialized and one failure should short-circuit later targets there.) const perTarget = await Promise.all(targets.map(async (target): Promise<{ bytes: number; error?: string }> => { try { const response = await fetch(`${baseUrl}/api/system/prune/estimate`, { method: 'POST', headers: estimateHeaders, body: JSON.stringify({ target, scope }), signal: AbortSignal.timeout(15000), }); if (!response.ok) { const errBody = (await response.json().catch(() => ({}))) as { error?: string }; return { bytes: 0, error: errBody.error || `Remote returned ${response.status}` }; } const remote = (await response.json().catch(() => null)) as { reclaimableBytes?: number } | null; if (!remote || typeof remote.reclaimableBytes !== 'number') { return { bytes: 0, error: 'Invalid response from remote node' }; } return { bytes: remote.reclaimableBytes }; } catch (err) { return { bytes: 0, error: getErrorMessage(err, 'Failed to reach remote node') }; } })); const firstError = perTarget.find(t => t.error)?.error; if (firstError) { return { nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false, error: firstError }; } const nodeBytes = perTarget.reduce((sum, t) => sum + t.bytes, 0); return { nodeId: node.id, nodeName: node.name, reclaimableBytes: nodeBytes, reachable: true }; })); const totalBytes = perNode.reduce((acc, n) => acc + (n.reachable ? n.reclaimableBytes : 0), 0); res.json({ totalBytes, perNode }); } catch (error) { console.error('[Fleet] prune-estimate error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to compute prune estimate') }); } }); // ─── Fleet Snapshots (manual and scheduled: every tier) ─── fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(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 captureDocs = db.getGlobalSettings().snapshot_documentation === '1'; const captureStart = Date.now(); const results = await Promise.allSettled( nodes.map(async (node) => { if (node.type === 'remote') { return captureRemoteNodeFiles(node, captureDocs); } return captureLocalNodeFiles(node, captureDocs); }), ); 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 }> = []; const skippedStacks: Array<{ nodeId: number; nodeName: string; stackName: string; reason: 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, }); } } for (const warning of nodeData.warnings) { skippedStacks.push({ nodeId: nodeData.nodeId, nodeName: nodeData.nodeName, stackName: warning.stackName, reason: warning.reason, }); } } const documentation = captureDocs ? buildSnapshotDocumentation(capturedNodes, new Date().toISOString()) : null; const snapshotId = db.createSnapshot( description, username, capturedNodes.length, totalStacks, JSON.stringify(skippedNodes), JSON.stringify(skippedStacks), documentation ? JSON.stringify(documentation) : '', ); if (allFiles.length > 0) { db.insertSnapshotFiles(snapshotId, allFiles); } const cloudSvc = CloudBackupService.getInstance(); if (cloudSvc.isEnabled() && cloudSvc.isAutoUploadOn()) { void cloudSvc.uploadSnapshot(snapshotId) .then(() => console.log(`[Fleet Snapshot] Cloud auto-upload OK for snapshot ${snapshotId}`)) .catch(uploadErr => { const message = uploadErr instanceof Error ? uploadErr.message : String(uploadErr); console.error('[Fleet Snapshot] Cloud upload failed:', message); void NotificationService.getInstance() .dispatchAlert('error', 'system', `Cloud backup upload failed for snapshot ${snapshotId}: ${message}`) .catch(() => { /* notification dispatch is best-effort */ }); }); } if (skippedNodes.length > 0 || skippedStacks.length > 0) { console.warn(`[Fleet] Snapshot ${snapshotId} partial: ${capturedNodes.length} node(s), ${totalStacks} stack(s); skipped ${skippedNodes.length} node(s), ${skippedStacks.length} stack(s)`); } else { 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}`); } for (const skip of skippedStacks) { console.debug(`[Fleet:debug] Skipped stack "${skip.stackName}" on "${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' }); } }); fleetRouter.get('/snapshots', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(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' }); } }); // Registered before /snapshots/:id so "coverage" is never parsed as an id. // Hub-local by design: snapshot rows exist only in the hub database, so the // readiness UI fetches this with localOnly and merges it client-side. fleetRouter.get('/snapshots/coverage', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { // Strict digits only: parseInt would accept '1abc' as 1. const nodeIdRaw = typeof req.query.nodeId === 'string' ? req.query.nodeId : ''; const stackName = req.query.stackName as string; if (!/^\d+$/.test(nodeIdRaw)) { res.status(400).json({ error: 'nodeId must be a non-negative integer' }); return; } const nodeId = parseInt(nodeIdRaw, 10); if (typeof stackName !== 'string' || !isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } const latestAt = DatabaseService.getInstance().getLatestSnapshotTimestampFor(nodeId, stackName); res.json({ latestAt }); } catch (error) { console.error('[Fleet Snapshot] Coverage lookup error:', error); res.status(500).json({ error: 'Failed to look up snapshot coverage' }); } }); fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'snapshot ID'); if (id === null) 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> }>(); 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, })), })); // Surface the captured dossier metadata when present so the detail view can // render notes and offer to restore them. The list payload stays lean (only // the has_documentation flag); the blob is decrypted here on demand. let documentation: SnapshotDocumentation | undefined; if (snapshot.has_documentation) { const raw = db.getSnapshotDocumentation(id); if (raw) { try { const parsed = JSON.parse(raw) as SnapshotDocumentation; // Re-project each dossier through pickDossierFields so the client // receives the same shape restore writes back, and never any field // outside the eleven operator notes. documentation = { ...parsed, stacks: parsed.stacks.map(s => ({ ...s, dossier: pickDossierFields(s.dossier) })), }; } catch (e) { console.error(`[Fleet Snapshot] Failed to parse documentation for snapshot ${id}:`, getErrorMessage(e, 'parse error')); } } } if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshot detail:', id, files.length, 'files'); res.json({ ...snapshot, nodes, documentation }); } catch (error) { console.error('[Fleet Snapshot] Detail error:', error); res.status(500).json({ error: 'Failed to fetch snapshot details' }); } }); // Raised when a remote target node has no reachable proxy address. The // single-stack restore route maps it to a 503; restore-all records it as a // per-stack failure instead. class SnapshotProxyTargetError extends Error { constructor(message: string) { super(message); this.name = 'SnapshotProxyTargetError'; } } interface RemoteProxyContext { baseUrl: string; headers: Record; } // Builds an error from a failed remote response so the thrown message names the // remote node's actual reason (policy block, validation, write error) instead // of a generic string. The body is truncated to keep the recorded message bounded. async function remoteStackError(action: string, res: Awaited>): Promise { let detail = ''; try { detail = (await res.text()).slice(0, 300).trim(); } catch { // Remote body unavailable; the status code alone still names the failure. } return new Error(`${action} on remote node (${res.status})${detail ? `: ${detail}` : ''}`); } // Builds the base URL + proxy headers for a remote node, or null when the node // has no reachable target. The tier header describes the central instance and // stays unconditional; the Bearer header is gated on a non-empty token because // pilot-loopback dispatch carries auth via the tunnel. function buildRemoteProxyContext(node: Node): RemoteProxyContext | null { const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); if (!proxyTarget) return null; const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); const headers: Record = { 'Content-Type': 'application/json', [PROXY_TIER_HEADER]: proxyHeaders.tier, }; if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; return { baseUrl: proxyTarget.apiUrl.replace(/\/$/, ''), headers }; } // Writes a snapshot stack's files back to its node: local nodes write to disk // (backing up any current files first), remote nodes receive them over the // proxy. Throws SnapshotProxyTargetError when a remote node is unreachable, and // an Error carrying the remote node's status and reason on a failed remote write. async function applySnapshotStackFiles( node: Node, stackName: string, files: Array<{ filename: string; content: string }>, ): Promise { if (node.type === 'local') { const fsService = FileSystemService.getInstance(node.id); try { await fsService.backupStackFiles(stackName); } catch (e) { // Stack may not exist yet before first restore; that is ok. console.warn(`[Fleet Snapshot] Pre-restore backup failed for stack "${stackName}" (may not exist yet):`, getErrorMessage(e, 'unknown')); } 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); } } return; } const ctx = buildRemoteProxyContext(node); if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node)); for (const file of files) { if (file.filename === 'compose.yaml') { const putRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { method: 'PUT', headers: ctx.headers, body: JSON.stringify({ content: file.content }), signal: AbortSignal.timeout(15000), }); if (!putRes.ok) throw await remoteStackError('Failed to restore compose file', putRes); } else if (file.filename === '.env') { const putRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { method: 'PUT', headers: ctx.headers, body: JSON.stringify({ content: file.content }), signal: AbortSignal.timeout(15000), }); if (!putRes.ok) throw await remoteStackError('Failed to restore env file', putRes); } } } // Redeploys a stack after its files are restored. The deploy policy gate stays // with the caller (local deploys gate centrally; remote deploys are gated by // the remote node), so this only performs the deploy itself. async function redeploySnapshotStack(node: Node, stackName: string): Promise { if (node.type === 'local') { const lock = await StackOpLockService.getInstance().runExclusive( node.id, stackName, 'deploy', 'system', () => ComposeService.getInstance(node.id).deployStack(stackName), ); if (!lock.ran) { throw new Error(`Cannot redeploy "${stackName}": another operation (${lock.existing.action}) is already in progress.`); } return; } const ctx = buildRemoteProxyContext(node); if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node)); const deployRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/deploy`, { method: 'POST', headers: ctx.headers, signal: AbortSignal.timeout(30000), }); if (!deployRes.ok) throw await remoteStackError('Failed to redeploy stack', deployRes); } // Looks up the dossier notes a snapshot preserved for one stack, or undefined // when no usable dossier entry exists for that stack (no documentation, an // unparseable blob, or no matching entry). function findSnapshotDossier(snapshotId: number, nodeId: number, stackName: string): StackDossierFields | undefined { const raw = DatabaseService.getInstance().getSnapshotDocumentation(snapshotId); if (!raw) return undefined; let doc: SnapshotDocumentation; try { doc = JSON.parse(raw) as SnapshotDocumentation; } catch (e) { console.error(`[Fleet Snapshot] Failed to parse documentation for snapshot ${snapshotId}:`, getErrorMessage(e, 'parse error')); return undefined; } // Guard against a malformed-but-parseable blob: `stacks` must be an array, and // only restore notes that actually carry content, so a blank or tampered entry // can never silently clobber the operator's current notes with empty fields. if (!Array.isArray(doc.stacks)) return undefined; const entry = doc.stacks.find(s => s?.nodeId === nodeId && s?.stackName === stackName); if (!entry) return undefined; const fields = pickDossierFields(entry.dossier); return dossierHasContent(fields) ? fields : undefined; } // Writes captured dossier notes back to a stack: local nodes upsert into the // DB, remote nodes receive them over the proxy. Only ever called when the // operator explicitly opted in to restoring notes. async function restoreSnapshotStackDossier(node: Node, stackName: string, fields: StackDossierFields): Promise { if (node.type === 'local') { DatabaseService.getInstance().upsertStackDossier(node.id, stackName, fields); return; } const ctx = buildRemoteProxyContext(node); if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node)); const putRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, { method: 'PUT', headers: ctx.headers, body: JSON.stringify(fields), signal: AbortSignal.timeout(15000), }); if (!putRes.ok) throw await remoteStackError('Failed to restore dossier notes', putRes); } fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID'); if (snapshotId === null) return; const { nodeId, stackName, redeploy = false } = req.body; // Strict boolean: only an explicit `true` opts in, so a stray `"false"` or // truthy value can never trigger an overwrite of the operator's notes. const restoreNotes: boolean = req.body?.restoreNotes === true; 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=%s, node=%s, stack="%s", files=[%s], redeploy=%s', sanitizeForLog(snapshotId), sanitizeForLog(nodeId), sanitizeForLog(stackName), sanitizeForLog(fileNames), sanitizeForLog(redeploy)); } const node = db.getNode(nodeId); if (!node) { res.status(404).json({ error: 'Target node no longer exists' }); return; } await applySnapshotStackFiles(node, stackName, files); // Dossier notes are restored only on explicit opt-in, so a routine file // restore never clobbers the operator's current notes. The note write is // best-effort relative to the file restore: the files are already on disk, // so a notes failure (e.g. a remote dossier PUT) is reported, not fatal. let notesRestored = false; let notesError: string | undefined; if (restoreNotes) { const fields = findSnapshotDossier(snapshotId, nodeId, stackName); if (fields) { try { await restoreSnapshotStackDossier(node, stackName, fields); notesRestored = true; } catch (e) { notesError = getErrorMessage(e, 'Failed to restore documentation notes'); console.error(`[Fleet Snapshot] Note restore failed for stack "${sanitizeForLog(stackName)}":`, notesError); } } } if (redeploy) { // Local deploys are gated centrally here; remote deploys are gated by the // remote node's own deploy endpoint. if (node.type === 'local' && !(await runPolicyGate(req, res, stackName, node.id))) return; await redeploySnapshotStack(node, stackName); } console.log('[Fleet] Snapshot restore: snapshot=%s node=%s stack=%s', snapshotId, sanitizeForLog(nodeId), sanitizeForLog(stackName)); res.json({ message: 'Stack restored successfully', redeployed: redeploy, notesRestored, notesError }); } catch (error) { if (error instanceof SnapshotProxyTargetError) { res.status(503).json({ error: error.message }); return; } console.error('[Fleet Snapshot] Restore error:', error); res.status(500).json({ error: 'Failed to restore stack from snapshot' }); } }); // One row per (node, stack) in a restore-all run. A failed row carries the // reason in `error`; a succeeded row reports whether it was also redeployed. interface SnapshotRestoreResult { nodeId: number; nodeName: string; stackName: string; success: boolean; redeployed: boolean; notesRestored: boolean; error?: string; /** A non-fatal documentation-notes restore failure; files still restored. */ notesError?: string; } fleetRouter.post('/snapshots/:id/restore-all', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID'); if (snapshotId === null) return; const redeploy: boolean = req.body?.redeploy === true; const restoreNotes: boolean = req.body?.restoreNotes === true; const db = DatabaseService.getInstance(); const snapshot = db.getSnapshot(snapshotId); if (!snapshot) { res.status(404).json({ error: 'Snapshot not found' }); return; } const files = db.getSnapshotFiles(snapshotId); if (files.length === 0) { res.status(404).json({ error: 'Snapshot has no files to restore' }); return; } // Group the snapshot's files by node + stack, mirroring the detail route. const groups = new Map }>(); for (const file of files) { const key = `${file.node_id}:${file.stack_name}`; let entry = groups.get(key); if (!entry) { entry = { nodeId: file.node_id, nodeName: file.node_name, stackName: file.stack_name, files: [] }; groups.set(key, entry); } entry.files.push({ filename: file.filename, content: file.content }); } const policyOptions = buildPolicyGateOptions(req); const results: SnapshotRestoreResult[] = []; // Restore every stack independently: one stack failing (unreachable node, // blocked deploy, write error) is recorded and the rest still proceed. for (const group of groups.values()) { try { if (!isValidStackName(group.stackName)) throw new Error('Invalid stack name'); const node = db.getNode(group.nodeId); if (!node) throw new Error('Target node no longer exists'); await applySnapshotStackFiles(node, group.stackName, group.files); // Files are restored; a notes failure is recorded but does not fail the // stack (and must not block the redeploy below). let notesRestored = false; let notesError: string | undefined; if (restoreNotes) { const fields = findSnapshotDossier(snapshotId, group.nodeId, group.stackName); if (fields) { try { await restoreSnapshotStackDossier(node, group.stackName, fields); notesRestored = true; } catch (e) { notesError = getErrorMessage(e, 'Failed to restore documentation notes'); console.error(`[Fleet Snapshot] Note restore failed for stack "${sanitizeForLog(group.stackName)}":`, notesError); } } } let redeployed = false; if (redeploy) { if (node.type === 'local') await assertPolicyGateAllows(group.stackName, node.id, policyOptions); await redeploySnapshotStack(node, group.stackName); redeployed = true; } results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: true, redeployed, notesRestored, notesError }); } catch (e) { results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: false, redeployed: false, notesRestored: false, error: getErrorMessage(e, 'Restore failed') }); } } const restored = results.filter(r => r.success).length; const failed = results.length - restored; console.log('[Fleet] Snapshot restore-all: snapshot=%s restored=%s failed=%s redeploy=%s', snapshotId, restored, failed, redeploy); res.json({ restored, failed, redeploy, results }); } catch (error) { console.error('[Fleet Snapshot] Restore-all error:', error); res.status(500).json({ error: 'Failed to restore snapshot' }); } }); fleetRouter.delete('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'snapshot ID'); if (id === null) 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' }); } });