Adversarial audit 2026-08-16 (GLM-5.3 delegate, 2 rounds, 141 tool calls):
P0-1: Dashboard WebSocket (/api/v1/ws) dead on EVERY boot since DC-076.
server.js passed module exports (DependencyManager class, {AutoRestartManager}
namespace, SSLMonitor class) instead of createApp()'s live instances — first
.on() threw ERR_INVALID_ARG_TYPE, catch swallowed it. Fix: app.locals.ctx
exposed in src/app.js; server.js passes all 8 real EventEmitter instances.
P0-2: error.log corrupted since 2026-07-14. errorMiddleware called
logError(FILE, SIZE, path, err, meta) — 5 args into a 3-arg wrapper —
logging 'Error: 5242880' garbage every ~60s and DISCARDING the real error
object. Fix: correct 3-arg call + legacy-shape guard in logErrorWrapper +
~74 log.error sites swept to pass real error objects (AST-verified scope-
safe 71/71, 29/29 modules load clean).
P0-3: auth-polling storm (stranded grade=B commit never landed in prod):
401/403 behind TOTP gate hammered /api/v1/services/status + SSE reconnect
every 2-8s, with misleading direct-probe fallback marking services 'up'.
Fix landed + B-round MEDIUM follow-up: TOTP re-auth success now clears
_dcAuthLost, resumes SSE (new _sseResume clears the latch), and refreshes.
Also: eslintignore static-sites/ (33→0 errors); nodemailer 8→9.0.5 and
sharp 0.33→0.35.3 (3 high CVEs killed; jest green on new majors);
dockerode@5/uuid deferred (semver-major, Docker API surface).
Verification: 80/80 suites, 1837/1837 tests; ESLint 0 errors/743 warnings;
node --check all changed files; bundles rebuilt + SW cache bumped.
Judges: Codex quota-dead until Aug 19 (verified live) — GLM adversarial
delegate per operator directive 2026-08-07. Round 1: 98-call mechanical
verification (timed out pre-verdict). Round 2 (this grade): B, one MEDIUM
(re-auth freeze) — fixed in this commit as prescribed.
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
const express = require('express');
|
|
const { DOCKER } = require('../src/utilities/constants');
|
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
|
const { NotFoundError, ValidationError } = require('../src/utilities/errors');
|
|
const { success } = require('../src/utils/responses');
|
|
|
|
/**
|
|
* Validate a Docker container identifier (ID or name).
|
|
* Allows hex container IDs and Docker-compliant names.
|
|
* Blocks path traversal and shell metacharacters.
|
|
* @param {string} id - Container ID or name from route param
|
|
* @throws {ValidationError} if the ID is malformed
|
|
*/
|
|
function validateContainerId(id) {
|
|
if (!id || typeof id !== 'string') {
|
|
throw new ValidationError('Container ID is required');
|
|
}
|
|
// Docker names: [a-zA-Z0-9][a-zA-Z0-9_.-]*
|
|
// Docker IDs: 64-char hex — also matches the above pattern
|
|
// Max 128 chars covers IDs and names
|
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(id)) {
|
|
throw new ValidationError('Invalid container ID format');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate numeric resource limits for container update.
|
|
* @param {*} memory - Memory in MB (optional)
|
|
* @param {*} cpus - CPU count (optional)
|
|
* @throws {ValidationError} if values are out of range
|
|
*/
|
|
function validateResourceLimits(memory, cpus) {
|
|
if (memory !== undefined) {
|
|
const memNum = Number(memory);
|
|
if (isNaN(memNum) || memNum < 0 || memNum > 1048576) {
|
|
throw new ValidationError('Memory must be a number between 0 and 1048576 MB');
|
|
}
|
|
}
|
|
if (cpus !== undefined) {
|
|
const cpuNum = Number(cpus);
|
|
if (isNaN(cpuNum) || cpuNum < 0 || cpuNum > 1024) {
|
|
throw new ValidationError('CPUs must be a number between 0 and 1024');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Containers route factory
|
|
* @param {Object} deps - Explicit dependencies
|
|
* @param {Object} deps.docker - Docker client wrapper (client, pull methods)
|
|
* @param {Object} deps.log - Logger instance
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @param {Object} deps.workflowEngine - WorkflowEngine instance (optional)
|
|
* @returns {express.Router}
|
|
*/
|
|
module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
|
|
const router = express.Router();
|
|
|
|
// Helper: verify container exists before operating on it
|
|
async function getVerifiedContainer(id) {
|
|
validateContainerId(id);
|
|
const container = docker.client.getContainer(id);
|
|
try {
|
|
await container.inspect();
|
|
} catch (err) {
|
|
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
|
|
throw new NotFoundError(`Container ${id}`);
|
|
}
|
|
throw err;
|
|
}
|
|
return container;
|
|
}
|
|
|
|
// Start container
|
|
router.post('/:id/start', asyncHandler(async (req, res) => {
|
|
const container = await getVerifiedContainer(req.params.id);
|
|
await container.start();
|
|
success(res, { message: 'Container started' });
|
|
}, 'container-start'));
|
|
|
|
// Stop container
|
|
router.post('/:id/stop', asyncHandler(async (req, res) => {
|
|
const container = await getVerifiedContainer(req.params.id);
|
|
await container.stop();
|
|
success(res, { message: 'Container stopped' });
|
|
}, 'container-stop'));
|
|
|
|
// Restart container
|
|
router.post('/:id/restart', asyncHandler(async (req, res) => {
|
|
const container = await getVerifiedContainer(req.params.id);
|
|
await container.restart();
|
|
success(res, { message: 'Container restarted' });
|
|
}, 'container-restart'));
|
|
|
|
// Update container to latest image version
|
|
router.post('/:id/update', asyncHandler(async (req, res) => {
|
|
const containerId = req.params.id;
|
|
const container = await getVerifiedContainer(containerId);
|
|
|
|
// Get container info
|
|
const containerInfo = await container.inspect();
|
|
const imageName = containerInfo.Config.Image;
|
|
const containerName = containerInfo.Name.replace(/^\//, '');
|
|
|
|
log.info('docker', 'Updating container', { containerName, imageName });
|
|
|
|
// Pull the latest image
|
|
log.info('docker', `Pulling latest image: ${imageName}`);
|
|
await docker.pull(imageName);
|
|
|
|
// Trigger pre-update workflow (backup before update)
|
|
if (workflowEngine) {
|
|
try { await workflowEngine.triggerEvent('pre-update', { containerId: containerId, containerName, imageName }); } catch (w) { log.warn('workflow', 'pre-update trigger failed: ' + w.message); }
|
|
}
|
|
|
|
// Get current container config for recreation
|
|
const hostConfig = containerInfo.HostConfig;
|
|
const config = {
|
|
Image: imageName,
|
|
name: containerName,
|
|
Env: containerInfo.Config.Env,
|
|
ExposedPorts: containerInfo.Config.ExposedPorts,
|
|
Labels: containerInfo.Config.Labels,
|
|
HostConfig: {
|
|
Binds: hostConfig.Binds,
|
|
PortBindings: hostConfig.PortBindings,
|
|
RestartPolicy: hostConfig.RestartPolicy,
|
|
NetworkMode: hostConfig.NetworkMode,
|
|
ExtraHosts: hostConfig.ExtraHosts,
|
|
Privileged: hostConfig.Privileged,
|
|
CapAdd: hostConfig.CapAdd,
|
|
CapDrop: hostConfig.CapDrop,
|
|
Devices: hostConfig.Devices,
|
|
LogConfig: DOCKER.LOG_CONFIG // Ensure log rotation on updated containers
|
|
},
|
|
NetworkingConfig: {}
|
|
};
|
|
|
|
// Get network settings if using a custom network
|
|
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
|
|
const networkName = hostConfig.NetworkMode;
|
|
config.NetworkingConfig.EndpointsConfig = {
|
|
[networkName]: containerInfo.NetworkSettings.Networks[networkName]
|
|
};
|
|
}
|
|
|
|
// Stop and remove old container
|
|
log.info('docker', 'Stopping container', { containerName });
|
|
await container.stop().catch(() => {}); // Ignore if already stopped
|
|
log.info('docker', 'Removing container', { containerName });
|
|
await container.remove();
|
|
|
|
// Wait for port release (Windows/Docker Desktop can be slow to free ports)
|
|
await new Promise(r => setTimeout(r, 3000));
|
|
|
|
// Create and start new container
|
|
log.info('docker', 'Creating new container', { containerName });
|
|
let newContainer;
|
|
try {
|
|
newContainer = await docker.client.createContainer(config);
|
|
log.info('docker', 'Starting container', { containerName });
|
|
await newContainer.start();
|
|
} catch (startError) {
|
|
// Clean up the failed container so it doesn't block future attempts
|
|
log.error('docker', startError, null, { note: 'Failed to start new container', containerName });
|
|
if (newContainer) {
|
|
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
|
|
}
|
|
throw startError;
|
|
}
|
|
|
|
const newContainerInfo = await newContainer.inspect();
|
|
|
|
// Prune dangling images after update
|
|
try {
|
|
const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } });
|
|
if (pruneResult.SpaceReclaimed > 0) {
|
|
log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
|
|
}
|
|
} catch (pruneErr) {
|
|
log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
|
|
}
|
|
|
|
success(res, {
|
|
message: `Container ${containerName} updated successfully`,
|
|
newContainerId: newContainerInfo.Id
|
|
});
|
|
|
|
// Trigger post-update workflow
|
|
if (workflowEngine) {
|
|
try { await workflowEngine.triggerEvent('post-update', { containerId: containerId, containerName, imageName, newContainerId: newContainerInfo.Id }); } catch (w) { log.warn('workflow', 'post-update trigger failed: ' + w.message); }
|
|
}
|
|
}, 'container-update'));
|
|
|
|
// Check for available updates (compares local and remote image digests)
|
|
router.get('/:id/check-update', asyncHandler(async (req, res) => {
|
|
const containerId = req.params.id;
|
|
const container = await getVerifiedContainer(containerId);
|
|
const containerInfo = await container.inspect();
|
|
const imageName = containerInfo.Config.Image;
|
|
|
|
const localImage = docker.client.getImage(containerInfo.Image);
|
|
const localImageInfo = await localImage.inspect();
|
|
const localDigest = localImageInfo.RepoDigests?.[0] || null;
|
|
|
|
let updateAvailable = false;
|
|
try {
|
|
const pullStream = await docker.pull(imageName);
|
|
|
|
const downloadedLayers = pullStream.filter(e =>
|
|
e.status === 'Downloading' || e.status === 'Download complete'
|
|
);
|
|
updateAvailable = downloadedLayers.length > 0;
|
|
|
|
const newImage = docker.client.getImage(imageName);
|
|
const newImageInfo = await newImage.inspect();
|
|
const newDigest = newImageInfo.RepoDigests?.[0] || null;
|
|
|
|
if (localDigest && newDigest && localDigest !== newDigest) {
|
|
updateAvailable = true;
|
|
}
|
|
} catch (pullError) {
|
|
log.debug('docker', 'Could not check for updates', { error: pullError.message });
|
|
}
|
|
|
|
success(res, {
|
|
imageName,
|
|
updateAvailable,
|
|
currentDigest: localDigest
|
|
});
|
|
}, 'container-check-update'));
|
|
|
|
// Get container logs
|
|
router.get('/:id/logs', asyncHandler(async (req, res) => {
|
|
const container = await getVerifiedContainer(req.params.id);
|
|
const logs = await container.logs({
|
|
stdout: true,
|
|
stderr: true,
|
|
tail: 100,
|
|
timestamps: true
|
|
});
|
|
success(res, { logs: logs.toString() });
|
|
}, 'container-logs'));
|
|
|
|
// Update resource limits on a running container
|
|
router.put('/:id/resources', asyncHandler(async (req, res) => {
|
|
const container = await getVerifiedContainer(req.params.id);
|
|
const { memory, cpus } = req.body;
|
|
|
|
// Validate resource limits before applying to Docker
|
|
validateResourceLimits(memory, cpus);
|
|
|
|
const updateConfig = {};
|
|
|
|
if (memory !== undefined) {
|
|
updateConfig.Memory = memory > 0 ? Math.round(memory * 1024 * 1024) : 0; // MB to bytes, 0 = unlimited
|
|
updateConfig.MemoryReservation = memory > 0 ? Math.round(memory * 1024 * 1024 * 0.5) : 0;
|
|
}
|
|
if (cpus !== undefined) {
|
|
updateConfig.NanoCpus = cpus > 0 ? Math.round(cpus * 1e9) : 0; // 0 = unlimited
|
|
}
|
|
|
|
await container.update(updateConfig);
|
|
success(res, { message: 'Resource limits updated' });
|
|
}, 'container-resources'));
|
|
|
|
// Get resource limits for a container
|
|
router.get('/:id/resources', asyncHandler(async (req, res) => {
|
|
const container = await getVerifiedContainer(req.params.id);
|
|
const info = await container.inspect();
|
|
const hc = info.HostConfig;
|
|
success(res, {
|
|
memory: hc.Memory ? Math.round(hc.Memory / 1024 / 1024) : 0, // bytes to MB
|
|
memoryReservation: hc.MemoryReservation ? Math.round(hc.MemoryReservation / 1024 / 1024) : 0,
|
|
cpus: hc.NanoCpus ? hc.NanoCpus / 1e9 : 0,
|
|
});
|
|
}, 'container-resources-get'));
|
|
|
|
// Delete container
|
|
router.delete('/:id', asyncHandler(async (req, res) => {
|
|
const container = await getVerifiedContainer(req.params.id);
|
|
await container.remove({ force: true });
|
|
success(res, { message: 'Container removed' });
|
|
}, 'container-delete'));
|
|
|
|
// Discover running containers
|
|
router.get('/discover', asyncHandler(async (req, res) => {
|
|
const containers = await docker.client.listContainers({ all: true });
|
|
const samiContainers = containers.filter(container =>
|
|
container.Labels && container.Labels['sami.managed'] === 'true'
|
|
);
|
|
|
|
const discoveredContainers = samiContainers.map(container => ({
|
|
id: container.Id,
|
|
name: container.Names[0].replace('/', ''),
|
|
image: container.Image,
|
|
state: container.State,
|
|
status: container.Status,
|
|
appTemplate: container.Labels['sami.app'],
|
|
subdomain: container.Labels['sami.subdomain'],
|
|
ports: container.Ports
|
|
}));
|
|
|
|
const paginationParams = parsePaginationParams(req.query);
|
|
const result = paginate(discoveredContainers, paginationParams);
|
|
success(res, { containers: result.data, ...(result.pagination && { pagination: result.pagination }) });
|
|
}, 'containers-discover'));
|
|
|
|
return router;
|
|
};
|