Secures 20 mutating routes across 7 files against path traversal, shell injection, and ReDoS vectors: - containers.js: container ID validation + resource limit bounds (6 routes) - recipes/manage.js: recipe ID slug validation (4 routes) - tailscale.js: subdomain regex before interpolation + shell char blocking (2) - workflows.js: workflow ID slug validation (3 routes) - dependencies.js: service ID + dependsOn array validation (3 routes) - logs.js: YYYY-MM-DD date format validation (1 route) - sites.js: additional domain validation (1 route) Uses existing REGEX patterns from constants.js. No new dependencies. Codex: B (no blocking issues, 4 Low follow-ups for tests + strict bools). 1552/1552 tests pass, 0 regressions.
274 lines
9.7 KiB
JavaScript
274 lines
9.7 KiB
JavaScript
/**
|
|
* Dependencies Route — REST API for service dependency tracking
|
|
*
|
|
* Endpoints:
|
|
* GET /dependencies/graph Full dependency graph
|
|
* GET /dependencies/validate Validate a proposed dep chain
|
|
* GET /dependencies/:serviceId Direct deps for one service
|
|
* GET /dependencies/:serviceId/chain Ordered restart chain
|
|
* GET /dependencies/:serviceId/status Dependency health status
|
|
* POST /dependencies/:serviceId Set dependencies
|
|
* DELETE /dependencies/:serviceId Remove all dependencies
|
|
* POST /dependencies/:serviceId/restart Restart with dependency chain
|
|
*
|
|
* @module routes/dependencies
|
|
*/
|
|
|
|
const express = require('express');
|
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
|
const { NotFoundError, ValidationError } = require('../src/utilities/errors');
|
|
|
|
/**
|
|
* Validate a service ID for use in dependency lookups and config updates.
|
|
* @param {string} serviceId - Service ID from route param
|
|
* @throws {ValidationError} if the ID contains unsafe characters
|
|
*/
|
|
function validateServiceId(serviceId) {
|
|
if (!serviceId || typeof serviceId !== 'string') {
|
|
throw new ValidationError('Service ID is required');
|
|
}
|
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
|
throw new ValidationError('Invalid service ID format');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate each entry in a dependsOn array.
|
|
* @param {Array} dependsOn - Array of dependency service IDs
|
|
* @throws {ValidationError} if any entry is malformed
|
|
*/
|
|
function validateDependsOnArray(dependsOn) {
|
|
if (!Array.isArray(dependsOn)) return;
|
|
for (const dep of dependsOn) {
|
|
if (typeof dep !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(dep)) {
|
|
throw new ValidationError(`Invalid dependency ID: ${String(dep)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dependencies route factory
|
|
*
|
|
* @param {Object} deps - Explicit dependencies
|
|
* @param {Object} deps.dependencyManager - DependencyManager instance
|
|
* @param {Object} deps.servicesStateManager - State manager for services.json
|
|
* @param {Object} deps.docker - Docker client wrapper
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @param {Function} deps.logError - Error logging function
|
|
* @param {Function} deps.resyncHealthChecker - Health checker resync function
|
|
* @param {Object} deps.log - Logger instance
|
|
* @returns {express.Router}
|
|
*/
|
|
module.exports = function({
|
|
dependencyManager,
|
|
servicesStateManager,
|
|
docker,
|
|
asyncHandler,
|
|
logError,
|
|
resyncHealthChecker,
|
|
log,
|
|
}) {
|
|
const router = express.Router();
|
|
|
|
// -------------------------------------------------------------------------
|
|
// GET /dependencies/graph — Full dependency graph
|
|
// -------------------------------------------------------------------------
|
|
router.get('/graph', asyncHandler(async (req, res) => {
|
|
const graph = await dependencyManager.getDependencyGraph();
|
|
success(res, { graph });
|
|
}, 'dep-graph'));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// GET /dependencies/validate — Validate a proposed dep chain (query params)
|
|
// -------------------------------------------------------------------------
|
|
router.get('/validate', asyncHandler(async (req, res) => {
|
|
const { serviceId, dependsOn } = req.query;
|
|
|
|
if (!serviceId) {
|
|
throw new ValidationError('serviceId query parameter is required');
|
|
}
|
|
|
|
// dependsOn may be a comma-separated string or already an array
|
|
let parsed;
|
|
if (Array.isArray(dependsOn)) {
|
|
parsed = dependsOn;
|
|
} else if (typeof dependsOn === 'string' && dependsOn.length > 0) {
|
|
parsed = dependsOn.split(',').map(s => s.trim()).filter(Boolean);
|
|
} else {
|
|
parsed = [];
|
|
}
|
|
|
|
const result = await dependencyManager.validateDependencies(serviceId, parsed);
|
|
success(res, result);
|
|
}, 'dep-validate'));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// GET /dependencies/:serviceId — Direct deps for one service
|
|
// -------------------------------------------------------------------------
|
|
router.get('/:serviceId', asyncHandler(async (req, res) => {
|
|
const { serviceId } = req.params;
|
|
const dependencies = await dependencyManager.getDependencies(serviceId);
|
|
const dependents = await dependencyManager.getDependents(serviceId);
|
|
|
|
// Read the service's current dependsOn array
|
|
const services = await servicesStateManager.read();
|
|
const allServices = Array.isArray(services) ? services : (services.services || []);
|
|
const service = allServices.find(s => s.id === serviceId);
|
|
|
|
if (!service) {
|
|
throw new NotFoundError(`Service "${serviceId}"`);
|
|
}
|
|
|
|
success(res, {
|
|
serviceId,
|
|
dependsOn: service.dependsOn || [],
|
|
dependencies,
|
|
dependents: dependents.map(d => ({ id: d.id, name: d.name })),
|
|
});
|
|
}, 'dep-get'));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// GET /dependencies/:serviceId/chain — Ordered restart chain
|
|
// -------------------------------------------------------------------------
|
|
router.get('/:serviceId/chain', asyncHandler(async (req, res) => {
|
|
const { serviceId } = req.params;
|
|
const chain = await dependencyManager.getOrderedRestartChain(serviceId);
|
|
success(res, { serviceId, chain });
|
|
}, 'dep-chain'));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// GET /dependencies/:serviceId/status — Dependency health status
|
|
// -------------------------------------------------------------------------
|
|
router.get('/:serviceId/status', asyncHandler(async (req, res) => {
|
|
const { serviceId } = req.params;
|
|
const statuses = await dependencyManager.getDependencyStatus(serviceId);
|
|
success(res, { serviceId, statuses });
|
|
}, 'dep-status'));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// POST /dependencies/:serviceId — Set dependencies
|
|
// -------------------------------------------------------------------------
|
|
router.post('/:serviceId', asyncHandler(async (req, res) => {
|
|
const { serviceId } = req.params;
|
|
const { dependsOn } = req.body;
|
|
|
|
// Validate service ID and dependsOn entries before any state mutation
|
|
validateServiceId(serviceId);
|
|
|
|
if (!Array.isArray(dependsOn)) {
|
|
throw new ValidationError('Request body must include dependsOn as an array of service IDs');
|
|
}
|
|
|
|
validateDependsOnArray(dependsOn);
|
|
|
|
// Validate first
|
|
const validation = await dependencyManager.validateDependencies(serviceId, dependsOn);
|
|
if (!validation.valid) {
|
|
return errorResponse(res, validation.errors.join('; '), 400);
|
|
}
|
|
|
|
// Update the service
|
|
let found = false;
|
|
await servicesStateManager.update(services => {
|
|
const arr = Array.isArray(services) ? services : [];
|
|
return arr.map(s => {
|
|
if (s.id === serviceId) {
|
|
found = true;
|
|
return { ...s, dependsOn: dependsOn.slice() };
|
|
}
|
|
return s;
|
|
});
|
|
});
|
|
|
|
if (!found) {
|
|
throw new NotFoundError(`Service "${serviceId}"`);
|
|
}
|
|
|
|
log.info('dependency', 'Dependencies updated', { serviceId, dependsOn });
|
|
|
|
success(res, {
|
|
message: `Dependencies updated for "${serviceId}"`,
|
|
serviceId,
|
|
dependsOn,
|
|
});
|
|
}, 'dep-set'));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// DELETE /dependencies/:serviceId — Remove all dependencies for a service
|
|
// -------------------------------------------------------------------------
|
|
router.delete('/:serviceId', asyncHandler(async (req, res) => {
|
|
const { serviceId } = req.params;
|
|
|
|
validateServiceId(serviceId);
|
|
|
|
let found = false;
|
|
await servicesStateManager.update(services => {
|
|
const arr = Array.isArray(services) ? services : [];
|
|
return arr.map(s => {
|
|
if (s.id === serviceId) {
|
|
found = true;
|
|
const updated = { ...s };
|
|
delete updated.dependsOn;
|
|
return updated;
|
|
}
|
|
return s;
|
|
});
|
|
});
|
|
|
|
if (!found) {
|
|
throw new NotFoundError(`Service "${serviceId}"`);
|
|
}
|
|
|
|
log.info('dependency', 'Dependencies removed', { serviceId });
|
|
|
|
success(res, {
|
|
message: `All dependencies removed for "${serviceId}"`,
|
|
serviceId,
|
|
});
|
|
}, 'dep-delete'));
|
|
|
|
// -------------------------------------------------------------------------
|
|
// POST /dependencies/:serviceId/restart — Restart with dependency chain
|
|
// -------------------------------------------------------------------------
|
|
router.post('/:serviceId/restart', asyncHandler(async (req, res) => {
|
|
const { serviceId } = req.params;
|
|
|
|
// Validate service ID before any Docker or state operations
|
|
validateServiceId(serviceId);
|
|
|
|
// Verify the service exists
|
|
const services = await servicesStateManager.read();
|
|
const allServices = Array.isArray(services) ? services : (services.services || []);
|
|
if (!allServices.find(s => s.id === serviceId)) {
|
|
throw new NotFoundError(`Service "${serviceId}"`);
|
|
}
|
|
|
|
// Get the chain first for the response (before async restart begins)
|
|
let chain;
|
|
try {
|
|
chain = await dependencyManager.getOrderedRestartChain(serviceId);
|
|
} catch (err) {
|
|
return errorResponse(res, err.message, 400);
|
|
}
|
|
|
|
// Respond immediately with the chain order
|
|
success(res, {
|
|
message: `Dependency restart initiated for "${serviceId}"`,
|
|
serviceId,
|
|
chain,
|
|
});
|
|
|
|
// Run the restart chain asynchronously so the client doesn't block
|
|
dependencyManager.restartWithDependencies(serviceId).catch(err => {
|
|
if (log) {
|
|
log.error('dependency', 'Async dependency restart failed', {
|
|
serviceId,
|
|
error: err.message,
|
|
});
|
|
}
|
|
});
|
|
}, 'dep-restart'));
|
|
|
|
return router;
|
|
};
|