Files
dashcaddy/dashcaddy-api/routes/services.js
Krystie 55c405082a fix: use TIMEOUTS constants instead of magic numbers in health and services routes
- health.js: replace magic number 5000 with TIMEOUTS.HTTP_DEFAULT (twice)
- services.js: replace magic number 5000 with TIMEOUTS.HTTP_DEFAULT

Both files already import TIMEOUTS from constants but weren't using it.
2026-05-01 02:36:31 -07:00

548 lines
18 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const http = require('http');
const https = require('https');
const tls = require('tls');
const validatorLib = require('validator');
const { APP, REGEX, TIMEOUTS } = require('../constants');
const { validateServiceConfig, isValidPort } = require('../input-validator');
const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination');
const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers');
const { ConflictError, ValidationError, NotFoundError } = require('../errors');
/**
* Services route factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.servicesStateManager - State manager for services.json
* @param {Object} deps.credentialManager - Credential storage manager
* @param {Object} deps.siteConfig - Site configuration
* @param {Function} deps.buildServiceUrl - URL builder function
* @param {Function} deps.buildDomain - Domain builder function
* @param {Function} deps.fetchT - Fetch wrapper with timeout
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {string} deps.SERVICES_FILE - Path to services.json
* @param {Object} deps.log - Logger instance
* @param {Function} deps.safeErrorMessage - Safe error message extractor
* @param {Function} deps.resyncHealthChecker - Health checker resync function
* @param {Object} deps.caddy - Caddy management interface
* @param {Object} deps.dns - DNS management interface
* @returns {express.Router}
*/
module.exports = function({
servicesStateManager,
credentialManager,
siteConfig,
buildServiceUrl,
buildDomain,
fetchT,
asyncHandler,
SERVICES_FILE,
log,
safeErrorMessage,
resyncHealthChecker,
caddy,
dns
}) {
const router = express.Router();
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
const PROBE_CONCURRENCY = 6;
let probeHttpsAgent;
try {
const caCert = fs.readFileSync(CA_CERT_PATH);
probeHttpsAgent = new https.Agent({ ca: [...tls.rootCertificates, caCert] });
} catch (_) {
probeHttpsAgent = new https.Agent();
}
function isServiceUp(statusCode) {
return (statusCode >= 200 && statusCode < 400) || statusCode === 401 || statusCode === 403;
}
async function loadServicesList() {
if (!await exists(SERVICES_FILE)) return [];
const data = await servicesStateManager.read();
return Array.isArray(data) ? data : data.services || [];
}
function resolveProbeUrl(id, service) {
return resolveServiceUrl(id, service, siteConfig, buildServiceUrl);
}
const PROBE_TIMEOUT = 3000; // 3s — covers DNS + connect + response
function requestStatusCode(url, method) {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const lib = isHttps ? https : http;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
req.destroy();
reject(new Error('Timeout'));
}, PROBE_TIMEOUT);
const req = lib.request({
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method,
agent: isHttps ? probeHttpsAgent : undefined,
headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
}, (response) => {
clearTimeout(timer);
response.resume();
resolve(response.statusCode || 0);
});
req.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
req.end();
});
}
async function probeViaPylon(targetUrl) {
const pylonConfig = siteConfig?.pylon;
if (!pylonConfig?.url) return null;
try {
const probeUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(targetUrl)}`;
const headers = {};
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUTS.HTTP_DEFAULT);
const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
clearTimeout(timeout);
if (!response.ok) return null;
const data = await response.json();
return data;
} catch (_) {
return null;
}
}
async function probeServiceStatus(id, service) {
const startedAt = process.hrtime.bigint();
const url = resolveProbeUrl(id, service);
let statusCode = 502;
let error = null;
try {
statusCode = await requestStatusCode(url, 'HEAD');
if (statusCode === 405 || statusCode === 501) {
statusCode = await requestStatusCode(url, 'GET');
}
} catch (primaryError) {
error = primaryError;
}
// Pylon relay fallback — if direct probe failed, try through the pylon
if (error && siteConfig?.pylon) {
const pylonResult = await probeViaPylon(url);
if (pylonResult && pylonResult.status) {
const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n);
return {
id,
isUp: pylonResult.status === 'healthy',
statusCode: pylonResult.statusCode || 0,
responseTime,
url,
via: 'pylon'
};
}
}
const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n);
if (error) {
return {
id,
isUp: false,
statusCode: 502,
responseTime,
error: error.message
};
}
return {
id,
isUp: isServiceUp(statusCode),
statusCode,
responseTime,
url
};
}
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
async function next() {
while (true) {
const index = cursor++;
if (index >= items.length) return;
results[index] = await worker(items[index], index);
}
}
const workers = Array.from({ length: Math.min(limit, items.length || 1) }, () => next());
await Promise.all(workers);
return results;
}
// ===== SERVICE CREDENTIAL ENDPOINTS =====
// Store credentials for a service
router.post('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const { apiKey, username, password } = req.body;
if (apiKey) {
await credentialManager.store(`service.${serviceId}.apikey`, apiKey);
}
if (username) {
await credentialManager.store(`service.${serviceId}.username`, username);
}
if (password) {
await credentialManager.store(`service.${serviceId}.password`, password);
}
success(res, { message: `Credentials stored for ${serviceId}` });
}, 'store-service-creds'));
// Delete credentials for a service
router.delete('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
await credentialManager.delete(`service.${serviceId}.apikey`);
await credentialManager.delete(`service.${serviceId}.username`);
await credentialManager.delete(`service.${serviceId}.password`);
success(res, { message: `Credentials removed for ${serviceId}` });
}, 'delete-service-creds'));
// Check credential status for a service (what's stored)
router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
try {
const { serviceId } = req.params;
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
success(res, {
hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username,
username: username || null
});
} catch (error) {
success(res, { hasApiKey: false, hasBasicAuth: false });
}
}, 'service-creds'));
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
// Store seedhost credentials (shared username + per-service passwords)
router.post('/seedhost-creds', asyncHandler(async (req, res) => {
const { username, password, serviceId } = req.body;
if (!username) {
throw new ValidationError('Username required');
}
await credentialManager.store('seedhost.username', username);
if (password) {
if (serviceId) {
await credentialManager.store(`seedhost.password.${serviceId}`, password);
} else {
await credentialManager.store('seedhost.password', password);
}
}
success(res, { message: 'Seedhost credentials stored' });
}, 'store-seedhost-creds'));
// Get seedhost credential status
router.get('/seedhost-creds', asyncHandler(async (req, res) => {
try {
const username = await credentialManager.retrieve('seedhost.username').catch(() => null);
const serviceId = req.query.serviceId;
let hasPassword = false;
if (serviceId) {
const svcPass = await credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
hasPassword = !!svcPass;
}
// Fall back to checking shared password
if (!hasPassword) {
const sharedPass = await credentialManager.retrieve('seedhost.password').catch(() => null);
hasPassword = !!sharedPass;
}
success(res, { hasCredentials: !!username && hasPassword, username: username || null, hasPassword });
} catch (error) {
success(res, { hasCredentials: false });
}
}, 'seedhost-creds'));
// Delete seedhost credentials
router.delete('/seedhost-creds', asyncHandler(async (req, res) => {
const serviceId = req.query.serviceId;
if (serviceId) {
await credentialManager.delete(`seedhost.password.${serviceId}`);
success(res, { message: `Password for ${serviceId} removed` });
} else {
await credentialManager.delete('seedhost.username');
await credentialManager.delete('seedhost.password');
success(res, { message: 'Seedhost credentials removed' });
}
}, 'delete-seedhost-creds'));
// ===== SERVICE CRUD ENDPOINTS =====
// Batched live status for dashboard cards
const STATUS_DEADLINE = 10000; // 10s — return whatever we have by then
router.get('/services/status', asyncHandler(async (req, res) => {
const services = await loadServicesList();
const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s]));
const ids = [];
const seen = new Set();
function addId(id) {
if (!id || seen.has(id)) return;
seen.add(id);
ids.push(id);
}
addId('internet');
Object.keys(siteConfig?.dnsServers || {}).forEach(addId);
services.forEach(service => addId(service.id));
// Collect results as they arrive; deadline returns whatever we have
const statuses = {};
const probeWork = mapWithConcurrency(ids, PROBE_CONCURRENCY, async (id) => {
const result = await probeServiceStatus(id, serviceMap.get(id));
statuses[result.id] = result;
return result;
});
const deadline = new Promise((resolve) =>
setTimeout(() => resolve(null), STATUS_DEADLINE)
);
await Promise.race([probeWork, deadline]);
// Fill any IDs that didn't finish before the deadline
const partial = ids.some((id) => !statuses[id]);
ids.forEach((id) => {
if (!statuses[id]) {
statuses[id] = { id, isUp: false, statusCode: 0, responseTime: STATUS_DEADLINE, error: 'deadline' };
}
});
res.set('Cache-Control', 'no-store');
success(res, {
checkedAt: new Date().toISOString(),
partial,
statuses
});
}, 'services-status'));
// List all services
router.get('/services', asyncHandler(async (req, res) => {
if (!await exists(SERVICES_FILE)) {
return res.json([]);
}
const services = await servicesStateManager.read();
const paginationParams = parsePaginationParams(req.query);
const result = paginate(services, paginationParams);
if (paginationParams) {
success(res, { services: result.data, pagination: result.pagination });
} else {
res.json(result.data);
}
}, 'services-list'));
// Add a new service
router.post('/services', asyncHandler(async (req, res) => {
try {
const { id, name, logo } = req.body;
if (!id || !name) {
throw new ValidationError('id and name are required');
}
// Validate service configuration
try {
validateServiceConfig({ id, name });
} catch (validationErr) {
return errorResponse(res, validationErr.message, 400, { errors: validationErr.errors });
}
await servicesStateManager.update(services => {
// Check if service already exists
if (services.find(s => s.id === id)) {
throw new ConflictError(`Service "${id}" already exists`, id);
}
services.push({ id, name, logo: logo || `/assets/${id}.png` });
return services;
});
resyncHealthChecker?.().catch(() => {});
success(res, { message: `Service "${name}" added to dashboard` });
} catch (error) {
log.error('deploy', 'Error adding service', { error: error.message });
if (error.message.includes('already exists')) {
errorResponse(res, safeErrorMessage(error), 409);
} else {
// Error handled by middleware
}
}
}, 'services-update'));
// Bulk import/replace services (for dashboard import feature)
router.put('/services', asyncHandler(async (req, res) => {
const services = req.body;
if (!Array.isArray(services)) {
throw new ValidationError('Request body must be an array of services');
}
for (const service of services) {
if (!service.id || !service.name) {
throw new ValidationError('Each service must have id and name fields');
}
try {
validateServiceConfig(service);
} catch (validationErr) {
return errorResponse(res, `Invalid service "${service.id}": ${validationErr.message}`, 400, { errors: validationErr.errors });
}
}
await servicesStateManager.write(services);
resyncHealthChecker?.().catch(() => {});
success(res, {
message: `Successfully imported ${services.length} services`,
count: services.length
});
}, 'services-import'));
// Delete a service
router.delete('/services/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
if (!await exists(SERVICES_FILE)) {
throw new NotFoundError('No services found');
}
let found = false;
await servicesStateManager.update(services => {
const initialLength = services.length;
const filtered = services.filter(s => s.id !== id);
found = filtered.length !== initialLength;
return filtered;
});
if (!found) {
return errorResponse(res, `Service "${id}" not found`, 404);
}
resyncHealthChecker?.().catch(() => {});
success(res, { message: `Service "${id}" removed from dashboard` });
}, 'services-delete'));
// Update service configuration (subdomain, port, IP, tailscale, name, logo)
router.post('/services/update', asyncHandler(async (req, res) => {
const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body;
if (!oldSubdomain || !newSubdomain) {
throw new ValidationError('oldSubdomain and newSubdomain are required');
}
if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) {
throw new ValidationError('[DC-301] Invalid subdomain format');
}
if (port && !isValidPort(port)) {
throw new ValidationError('Invalid port number (must be 1-65535)');
}
if (ip && ip !== 'localhost' && !validatorLib.isIP(ip)) {
throw new ValidationError('[DC-210] Invalid IP address');
}
const results = { dns: null, caddy: null, services: null };
const oldDomain = buildDomain(oldSubdomain);
const newDomain = buildDomain(newSubdomain);
let content = await caddy.read();
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(
`${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`,
's'
);
const oldBlockMatch = content.match(siteBlockRegex);
if (oldBlockMatch) {
const proxyMatch = oldBlockMatch[0].match(/reverse_proxy\s+([^\s\n]+)/);
const existingTarget = proxyMatch ? proxyMatch[1] : null;
const [existingIp, existingPort] = existingTarget ? existingTarget.split(':') : ['localhost', '80'];
const finalIp = ip || existingIp;
const finalPort = port || existingPort;
const newConfig = caddy.generateConfig(newSubdomain, finalIp, finalPort, {
tailscaleOnly: tailscaleOnly || false
});
const caddyResult = await caddy.modify(c => c.replace(siteBlockRegex, newConfig));
results.caddy = caddyResult.success ? 'updated' : `config saved, reload failed: ${caddyResult.error}`;
} else {
results.caddy = 'old config not found';
}
if (oldSubdomain !== newSubdomain) {
try {
const dnsToken = dns.getToken();
await dns.call(siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' });
await dns.createRecord(newSubdomain, ip || 'localhost');
results.dns = 'updated';
} catch (e) {
results.dns = `failed: ${e.message}`;
}
} else {
results.dns = 'unchanged';
}
if (await exists(SERVICES_FILE)) {
await servicesStateManager.update(services => {
const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.url?.includes(oldSubdomain));
if (serviceIndex !== -1) {
const existing = services[serviceIndex];
const finalPort = port || existing.port;
const finalIp = ip || existing.ip;
services[serviceIndex] = {
...existing,
// Keep the original ID — don't change it to the subdomain
port: finalPort,
ip: finalIp,
tailscaleOnly: tailscaleOnly || false,
url: buildServiceUrl(newSubdomain)
};
if (name) services[serviceIndex].name = name;
if (logo) services[serviceIndex].logo = logo;
results.services = 'updated';
} else {
results.services = 'not found';
}
return services;
});
}
resyncHealthChecker?.().catch(() => {});
success(res, {
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
results
});
}, 'services-update'));
return router;
};