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'); /** * 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(), 5000); 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) { return errorResponse(res, 'Username required', 400); } 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) { return errorResponse(res, 'id and name are required', 400); } // 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 Error(`Service "${id}" already exists`); } 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 { errorResponse(res, safeErrorMessage(error), 500); } } }, '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)) { return errorResponse(res, 'Request body must be an array of services', 400); } for (const service of services) { if (!service.id || !service.name) { return errorResponse(res, 'Each service must have id and name fields', 400); } 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)) { return errorResponse(res, 'No services found', 404); } 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) { return errorResponse(res, 'oldSubdomain and newSubdomain are required', 400); } if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) { return errorResponse(res, '[DC-301] Invalid subdomain format', 400); } if (port && !isValidPort(port)) { return errorResponse(res, 'Invalid port number (must be 1-65535)', 400); } if (ip && ip !== 'localhost' && !validatorLib.isIP(ip)) { return errorResponse(res, '[DC-210] Invalid IP address', 400); } 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; };