diff --git a/backend/src/index.ts b/backend/src/index.ts index b0cfcda8..8f3ad280 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from 'express'; import jwt from 'jsonwebtoken'; -import DockerController, { type CreateNetworkOptions, type NetworkDriver } from './services/DockerController'; +import DockerController from './services/DockerController'; import { FileSystemService } from './services/FileSystemService'; import { ComposeService } from './services/ComposeService'; import crypto from 'crypto'; @@ -11,15 +11,12 @@ import { AutoHealService } from './services/AutoHealService'; import { DockerEventManager } from './services/DockerEventManager'; import { ImageUpdateService } from './services/ImageUpdateService'; import { UpdatePreviewService } from './services/UpdatePreviewService'; -import { templateService } from './services/TemplateService'; -import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; import { PilotTunnelManager } from './services/PilotTunnelManager'; import { PilotCloseCode } from './pilot/protocol'; import { FleetSyncService } from './services/FleetSyncService'; import { LicenseService } from './services/LicenseService'; import { SchedulerService } from './services/SchedulerService'; -import { RegistryService } from './services/RegistryService'; import { CacheService } from './services/CacheService'; import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta, type RemoteMeta } from './services/CapabilityRegistry'; import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, repoHost as gitRepoHost } from './services/GitSourceService'; @@ -78,6 +75,9 @@ import { autoHealRouter } from './routes/autoHeal'; import { notificationsRouter, notificationRoutesRouter } from './routes/notifications'; import { consoleRouter } from './routes/console'; import { ssoConfigRouter } from './routes/ssoConfig'; +import { registriesRouter } from './routes/registries'; +import { systemMaintenanceRouter } from './routes/systemMaintenance'; +import { templatesRouter } from './routes/templates'; import { isDebugEnabled } from './utils/debug'; import { getErrorMessage } from './utils/errors'; @@ -88,9 +88,8 @@ import { enforcePolicyPreDeploy } from './services/PolicyEnforcement'; import { validateImageRef } from './utils/image-ref'; import { applySuppressions } from './utils/suppression-filter'; import { generateSarif } from './services/SarifExporter'; -import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation'; +import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation'; import YAML from 'yaml'; -import { promises as fsPromises } from 'fs'; // Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls // util._extend internally. The warning fires at runtime when createProxyServer() is @@ -160,6 +159,9 @@ app.use('/api/notifications', notificationsRouter); app.use('/api/notification-routes', notificationRoutesRouter); app.use('/api/system', consoleRouter); app.use('/api/sso/config', ssoConfigRouter); +app.use('/api/registries', registriesRouter); +app.use('/api/system', systemMaintenanceRouter); +app.use('/api/templates', templatesRouter); // Symbols still consumed by inline security and node routes still living in // index.ts. These will move with their route groups in a later slice. @@ -963,617 +965,6 @@ app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => } }); - - - -// --- Private Registry Routes (Admiral, admin-only, local-only) --- - -const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const; - -function isValidRegistryUrl(url: string, type: string): boolean { - // Docker Hub is fixed server-side to the legacy URL; no validation needed. - if (type === 'dockerhub') return true; - const trimmed = url.trim(); - if (!trimmed) return false; - // Reject any non-http(s) scheme (file://, ftp://, javascript:, etc.). - const lower = trimmed.toLowerCase(); - if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('file:') || lower.startsWith('ftp:')) { - return false; - } - // Parse with a default https:// prefix so bare hosts validate. - try { - const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; - if (!parsed.hostname) return false; - } catch { - return false; - } - return true; -} - -app.get('/api/registries', (req: Request, res: Response): void => { - if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } - if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; - try { - res.json(RegistryService.getInstance().getAll()); - } catch (error) { - console.error('[Registries] List error:', error); - res.status(500).json({ error: 'Failed to fetch registries' }); - } -}); - -app.post('/api/registries', (req: Request, res: Response): void => { - if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } - if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; - try { - const { name, url, type, username, secret, aws_region } = req.body; - - if (!name || typeof name !== 'string' || name.length > 100) { - res.status(400).json({ error: 'Name is required (max 100 characters).' }); return; - } - if (!url || typeof url !== 'string' || url.length > 500) { - res.status(400).json({ error: 'URL is required (max 500 characters).' }); return; - } - if (!type || !VALID_REGISTRY_TYPES.includes(type)) { - res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; - } - if (!isValidRegistryUrl(url, type)) { - res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; - } - if (!username || typeof username !== 'string') { - res.status(400).json({ error: 'Username is required.' }); return; - } - if (!secret || typeof secret !== 'string') { - res.status(400).json({ error: 'Secret/token is required.' }); return; - } - if (type === 'ecr' && (!aws_region || typeof aws_region !== 'string')) { - res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; - } - - const id = RegistryService.getInstance().create({ name, url, type, username, secret, aws_region: aws_region ?? null }); - res.status(201).json({ id }); - } catch (error) { - console.error('[Registries] Create error:', error); - res.status(500).json({ error: 'Failed to create registry' }); - } -}); - -app.put('/api/registries/:id', (req: Request, res: Response): void => { - if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } - if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } - - const existing = RegistryService.getInstance().getById(id); - if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } - - const { name, url, type, username, secret, aws_region } = req.body; - - if (name !== undefined && (typeof name !== 'string' || name.length > 100)) { - res.status(400).json({ error: 'Name must be a string (max 100 characters).' }); return; - } - if (url !== undefined && (typeof url !== 'string' || url.length > 500)) { - res.status(400).json({ error: 'URL must be a string (max 500 characters).' }); return; - } - if (type !== undefined && !VALID_REGISTRY_TYPES.includes(type)) { - res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; - } - const effectiveType = type ?? existing.type; - if (url !== undefined && !isValidRegistryUrl(url, effectiveType)) { - res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; - } - if (effectiveType === 'ecr' && aws_region !== undefined && (typeof aws_region !== 'string' || !aws_region)) { - res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; - } - - RegistryService.getInstance().update(id, { name, url, type, username, secret, aws_region }); - res.json({ success: true }); - } catch (error) { - console.error('[Registries] Update error:', error); - res.status(500).json({ error: 'Failed to update registry' }); - } -}); - -app.delete('/api/registries/:id', (req: Request, res: Response): void => { - if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } - if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } - - const existing = RegistryService.getInstance().getById(id); - if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } - - RegistryService.getInstance().delete(id); - res.json({ success: true }); - } catch (error) { - console.error('[Registries] Delete error:', error); - res.status(500).json({ error: 'Failed to delete registry' }); - } -}); - -app.post('/api/registries/:id/test', async (req: Request, res: Response): Promise => { - if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } - if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; - try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } - - const result = await RegistryService.getInstance().testConnection(id); - res.json(result); - } catch (error) { - console.error('[Registries] Test error:', error); - res.status(500).json({ error: 'Failed to test registry connection' }); - } -}); - -// Stateless test: validate credentials without persisting. Powers the -// "Test connection" button inside the create/edit form so users can verify -// creds before saving. -app.post('/api/registries/test', async (req: Request, res: Response): Promise => { - if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } - if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; - try { - const { type, url, username, secret, aws_region } = req.body; - - if (!type || !VALID_REGISTRY_TYPES.includes(type)) { - res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; - } - if (typeof url !== 'string' || url.length === 0 || url.length > 500) { - res.status(400).json({ error: 'URL is required (max 500 characters).' }); return; - } - if (!isValidRegistryUrl(url, type)) { - res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; - } - if (typeof username !== 'string' || username.length === 0) { - res.status(400).json({ error: 'Username is required.' }); return; - } - if (typeof secret !== 'string' || secret.length === 0) { - res.status(400).json({ error: 'Secret/token is required.' }); return; - } - if (type === 'ecr' && (typeof aws_region !== 'string' || !aws_region)) { - res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; - } - - const result = await RegistryService.getInstance().testWithCredentials({ - type, - url, - username, - secret, - aws_region: aws_region ?? null, - }); - res.json(result); - } catch (error) { - console.error('[Registries] Stateless test error:', error); - res.status(500).json({ error: 'Failed to test registry connection' }); - } -}); - -// --- System Maintenance Routes (The System Janitor) --- - -app.get('/api/system/orphans', async (req: Request, res: Response) => { - try { - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const dockerController = DockerController.getInstance(req.nodeId); - const orphans = await dockerController.getOrphanContainers(knownStacks); - res.json(orphans); - } catch (error) { - console.error('Failed to fetch orphan containers:', error); - res.status(500).json({ error: 'Failed to fetch orphan containers' }); - } -}); - -app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { containerIds } = req.body; - if (!Array.isArray(containerIds)) { - return res.status(400).json({ error: 'containerIds must be an array' }); - } - const invalidIds = containerIds.filter((id: unknown) => typeof id !== 'string' || !isValidDockerResourceId(id)); - if (invalidIds.length > 0) { - return res.status(400).json({ error: 'One or more container IDs have an invalid format' }); - } - console.log(`[Resources] Prune orphans: ${containerIds.length} container(s) requested`); - const dockerController = DockerController.getInstance(req.nodeId); - const results = await dockerController.removeContainers(containerIds); - const succeeded = results.filter((r: { success: boolean }) => r.success).length; - console.log(`[Resources] Prune orphans completed: ${succeeded}/${containerIds.length} removed`); - invalidateNodeCaches(req.nodeId); - res.json({ results }); - } catch (error) { - console.error('Failed to prune orphan containers:', error); - res.status(500).json({ error: 'Failed to prune orphan containers' }); - } -}); - -app.post('/api/system/prune/system', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { target, scope } = req.body as { target: string; scope?: string }; - if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { - return res.status(400).json({ error: 'Invalid prune target' }); - } - - const pruneScope = scope === 'managed' ? 'managed' : 'all'; - console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`); - const dockerController = DockerController.getInstance(req.nodeId); - - let result: { success: boolean; reclaimedBytes: number }; - if (pruneScope === 'managed' && target !== 'containers') { - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - result = await dockerController.pruneManagedOnly( - target as 'images' | 'volumes' | 'networks', - knownStacks - ); - } else { - result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes'); - } - - console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`); - if (target === 'containers') { - invalidateNodeCaches(req.nodeId); - } - res.json({ message: 'Prune completed', ...result }); - } catch (error: unknown) { - console.error('System prune error:', error); - res.status(500).json({ error: 'System prune failed' }); - } -}); - -app.get('/api/system/docker-df', async (req: Request, res: Response) => { - try { - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks); - res.json(df); - } catch (error) { - console.error('Failed to fetch docker disk usage:', error); - res.status(500).json({ error: 'Failed to fetch docker disk usage' }); - } -}); - -// Single endpoint returning classified images, volumes, and networks in one call -app.get('/api/system/resources', async (req: Request, res: Response) => { - try { - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); - res.json(result); - } catch (error) { - console.error('Failed to fetch classified resources:', error); - res.status(500).json({ error: 'Failed to fetch resources' }); - } -}); - -// Keep legacy endpoints for backward compat with remote proxy routing -app.get('/api/system/images', async (req: Request, res: Response) => { - try { - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); - res.json(images); - } catch (error) { - console.error('Failed to fetch images:', error); - res.status(500).json({ error: 'Failed to fetch images' }); - } -}); - -app.get('/api/system/volumes', async (req: Request, res: Response) => { - try { - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); - res.json(volumes); - } catch (error) { - console.error('Failed to fetch volumes:', error); - res.status(500).json({ error: 'Failed to fetch volumes' }); - } -}); - -app.get('/api/system/networks', async (req: Request, res: Response) => { - try { - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); - res.json(networks); - } catch (error) { - console.error('Failed to fetch networks:', error); - res.status(500).json({ error: 'Failed to fetch networks' }); - } -}); - -app.post('/api/system/images/delete', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { id } = req.body; - if (!id) return res.status(400).json({ error: 'ID is required' }); - if (typeof id !== 'string' || !isValidDockerResourceId(id)) { - return res.status(400).json({ error: 'Invalid image ID format' }); - } - console.log(`[Resources] Delete image: ${id.substring(0, 12)}`); - const dockerController = DockerController.getInstance(req.nodeId); - await dockerController.removeImage(id); - invalidateNodeCaches(req.nodeId); - res.json({ success: true, message: 'Image deleted' }); - } catch (error: unknown) { - console.error('Failed to delete image:', error); - res.status(500).json({ error: 'Failed to delete image' }); - } -}); - -app.post('/api/system/volumes/delete', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { id } = req.body; - if (!id || typeof id !== 'string') return res.status(400).json({ error: 'Volume name is required' }); - console.log(`[Resources] Delete volume: ${id}`); - const dockerController = DockerController.getInstance(req.nodeId); - await dockerController.removeVolume(id); - invalidateNodeCaches(req.nodeId); - res.json({ success: true, message: 'Volume deleted' }); - } catch (error: unknown) { - console.error('Failed to delete volume:', error); - res.status(500).json({ error: 'Failed to delete volume' }); - } -}); - -app.post('/api/system/networks/delete', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { id } = req.body; - if (!id) return res.status(400).json({ error: 'ID is required' }); - if (typeof id !== 'string' || !isValidDockerResourceId(id)) { - return res.status(400).json({ error: 'Invalid network ID format' }); - } - console.log(`[Resources] Delete network: ${id.substring(0, 12)}`); - const dockerController = DockerController.getInstance(req.nodeId); - await dockerController.removeNetwork(id); - invalidateNodeCaches(req.nodeId); - res.json({ success: true, message: 'Network deleted' }); - } catch (error: unknown) { - console.error('Failed to delete network:', error); - res.status(500).json({ error: 'Failed to delete network' }); - } -}); - -app.get('/api/system/networks/topology', async (req: Request, res: Response) => { - if (!requirePaid(req, res)) return; - try { - const includeSystem = req.query.includeSystem === 'true'; - const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const dockerController = DockerController.getInstance(req.nodeId); - const topology = await dockerController.getTopologyData(knownStacks, includeSystem); - console.log(`[Resources] Topology fetched: ${topology.length} networks, includeSystem=${includeSystem}`); - if (isDebugEnabled()) console.debug('[Resources:debug] Topology fetched', { networkCount: topology.length, includeSystem }); - res.json(topology); - } catch (error: unknown) { - console.error('Failed to fetch network topology:', error); - res.status(500).json({ error: 'Failed to fetch network topology' }); - } -}); - -app.get('/api/system/networks/:id', async (req: Request, res: Response) => { - try { - const id = req.params.id as string; - if (!id) return res.status(400).json({ error: 'Network ID is required' }); - const dockerController = DockerController.getInstance(req.nodeId); - const networkInfo = await dockerController.inspectNetwork(id); - res.json(networkInfo); - } catch (error: unknown) { - console.error('Failed to inspect network:', error); - const err = error as Record; - const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404) - || (error instanceof Error && error.message.includes('404')); - res.status(is404 ? 404 : 500).json({ error: is404 ? 'Network not found' : 'Failed to inspect network' }); - } -}); - -app.post('/api/system/networks', async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { name, driver, subnet, gateway, labels, internal, attachable } = req.body; - if (!name) return res.status(400).json({ error: 'Network name is required' }); - - const options: CreateNetworkOptions = { Name: name }; - - const VALID_DRIVERS: NetworkDriver[] = ['bridge', 'overlay', 'macvlan', 'host', 'none']; - if (driver) { - if (!VALID_DRIVERS.includes(driver)) return res.status(400).json({ error: 'Invalid network driver' }); - options.Driver = driver; - } - if (subnet || gateway) { - if (subnet && !isValidCidr(subnet)) return res.status(400).json({ error: 'Invalid subnet CIDR notation (e.g. 172.20.0.0/16)' }); - if (gateway && !isValidIPv4(gateway)) return res.status(400).json({ error: 'Invalid gateway IP address (e.g. 172.20.0.1)' }); - options.IPAM = { Config: [{}] }; - if (subnet) options.IPAM.Config[0].Subnet = subnet; - if (gateway) options.IPAM.Config[0].Gateway = gateway; - } - if (labels && typeof labels === 'object' && !Array.isArray(labels)) options.Labels = labels; - if (internal) options.Internal = true; - if (attachable) options.Attachable = true; - - const dockerController = DockerController.getInstance(req.nodeId); - const network = await dockerController.createNetwork(options); - console.log(`[Resources] Network created: ${name}`); - invalidateNodeCaches(req.nodeId); - res.status(201).json({ success: true, message: 'Network created', id: network.id }); - } catch (error: unknown) { - console.error('Failed to create network:', error); - const msg = getErrorMessage(error, ''); - const safePatterns = ['already exists', 'name is invalid', 'invalid network name']; - const lowerMsg = msg.toLowerCase(); - const isSafe = safePatterns.some(p => lowerMsg.includes(p)); - res.status(isSafe ? 409 : 500).json({ error: isSafe ? msg : 'Failed to create network' }); - } -}); - -// --- App Templates Routes --- - -app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => { - try { - const templates = await templateService.getTemplates(); - - const imageRefs = templates.map(t => t.image).filter((i): i is string => !!i); - const scanSummary = DatabaseService.getInstance().getLatestScanSummaryByImageRefs(req.nodeId, imageRefs); - - let featuredIndex = -1; - let featuredStars = 0; - templates.forEach((t, i) => { - const s = t.stars ?? 0; - if (s > featuredStars) { - featuredStars = s; - featuredIndex = i; - } - }); - - const enriched = templates.map((t, i) => { - const summary = t.image ? scanSummary.get(t.image) : undefined; - const scan_status: 'clean' | 'vulnerable' | 'unscanned' = summary - ? (summary.total === 0 ? 'clean' : 'vulnerable') - : 'unscanned'; - return { - ...t, - scan_status, - scan_cve_count: summary?.total ?? 0, - scan_critical_count: summary?.critical ?? 0, - scan_high_count: summary?.high ?? 0, - featured: i === featuredIndex, - }; - }); - - res.json(enriched); - } catch (error) { - console.error('[Templates] Failed to fetch:', error); - res.status(500).json({ error: 'Failed to fetch templates' }); - } -}); - -app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - templateService.clearCache(); - console.log('[Templates] Cache cleared by', req.user?.username || 'unknown'); - res.json({ success: true }); -}); - -app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; - try { - const { stackName, template, envVars, skip_scan } = req.body; - - if (!stackName || !template) { - return res.status(400).json({ error: 'stackName and template are required' }); - } - - if (!isValidStackName(stackName)) { - return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' }); - } - - const fsService = FileSystemService.getInstance(req.nodeId); - const baseDir = fsService.getBaseDir(); - const stackPath = path.join(baseDir, stackName); - if (!isPathWithinBase(stackPath, baseDir)) { - return res.status(400).json({ error: 'Invalid stack path' }); - } - - try { - await fsPromises.access(stackPath); - - if (await fsService.hasComposeFile(stackPath)) { - return res.status(409).json({ - error: `A stack directory named '${stackName}' already exists. Please choose a different Stack Name.`, - rolledBack: false - }); - } - - // Orphaned directory left by external deletion (e.g. Docker Desktop). - console.log(`[Templates] Cleaned up orphaned stack directory: ${stackName}`); - await fsService.deleteStack(stackName); - } catch { - // Directory does not exist; proceed with deploy - } - - const debug = isDebugEnabled(); - console.log(`[Templates] Deploy started: ${stackName}`); - if (debug) console.debug('[Templates:debug] Deploy payload', { stackName, templateTitle: template.title, envVarCount: envVars ? Object.keys(envVars).length : 0 }); - - // 1. Create stack directory - await fsService.createStack(stackName); - - // 2. Generate compose YAML and save - const composeYaml = templateService.generateComposeFromTemplate(template, stackName); - await fsService.saveStackContent(stackName, composeYaml); - - // 3. Generate env string and save to default .env - if (envVars && Object.keys(envVars).length > 0) { - const envString = templateService.generateEnvString(envVars); - const defaultEnvPath = path.join(stackPath, '.env'); - await fsPromises.writeFile(defaultEnvPath, envString, 'utf-8'); - } - - // 4. Deploy the stack with atomic rollback - try { - if (!(await runPolicyGate(req, res, stackName, req.nodeId))) { - // Gate blocked: clean up the files we just wrote so the user can - // retry after remediating the vulnerable image. - try { - await fsService.deleteStack(stackName); - } catch (cleanupErr) { - console.error(`[Templates] Cleanup after policy block failed for ${stackName}:`, cleanupErr); - } - return; - } - const atomic = LicenseService.getInstance().getTier() === 'paid'; - await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic); - invalidateNodeCaches(req.nodeId); - console.log(`[Templates] Deploy completed: ${stackName}`); - res.json({ success: true, message: 'Template deployed successfully' }); - if (!skip_scan) { - triggerPostDeployScan(stackName, req.nodeId).catch(err => - console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err), - ); - } - } catch (deployError: unknown) { - const rawError = getErrorMessage(deployError, String(deployError)); - console.error(`[Templates] Deploy failed: ${stackName} -`, rawError); - const parsed = ErrorParser.parse(rawError); - - const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true; - - if (shouldRollback) { - try { - // Stage 1: Tell Docker to clean up ghost networks/containers - await ComposeService.getInstance(req.nodeId).downStack(stackName); - } catch (downErr) { - console.error("[Templates] Rollback Stage 1 (Docker down) failed:", downErr); - } - - try { - // Stage 2: Remove the stack files - await fsService.deleteStack(stackName); - } catch (fsErr) { - console.error("[Templates] Rollback Stage 2 (File deletion) failed:", fsErr); - } - } - - // Partial state may linger (directory created, deploy failed, rollback - // may or may not have cleaned up). Drop node caches either way. - invalidateNodeCaches(req.nodeId); - res.status(500).json({ - error: parsed.message, - rolledBack: shouldRollback, - ruleId: parsed.rule?.id || 'UNKNOWN' - }); - } - } catch (error: unknown) { - const message = getErrorMessage(error, 'Failed to deploy template'); - console.error('[Templates] Deploy error:', message); - res.status(500).json({ error: message }); - } -}); - // ========================= // Vulnerability Scanning Routes // ========================= diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts new file mode 100644 index 00000000..b3c09930 --- /dev/null +++ b/backend/src/routes/registries.ts @@ -0,0 +1,189 @@ +import { Router, type Request, type Response } from 'express'; +import { RegistryService } from '../services/RegistryService'; +import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; +import { rejectApiTokenScope } from '../middleware/apiTokenScope'; + +const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const; +const REGISTRY_SCOPE_MESSAGE = 'API tokens cannot manage registry credentials.'; + +function isValidRegistryUrl(url: string, type: string): boolean { + if (type === 'dockerhub') return true; + const trimmed = url.trim(); + if (!trimmed) return false; + const lower = trimmed.toLowerCase(); + if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('file:') || lower.startsWith('ftp:')) { + return false; + } + try { + const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + if (!parsed.hostname) return false; + } catch { + return false; + } + return true; +} + +export const registriesRouter = Router(); + +registriesRouter.get('/', (req: Request, res: Response): void => { + if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + res.json(RegistryService.getInstance().getAll()); + } catch (error) { + console.error('[Registries] List error:', error); + res.status(500).json({ error: 'Failed to fetch registries' }); + } +}); + +registriesRouter.post('/', (req: Request, res: Response): void => { + if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const { name, url, type, username, secret, aws_region } = req.body; + + if (!name || typeof name !== 'string' || name.length > 100) { + res.status(400).json({ error: 'Name is required (max 100 characters).' }); return; + } + if (!url || typeof url !== 'string' || url.length > 500) { + res.status(400).json({ error: 'URL is required (max 500 characters).' }); return; + } + if (!type || !(VALID_REGISTRY_TYPES as readonly string[]).includes(type)) { + res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; + } + if (!isValidRegistryUrl(url, type)) { + res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; + } + if (!username || typeof username !== 'string') { + res.status(400).json({ error: 'Username is required.' }); return; + } + if (!secret || typeof secret !== 'string') { + res.status(400).json({ error: 'Secret/token is required.' }); return; + } + if (type === 'ecr' && (!aws_region || typeof aws_region !== 'string')) { + res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; + } + + const id = RegistryService.getInstance().create({ name, url, type, username, secret, aws_region: aws_region ?? null }); + res.status(201).json({ id }); + } catch (error) { + console.error('[Registries] Create error:', error); + res.status(500).json({ error: 'Failed to create registry' }); + } +}); + +registriesRouter.put('/:id', (req: Request, res: Response): void => { + if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + + const existing = RegistryService.getInstance().getById(id); + if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } + + const { name, url, type, username, secret, aws_region } = req.body; + + if (name !== undefined && (typeof name !== 'string' || name.length > 100)) { + res.status(400).json({ error: 'Name must be a string (max 100 characters).' }); return; + } + if (url !== undefined && (typeof url !== 'string' || url.length > 500)) { + res.status(400).json({ error: 'URL must be a string (max 500 characters).' }); return; + } + if (type !== undefined && !(VALID_REGISTRY_TYPES as readonly string[]).includes(type)) { + res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; + } + const effectiveType = type ?? existing.type; + if (url !== undefined && !isValidRegistryUrl(url, effectiveType)) { + res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; + } + if (effectiveType === 'ecr' && aws_region !== undefined && (typeof aws_region !== 'string' || !aws_region)) { + res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; + } + + RegistryService.getInstance().update(id, { name, url, type, username, secret, aws_region }); + res.json({ success: true }); + } catch (error) { + console.error('[Registries] Update error:', error); + res.status(500).json({ error: 'Failed to update registry' }); + } +}); + +registriesRouter.delete('/:id', (req: Request, res: Response): void => { + if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + + const existing = RegistryService.getInstance().getById(id); + if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } + + RegistryService.getInstance().delete(id); + res.json({ success: true }); + } catch (error) { + console.error('[Registries] Delete error:', error); + res.status(500).json({ error: 'Failed to delete registry' }); + } +}); + +registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + + const result = await RegistryService.getInstance().testConnection(id); + res.json(result); + } catch (error) { + console.error('[Registries] Test error:', error); + res.status(500).json({ error: 'Failed to test registry connection' }); + } +}); + +registriesRouter.post('/test', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + if (!requireAdmiral(req, res)) return; + try { + const { type, url, username, secret, aws_region } = req.body; + + if (!type || !(VALID_REGISTRY_TYPES as readonly string[]).includes(type)) { + res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; + } + if (typeof url !== 'string' || url.length === 0 || url.length > 500) { + res.status(400).json({ error: 'URL is required (max 500 characters).' }); return; + } + if (!isValidRegistryUrl(url, type)) { + res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; + } + if (typeof username !== 'string' || username.length === 0) { + res.status(400).json({ error: 'Username is required.' }); return; + } + if (typeof secret !== 'string' || secret.length === 0) { + res.status(400).json({ error: 'Secret/token is required.' }); return; + } + if (type === 'ecr' && (typeof aws_region !== 'string' || !aws_region)) { + res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; + } + + const result = await RegistryService.getInstance().testWithCredentials({ + type, + url, + username, + secret, + aws_region: aws_region ?? null, + }); + res.json(result); + } catch (error) { + console.error('[Registries] Stateless test error:', error); + res.status(500).json({ error: 'Failed to test registry connection' }); + } +}); diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts new file mode 100644 index 00000000..2abda37a --- /dev/null +++ b/backend/src/routes/systemMaintenance.ts @@ -0,0 +1,260 @@ +import { Router, type Request, type Response } from 'express'; +import DockerController, { type CreateNetworkOptions, type NetworkDriver } from '../services/DockerController'; +import { FileSystemService } from '../services/FileSystemService'; +import { requireAdmin, requirePaid } from '../middleware/tierGates'; +import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; +import { isValidDockerResourceId, isValidCidr, isValidIPv4 } from '../utils/validation'; +import { isDebugEnabled } from '../utils/debug'; +import { getErrorMessage } from '../utils/errors'; + +export const systemMaintenanceRouter = Router(); + +systemMaintenanceRouter.get('/orphans', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const dockerController = DockerController.getInstance(req.nodeId); + const orphans = await dockerController.getOrphanContainers(knownStacks); + res.json(orphans); + } catch (error) { + console.error('Failed to fetch orphan containers:', error); + res.status(500).json({ error: 'Failed to fetch orphan containers' }); + } +}); + +systemMaintenanceRouter.post('/prune/orphans', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { containerIds } = req.body; + if (!Array.isArray(containerIds)) { + return res.status(400).json({ error: 'containerIds must be an array' }); + } + const invalidIds = containerIds.filter((id: unknown) => typeof id !== 'string' || !isValidDockerResourceId(id)); + if (invalidIds.length > 0) { + return res.status(400).json({ error: 'One or more container IDs have an invalid format' }); + } + console.log(`[Resources] Prune orphans: ${containerIds.length} container(s) requested`); + const dockerController = DockerController.getInstance(req.nodeId); + const results = await dockerController.removeContainers(containerIds); + const succeeded = results.filter((r: { success: boolean }) => r.success).length; + console.log(`[Resources] Prune orphans completed: ${succeeded}/${containerIds.length} removed`); + invalidateNodeCaches(req.nodeId); + res.json({ results }); + } catch (error) { + console.error('Failed to prune orphan containers:', error); + res.status(500).json({ error: 'Failed to prune orphan containers' }); + } +}); + +systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { target, scope } = req.body as { target: string; scope?: string }; + if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { + return res.status(400).json({ error: 'Invalid prune target' }); + } + + const pruneScope = scope === 'managed' ? 'managed' : 'all'; + console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`); + const dockerController = DockerController.getInstance(req.nodeId); + + let result: { success: boolean; reclaimedBytes: number }; + if (pruneScope === 'managed' && target !== 'containers') { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + result = await dockerController.pruneManagedOnly( + target as 'images' | 'volumes' | 'networks', + knownStacks + ); + } else { + result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes'); + } + + console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`); + if (target === 'containers') { + invalidateNodeCaches(req.nodeId); + } + res.json({ message: 'Prune completed', ...result }); + } catch (error: unknown) { + console.error('System prune error:', error); + res.status(500).json({ error: 'System prune failed' }); + } +}); + +systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks); + res.json(df); + } catch (error) { + console.error('Failed to fetch docker disk usage:', error); + res.status(500).json({ error: 'Failed to fetch docker disk usage' }); + } +}); + +systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); + res.json(result); + } catch (error) { + console.error('Failed to fetch classified resources:', error); + res.status(500).json({ error: 'Failed to fetch resources' }); + } +}); + +systemMaintenanceRouter.get('/images', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); + res.json(images); + } catch (error) { + console.error('Failed to fetch images:', error); + res.status(500).json({ error: 'Failed to fetch images' }); + } +}); + +systemMaintenanceRouter.get('/volumes', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); + res.json(volumes); + } catch (error) { + console.error('Failed to fetch volumes:', error); + res.status(500).json({ error: 'Failed to fetch volumes' }); + } +}); + +systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); + res.json(networks); + } catch (error) { + console.error('Failed to fetch networks:', error); + res.status(500).json({ error: 'Failed to fetch networks' }); + } +}); + +systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { id } = req.body; + if (!id) return res.status(400).json({ error: 'ID is required' }); + if (typeof id !== 'string' || !isValidDockerResourceId(id)) { + return res.status(400).json({ error: 'Invalid image ID format' }); + } + console.log(`[Resources] Delete image: ${id.substring(0, 12)}`); + const dockerController = DockerController.getInstance(req.nodeId); + await dockerController.removeImage(id); + invalidateNodeCaches(req.nodeId); + res.json({ success: true, message: 'Image deleted' }); + } catch (error: unknown) { + console.error('Failed to delete image:', error); + res.status(500).json({ error: 'Failed to delete image' }); + } +}); + +systemMaintenanceRouter.post('/volumes/delete', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { id } = req.body; + if (!id || typeof id !== 'string') return res.status(400).json({ error: 'Volume name is required' }); + console.log(`[Resources] Delete volume: ${id}`); + const dockerController = DockerController.getInstance(req.nodeId); + await dockerController.removeVolume(id); + invalidateNodeCaches(req.nodeId); + res.json({ success: true, message: 'Volume deleted' }); + } catch (error: unknown) { + console.error('Failed to delete volume:', error); + res.status(500).json({ error: 'Failed to delete volume' }); + } +}); + +systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { id } = req.body; + if (!id) return res.status(400).json({ error: 'ID is required' }); + if (typeof id !== 'string' || !isValidDockerResourceId(id)) { + return res.status(400).json({ error: 'Invalid network ID format' }); + } + console.log(`[Resources] Delete network: ${id.substring(0, 12)}`); + const dockerController = DockerController.getInstance(req.nodeId); + await dockerController.removeNetwork(id); + invalidateNodeCaches(req.nodeId); + res.json({ success: true, message: 'Network deleted' }); + } catch (error: unknown) { + console.error('Failed to delete network:', error); + res.status(500).json({ error: 'Failed to delete network' }); + } +}); + +systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Response) => { + if (!requirePaid(req, res)) return; + try { + const includeSystem = req.query.includeSystem === 'true'; + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const dockerController = DockerController.getInstance(req.nodeId); + const topology = await dockerController.getTopologyData(knownStacks, includeSystem); + console.log(`[Resources] Topology fetched: ${topology.length} networks, includeSystem=${includeSystem}`); + if (isDebugEnabled()) console.debug('[Resources:debug] Topology fetched', { networkCount: topology.length, includeSystem }); + res.json(topology); + } catch (error: unknown) { + console.error('Failed to fetch network topology:', error); + res.status(500).json({ error: 'Failed to fetch network topology' }); + } +}); + +systemMaintenanceRouter.get('/networks/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id as string; + if (!id) return res.status(400).json({ error: 'Network ID is required' }); + const dockerController = DockerController.getInstance(req.nodeId); + const networkInfo = await dockerController.inspectNetwork(id); + res.json(networkInfo); + } catch (error: unknown) { + console.error('Failed to inspect network:', error); + const err = error as Record; + const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404) + || (error instanceof Error && error.message.includes('404')); + res.status(is404 ? 404 : 500).json({ error: is404 ? 'Network not found' : 'Failed to inspect network' }); + } +}); + +systemMaintenanceRouter.post('/networks', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { name, driver, subnet, gateway, labels, internal, attachable } = req.body; + if (!name) return res.status(400).json({ error: 'Network name is required' }); + + const options: CreateNetworkOptions = { Name: name }; + + const VALID_DRIVERS: NetworkDriver[] = ['bridge', 'overlay', 'macvlan', 'host', 'none']; + if (driver) { + if (!VALID_DRIVERS.includes(driver)) return res.status(400).json({ error: 'Invalid network driver' }); + options.Driver = driver; + } + if (subnet || gateway) { + if (subnet && !isValidCidr(subnet)) return res.status(400).json({ error: 'Invalid subnet CIDR notation (e.g. 172.20.0.0/16)' }); + if (gateway && !isValidIPv4(gateway)) return res.status(400).json({ error: 'Invalid gateway IP address (e.g. 172.20.0.1)' }); + options.IPAM = { Config: [{}] }; + if (subnet) options.IPAM.Config[0].Subnet = subnet; + if (gateway) options.IPAM.Config[0].Gateway = gateway; + } + if (labels && typeof labels === 'object' && !Array.isArray(labels)) options.Labels = labels; + if (internal) options.Internal = true; + if (attachable) options.Attachable = true; + + const dockerController = DockerController.getInstance(req.nodeId); + const network = await dockerController.createNetwork(options); + console.log(`[Resources] Network created: ${name}`); + invalidateNodeCaches(req.nodeId); + res.status(201).json({ success: true, message: 'Network created', id: network.id }); + } catch (error: unknown) { + console.error('Failed to create network:', error); + const msg = getErrorMessage(error, ''); + const safePatterns = ['already exists', 'name is invalid', 'invalid network name']; + const lowerMsg = msg.toLowerCase(); + const isSafe = safePatterns.some(p => lowerMsg.includes(p)); + res.status(isSafe ? 409 : 500).json({ error: isSafe ? msg : 'Failed to create network' }); + } +}); diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts new file mode 100644 index 00000000..78b66674 --- /dev/null +++ b/backend/src/routes/templates.ts @@ -0,0 +1,170 @@ +import { Router, type Request, type Response } from 'express'; +import path from 'path'; +import { promises as fsPromises } from 'fs'; +import { authMiddleware } from '../middleware/auth'; +import { requireAdmin } from '../middleware/tierGates'; +import { templateService } from '../services/TemplateService'; +import { FileSystemService } from '../services/FileSystemService'; +import { ComposeService } from '../services/ComposeService'; +import { DatabaseService } from '../services/DatabaseService'; +import { LicenseService } from '../services/LicenseService'; +import { ErrorParser } from '../utils/ErrorParser'; +import { isValidStackName, isPathWithinBase } from '../utils/validation'; +import { isDebugEnabled } from '../utils/debug'; +import { getErrorMessage } from '../utils/errors'; +import { runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate'; +import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; +import { getTerminalWs } from '../websocket/generic'; + +export const templatesRouter = Router(); + +templatesRouter.get('/', authMiddleware, async (req: Request, res: Response) => { + try { + const templates = await templateService.getTemplates(); + + const imageRefs = templates.map(t => t.image).filter((i): i is string => !!i); + const scanSummary = DatabaseService.getInstance().getLatestScanSummaryByImageRefs(req.nodeId, imageRefs); + + let featuredIndex = -1; + let featuredStars = 0; + templates.forEach((t, i) => { + const s = t.stars ?? 0; + if (s > featuredStars) { + featuredStars = s; + featuredIndex = i; + } + }); + + const enriched = templates.map((t, i) => { + const summary = t.image ? scanSummary.get(t.image) : undefined; + const scan_status: 'clean' | 'vulnerable' | 'unscanned' = summary + ? (summary.total === 0 ? 'clean' : 'vulnerable') + : 'unscanned'; + return { + ...t, + scan_status, + scan_cve_count: summary?.total ?? 0, + scan_critical_count: summary?.critical ?? 0, + scan_high_count: summary?.high ?? 0, + featured: i === featuredIndex, + }; + }); + + res.json(enriched); + } catch (error) { + console.error('[Templates] Failed to fetch:', error); + res.status(500).json({ error: 'Failed to fetch templates' }); + } +}); + +templatesRouter.post('/refresh-cache', authMiddleware, (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + templateService.clearCache(); + console.log('[Templates] Cache cleared by', req.user?.username || 'unknown'); + res.json({ success: true }); +}); + +templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { stackName, template, envVars, skip_scan } = req.body; + + if (!stackName || !template) { + return res.status(400).json({ error: 'stackName and template are required' }); + } + + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' }); + } + + const fsService = FileSystemService.getInstance(req.nodeId); + const baseDir = fsService.getBaseDir(); + const stackPath = path.join(baseDir, stackName); + if (!isPathWithinBase(stackPath, baseDir)) { + return res.status(400).json({ error: 'Invalid stack path' }); + } + + try { + await fsPromises.access(stackPath); + + if (await fsService.hasComposeFile(stackPath)) { + return res.status(409).json({ + error: `A stack directory named '${stackName}' already exists. Please choose a different Stack Name.`, + rolledBack: false + }); + } + + console.log(`[Templates] Cleaned up orphaned stack directory: ${stackName}`); + await fsService.deleteStack(stackName); + } catch { + // Directory does not exist; proceed with deploy + } + + const debug = isDebugEnabled(); + console.log(`[Templates] Deploy started: ${stackName}`); + if (debug) console.debug('[Templates:debug] Deploy payload', { stackName, templateTitle: template.title, envVarCount: envVars ? Object.keys(envVars).length : 0 }); + + await fsService.createStack(stackName); + + const composeYaml = templateService.generateComposeFromTemplate(template, stackName); + await fsService.saveStackContent(stackName, composeYaml); + + if (envVars && Object.keys(envVars).length > 0) { + const envString = templateService.generateEnvString(envVars); + const defaultEnvPath = path.join(stackPath, '.env'); + await fsPromises.writeFile(defaultEnvPath, envString, 'utf-8'); + } + + try { + if (!(await runPolicyGate(req, res, stackName, req.nodeId))) { + try { + await fsService.deleteStack(stackName); + } catch (cleanupErr) { + console.error(`[Templates] Cleanup after policy block failed for ${stackName}:`, cleanupErr); + } + return; + } + const atomic = LicenseService.getInstance().getTier() === 'paid'; + await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic); + invalidateNodeCaches(req.nodeId); + console.log(`[Templates] Deploy completed: ${stackName}`); + res.json({ success: true, message: 'Template deployed successfully' }); + if (!skip_scan) { + triggerPostDeployScan(stackName, req.nodeId).catch(err => + console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err), + ); + } + } catch (deployError: unknown) { + const rawError = getErrorMessage(deployError, String(deployError)); + console.error(`[Templates] Deploy failed: ${stackName} -`, rawError); + const parsed = ErrorParser.parse(rawError); + + const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true; + + if (shouldRollback) { + try { + await ComposeService.getInstance(req.nodeId).downStack(stackName); + } catch (downErr) { + console.error("[Templates] Rollback Stage 1 (Docker down) failed:", downErr); + } + + try { + await fsService.deleteStack(stackName); + } catch (fsErr) { + console.error("[Templates] Rollback Stage 2 (File deletion) failed:", fsErr); + } + } + + invalidateNodeCaches(req.nodeId); + res.status(500).json({ + error: parsed.message, + rolledBack: shouldRollback, + ruleId: parsed.rule?.id || 'UNKNOWN' + }); + } + } catch (error: unknown) { + const message = getErrorMessage(error, 'Failed to deploy template'); + console.error('[Templates] Deploy error:', message); + res.status(500).json({ error: message }); + } +});