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'); module.exports = function(ctx) { 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(ctx.SERVICES_FILE)) return []; const data = await ctx.servicesStateManager.read(); return Array.isArray(data) ? data : data.services || []; } function resolveProbeUrl(id, service) { return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl); } 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 req = lib.request({ hostname: parsed.hostname, port: parsed.port || (isHttps ? 443 : 80), path: parsed.pathname + parsed.search, method, timeout: TIMEOUTS.HTTP_DEFAULT, agent: isHttps ? probeHttpsAgent : undefined, headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, }, (response) => { response.resume(); resolve(response.statusCode || 0); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); req.end(); }); } async function probeServiceStatus(id, service) { const startedAt = process.hrtime.bigint(); let 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; if (id !== 'internet') { const fallbackUrl = ctx.buildServiceUrl(id); if (fallbackUrl !== url) { try { statusCode = await requestStatusCode(fallbackUrl, 'GET'); url = fallbackUrl; error = null; } catch (fallbackError) { error = fallbackError; } } } } 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', ctx.asyncHandler(async (req, res) => { const { serviceId } = req.params; const { apiKey, username, password } = req.body; if (apiKey) { await ctx.credentialManager.store(`service.${serviceId}.apikey`, apiKey); } if (username) { await ctx.credentialManager.store(`service.${serviceId}.username`, username); } if (password) { await ctx.credentialManager.store(`service.${serviceId}.password`, password); } res.json({ success: true, message: `Credentials stored for ${serviceId}` }); }, 'store-service-creds')); // Delete credentials for a service router.delete('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => { const { serviceId } = req.params; await ctx.credentialManager.delete(`service.${serviceId}.apikey`); await ctx.credentialManager.delete(`service.${serviceId}.username`); await ctx.credentialManager.delete(`service.${serviceId}.password`); res.json({ success: true, message: `Credentials removed for ${serviceId}` }); }, 'delete-service-creds')); // Check credential status for a service (what's stored) router.get('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => { try { const { serviceId } = req.params; const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null); const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null); const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); res.json({ success: true, hasApiKey: !!(arrKey || svcKey), hasBasicAuth: !!username, username: username || null }); } catch (error) { res.json({ success: true, hasApiKey: false, hasBasicAuth: false }); } }, 'service-creds')); // ===== SEEDHOST CREDENTIAL ENDPOINTS ===== // Store seedhost credentials (shared username + per-service passwords) router.post('/seedhost-creds', ctx.asyncHandler(async (req, res) => { const { username, password, serviceId } = req.body; if (!username) { return ctx.errorResponse(res, 400, 'Username required'); } await ctx.credentialManager.store('seedhost.username', username); if (password) { if (serviceId) { await ctx.credentialManager.store(`seedhost.password.${serviceId}`, password); } else { await ctx.credentialManager.store('seedhost.password', password); } } res.json({ success: true, message: 'Seedhost credentials stored' }); }, 'store-seedhost-creds')); // Get seedhost credential status router.get('/seedhost-creds', ctx.asyncHandler(async (req, res) => { try { const username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null); const serviceId = req.query.serviceId; let hasPassword = false; if (serviceId) { const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null); hasPassword = !!svcPass; } // Fall back to checking shared password if (!hasPassword) { const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null); hasPassword = !!sharedPass; } res.json({ success: true, hasCredentials: !!username && hasPassword, username: username || null, hasPassword }); } catch (error) { res.json({ success: true, hasCredentials: false }); } }, 'seedhost-creds')); // Delete seedhost credentials router.delete('/seedhost-creds', ctx.asyncHandler(async (req, res) => { const serviceId = req.query.serviceId; if (serviceId) { await ctx.credentialManager.delete(`seedhost.password.${serviceId}`); res.json({ success: true, message: `Password for ${serviceId} removed` }); } else { await ctx.credentialManager.delete('seedhost.username'); await ctx.credentialManager.delete('seedhost.password'); res.json({ success: true, message: 'Seedhost credentials removed' }); } }, 'delete-seedhost-creds')); // ===== SERVICE CRUD ENDPOINTS ===== // Batched live status for dashboard cards router.get('/services/status', ctx.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(ctx.siteConfig?.dnsServers || {}).forEach(addId); services.forEach(service => addId(service.id)); const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) => probeServiceStatus(id, serviceMap.get(id)) ); const statuses = {}; statusResults.forEach((result) => { statuses[result.id] = result; }); res.set('Cache-Control', 'no-store'); res.json({ success: true, checkedAt: new Date().toISOString(), statuses }); }, 'services-status')); // List all services router.get('/services', ctx.asyncHandler(async (req, res) => { if (!await exists(ctx.SERVICES_FILE)) { return res.json([]); } const services = await ctx.servicesStateManager.read(); const paginationParams = parsePaginationParams(req.query); const result = paginate(services, paginationParams); if (paginationParams) { res.json({ success: true, services: result.data, pagination: result.pagination }); } else { res.json(result.data); } }, 'services-list')); // Add a new service router.post('/services', ctx.asyncHandler(async (req, res) => { try { const { id, name, logo } = req.body; if (!id || !name) { return ctx.errorResponse(res, 400, 'id and name are required'); } // Validate service configuration try { validateServiceConfig({ id, name }); } catch (validationErr) { return ctx.errorResponse(res, 400, validationErr.message, { errors: validationErr.errors }); } await ctx.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; }); ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, message: `Service "${name}" added to dashboard` }); } catch (error) { ctx.log.error('deploy', 'Error adding service', { error: error.message }); if (error.message.includes('already exists')) { ctx.errorResponse(res, 409, ctx.safeErrorMessage(error)); } else { ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); } } }, 'services-update')); // Bulk import/replace services (for dashboard import feature) router.put('/services', ctx.asyncHandler(async (req, res) => { const services = req.body; if (!Array.isArray(services)) { return ctx.errorResponse(res, 400, 'Request body must be an array of services'); } for (const service of services) { if (!service.id || !service.name) { return ctx.errorResponse(res, 400, 'Each service must have id and name fields'); } try { validateServiceConfig(service); } catch (validationErr) { return ctx.errorResponse(res, 400, `Invalid service "${service.id}": ${validationErr.message}`, { errors: validationErr.errors }); } } await ctx.servicesStateManager.write(services); ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, message: `Successfully imported ${services.length} services`, count: services.length }); }, 'services-import')); // Delete a service router.delete('/services/:id', ctx.asyncHandler(async (req, res) => { const { id } = req.params; if (!await exists(ctx.SERVICES_FILE)) { return ctx.errorResponse(res, 404, 'No services found'); } let found = false; await ctx.servicesStateManager.update(services => { const initialLength = services.length; const filtered = services.filter(s => s.id !== id); found = filtered.length !== initialLength; return filtered; }); if (!found) { return ctx.errorResponse(res, 404, `Service "${id}" not found`); } ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, message: `Service "${id}" removed from dashboard` }); }, 'services-delete')); // Update service configuration (subdomain, port, IP, tailscale) router.post('/services/update', ctx.asyncHandler(async (req, res) => { const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly } = req.body; if (!oldSubdomain || !newSubdomain) { return ctx.errorResponse(res, 400, 'oldSubdomain and newSubdomain are required'); } if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) { return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format'); } if (port && !isValidPort(port)) { return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)'); } if (ip && !validatorLib.isIP(ip)) { return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address'); } const results = { dns: null, caddy: null, services: null }; const oldDomain = ctx.buildDomain(oldSubdomain); const newDomain = ctx.buildDomain(newSubdomain); let content = await ctx.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 = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, { tailscaleOnly: tailscaleOnly || false }); const caddyResult = await ctx.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 = ctx.dns.getToken(); await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' }); await ctx.dns.createRecord(newSubdomain, ip || 'localhost'); results.dns = 'updated'; } catch (e) { results.dns = `failed: ${e.message}`; } } else { results.dns = 'unchanged'; } if (await exists(ctx.SERVICES_FILE)) { await ctx.servicesStateManager.update(services => { const serviceIndex = services.findIndex(s => s.id === oldSubdomain); if (serviceIndex !== -1) { services[serviceIndex] = { ...services[serviceIndex], id: newSubdomain, port: port || services[serviceIndex].port, ip: ip || services[serviceIndex].ip, tailscaleOnly: tailscaleOnly || false }; results.services = 'updated'; } else { results.services = 'not found'; } return services; }); } ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, results }); }, 'services-update')); return router; };