const express = require('express'); const fs = require('fs'); const validatorLib = require('validator'); const { REGEX } = require('../constants'); const { validateServiceConfig, isValidPort } = require('../input-validator'); const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); module.exports = function(ctx) { const router = express.Router(); // ===== 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 ===== // 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; }); 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); 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`); } 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; }); } res.json({ success: true, message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, results }); }, 'services-update')); return router; };