[grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13. These files were modified during the Aug 12 sprint but never committed.
This commit is contained in:
@@ -243,7 +243,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
||||
const appConfigPath = path.join(tempDir, 'config.json');
|
||||
const appCredsPath = path.join(tempDir, 'credentials.json');
|
||||
|
||||
let restoreData = { services: null, config: null, credentials: null };
|
||||
const restoreData = { services: null, config: null, credentials: null };
|
||||
|
||||
if (fs.existsSync(appServicesPath)) {
|
||||
try { restoreData.services = JSON.parse(fs.readFileSync(appServicesPath, 'utf8')); } catch (_) {}
|
||||
|
||||
@@ -241,7 +241,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
||||
|
||||
let deliveredVia = 'none';
|
||||
let maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
||||
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
||||
if (sendEmail !== false) {
|
||||
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
|
||||
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
|
||||
|
||||
@@ -36,7 +36,7 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
|
||||
break;
|
||||
case 'router': {
|
||||
// Validate baseUrl is a safe hostname before using in shell command
|
||||
if (!baseUrl || typeof baseUrl !== 'string' || !/^https?:\/\/[a-zA-Z0-9]([a-zA-Z0-9.\-]{0,253}[a-zA-Z0-9])?(:\d{1,5})?(\/|$)/.test(baseUrl)) {
|
||||
if (!baseUrl || typeof baseUrl !== 'string' || !/^https?:\/\/[a-zA-Z0-9]([a-zA-Z0-9.-]{0,253}[a-zA-Z0-9])?(:\d{1,5})?(\/|$)/.test(baseUrl)) {
|
||||
log.warn('auth', 'Router auto-login rejected: invalid baseUrl', { serviceId, baseUrl: String(baseUrl).substring(0, 50) });
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
return null;
|
||||
|
||||
@@ -775,7 +775,7 @@ async function getStorageInfo() {
|
||||
: 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupsRouter] Error getting storage info:', error.message);
|
||||
process.stderr.write(`[BackupsRouter] Error getting storage info: ${error.message}\n`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { execSync, execFileSync } = require('child_process');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { exists } = require('../src/utilities/fs-helpers');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
const { ok } = require('../src/utils/responses');
|
||||
@@ -161,7 +161,7 @@ module.exports = function(ctx) {
|
||||
let needsRegeneration = true;
|
||||
if (await exists(certFile)) {
|
||||
try {
|
||||
const certDates = execSync(`openssl x509 -in "${certFile}" -noout -dates`).toString();
|
||||
const certDates = execFileSync('openssl', ['x509', '-in', certFile, '-noout', '-dates']).toString();
|
||||
const notAfter = certDates.match(/notAfter=(.*)/)[1].trim();
|
||||
const expirationDate = new Date(notAfter);
|
||||
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||
@@ -172,12 +172,12 @@ module.exports = function(ctx) {
|
||||
}
|
||||
|
||||
if (needsRegeneration) {
|
||||
execSync(`openssl genrsa -out "${keyFile}" 2048`, { stdio: 'pipe' });
|
||||
execFileSync('openssl', ['genrsa', '-out', keyFile, '2048'], { stdio: 'pipe' });
|
||||
|
||||
// Sanitize domain for safe use in shell arguments — defensive, since validation already restricts input
|
||||
const safeDomain = domain.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
const subject = `/CN=${safeDomain}`;
|
||||
execSync(`openssl req -new -key "${keyFile}" -out "${csrFile}" -subj "${subject}"`, { stdio: 'pipe' });
|
||||
execFileSync('openssl', ['req', '-new', '-key', keyFile, '-out', csrFile, '-subj', subject], { stdio: 'pipe' });
|
||||
|
||||
const configContent = `[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
@@ -200,7 +200,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
||||
await fsp.writeFile(configFile, configContent);
|
||||
|
||||
const serialFile = path.join(domainDir, 'ca.srl');
|
||||
execSync(`openssl x509 -req -in "${csrFile}" -CA "${intermediateCert}" -CAkey "${intermediateKey}" -CAserial "${serialFile}" -CAcreateserial -out "${certFile}" -days 365 -sha256 -extfile "${configFile}" -extensions v3_req`, { stdio: 'pipe' });
|
||||
execFileSync('openssl', ['x509', '-req', '-in', csrFile, '-CA', intermediateCert, '-CAkey', intermediateKey, '-CAserial', serialFile, '-CAcreateserial', '-out', certFile, '-days', '365', '-sha256', '-extfile', configFile, '-extensions', 'v3_req'], { stdio: 'pipe' });
|
||||
|
||||
const serverCertContent = await fsp.readFile(certFile, 'utf8');
|
||||
const intermediateCertContent = await fsp.readFile(intermediateCert, 'utf8');
|
||||
@@ -260,7 +260,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
||||
if (!await exists(certFile)) return null;
|
||||
|
||||
try {
|
||||
const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString();
|
||||
const certInfo = execFileSync('openssl', ['x509', '-in', certFile, '-noout', '-subject', '-dates', '-fingerprint', '-sha256']).toString();
|
||||
const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain;
|
||||
const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : '';
|
||||
const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : '';
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* DC-106: Caddyfile-as-code — generate Caddyfile entries from structured JSON
|
||||
*
|
||||
* Allows building reverse proxy configs programmatically instead of editing
|
||||
* raw Caddyfile text. The frontend can present a visual form, send the JSON,
|
||||
* and get back a Caddyfile snippet + apply it via the Caddy admin API.
|
||||
*
|
||||
* POST /api/v1/caddycode/generate — generate Caddyfile block from JSON
|
||||
* POST /api/v1/caddycode/validate — validate a generated block
|
||||
* GET /api/v1/caddycode/importers — list supported import formats
|
||||
*/
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Generate a Caddyfile site block from a structured config.
|
||||
* @param {Object} config - Site configuration
|
||||
* @returns {string} Caddyfile snippet
|
||||
*/
|
||||
function generateSiteBlock(config) {
|
||||
const {
|
||||
domain,
|
||||
upstream,
|
||||
upstreamProtocol = 'http',
|
||||
tls = 'auto',
|
||||
websocket = false,
|
||||
auth = false,
|
||||
authService = null,
|
||||
headers = {},
|
||||
cors = false,
|
||||
rateLimit = null,
|
||||
cache = false,
|
||||
compress = true,
|
||||
stripPrefix = null,
|
||||
redirectToHttps = true,
|
||||
} = config;
|
||||
|
||||
const lines = [];
|
||||
lines.push(`${domain} {`);
|
||||
|
||||
// TLS
|
||||
if (tls === 'internal') {
|
||||
lines.push(` tls internal`);
|
||||
} else if (tls === 'auto') {
|
||||
// Default — Caddy auto-provisions Let's Encrypt
|
||||
} else if (typeof tls === 'string') {
|
||||
lines.push(` tls ${tls}`);
|
||||
}
|
||||
|
||||
// Redirect HTTP→HTTPS
|
||||
if (redirectToHttps) {
|
||||
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
|
||||
}
|
||||
|
||||
// Auth gate (DashCaddy forward_auth)
|
||||
if (auth && authService) {
|
||||
lines.push(` import dashcaddy_auth ${authService}`);
|
||||
}
|
||||
|
||||
// CORS headers
|
||||
if (cors) {
|
||||
lines.push(` header {`);
|
||||
lines.push(` Access-Control-Allow-Origin *`);
|
||||
lines.push(` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"`);
|
||||
lines.push(` Access-Control-Allow-Headers "Content-Type, Authorization"`);
|
||||
lines.push(` }`);
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
if (Object.keys(headers).length > 0) {
|
||||
lines.push(` header {`);
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
lines.push(` ${key} "${value}"`);
|
||||
}
|
||||
lines.push(` }`);
|
||||
}
|
||||
|
||||
// Strip prefix
|
||||
if (stripPrefix) {
|
||||
lines.push(` uri strip_prefix ${stripPrefix}`);
|
||||
}
|
||||
|
||||
// Compression
|
||||
if (compress) {
|
||||
lines.push(` encode gzip zstd`);
|
||||
}
|
||||
|
||||
// Reverse proxy
|
||||
const protocol = upstreamProtocol === 'https' ? 'https' : 'http';
|
||||
lines.push(` reverse_proxy ${protocol}://${upstream} {`);
|
||||
if (websocket) {
|
||||
lines.push(` # WebSocket support is automatic in Caddy 2`);
|
||||
}
|
||||
lines.push(` header_up Host {host}`);
|
||||
lines.push(` transport http {`);
|
||||
lines.push(` read_timeout 5m`);
|
||||
lines.push(` write_timeout 5m`);
|
||||
lines.push(` }`);
|
||||
lines.push(` }`);
|
||||
|
||||
lines.push(`}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = function({ asyncHandler }) {
|
||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/v1/caddycode/generate
|
||||
router.post('/caddycode/generate', wrap(async (req, res) => {
|
||||
const config = req.body || {};
|
||||
|
||||
if (!config.domain) {
|
||||
return errorResponse(res, 400, 'domain is required');
|
||||
}
|
||||
if (!config.upstream) {
|
||||
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
|
||||
}
|
||||
|
||||
try {
|
||||
const caddyfile = generateSiteBlock(config);
|
||||
ok(res, { caddyfile, config });
|
||||
} catch (err) {
|
||||
errorResponse(res, 500, `Generation failed: ${err.message}`);
|
||||
}
|
||||
}));
|
||||
|
||||
// POST /api/v1/caddycode/validate
|
||||
router.post('/caddycode/validate', wrap(async (req, res) => {
|
||||
const { caddyfile } = req.body || {};
|
||||
|
||||
if (!caddyfile) {
|
||||
return errorResponse(res, 400, 'caddyfile string is required');
|
||||
}
|
||||
|
||||
// Basic validation checks
|
||||
const issues = [];
|
||||
|
||||
// Check for balanced braces
|
||||
const openBraces = (caddyfile.match(/{/g) || []).length;
|
||||
const closeBraces = (caddyfile.match(/}/g) || []).length;
|
||||
if (openBraces !== closeBraces) {
|
||||
issues.push(`Unbalanced braces: ${openBraces} open vs ${closeBraces} close`);
|
||||
}
|
||||
|
||||
// Check for domain in first non-empty line
|
||||
const firstLine = caddyfile.trim().split('\n')[0].trim();
|
||||
if (!firstLine || firstLine.startsWith('#') || firstLine.startsWith('{')) {
|
||||
issues.push('First line should be a domain name');
|
||||
}
|
||||
|
||||
// Check for reverse_proxy directive
|
||||
if (!caddyfile.includes('reverse_proxy')) {
|
||||
issues.push('No reverse_proxy directive found — site will not proxy traffic');
|
||||
}
|
||||
|
||||
// Check for common mistakes
|
||||
if (caddyfile.includes('tls ')) {
|
||||
const tlsLine = caddyfile.split('\n').find(l => l.trim().startsWith('tls '));
|
||||
if (tlsLine && tlsLine.includes('auto')) {
|
||||
issues.push('tls auto is redundant — Caddy does this by default');
|
||||
}
|
||||
}
|
||||
|
||||
ok(res, {
|
||||
valid: issues.length === 0,
|
||||
issues,
|
||||
warnings: [],
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /api/v1/caddycode/templates — preset configs for common patterns
|
||||
router.get('/caddycode/templates', wrap(async (req, res) => {
|
||||
const templates = {
|
||||
'simple-proxy': {
|
||||
label: 'Simple Reverse Proxy',
|
||||
config: {
|
||||
domain: 'app.example.com',
|
||||
upstream: 'localhost:8080',
|
||||
tls: 'auto',
|
||||
websocket: false,
|
||||
auth: false,
|
||||
},
|
||||
},
|
||||
'websocket-app': {
|
||||
label: 'WebSocket Application',
|
||||
config: {
|
||||
domain: 'app.example.com',
|
||||
upstream: 'localhost:3000',
|
||||
websocket: true,
|
||||
compress: true,
|
||||
},
|
||||
},
|
||||
'auth-gated': {
|
||||
label: 'Auth-Gated Service (DashCaddy SSO)',
|
||||
config: {
|
||||
domain: 'app.example.com',
|
||||
upstream: 'localhost:8096',
|
||||
auth: true,
|
||||
authService: 'app',
|
||||
},
|
||||
},
|
||||
'cors-api': {
|
||||
label: 'API with CORS',
|
||||
config: {
|
||||
domain: 'api.example.com',
|
||||
upstream: 'localhost:3001',
|
||||
cors: true,
|
||||
compress: true,
|
||||
},
|
||||
},
|
||||
'subdirectory': {
|
||||
label: 'Subdirectory Proxy',
|
||||
config: {
|
||||
domain: 'example.com',
|
||||
upstream: 'localhost:8080',
|
||||
stripPrefix: '/app',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
ok(res, { templates });
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* DC-104: App Catalog API — curated templates with categories and search
|
||||
*
|
||||
* Exposes the existing app-templates.js as a browsable catalog.
|
||||
* GET /api/v1/catalog — list all apps (with optional category filter)
|
||||
* GET /api/v1/catalog/:appId — get details for a specific app
|
||||
* GET /api/v1/catalog/search — search apps by name/category/keyword
|
||||
*/
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
|
||||
// Category mapping for common apps
|
||||
const CATEGORY_MAP = {
|
||||
plex: 'media', jellyfin: 'media', emby: 'media',
|
||||
sonarr: 'media', radarr: 'media', prowlarr: 'media', lidarr: 'media',
|
||||
readarr: 'media', qbittorrent: 'media', transmission: 'media',
|
||||
sabnzbd: 'media', nzbget: 'media',
|
||||
nextcloud: 'productivity', vaultwarden: 'productivity',
|
||||
gitea: 'development', portainer: 'development', code: 'development',
|
||||
node: 'development',
|
||||
redis: 'database', postgres: 'database', mariadb: 'database', mongo: 'database',
|
||||
mysql: 'database',
|
||||
nginx: 'network', caddy: 'network', adguard: 'network', pihole: 'network',
|
||||
technitium: 'network', wireguard: 'network',
|
||||
homeassistant: 'smart-home', mosquitto: 'smart-home',
|
||||
grafana: 'monitoring', prometheus: 'monitoring', uptimekuma: 'monitoring',
|
||||
};
|
||||
|
||||
function getTemplateCategory(template) {
|
||||
const id = (template.id || template.name || '').toLowerCase();
|
||||
for (const [key, cat] of Object.entries(CATEGORY_MAP)) {
|
||||
if (id.includes(key)) return cat;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
module.exports = function({ APP_TEMPLATES, asyncHandler } = {}) {
|
||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/v1/catalog — list all apps
|
||||
router.get('/catalog', wrap(async (req, res) => {
|
||||
const { category, sort } = req.query;
|
||||
let apps = APP_TEMPLATES || [];
|
||||
// APP_TEMPLATES can be an array or an object map { plex: {...}, ... }
|
||||
let appArray = Array.isArray(apps) ? apps : Object.values(apps);
|
||||
|
||||
// Build catalog entries
|
||||
let entries = appArray.map(t => ({
|
||||
id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
category: getTemplateCategory(t),
|
||||
logo: t.logo || null,
|
||||
popular: ['plex', 'jellyfin', 'sonarr', 'radarr', 'nextcloud', 'gitea', 'qbittorrent']
|
||||
.includes((t.id || t.name || '').toLowerCase().replace(/\s+/g, '-')),
|
||||
}));
|
||||
|
||||
// Filter by category
|
||||
if (category && category !== 'all') {
|
||||
entries = entries.filter(e => e.category === category);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sort === 'name') {
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else {
|
||||
// Default: popular first, then alphabetical
|
||||
entries.sort((a, b) => {
|
||||
if (a.popular !== b.popular) return a.popular ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
// Get categories
|
||||
const categories = [...new Set(entries.map(e => e.category))].sort();
|
||||
|
||||
ok(res, {
|
||||
total: entries.length,
|
||||
categories,
|
||||
apps: entries,
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /api/v1/catalog/search?q=plex
|
||||
router.get('/catalog/search', wrap(async (req, res) => {
|
||||
const q = (req.query.q || '').toLowerCase().trim();
|
||||
if (!q) {
|
||||
return errorResponse(res, 400, 'Search query (q) is required');
|
||||
}
|
||||
|
||||
const allApps = APP_TEMPLATES || [];
|
||||
const appArray = Array.isArray(allApps) ? allApps : Object.values(allApps);
|
||||
const apps = appArray.filter(t => {
|
||||
const name = (t.name || '').toLowerCase();
|
||||
const desc = (t.description || '').toLowerCase();
|
||||
const cat = getTemplateCategory(t).toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || cat.includes(q);
|
||||
}).map(t => ({
|
||||
id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
category: getTemplateCategory(t),
|
||||
}));
|
||||
|
||||
ok(res, { query: q, results: apps.length, apps });
|
||||
}));
|
||||
|
||||
// GET /api/v1/catalog/:appId — get specific app details
|
||||
router.get('/catalog/:appId', wrap(async (req, res) => {
|
||||
const appId = req.params.appId;
|
||||
const allApps = APP_TEMPLATES || [];
|
||||
const appArray = Array.isArray(allApps) ? allApps : Object.values(allApps);
|
||||
const app = appArray.find(t => {
|
||||
const tid = (t.id || t.name?.toLowerCase().replace(/\s+/g, '-'));
|
||||
return tid === appId;
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
return errorResponse(res, 404, `App '${appId}' not found in catalog`);
|
||||
}
|
||||
|
||||
ok(res, {
|
||||
id: app.id || appId,
|
||||
name: app.name,
|
||||
description: app.description || '',
|
||||
category: getTemplateCategory(app),
|
||||
image: app.image || '',
|
||||
ports: app.ports || [],
|
||||
env: app.env || {},
|
||||
volumes: app.volumes || [],
|
||||
network: app.network || 'bridge',
|
||||
restart: app.restart || 'unless-stopped',
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,9 +1,49 @@
|
||||
const express = require('express');
|
||||
const { DOCKER } = require('../src/utilities/constants');
|
||||
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||
const { NotFoundError } = require('../src/utilities/errors');
|
||||
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
|
||||
@@ -18,6 +58,7 @@ module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
|
||||
|
||||
// Helper: verify container exists before operating on it
|
||||
async function getVerifiedContainer(id) {
|
||||
validateContainerId(id);
|
||||
const container = docker.client.getContainer(id);
|
||||
try {
|
||||
await container.inspect();
|
||||
@@ -205,6 +246,10 @@ module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
|
||||
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) {
|
||||
|
||||
@@ -18,6 +18,34 @@ 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
|
||||
*
|
||||
@@ -124,10 +152,15 @@ module.exports = function({
|
||||
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) {
|
||||
@@ -166,6 +199,8 @@ module.exports = function({
|
||||
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 : [];
|
||||
@@ -198,6 +233,9 @@ module.exports = function({
|
||||
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 || []);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* DC-107: Disaster Recovery — one-click backup + restore of entire DashCaddy setup
|
||||
*
|
||||
* Creates a complete system snapshot including:
|
||||
* - All services config (services.json)
|
||||
* - DashCaddy config (config.json)
|
||||
* - Encrypted credentials (credentials.json)
|
||||
* - Caddyfile
|
||||
* - DNS credentials
|
||||
* - Custom themes, logo, favicon
|
||||
* - Notification config
|
||||
* - Audit log
|
||||
*
|
||||
* Excludes: Docker images, container data volumes (too large for API)
|
||||
*
|
||||
* POST /api/v1/disaster/backup — create full snapshot (returns download)
|
||||
* POST /api/v1/disaster/restore — restore from uploaded snapshot
|
||||
* GET /api/v1/disaster/status — check last backup/restore status
|
||||
*/
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ErrorCodes } = require('../src/utilities/error-codes');
|
||||
|
||||
// Files that make up a complete DashCaddy backup
|
||||
const BACKUP_FILES = [
|
||||
{ key: 'services', path: 'services.json', required: true },
|
||||
{ key: 'config', path: 'config.json', required: true },
|
||||
{ key: 'credentials', path: 'credentials.json', required: false },
|
||||
{ key: 'dnsCredentials', path: 'dns-credentials.json', required: false },
|
||||
{ key: 'notifications', path: 'notifications.json', required: false },
|
||||
{ key: 'auditLog', path: 'audit-log.json', required: false },
|
||||
];
|
||||
|
||||
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
|
||||
|
||||
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
|
||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||
const router = express.Router();
|
||||
|
||||
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
||||
let lastRestoreStatus = { timestamp: null, status: null };
|
||||
|
||||
/**
|
||||
* POST /api/v1/disaster/backup
|
||||
* Creates a complete system snapshot as a downloadable JSON file.
|
||||
*/
|
||||
router.post('/disaster/backup', wrap(async (req, res) => {
|
||||
const dataDir = platformPaths?.dataDir || '/app/data';
|
||||
const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile';
|
||||
|
||||
const snapshot = {
|
||||
version: '1.0',
|
||||
createdAt: new Date().toISOString(),
|
||||
hostname: require('os').hostname(),
|
||||
dashcaddyVersion: process.env.npm_package_version || 'unknown',
|
||||
files: {},
|
||||
assets: {},
|
||||
caddyfile: null,
|
||||
};
|
||||
|
||||
// Collect config files
|
||||
for (const { key, path: filePath, required } of BACKUP_FILES) {
|
||||
const fullPath = path.join(dataDir, filePath);
|
||||
try {
|
||||
const content = await fsp.readFile(fullPath, 'utf8');
|
||||
snapshot.files[key] = JSON.parse(content);
|
||||
} catch (err) {
|
||||
if (required) {
|
||||
return errorResponse(res, 500, `Required file missing: ${filePath}`, {
|
||||
code: ErrorCodes.BACKUP.BACKUP_FAILED,
|
||||
});
|
||||
}
|
||||
// Optional file — skip
|
||||
}
|
||||
}
|
||||
|
||||
// Collect Caddyfile
|
||||
try {
|
||||
snapshot.caddyfile = await fsp.readFile(caddyfilePath, 'utf8');
|
||||
} catch {
|
||||
// Caddyfile not accessible — continue without it
|
||||
}
|
||||
|
||||
// Collect assets (logo, favicon)
|
||||
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
||||
for (const assetName of ASSET_FILES) {
|
||||
const assetPath = path.join(assetsDir, assetName);
|
||||
try {
|
||||
const data = await fsp.readFile(assetPath);
|
||||
snapshot.assets[assetName] = data.toString('base64');
|
||||
} catch {
|
||||
// Asset doesn't exist — skip
|
||||
}
|
||||
}
|
||||
|
||||
// Collect themes
|
||||
try {
|
||||
const themesDir = path.join(dataDir, 'themes');
|
||||
const themes = await fsp.readdir(themesDir);
|
||||
snapshot.themes = {};
|
||||
for (const theme of themes) {
|
||||
if (theme.endsWith('.json')) {
|
||||
const content = await fsp.readFile(path.join(themesDir, theme), 'utf8');
|
||||
snapshot.themes[theme] = JSON.parse(content);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No themes directory
|
||||
}
|
||||
|
||||
// Generate checksum for integrity verification
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
snapshot.checksum = crypto.createHash('sha256').update(snapshotJson).digest('hex');
|
||||
|
||||
lastBackupStatus = {
|
||||
timestamp: snapshot.createdAt,
|
||||
status: 'success',
|
||||
size: Buffer.byteLength(snapshotJson),
|
||||
};
|
||||
|
||||
if (log) log.info('disaster-recovery', 'Backup created', { size: lastBackupStatus.size });
|
||||
|
||||
// Send as downloadable file
|
||||
const filename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.json(snapshot);
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/v1/disaster/restore
|
||||
* Restores from an uploaded snapshot JSON.
|
||||
* Body: { snapshot: {...} } or raw JSON snapshot
|
||||
*/
|
||||
router.post('/disaster/restore', wrap(async (req, res) => {
|
||||
const dataDir = platformPaths?.dataDir || '/app/data';
|
||||
const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile';
|
||||
|
||||
let snapshot = req.body?.snapshot || req.body;
|
||||
|
||||
if (!snapshot || !snapshot.version) {
|
||||
return errorResponse(res, 400, 'Invalid snapshot: missing version field', {
|
||||
code: ErrorCodes.BACKUP.INVALID_CONFIG,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify checksum if present
|
||||
if (snapshot.checksum) {
|
||||
const expectedChecksum = snapshot.checksum;
|
||||
const { checksum, ...rest } = snapshot;
|
||||
const actualChecksum = crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex');
|
||||
if (expectedChecksum !== actualChecksum) {
|
||||
return errorResponse(res, 400, 'Snapshot checksum mismatch — file may be corrupted', {
|
||||
code: ErrorCodes.BACKUP.INVALID_CONFIG,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const restored = [];
|
||||
const errors = [];
|
||||
|
||||
// Restore config files
|
||||
for (const { key, path: filePath } of BACKUP_FILES) {
|
||||
if (!snapshot.files?.[key]) continue;
|
||||
try {
|
||||
const fullPath = path.join(dataDir, filePath);
|
||||
await fsp.writeFile(fullPath, JSON.stringify(snapshot.files[key], null, 2));
|
||||
restored.push(filePath);
|
||||
} catch (err) {
|
||||
errors.push({ file: filePath, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore Caddyfile
|
||||
if (snapshot.caddyfile) {
|
||||
try {
|
||||
await fsp.writeFile(caddyfilePath, snapshot.caddyfile);
|
||||
restored.push('Caddyfile');
|
||||
} catch (err) {
|
||||
errors.push({ file: 'Caddyfile', error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore assets
|
||||
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
||||
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
||||
try {
|
||||
await fsp.mkdir(assetsDir, { recursive: true });
|
||||
await fsp.writeFile(path.join(assetsDir, name), Buffer.from(base64, 'base64'));
|
||||
restored.push(`assets/${name}`);
|
||||
} catch (err) {
|
||||
errors.push({ file: `assets/${name}`, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore themes
|
||||
if (snapshot.themes) {
|
||||
const themesDir = path.join(dataDir, 'themes');
|
||||
try {
|
||||
await fsp.mkdir(themesDir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(snapshot.themes)) {
|
||||
await fsp.writeFile(path.join(themesDir, name), JSON.stringify(content, null, 2));
|
||||
restored.push(`themes/${name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ file: 'themes', error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
lastRestoreStatus = {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: errors.length === 0 ? 'success' : 'partial',
|
||||
restored: restored.length,
|
||||
errors: errors.length,
|
||||
};
|
||||
|
||||
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
|
||||
|
||||
ok(res, {
|
||||
status: errors.length === 0 ? 'success' : 'partial',
|
||||
restored,
|
||||
errors,
|
||||
message: errors.length === 0
|
||||
? `Successfully restored ${restored.length} files. Restart DashCaddy to apply.`
|
||||
: `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/v1/disaster/status
|
||||
*/
|
||||
router.get('/disaster/status', wrap(async (req, res) => {
|
||||
ok(res, { lastBackup: lastBackupStatus, lastRestore: lastRestoreStatus });
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* DC-103: Auto-route generation — generates Caddyfile entries and DNS records
|
||||
* for discovered containers.
|
||||
*
|
||||
* Takes a discovered container's info and generates:
|
||||
* 1. A Caddyfile site block with reverse_proxy
|
||||
* 2. A DNS A record pointing to the host
|
||||
* 3. A DashCaddy service entry
|
||||
*
|
||||
* Used by the "one-click add" flow in the discovery UI.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ErrorCodes } = require('../src/utilities/error-codes');
|
||||
|
||||
module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig, asyncHandler }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/discover/adopt
|
||||
*
|
||||
* Body: {
|
||||
* containerId: string, // Docker container ID (12 chars)
|
||||
* serviceId: string, // Desired service ID (subdomain)
|
||||
* name: string, // Display name
|
||||
* port: number, // Port to proxy to
|
||||
* protocol: 'http'|'https', // Protocol for the upstream
|
||||
* generateDns: boolean, // Whether to create a DNS record
|
||||
* generateRoute: boolean, // Whether to create a Caddyfile entry
|
||||
* }
|
||||
*
|
||||
* Returns: { service, caddyRoute, dnsRecord }
|
||||
*/
|
||||
router.post('/discover/adopt', asyncHandler(async (req, res) => {
|
||||
const {
|
||||
containerId,
|
||||
serviceId,
|
||||
name,
|
||||
port,
|
||||
protocol = 'http',
|
||||
generateDns = true,
|
||||
generateRoute = true,
|
||||
} = req.body || {};
|
||||
|
||||
// Validate required fields
|
||||
if (!containerId || !serviceId || !name) {
|
||||
return errorResponse(res, 400, 'containerId, serviceId, and name are required', {
|
||||
code: ErrorCodes.GENERAL.INVALID_INPUT,
|
||||
});
|
||||
}
|
||||
|
||||
if (!port || port < 1 || port > 65535) {
|
||||
return errorResponse(res, 400, 'Valid port (1-65535) is required', {
|
||||
code: ErrorCodes.SERVICE.INVALID_PORT,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate serviceId format (subdomain-safe)
|
||||
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(serviceId)) {
|
||||
return errorResponse(res, 400, 'serviceId must be a valid subdomain (lowercase, alphanumeric, hyphens)', {
|
||||
code: ErrorCodes.SERVICE.INVALID_SUBDOMAIN,
|
||||
});
|
||||
}
|
||||
|
||||
const tld = siteConfig?.tld || '.sami';
|
||||
const domain = `${serviceId}${tld}`;
|
||||
const upstreamHost = protocol === 'https' ? 'https' : 'http';
|
||||
const caddyAdminUrl = 'http://localhost:2019';
|
||||
|
||||
const result = {
|
||||
service: null,
|
||||
caddyRoute: null,
|
||||
dnsRecord: null,
|
||||
};
|
||||
|
||||
// 1. Create the service entry
|
||||
try {
|
||||
const service = {
|
||||
id: serviceId,
|
||||
name,
|
||||
subdomain: serviceId,
|
||||
domain,
|
||||
url: `https://${domain}`,
|
||||
port,
|
||||
protocol,
|
||||
containerId,
|
||||
type: 'auto-discovered',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (servicesStateManager) {
|
||||
await servicesStateManager.update(services => {
|
||||
// Check for duplicate
|
||||
if (services.some(s => s.id === serviceId)) {
|
||||
throw new Error(`Service ${serviceId} already exists`);
|
||||
}
|
||||
services.push(service);
|
||||
return services;
|
||||
});
|
||||
}
|
||||
|
||||
result.service = service;
|
||||
} catch (err) {
|
||||
return errorResponse(res, 409, err.message, {
|
||||
code: ErrorCodes.SERVICE.DUPLICATE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Generate Caddyfile route
|
||||
if (generateRoute && caddy) {
|
||||
try {
|
||||
// Use Caddy admin API to add the route
|
||||
const routeConfig = {
|
||||
match: [{ host: [domain] }],
|
||||
handle: [{
|
||||
handler: 'reverse_proxy',
|
||||
upstreams: [{ dial: `localhost:${port}` }],
|
||||
}],
|
||||
terminal: true,
|
||||
};
|
||||
|
||||
// Add via Caddy admin API
|
||||
const response = await fetch(`${caddyAdminUrl}/config/apps/http/servers/srv0/routes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(routeConfig),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
result.caddyRoute = { domain, upstream: `localhost:${port}`, status: 'created' };
|
||||
} else {
|
||||
result.caddyRoute = { domain, status: 'failed', error: `Caddy API returned ${response.status}` };
|
||||
}
|
||||
} catch (err) {
|
||||
result.caddyRoute = { domain, status: 'failed', error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate DNS record
|
||||
if (generateDns && dns) {
|
||||
try {
|
||||
// Create an A record pointing to the host
|
||||
result.dnsRecord = {
|
||||
domain,
|
||||
type: 'A',
|
||||
// The actual DNS creation depends on the DNS provider configured
|
||||
status: 'pending',
|
||||
message: 'DNS record creation depends on configured DNS provider',
|
||||
};
|
||||
} catch (err) {
|
||||
result.dnsRecord = { status: 'failed', error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
ok(res, result, 201);
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* DC-100: Service Discovery — auto-detect running Docker containers
|
||||
* and suggest them as services to add to the dashboard.
|
||||
*
|
||||
* Scans all running containers, extracts port mappings, image info,
|
||||
* and labels to suggest service configurations.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ErrorCodes } = require('../src/utilities/error-codes');
|
||||
|
||||
// Known image patterns → suggested service type and default config
|
||||
const IMAGE_PATTERNS = {
|
||||
'plexinc/pms': { type: 'plex', name: 'Plex', port: 32400, https: false },
|
||||
'linuxserver/jellyfin': { type: 'jellyfin', name: 'Jellyfin', port: 8096, https: false },
|
||||
'linuxserver/emby': { type: 'emby', name: 'Emby', port: 8096, https: false },
|
||||
'lscr.io/linuxserver/sonarr': { type: 'sonarr', name: 'Sonarr', port: 8989, https: false },
|
||||
'lscr.io/linuxserver/radarr': { type: 'radarr', name: 'Radarr', port: 7878, https: false },
|
||||
'lscr.io/linuxserver/prowlarr': { type: 'prowlarr', name: 'Prowlarr', port: 9696, https: false },
|
||||
'lscr.io/linuxserver/lidarr': { type: 'lidarr', name: 'Lidarr', port: 8686, https: false },
|
||||
'lscr.io/linuxserver/readarr': { type: 'readarr', name: 'Readarr', port: 8787, https: false },
|
||||
'lscr.io/linuxserver/qbittorrent': { type: 'qbittorrent', name: 'qBittorrent', port: 8080, https: false },
|
||||
'lscr.io/linuxserver/transmission': { type: 'transmission', name: 'Transmission', port: 9091, https: false },
|
||||
'haugene/transmission-openvpn': { type: 'transmission', name: 'Transmission+VPN', port: 9091, https: false },
|
||||
'gitea/gitea': { type: 'gitea', name: 'Gitea', port: 3000, https: false },
|
||||
'nextcloud': { type: 'nextcloud', name: 'Nextcloud', port: 80, https: false },
|
||||
'vaultwarden': { type: 'vaultwarden', name: 'Vaultwarden', port: 80, https: false },
|
||||
'nginx': { type: 'web', name: 'Nginx', port: 80, https: false },
|
||||
'caddy': { type: 'web', name: 'Caddy', port: 80, https: false },
|
||||
'redis': { type: 'redis', name: 'Redis', port: 6379, https: false },
|
||||
'postgres': { type: 'postgres', name: 'PostgreSQL', port: 5432, https: false },
|
||||
'mariadb': { type: 'mariadb', name: 'MariaDB', port: 3306, https: false },
|
||||
'mongo': { type: 'mongodb', name: 'MongoDB', port: 27017, https: false },
|
||||
};
|
||||
|
||||
module.exports = function({ docker, servicesStateManager, asyncHandler }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/v1/discover — scan running containers for auto-detection
|
||||
*
|
||||
* Returns a list of discovered services with suggested configurations.
|
||||
* Services already in the dashboard are marked as `existing: true`.
|
||||
*/
|
||||
router.get('/discover', asyncHandler(async (req, res) => {
|
||||
if (!docker || !docker.client) {
|
||||
return errorResponse(res, 503, 'Docker daemon not available', {
|
||||
code: ErrorCodes.CONTAINER.DOCKER_UNREACHABLE,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all running containers
|
||||
const containers = await docker.client.listContainers({ all: false });
|
||||
|
||||
// Get existing service IDs to mark duplicates
|
||||
let existingIds = new Set();
|
||||
if (servicesStateManager) {
|
||||
try {
|
||||
const services = await servicesStateManager.read();
|
||||
const list = Array.isArray(services) ? services : (services.services || []);
|
||||
existingIds = new Set(list.map(s => s.id));
|
||||
} catch { /* ignore — treat as empty */ }
|
||||
}
|
||||
|
||||
const discovered = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const container of containers) {
|
||||
const name = (container.Names && container.Names[0] || '').replace(/^\//, '');
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
|
||||
const image = container.Image || '';
|
||||
const imageBase = image.split(':')[0].toLowerCase();
|
||||
|
||||
// Match against known patterns
|
||||
let matched = null;
|
||||
for (const [pattern, config] of Object.entries(IMAGE_PATTERNS)) {
|
||||
if (imageBase.includes(pattern)) {
|
||||
matched = config;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract port mappings
|
||||
const ports = (container.Ports || []).map(p => ({
|
||||
ip: p.IP || '0.0.0.0',
|
||||
privatePort: p.PrivatePort,
|
||||
publicPort: p.PublicPort,
|
||||
type: p.Type || 'tcp',
|
||||
})).filter(p => p.publicPort);
|
||||
|
||||
// Suggested config
|
||||
const suggestedPort = matched ? matched.port : (ports[0] && ports[0].publicPort) || null;
|
||||
const suggestedId = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
||||
|
||||
discovered.push({
|
||||
containerId: container.Id.substring(0, 12),
|
||||
name,
|
||||
image,
|
||||
status: container.State,
|
||||
suggested: {
|
||||
id: suggestedId,
|
||||
name: matched ? matched.name : name.charAt(0).toUpperCase() + name.slice(1),
|
||||
type: matched ? matched.type : 'generic',
|
||||
port: suggestedPort,
|
||||
protocol: matched ? (matched.https ? 'https' : 'http') : 'http',
|
||||
},
|
||||
ports,
|
||||
labels: container.Labels || {},
|
||||
existing: existingIds.has(suggestedId),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: unmatched first (more interesting to discover), then by name
|
||||
discovered.sort((a, b) => {
|
||||
if (a.existing !== b.existing) return a.existing ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
ok(res, {
|
||||
total: discovered.length,
|
||||
matched: discovered.filter(d => d.suggested.type !== 'generic').length,
|
||||
newServices: discovered.filter(d => !d.existing).length,
|
||||
discovered,
|
||||
});
|
||||
} catch (err) {
|
||||
return errorResponse(res, 500, `Discovery failed: ${err.message}`, {
|
||||
code: ErrorCodes.GENERAL.INTERNAL,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Disk space management routes
|
||||
*
|
||||
* GET /disk — current usage snapshot (budget, breakdown, status)
|
||||
* GET /disk/breakdown — detailed breakdown incl. per-container log sizes
|
||||
* GET /disk/config — get disk budget settings
|
||||
* POST /disk/config — update disk budget settings
|
||||
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
|
||||
*/
|
||||
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Current disk usage snapshot
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const snapshot = await diskSpaceMonitor.getSnapshot();
|
||||
success(res, snapshot);
|
||||
}, 'disk-get'));
|
||||
|
||||
// Detailed breakdown (includes per-container log sizes)
|
||||
router.get('/breakdown', asyncHandler(async (req, res) => {
|
||||
const breakdown = await diskSpaceMonitor.getDetailedBreakdown();
|
||||
success(res, breakdown);
|
||||
}, 'disk-breakdown'));
|
||||
|
||||
// Get disk budget config
|
||||
router.get('/config', asyncHandler(async (req, res) => {
|
||||
success(res, diskSpaceMonitor.getConfig());
|
||||
}, 'disk-config-get'));
|
||||
|
||||
// Update disk budget config
|
||||
router.post('/config', asyncHandler(async (req, res) => {
|
||||
const { diskBudgetGB, warningThresholdPct, criticalThresholdPct, autoCleanup, enabled, cleanupAggressivePct } = req.body;
|
||||
|
||||
const updates = {};
|
||||
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
|
||||
if (typeof warningThresholdPct === 'number') updates.warningThresholdPct = Math.min(Math.max(warningThresholdPct, 50), 99);
|
||||
if (typeof criticalThresholdPct === 'number') updates.criticalThresholdPct = Math.min(Math.max(criticalThresholdPct, 60), 99);
|
||||
if (typeof cleanupAggressivePct === 'number') updates.cleanupAggressivePct = Math.min(Math.max(cleanupAggressivePct, 70), 99);
|
||||
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
|
||||
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||
|
||||
const config = diskSpaceMonitor.configure(updates);
|
||||
log.info('disk', 'Disk budget updated', updates);
|
||||
|
||||
success(res, { message: 'Disk budget updated', config });
|
||||
}, 'disk-config-set'));
|
||||
|
||||
// Manual cleanup trigger
|
||||
router.post('/cleanup', asyncHandler(async (req, res) => {
|
||||
const level = req.body?.level || 'standard';
|
||||
if (!['standard', 'aggressive', 'logs-only'].includes(level)) {
|
||||
return errorResponse(res, 'Invalid cleanup level. Use: standard, aggressive, or logs-only', 400);
|
||||
}
|
||||
|
||||
log.info('disk', 'Manual cleanup triggered', { level, by: req.auth?.user || 'api' });
|
||||
const result = await diskSpaceMonitor.performCleanup(level);
|
||||
success(res, result);
|
||||
}, 'disk-cleanup'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* DC-108: Multi-host fleet management — deploy across multiple servers
|
||||
*
|
||||
* Foundation API for registering remote DashCaddy instances and coordinating
|
||||
* deployments across them. Each host runs its own DashCaddy container; this
|
||||
* module tracks the fleet state and can forward commands.
|
||||
*
|
||||
* GET /api/v1/fleet/hosts — list all registered hosts
|
||||
* POST /api/v1/fleet/hosts — register a new host
|
||||
* DELETE /api/v1/fleet/hosts/:hostId — deregister a host
|
||||
* GET /api/v1/fleet/status — fleet-wide status overview
|
||||
* POST /api/v1/fleet/deploy — deploy to multiple hosts
|
||||
*
|
||||
* Host state is persisted in {dataDir}/fleet-hosts.json
|
||||
*/
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ErrorCodes } = require('../src/utilities/error-codes');
|
||||
|
||||
const HOSTS_FILE = process.env.FLEET_HOSTS_FILE || path.join(process.cwd(), 'data', 'fleet-hosts.json');
|
||||
|
||||
module.exports = function({ log, asyncHandler }) {
|
||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||
const router = express.Router();
|
||||
|
||||
async function loadHosts() {
|
||||
const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
|
||||
try {
|
||||
const data = await fsp.readFile(hostsFile, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHosts(hosts) {
|
||||
const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
|
||||
await fsp.mkdir(path.dirname(hostsFile), { recursive: true });
|
||||
await fsp.writeFile(hostsFile, JSON.stringify(hosts, null, 2));
|
||||
}
|
||||
|
||||
// GET /api/v1/fleet/hosts
|
||||
router.get('/fleet/hosts', wrap(async (req, res) => {
|
||||
const hosts = await loadHosts();
|
||||
ok(res, { total: hosts.length, hosts });
|
||||
}));
|
||||
|
||||
// POST /api/v1/fleet/hosts — register a new host
|
||||
router.post('/fleet/hosts', wrap(async (req, res) => {
|
||||
const { name, hostname, apiKey, port = 3001, tags = [] } = req.body || {};
|
||||
|
||||
if (!name || !hostname) {
|
||||
return errorResponse(res, 400, 'name and hostname are required', {
|
||||
code: ErrorCodes.GENERAL.INVALID_INPUT,
|
||||
});
|
||||
}
|
||||
|
||||
const hosts = await loadHosts();
|
||||
|
||||
// Check for duplicate
|
||||
if (hosts.some(h => h.hostname === hostname)) {
|
||||
return errorResponse(res, 409, `Host ${hostname} already registered`, {
|
||||
code: ErrorCodes.GENERAL.CONFLICT,
|
||||
});
|
||||
}
|
||||
|
||||
const host = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
apiKey: apiKey ? '***' : null, // Never store the actual key
|
||||
apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null,
|
||||
tags,
|
||||
status: 'unknown',
|
||||
registeredAt: new Date().toISOString(),
|
||||
lastSeen: null,
|
||||
containerCount: null,
|
||||
};
|
||||
|
||||
hosts.push(host);
|
||||
await saveHosts(hosts);
|
||||
|
||||
if (log) log.info('fleet', 'Host registered', { name, hostname });
|
||||
|
||||
ok(res, { host }, 201);
|
||||
}));
|
||||
|
||||
// DELETE /api/v1/fleet/hosts/:hostId
|
||||
router.delete('/fleet/hosts/:hostId', wrap(async (req, res) => {
|
||||
const { hostId } = req.params;
|
||||
const hosts = await loadHosts();
|
||||
const filtered = hosts.filter(h => h.id !== hostId);
|
||||
|
||||
if (filtered.length === hosts.length) {
|
||||
return errorResponse(res, 404, `Host ${hostId} not found`);
|
||||
}
|
||||
|
||||
await saveHosts(filtered);
|
||||
ok(res, { message: 'Host deregistered' });
|
||||
}));
|
||||
|
||||
// GET /api/v1/fleet/status — aggregate fleet status
|
||||
router.get('/fleet/status', wrap(async (req, res) => {
|
||||
const hosts = await loadHosts();
|
||||
|
||||
// Try to reach each host and get its health
|
||||
const statusPromises = hosts.map(async (host) => {
|
||||
try {
|
||||
const url = `http://${host.hostname}:${host.port}/api/v1/system/health`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: host.apiKeyHash ? { 'x-api-key': host.apiKeyHash } : {},
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
host.status = data.status || 'healthy';
|
||||
host.lastSeen = new Date().toISOString();
|
||||
host.containerCount = data.checks?.services?.total || null;
|
||||
} else {
|
||||
host.status = 'unreachable';
|
||||
}
|
||||
} catch {
|
||||
host.status = 'offline';
|
||||
}
|
||||
return host;
|
||||
});
|
||||
|
||||
const updatedHosts = await Promise.all(statusPromises);
|
||||
await saveHosts(updatedHosts);
|
||||
|
||||
const summary = {
|
||||
total: updatedHosts.length,
|
||||
healthy: updatedHosts.filter(h => h.status === 'healthy').length,
|
||||
degraded: updatedHosts.filter(h => h.status === 'degraded').length,
|
||||
unhealthy: updatedHosts.filter(h => h.status === 'unhealthy').length,
|
||||
offline: updatedHosts.filter(h => h.status === 'offline' || h.status === 'unreachable').length,
|
||||
};
|
||||
|
||||
ok(res, { summary, hosts: updatedHosts });
|
||||
}));
|
||||
|
||||
// POST /api/v1/fleet/deploy — deploy a template to multiple hosts
|
||||
router.post('/fleet/deploy', wrap(async (req, res) => {
|
||||
const { templateId, hostIds = [], config = {} } = req.body || {};
|
||||
|
||||
if (!templateId) {
|
||||
return errorResponse(res, 400, 'templateId is required');
|
||||
}
|
||||
|
||||
const hosts = await loadHosts();
|
||||
const targetHosts = hostIds.length > 0
|
||||
? hosts.filter(h => hostIds.includes(h.id))
|
||||
: hosts;
|
||||
|
||||
if (targetHosts.length === 0) {
|
||||
return errorResponse(res, 400, 'No valid hosts to deploy to');
|
||||
}
|
||||
|
||||
// Generate deployment plan
|
||||
const plan = targetHosts.map(host => ({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
templateId,
|
||||
config,
|
||||
status: 'pending',
|
||||
deployUrl: `http://${host.hostname}:${host.port}/api/v1/apps/deploy`,
|
||||
}));
|
||||
|
||||
ok(res, {
|
||||
templateId,
|
||||
totalHosts: plan.length,
|
||||
plan,
|
||||
message: 'Deployment plan generated. Forward each step to the host API.',
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -377,5 +377,101 @@ module.exports = function({
|
||||
success(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) });
|
||||
}, 'health-check-incidents-history'));
|
||||
|
||||
// ── DC-075: System health endpoint for operators/uptime monitoring ─────────
|
||||
// Returns a single "is everything OK" summary suitable for external monitors
|
||||
// like UptimeRobot or BetterStack. No auth required (read-only status).
|
||||
router.get('/system/health', asyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
|
||||
// Service health from health checker
|
||||
try {
|
||||
const status = healthChecker.getCurrentStatus();
|
||||
const entries = Object.values(status || {});
|
||||
const unhealthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'down' || st === 'unhealthy' || st === 'offline' || st === 'error';
|
||||
}).length;
|
||||
const total = entries.length;
|
||||
const knownHealthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'up' || st === 'healthy' || st === 'online';
|
||||
}).length;
|
||||
checks.services = {
|
||||
status: unhealthy === 0 ? 'ok' : (unhealthy < total ? 'degraded' : 'down'),
|
||||
healthy: knownHealthy,
|
||||
unhealthy,
|
||||
unknown: total - knownHealthy - unhealthy,
|
||||
total,
|
||||
};
|
||||
} catch {
|
||||
checks.services = { status: 'unknown' };
|
||||
}
|
||||
|
||||
// Memory usage
|
||||
try {
|
||||
const os = require('os');
|
||||
const total = os.totalmem ? os.totalmem() : 0;
|
||||
const free = os.freemem ? os.freemem() : 0;
|
||||
checks.memory = {
|
||||
status: free / total > 0.1 ? 'ok' : 'warning',
|
||||
usedPercent: parseFloat((((total - free) / total) * 100).toFixed(1)),
|
||||
totalMB: Math.round(total / 1048576),
|
||||
freeMB: Math.round(free / 1048576),
|
||||
};
|
||||
} catch {
|
||||
checks.memory = { status: 'unknown' };
|
||||
}
|
||||
|
||||
// Disk space (data dir)
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const dfOutput = execSync('df -h --output=pcent,size,avail ' + (platformPaths.dataDir || '/'), { encoding: 'utf8', timeout: 3000 });
|
||||
const lines = dfOutput.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const parts = lines[1].trim().split(/\s+/);
|
||||
const usedPercent = parseInt(parts[0]);
|
||||
checks.diskSpace = {
|
||||
status: usedPercent < 90 ? 'ok' : (usedPercent < 95 ? 'warning' : 'critical'),
|
||||
usedPercent,
|
||||
total: parts[1],
|
||||
available: parts[2],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
checks.diskSpace = { status: 'unknown' };
|
||||
}
|
||||
|
||||
// Uptime
|
||||
const uptime = process.uptime();
|
||||
checks.uptime = {
|
||||
seconds: Math.round(uptime),
|
||||
human: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
|
||||
};
|
||||
|
||||
// Open incidents
|
||||
try {
|
||||
const incidents = healthChecker.getOpenIncidents();
|
||||
checks.incidents = {
|
||||
status: incidents.length === 0 ? 'ok' : 'degraded',
|
||||
count: incidents.length,
|
||||
};
|
||||
} catch {
|
||||
checks.incidents = { status: 'unknown', count: 0 };
|
||||
}
|
||||
|
||||
// Overall status: 'unknown' is treated as degraded (not healthy)
|
||||
const statuses = Object.values(checks).map(c => c.status);
|
||||
const overall = statuses.includes('down') || statuses.includes('critical') ? 'unhealthy'
|
||||
: statuses.some(s => s === 'degraded' || s === 'warning' || s === 'unknown') ? 'degraded'
|
||||
: 'healthy';
|
||||
|
||||
res.set('Cache-Control', 'no-store');
|
||||
success(res, {
|
||||
status: overall,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
});
|
||||
}, 'system-health'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* DC-077: i18n route — serves translations and language metadata
|
||||
*/
|
||||
const express = require('express');
|
||||
const { ok } = require('../src/utils/responses');
|
||||
const i18n = require('../src/utilities/i18n');
|
||||
|
||||
module.exports = function() {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/v1/i18n/languages — list supported languages
|
||||
router.get('/i18n/languages', (req, res) => {
|
||||
ok(res, {
|
||||
languages: i18n.getSupportedLanguages().map(code => ({
|
||||
code,
|
||||
name: {
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
ar: 'العربية',
|
||||
}[code] || code,
|
||||
rtl: code === 'ar',
|
||||
})),
|
||||
default: i18n.DEFAULT_LANGUAGE,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/v1/i18n/translations/:lang — get all translations for a language
|
||||
router.get('/i18n/translations/:lang', (req, res) => {
|
||||
const lang = req.params.lang;
|
||||
if (!i18n.isSupported(lang)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Unsupported language: ${lang}`,
|
||||
supported: i18n.getSupportedLanguages(),
|
||||
});
|
||||
}
|
||||
ok(res, { lang, translations: i18n.TRANSLATIONS[lang] || {} });
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,7 +1,20 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
// Dedicated rate limiter for license activation — prevents brute-force key guessing.
|
||||
// Pro keys follow a predictable format (DC-XXX-XXXXX-XXXXXX), so without rate
|
||||
// limiting an attacker could enumerate valid keys.
|
||||
const licenseActivateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // 10 attempts per window per IP
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many license activation attempts. Please try again later.' },
|
||||
skip: () => process.env.NODE_ENV === 'test',
|
||||
});
|
||||
|
||||
/**
|
||||
* License routes factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
@@ -13,7 +26,7 @@ module.exports = function({ licenseManager, asyncHandler }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Activate a license code
|
||||
router.post('/activate', asyncHandler(async (req, res) => {
|
||||
router.post('/activate', licenseActivateLimiter, asyncHandler(async (req, res) => {
|
||||
const { code } = req.body;
|
||||
if (!code) {
|
||||
throw new ValidationError('License code is required');
|
||||
|
||||
@@ -176,6 +176,10 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
|
||||
router.post('/logs/digest/generate', asyncHandler(async (req, res) => {
|
||||
if (!logDigest) throw new Error('Log digest not available');
|
||||
const date = req.body.date || new Date().toISOString().slice(0, 10);
|
||||
// Validate date format before passing to digest generator
|
||||
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new ValidationError('Invalid date format. Use YYYY-MM-DD.');
|
||||
}
|
||||
const digest = await logDigest.generateDailyDigest(date);
|
||||
ok(res, { digest });
|
||||
}, 'logs-digest-generate'));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
@@ -263,10 +264,5 @@ module.exports = function openClawRoutes(ctx) {
|
||||
// ── token generator ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateToken() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
return crypto.randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
const express = require('express');
|
||||
const { DOCKER } = require('../../src/utilities/constants');
|
||||
const { NotFoundError } = require('../../src/utilities/errors');
|
||||
const { NotFoundError, ValidationError } = require('../../src/utilities/errors');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Validate a recipe ID for use in Docker label filters.
|
||||
* @param {string} recipeId - Recipe ID from route param
|
||||
* @throws {ValidationError} if the ID contains unsafe characters
|
||||
*/
|
||||
function validateRecipeId(recipeId) {
|
||||
if (!recipeId || typeof recipeId !== 'string') {
|
||||
throw new ValidationError('Recipe ID is required');
|
||||
}
|
||||
// Recipe IDs are slug-style: lowercase letters, numbers, hyphens
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(recipeId)) {
|
||||
throw new ValidationError('Invalid recipe ID format');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -107,6 +122,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
||||
*/
|
||||
router.post('/:recipeId/start', asyncHandler(async (req, res) => {
|
||||
const { recipeId } = req.params;
|
||||
validateRecipeId(recipeId);
|
||||
const containers = await findRecipeContainers(recipeId);
|
||||
|
||||
if (containers.length === 0) {
|
||||
@@ -138,6 +154,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
||||
*/
|
||||
router.post('/:recipeId/stop', asyncHandler(async (req, res) => {
|
||||
const { recipeId } = req.params;
|
||||
validateRecipeId(recipeId);
|
||||
const containers = await findRecipeContainers(recipeId);
|
||||
|
||||
if (containers.length === 0) {
|
||||
@@ -170,6 +187,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
||||
*/
|
||||
router.post('/:recipeId/restart', asyncHandler(async (req, res) => {
|
||||
const { recipeId } = req.params;
|
||||
validateRecipeId(recipeId);
|
||||
const containers = await findRecipeContainers(recipeId);
|
||||
|
||||
if (containers.length === 0) {
|
||||
@@ -196,6 +214,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
||||
*/
|
||||
router.delete('/:recipeId', asyncHandler(async (req, res) => {
|
||||
const { recipeId } = req.params;
|
||||
validateRecipeId(recipeId);
|
||||
const containers = await findRecipeContainers(recipeId);
|
||||
|
||||
if (containers.length === 0) {
|
||||
|
||||
@@ -135,6 +135,10 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
||||
router.delete('/site/:domain', asyncHandler(async (req, res) => {
|
||||
const { domain } = req.params;
|
||||
if (!domain) throw new ValidationError('Domain is required');
|
||||
// Validate domain format before it is escaped and interpolated into a regex
|
||||
if (!REGEX.DOMAIN.test(domain)) {
|
||||
throw new ValidationError('[DC-301] Invalid domain format');
|
||||
}
|
||||
|
||||
const result = await caddy.modify((content) => {
|
||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const { TAILSCALE } = require('../src/utilities/constants');
|
||||
const { TAILSCALE, REGEX } = require('../src/utilities/constants');
|
||||
const { exists } = require('../src/utilities/fs-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
const { ok, successMessage, unauthorized } = require('../src/utils/responses');
|
||||
@@ -80,6 +80,17 @@ module.exports = function({
|
||||
router.post('/config', asyncHandler(async (req, res) => {
|
||||
const { enabled, requireAuth, allowedTailnet } = req.body;
|
||||
|
||||
// Validate allowedTailnet is a safe CIDR/domain string if provided
|
||||
if (typeof allowedTailnet !== 'undefined' && allowedTailnet !== null) {
|
||||
if (typeof allowedTailnet !== 'string' || allowedTailnet.length > 255) {
|
||||
throw new ValidationError('allowedTailnet must be a string (max 255 chars)');
|
||||
}
|
||||
// Block shell metacharacters and path traversal
|
||||
if (/[;&|`$()<>\\]/.test(allowedTailnet)) {
|
||||
throw new ValidationError('allowedTailnet contains invalid characters');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof enabled !== 'undefined') tailscale.config.enabled = enabled;
|
||||
if (typeof requireAuth !== 'undefined') tailscale.config.requireAuth = requireAuth;
|
||||
if (typeof allowedTailnet !== 'undefined') tailscale.config.allowedTailnet = allowedTailnet;
|
||||
@@ -150,6 +161,10 @@ module.exports = function({
|
||||
if (!subdomain) {
|
||||
throw new ValidationError('subdomain is required');
|
||||
}
|
||||
// Validate subdomain before it is interpolated into a regex
|
||||
if (!REGEX.SUBDOMAIN.test(subdomain)) {
|
||||
throw new ValidationError('[DC-301] Invalid subdomain format');
|
||||
}
|
||||
|
||||
const content = await caddy.read();
|
||||
const domain = buildDomain(subdomain);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* DC-105: Smart defaults wizard — "What do you want to self-host?"
|
||||
*
|
||||
* Guides users through initial setup by asking what they want to host,
|
||||
* then generates optimal configuration based on their hardware and needs.
|
||||
*
|
||||
* POST /api/v1/wizard/recommend — returns recommended services based on answers
|
||||
* POST /api/v1/wizard/apply — applies the wizard configuration
|
||||
*/
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
|
||||
// Recommendation matrix: user intent → suggested services
|
||||
const RECOMMENDATIONS = {
|
||||
'media-streaming': {
|
||||
label: 'Media Streaming',
|
||||
icon: '🎬',
|
||||
services: [
|
||||
{ template: 'plex', priority: 1, reason: 'Stream movies, TV shows, and music' },
|
||||
{ template: 'sonarr', priority: 2, reason: 'Automatically download TV shows' },
|
||||
{ template: 'radarr', priority: 2, reason: 'Automatically download movies' },
|
||||
{ template: 'qbittorrent', priority: 3, reason: 'Download client for media' },
|
||||
{ template: 'prowlarr', priority: 3, reason: 'Indexer management' },
|
||||
],
|
||||
},
|
||||
'file-sync': {
|
||||
label: 'File Storage & Sync',
|
||||
icon: '📁',
|
||||
services: [
|
||||
{ template: 'nextcloud', priority: 1, reason: 'Self-hosted Google Drive alternative' },
|
||||
{ template: 'vaultwarden', priority: 2, reason: 'Password manager (Bitwarden compatible)' },
|
||||
],
|
||||
},
|
||||
'home-network': {
|
||||
label: 'Home Network',
|
||||
icon: '🌐',
|
||||
services: [
|
||||
{ template: 'adguard', priority: 1, reason: 'Network-wide ad blocking' },
|
||||
{ template: 'wireguard', priority: 2, reason: 'VPN for remote access' },
|
||||
{ template: 'pihole', priority: 3, reason: 'Alternative DNS ad blocker' },
|
||||
],
|
||||
},
|
||||
'smart-home': {
|
||||
label: 'Smart Home',
|
||||
icon: '🏠',
|
||||
services: [
|
||||
{ template: 'homeassistant', priority: 1, reason: 'Central smart home automation' },
|
||||
{ template: 'mosquitto', priority: 2, reason: 'MQTT broker for IoT devices' },
|
||||
],
|
||||
},
|
||||
'development': {
|
||||
label: 'Development',
|
||||
icon: '💻',
|
||||
services: [
|
||||
{ template: 'gitea', priority: 1, reason: 'Self-hosted Git with CI/CD' },
|
||||
{ template: 'code', priority: 2, reason: 'VS Code in the browser' },
|
||||
{ template: 'portainer', priority: 2, reason: 'Docker container management' },
|
||||
],
|
||||
},
|
||||
'monitoring': {
|
||||
label: 'Monitoring & Analytics',
|
||||
icon: '📊',
|
||||
services: [
|
||||
{ template: 'grafana', priority: 1, reason: 'Beautiful dashboards and graphs' },
|
||||
{ template: 'prometheus', priority: 2, reason: 'Time-series metrics collection' },
|
||||
{ template: 'uptimekuma', priority: 2, reason: 'Uptime monitoring with alerts' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = function({ APP_TEMPLATES, asyncHandler }) {
|
||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/v1/wizard/categories — list available categories
|
||||
router.get('/wizard/categories', wrap(async (req, res) => {
|
||||
ok(res, {
|
||||
categories: Object.entries(RECOMMENDATIONS).map(([key, val]) => ({
|
||||
id: key,
|
||||
label: val.label,
|
||||
icon: val.icon,
|
||||
serviceCount: val.services.length,
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
// POST /api/v1/wizard/recommend — get recommendations based on selected categories
|
||||
router.post('/wizard/recommend', wrap(async (req, res) => {
|
||||
const { categories = [], hardwareProfile = 'medium' } = req.body || {};
|
||||
|
||||
if (!Array.isArray(categories) || categories.length === 0) {
|
||||
return errorResponse(res, 400, 'categories array is required (at least one)');
|
||||
}
|
||||
|
||||
// Collect all recommended services from selected categories
|
||||
const recommended = new Map();
|
||||
for (const cat of categories) {
|
||||
const rec = RECOMMENDATIONS[cat];
|
||||
if (!rec) continue;
|
||||
for (const svc of rec.services) {
|
||||
if (!recommended.has(svc.template)) {
|
||||
recommended.set(svc.template, { ...svc, categories: [cat] });
|
||||
} else {
|
||||
recommended.get(svc.template).categories.push(cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority (lower = more important)
|
||||
const sorted = [...recommended.values()].sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Adjust based on hardware profile
|
||||
const limits = {
|
||||
minimal: { maxServices: 3, maxMemory: '512m' },
|
||||
medium: { maxServices: 6, maxMemory: '1g' },
|
||||
powerful: { maxServices: 12, maxMemory: '2g' },
|
||||
};
|
||||
const profile = limits[hardwareProfile] || limits.medium;
|
||||
const filtered = sorted.slice(0, profile.maxServices);
|
||||
|
||||
// Enrich with template details
|
||||
const enriched = filtered.map(svc => {
|
||||
const template = (APP_TEMPLATES || []).find(t =>
|
||||
(t.id || t.name?.toLowerCase().replace(/\s+/g, '-')) === svc.template
|
||||
);
|
||||
return {
|
||||
...svc,
|
||||
available: !!template,
|
||||
image: template?.image || null,
|
||||
ports: template?.ports || [],
|
||||
estimatedMemory: template?.memory || '256m',
|
||||
};
|
||||
});
|
||||
|
||||
ok(res, {
|
||||
hardwareProfile,
|
||||
categories: categories.filter(c => RECOMMENDATIONS[c]),
|
||||
totalRecommended: enriched.length,
|
||||
services: enriched,
|
||||
resourceLimits: profile,
|
||||
});
|
||||
}));
|
||||
|
||||
// POST /api/v1/wizard/apply — deploy the selected services
|
||||
// (Delegates to the existing deploy endpoint for each service)
|
||||
router.post('/wizard/apply', wrap(async (req, res) => {
|
||||
const { services = [], subdomainPrefix = '' } = req.body || {};
|
||||
|
||||
if (!Array.isArray(services) || services.length === 0) {
|
||||
return errorResponse(res, 400, 'services array is required (at least one template ID)');
|
||||
}
|
||||
|
||||
// Return deployment plan — actual deployment happens via the existing
|
||||
// POST /api/v1/apps/deploy endpoint for each service
|
||||
const plan = services.map((templateId, index) => ({
|
||||
step: index + 1,
|
||||
templateId,
|
||||
subdomain: `${subdomainPrefix}${templateId}`.toLowerCase(),
|
||||
deployEndpoint: '/api/v1/apps/deploy',
|
||||
status: 'pending',
|
||||
}));
|
||||
|
||||
ok(res, {
|
||||
totalSteps: plan.length,
|
||||
plan,
|
||||
message: 'Use POST /api/v1/apps/deploy for each step to execute',
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,5 +1,20 @@
|
||||
const express = require('express');
|
||||
const { ok } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* Validate a workflow ID.
|
||||
* @param {string} workflowId - Workflow ID from route param
|
||||
* @throws {ValidationError} if the ID contains unsafe characters
|
||||
*/
|
||||
function validateWorkflowId(workflowId) {
|
||||
if (!workflowId || typeof workflowId !== 'string') {
|
||||
throw new ValidationError('Workflow ID is required');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(workflowId)) {
|
||||
throw new ValidationError('Invalid workflow ID format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflows routes factory
|
||||
@@ -27,6 +42,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
|
||||
// Enable a workflow
|
||||
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
validateWorkflowId(workflowId);
|
||||
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
|
||||
ok(res, result);
|
||||
}, 'workflows-enable'));
|
||||
@@ -34,6 +50,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
|
||||
// Disable a workflow
|
||||
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
validateWorkflowId(workflowId);
|
||||
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
|
||||
ok(res, result);
|
||||
}, 'workflows-disable'));
|
||||
@@ -41,6 +58,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
|
||||
// Manually trigger a workflow
|
||||
router.post('/workflows/:workflowId/run', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
validateWorkflowId(workflowId);
|
||||
const triggerData = req.body || {};
|
||||
triggerData.trigger = 'manual';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user