mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
refactor(backend): extract registries, system-maintenance, templates routers (phase 4c-2) (#740)
Move three more Round C route groups out of index.ts:
- /api/registries/* -> routes/registries.ts
- /api/system/{orphans,prune,docker-df,resources,images,volumes,networks,...} -> routes/systemMaintenance.ts
- /api/templates/* -> routes/templates.ts
Handlers moved verbatim. Middleware chains, response shapes, and error
messages are preserved. Registry scope-denial now uses the shared
rejectApiTokenScope helper (same payload shape).
index.ts drops from 2739 to 2130 lines. Remaining inline groups:
containers, stacks, security (Trivy/scans/SBOM/policies), and nodes.
This commit is contained in:
+8
-617
@@ -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<void> => {
|
||||
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
|
||||
|
||||
const result = await RegistryService.getInstance().testConnection(id);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Registries] Test error:', error);
|
||||
res.status(500).json({ error: 'Failed to test registry connection' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stateless test: validate credentials without persisting. Powers the
|
||||
// "Test connection" button inside the create/edit form so users can verify
|
||||
// creds before saving.
|
||||
app.post('/api/registries/test', async (req: Request, res: Response): Promise<void> => {
|
||||
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const { type, url, username, secret, aws_region } = req.body;
|
||||
|
||||
if (!type || !VALID_REGISTRY_TYPES.includes(type)) {
|
||||
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
|
||||
}
|
||||
if (typeof url !== 'string' || url.length === 0 || url.length > 500) {
|
||||
res.status(400).json({ error: 'URL is required (max 500 characters).' }); return;
|
||||
}
|
||||
if (!isValidRegistryUrl(url, type)) {
|
||||
res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return;
|
||||
}
|
||||
if (typeof username !== 'string' || username.length === 0) {
|
||||
res.status(400).json({ error: 'Username is required.' }); return;
|
||||
}
|
||||
if (typeof secret !== 'string' || secret.length === 0) {
|
||||
res.status(400).json({ error: 'Secret/token is required.' }); return;
|
||||
}
|
||||
if (type === 'ecr' && (typeof aws_region !== 'string' || !aws_region)) {
|
||||
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
|
||||
}
|
||||
|
||||
const result = await RegistryService.getInstance().testWithCredentials({
|
||||
type,
|
||||
url,
|
||||
username,
|
||||
secret,
|
||||
aws_region: aws_region ?? null,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Registries] Stateless test error:', error);
|
||||
res.status(500).json({ error: 'Failed to test registry connection' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- System Maintenance Routes (The System Janitor) ---
|
||||
|
||||
app.get('/api/system/orphans', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const orphans = await dockerController.getOrphanContainers(knownStacks);
|
||||
res.json(orphans);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch orphan containers:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch orphan containers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { containerIds } = req.body;
|
||||
if (!Array.isArray(containerIds)) {
|
||||
return res.status(400).json({ error: 'containerIds must be an array' });
|
||||
}
|
||||
const invalidIds = containerIds.filter((id: unknown) => typeof id !== 'string' || !isValidDockerResourceId(id));
|
||||
if (invalidIds.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more container IDs have an invalid format' });
|
||||
}
|
||||
console.log(`[Resources] Prune orphans: ${containerIds.length} container(s) requested`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const results = await dockerController.removeContainers(containerIds);
|
||||
const succeeded = results.filter((r: { success: boolean }) => r.success).length;
|
||||
console.log(`[Resources] Prune orphans completed: ${succeeded}/${containerIds.length} removed`);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
console.error('Failed to prune orphan containers:', error);
|
||||
res.status(500).json({ error: 'Failed to prune orphan containers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { target, scope } = req.body as { target: string; scope?: string };
|
||||
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||
return res.status(400).json({ error: 'Invalid prune target' });
|
||||
}
|
||||
|
||||
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
||||
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
|
||||
let result: { success: boolean; reclaimedBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
result = await dockerController.pruneManagedOnly(
|
||||
target as 'images' | 'volumes' | 'networks',
|
||||
knownStacks
|
||||
);
|
||||
} else {
|
||||
result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes');
|
||||
}
|
||||
|
||||
console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`);
|
||||
if (target === 'containers') {
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
}
|
||||
res.json({ message: 'Prune completed', ...result });
|
||||
} catch (error: unknown) {
|
||||
console.error('System prune error:', error);
|
||||
res.status(500).json({ error: 'System prune failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/docker-df', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks);
|
||||
res.json(df);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch docker disk usage:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch docker disk usage' });
|
||||
}
|
||||
});
|
||||
|
||||
// Single endpoint returning classified images, volumes, and networks in one call
|
||||
app.get('/api/system/resources', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch classified resources:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch resources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Keep legacy endpoints for backward compat with remote proxy routing
|
||||
app.get('/api/system/images', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(images);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch images:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch images' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/volumes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(volumes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch volumes:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch volumes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(networks);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch networks:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch networks' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/images/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
if (typeof id !== 'string' || !isValidDockerResourceId(id)) {
|
||||
return res.status(400).json({ error: 'Invalid image ID format' });
|
||||
}
|
||||
console.log(`[Resources] Delete image: ${id.substring(0, 12)}`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
await dockerController.removeImage(id);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ success: true, message: 'Image deleted' });
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to delete image:', error);
|
||||
res.status(500).json({ error: 'Failed to delete image' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/volumes/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'Volume name is required' });
|
||||
console.log(`[Resources] Delete volume: ${id}`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
await dockerController.removeVolume(id);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ success: true, message: 'Volume deleted' });
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to delete volume:', error);
|
||||
res.status(500).json({ error: 'Failed to delete volume' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
if (typeof id !== 'string' || !isValidDockerResourceId(id)) {
|
||||
return res.status(400).json({ error: 'Invalid network ID format' });
|
||||
}
|
||||
console.log(`[Resources] Delete network: ${id.substring(0, 12)}`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
await dockerController.removeNetwork(id);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.json({ success: true, message: 'Network deleted' });
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to delete network:', error);
|
||||
res.status(500).json({ error: 'Failed to delete network' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const includeSystem = req.query.includeSystem === 'true';
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const topology = await dockerController.getTopologyData(knownStacks, includeSystem);
|
||||
console.log(`[Resources] Topology fetched: ${topology.length} networks, includeSystem=${includeSystem}`);
|
||||
if (isDebugEnabled()) console.debug('[Resources:debug] Topology fetched', { networkCount: topology.length, includeSystem });
|
||||
res.json(topology);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch network topology:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch network topology' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
if (!id) return res.status(400).json({ error: 'Network ID is required' });
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const networkInfo = await dockerController.inspectNetwork(id);
|
||||
res.json(networkInfo);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to inspect network:', error);
|
||||
const err = error as Record<string, unknown>;
|
||||
const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404)
|
||||
|| (error instanceof Error && error.message.includes('404'));
|
||||
res.status(is404 ? 404 : 500).json({ error: is404 ? 'Network not found' : 'Failed to inspect network' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/networks', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, driver, subnet, gateway, labels, internal, attachable } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Network name is required' });
|
||||
|
||||
const options: CreateNetworkOptions = { Name: name };
|
||||
|
||||
const VALID_DRIVERS: NetworkDriver[] = ['bridge', 'overlay', 'macvlan', 'host', 'none'];
|
||||
if (driver) {
|
||||
if (!VALID_DRIVERS.includes(driver)) return res.status(400).json({ error: 'Invalid network driver' });
|
||||
options.Driver = driver;
|
||||
}
|
||||
if (subnet || gateway) {
|
||||
if (subnet && !isValidCidr(subnet)) return res.status(400).json({ error: 'Invalid subnet CIDR notation (e.g. 172.20.0.0/16)' });
|
||||
if (gateway && !isValidIPv4(gateway)) return res.status(400).json({ error: 'Invalid gateway IP address (e.g. 172.20.0.1)' });
|
||||
options.IPAM = { Config: [{}] };
|
||||
if (subnet) options.IPAM.Config[0].Subnet = subnet;
|
||||
if (gateway) options.IPAM.Config[0].Gateway = gateway;
|
||||
}
|
||||
if (labels && typeof labels === 'object' && !Array.isArray(labels)) options.Labels = labels;
|
||||
if (internal) options.Internal = true;
|
||||
if (attachable) options.Attachable = true;
|
||||
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const network = await dockerController.createNetwork(options);
|
||||
console.log(`[Resources] Network created: ${name}`);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
res.status(201).json({ success: true, message: 'Network created', id: network.id });
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to create network:', error);
|
||||
const msg = getErrorMessage(error, '');
|
||||
const safePatterns = ['already exists', 'name is invalid', 'invalid network name'];
|
||||
const lowerMsg = msg.toLowerCase();
|
||||
const isSafe = safePatterns.some(p => lowerMsg.includes(p));
|
||||
res.status(isSafe ? 409 : 500).json({ error: isSafe ? msg : 'Failed to create network' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- App Templates Routes ---
|
||||
|
||||
app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const templates = await templateService.getTemplates();
|
||||
|
||||
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
|
||||
// =========================
|
||||
|
||||
@@ -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<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 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<void> => {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404)
|
||||
|| (error instanceof Error && error.message.includes('404'));
|
||||
res.status(is404 ? 404 : 500).json({ error: is404 ? 'Network not found' : 'Failed to inspect network' });
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user