const express = require('express'); const fsp = require('fs').promises; const path = require('path'); const validatorLib = require('validator'); const { REGEX, DOCKER } = require('../../constants'); const { isValidPort } = require('../../input-validator'); const { exists } = require('../../fs-helpers'); const platformPaths = require('../../platform-paths'); const { ValidationError } = require('../../errors'); const { logError } = require('../../src/utils/logging'); /** * Apps deployment routes factory * @param {Object} deps - Explicit dependencies * @param {Object} deps.docker - Docker client wrapper * @param {Object} deps.caddy - Caddy client * @param {Object} deps.credentialManager - Credential manager * @param {Object} deps.servicesStateManager - Services state manager * @param {Object} deps.portLockManager - Port lock manager * @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.errorResponse - Error response helper * @param {Object} deps.log - Logger instance * @param {Object} deps.helpers - Apps helpers module * @returns {express.Router} */ module.exports = function({ docker, caddy, credentialManager, servicesStateManager, portLockManager, asyncHandler, errorResponse, log, helpers, APP_TEMPLATES, siteConfig, buildDomain, buildServiceUrl, addServiceToConfig, dns, notification, safeErrorMessage }) { const router = express.Router(); // Ctx shim for backward compatibility with existing route code const ctx = { APP_TEMPLATES, siteConfig, buildDomain, buildServiceUrl, addServiceToConfig, dns, notification, safeErrorMessage }; async function deployDashCAStaticSite(template, userConfig) { const destPath = platformPaths.caCertDir; try { log.info('deploy', 'DashCA: Starting static site deployment'); if (!await exists(destPath)) { await fsp.mkdir(destPath, { recursive: true }); log.info('deploy', 'DashCA: Created destination directory', { path: destPath }); } log.info('deploy', 'DashCA: Verifying certificate files'); const rootCertExists = await exists(`${destPath}/root.crt`); const intermediateCertExists = await exists(`${destPath}/intermediate.crt`); if (rootCertExists) log.info('deploy', 'DashCA: Root certificate found'); else log.warn('deploy', 'DashCA: Root certificate not found', { expected: path.join(destPath, 'root.crt') }); if (intermediateCertExists) log.info('deploy', 'DashCA: Intermediate certificate found'); const indexPath = path.join(destPath, 'index.html'); if (!await exists(indexPath)) { log.info('deploy', 'DashCA: Creating minimal landing page'); const minimalHtml = `
To trust *${ctx.siteConfig.tld} domains on your device, install the root CA certificate:
Run PowerShell as Administrator:
irm http://ca${ctx.siteConfig.tld}/api/ca/install-script?platform=windows | iex
curl -fsSk http://ca${ctx.siteConfig.tld}/api/ca/install-script?platform=linux | sudo bash
Note: Full DashCA interface requires manual deployment of certificate files.
`; await fsp.writeFile(indexPath, minimalHtml); log.info('deploy', 'DashCA: Created minimal landing page'); } else { log.info('deploy', 'DashCA: Using existing index.html'); } log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath); log.info('deploy', 'DashCA: Static site deployment completed successfully'); } catch (error) { log.error('deploy', 'DashCA deployment error', { error: error.message }); throw new Error(`DashCA deployment failed: ${error.message}`); } } async function deployContainer(appId, userConfig, template) { const containerName = `${DOCKER.CONTAINER_PREFIX}${userConfig.subdomain}`; const processedTemplate = helpers.processTemplateVariables(template, userConfig); const requestedPorts = processedTemplate.docker.ports.map(portMapping => { const [hostPort] = portMapping.split(/[:/]/); return hostPort; }); let lockId = null; try { log.info('deploy', 'Acquiring port locks', { ports: requestedPorts }); lockId = await portLockManager.acquirePorts(requestedPorts); log.info('deploy', 'Port locks acquired', { lockId }); } catch (lockError) { throw new Error(`Failed to acquire port locks: ${lockError.message}`); } try { // Remove stale container with same name try { const existingContainer = docker.client.getContainer(containerName); const info = await existingContainer.inspect(); log.info('docker', 'Removing stale container', { containerName, status: info.State.Status }); await existingContainer.remove({ force: true }); await new Promise(r => setTimeout(r, 2000)); } catch (e) { // Container doesn't exist — normal case } const conflicts = await helpers.checkPortConflicts(requestedPorts, containerName); if (conflicts.length > 0) { const conflictDetails = conflicts.map(c => `Port ${c.port} is in use by ${c.usedBy} (${c.app})`).join('; '); throw new Error(`[DC-203] Port conflict detected: ${conflictDetails}. Please choose a different port.`); } // Translate volume paths for cross-platform compatibility const translatedVolumes = (processedTemplate.docker.volumes || []).map(volume => { const [hostPath, ...rest] = volume.split(':'); const translatedHost = platformPaths.toDockerMountPath(hostPath); return rest.length > 0 ? `${translatedHost}:${rest.join(':')}` : translatedHost; }); const containerConfig = { Image: processedTemplate.docker.image, name: containerName, ExposedPorts: {}, HostConfig: { PortBindings: {}, Binds: translatedVolumes, RestartPolicy: { Name: 'unless-stopped' }, LogConfig: DOCKER.LOG_CONFIG }, Env: Object.entries(processedTemplate.docker.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { 'sami.managed': 'true', 'sami.app': appId, 'sami.subdomain': userConfig.subdomain, 'sami.deployed': new Date().toISOString() } }; processedTemplate.docker.ports.forEach(portMapping => { const [hostPort, containerPort, protocol = 'tcp'] = portMapping.split(/[:/]/); const containerPortKey = `${containerPort}/${protocol}`; containerConfig.ExposedPorts[containerPortKey] = {}; containerConfig.HostConfig.PortBindings[containerPortKey] = [{ HostPort: hostPort }]; }); if (processedTemplate.docker.capabilities) { containerConfig.HostConfig.CapAdd = processedTemplate.docker.capabilities; } // Resource limits (CPU and memory) if (userConfig.resources) { if (userConfig.resources.memory) { const memBytes = Math.round(userConfig.resources.memory * 1024 * 1024); // MB to bytes containerConfig.HostConfig.Memory = memBytes; containerConfig.HostConfig.MemoryReservation = Math.round(memBytes * 0.5); // soft limit = 50% } if (userConfig.resources.cpus) { containerConfig.HostConfig.NanoCpus = Math.round(userConfig.resources.cpus * 1e9); } } try { log.info('docker', 'Pulling image', { image: processedTemplate.docker.image }); await docker.pull(processedTemplate.docker.image); log.info('docker', 'Image pulled successfully', { image: processedTemplate.docker.image }); } catch (e) { log.warn('docker', 'Image pull failed, checking if local image exists', { image: processedTemplate.docker.image, error: e.message }); try { const images = await docker.client.listImages({ filters: { reference: [processedTemplate.docker.image] } }); if (images.length === 0) throw new Error(`[DC-201] Image ${processedTemplate.docker.image} not found locally and pull failed: ${e.message}`); log.info('docker', 'Using existing local image', { image: processedTemplate.docker.image }); } catch (listError) { throw new Error(`[DC-201] Failed to pull or find image ${processedTemplate.docker.image}: ${e.message}`); } } const container = await docker.client.createContainer(containerConfig); await container.start(); // Prune dangling images to prevent disk bloat try { const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); } } catch (pruneErr) { log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message }); } await portLockManager.releasePorts(lockId); log.info('deploy', 'Port locks released', { lockId }); return container.id; } catch (deployError) { if (lockId) { try { await portLockManager.releasePorts(lockId); log.info('deploy', 'Port locks released after error', { lockId }); } catch (releaseError) { log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message }); } } throw deployError; } } /** * Check which template-declared dependencies are missing from running services. * Returns null if the template has no dependsOn field. * @param {Object} template - App template with optional `dependsOn: string[]` * @param {Array} runningServices - Array of services from services.json * @returns {{ dependsOn: string[], missing: string[], satisfied: string[] } | null} */ function checkDependencies(template, runningServices) { if (!template || !Array.isArray(template.dependsOn) || template.dependsOn.length === 0) { return null; } const deployedTemplateIds = new Set( (runningServices || []) .map(s => s.appTemplate || s.deploymentManifest?.templateId) .filter(Boolean) ); const satisfied = []; const missing = []; for (const depId of template.dependsOn) { if (deployedTemplateIds.has(depId)) satisfied.push(depId); else missing.push(depId); } return { dependsOn: [...template.dependsOn], missing, satisfied }; } /** * Topologically sort a list of template IDs by their dependsOn relationships. * Dependencies come before dependents. Skips IDs not present in templates. * Detects cycles and breaks them by emitting in DFS finish order. * @param {string[]} ids * @returns {string[]} */ function topologicalSortTemplates(ids) { const idSet = new Set(ids); const visited = new Set(); const visiting = new Set(); const result = []; function visit(id) { if (visited.has(id)) return; if (visiting.has(id)) return; // cycle — break here visiting.add(id); const tpl = ctx.APP_TEMPLATES[id]; if (tpl && Array.isArray(tpl.dependsOn)) { for (const dep of tpl.dependsOn) { if (idSet.has(dep)) visit(dep); } } visiting.delete(id); visited.add(id); result.push(id); } for (const id of ids) visit(id); return result; } /** * Build a default deploy config for a dependency template. * Uses the template's defaults: subdomain, port, IP from siteConfig. */ function buildDefaultDepConfig(template) { return { subdomain: template.subdomain, port: template.defaultPort, ip: ctx.siteConfig.dnsServerIp || 'localhost', createDns: true, tailscaleOnly: false, allowedIPs: [] }; } /** * Deploy a list of dependency apps in topological order, waiting for each * to become healthy before continuing. Skips deps already running. * Throws on first failure. */ async function deployDependencyChain(depIds) { const ordered = topologicalSortTemplates(depIds); const services = await servicesStateManager.read(); const deployedTemplateIds = new Set( services.map(s => s.appTemplate || s.deploymentManifest?.templateId).filter(Boolean) ); const deployed = []; for (const depId of ordered) { if (deployedTemplateIds.has(depId)) { log.info('deploy', 'Dependency already running, skipping', { depId }); continue; } const depTemplate = ctx.APP_TEMPLATES[depId]; if (!depTemplate) { throw new Error(`[DC-205] Dependency "${depId}" has no template`); } const depConfig = buildDefaultDepConfig(depTemplate); log.info('deploy', 'Deploying dependency', { depId, subdomain: depConfig.subdomain }); const containerId = await deployContainer(depId, depConfig, depTemplate); await helpers.waitForHealthCheck(containerId, depTemplate.healthCheck, depConfig.port); log.info('deploy', 'Dependency healthy', { depId, containerId }); // Generate Caddy config + DNS for the dep so it's actually reachable const caddyOptions = { tailscaleOnly: false, allowedIPs: [], subpathSupport: depTemplate.subpathSupport || 'strip' }; const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain; const depCaddyConfig = caddy.generateConfig(depConfig.subdomain, depConfig.ip, depConfig.port, caddyOptions); if (isSubdirectoryMode) { await helpers.ensureMainDomainBlock(); await helpers.addSubpathConfig(depConfig.subdomain, depCaddyConfig); } else { await helpers.addCaddyConfig(depConfig.subdomain, depCaddyConfig); } if (!isSubdirectoryMode) { try { await ctx.dns.createRecord(depConfig.subdomain, depConfig.ip); } catch (dnsErr) { log.warn('deploy', 'DNS creation failed for dependency', { depId, error: dnsErr.message }); } } // Build a minimal manifest for the dep so it's restorable later const processedDep = helpers.processTemplateVariables(depTemplate, depConfig); const depManifest = { templateId: depId, config: { subdomain: depConfig.subdomain, port: depConfig.port, ip: depConfig.ip, createDns: depConfig.createDns, tailscaleOnly: false, allowedIPs: [], useExisting: false }, container: { image: processedDep.docker.image, ports: processedDep.docker.ports, volumes: processedDep.docker.volumes || [], environment: { ...processedDep.docker.environment }, capabilities: processedDep.docker.capabilities || undefined }, caddy: { tailscaleOnly: false, allowedIPs: [], subpathSupport: depTemplate.subpathSupport || 'strip', routingMode: ctx.siteConfig.routingMode } }; await ctx.addServiceToConfig({ id: depConfig.subdomain, name: depTemplate.name, logo: depTemplate.logo || `/assets/${depId}.png`, url: ctx.buildServiceUrl(depConfig.subdomain), containerId, appTemplate: depId, tailscaleOnly: false, routingMode: ctx.siteConfig.routingMode, deployedAt: new Date().toISOString(), deploymentManifest: depManifest }); deployed.push({ depId, containerId, subdomain: depConfig.subdomain }); } return deployed; } // Check dependencies for an app (frontend calls this before opening deploy modal) router.post('/apps/check-dependencies', asyncHandler(async (req, res) => { const { appId } = req.body; if (!appId || typeof appId !== 'string') { throw new ValidationError('appId is required'); } const template = ctx.APP_TEMPLATES[appId]; if (!template) throw new ValidationError('Invalid app template'); const services = await servicesStateManager.read(); const result = checkDependencies(template, services); if (!result) { return res.json({ appId, dependsOn: [], missing: [], satisfied: [] }); } // Enrich missing deps with friendly names for the UI const missingDetails = result.missing.map(id => ({ id, name: ctx.APP_TEMPLATES[id]?.name || id, icon: ctx.APP_TEMPLATES[id]?.icon || '📦' })); res.json({ appId, ...result, missingDetails }); }, 'apps-check-dependencies')); // Check for existing container before deployment router.post('/apps/check-existing', asyncHandler(async (req, res) => { const { appId } = req.body; const template = ctx.APP_TEMPLATES[appId]; if (!template) throw new ValidationError('Invalid app template'); const existingContainer = await helpers.findExistingContainerByImage(template); if (existingContainer) { res.json({ success: true, exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` }); } else { res.json({ success: true, exists: false, message: `No existing ${template.name} container found` }); } }, 'check-existing')); // Deploy new app router.post('/apps/deploy', asyncHandler(async (req, res) => { const { appId, config, deployDependencies } = req.body; if (!appId || typeof appId !== 'string') { throw new ValidationError('appId is required'); } if (!config || typeof config !== 'object') { throw new ValidationError('config object is required'); } if (!config.subdomain || typeof config.subdomain !== 'string') { throw new ValidationError('config.subdomain is required'); } let deployedDeps = []; try { log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); const template = ctx.APP_TEMPLATES[appId]; if (!template) { await logError('app-deploy', new Error('Invalid app template'), { appId, config }); throw new ValidationError('Invalid app template'); } // Deploy declared dependencies first if requested if (Array.isArray(deployDependencies) && deployDependencies.length > 0) { log.info('deploy', 'Deploying dependencies first', { appId, deps: deployDependencies }); deployedDeps = await deployDependencyChain(deployDependencies); log.info('deploy', 'Dependencies deployed', { count: deployedDeps.length }); } if (config.subdomain) { if (!REGEX.SUBDOMAIN.test(config.subdomain)) { throw new ValidationError('[DC-301] Invalid subdomain format'); } // Block reserved path names in subdirectory mode if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) { return errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`); } } if (config.port && !isValidPort(config.port)) { throw new ValidationError('Invalid port number (must be 1-65535)'); } if (!template.isStaticSite) { const allowedHostnames = ['localhost', 'host.docker.internal']; if (config.ip && !validatorLib.isIP(config.ip) && !allowedHostnames.includes(config.ip)) { return errorResponse(res, 400, '[DC-210] Invalid IP address. Use a valid IP (e.g., 192.168.x.x) or "localhost".'); } if (!config.ip) config.ip = ctx.siteConfig.dnsServerIp || 'localhost'; } else { config.createDns = false; config.ip = ctx.siteConfig.dnsServerIp || 'localhost'; } let containerId; let usedExisting = false; // Process template variables for manifest (only needed for Docker containers) const processedTemplate = template.isStaticSite ? null : helpers.processTemplateVariables(template, config); if (template.isStaticSite) { log.info('deploy', 'Deploying static site', { appId }); if (appId === 'dashca') { await deployDashCAStaticSite(template, config); containerId = null; log.info('deploy', 'Static site deployed', { appId }); } else { throw new Error(`Unknown static site type: ${appId}`); } } else if (config.useExisting && config.existingContainerId) { containerId = config.existingContainerId; usedExisting = true; log.info('deploy', 'Using existing container', { containerId }); if (config.existingPort && !config.port) config.port = config.existingPort; } else { containerId = await deployContainer(appId, config, template); log.info('deploy', 'Container deployed', { containerId }); await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort); log.info('deploy', 'Container is healthy', { containerId }); } const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain; // DNS record creation (skip in subdirectory mode — only one domain needed) let dnsWarning = null; if (config.createDns && !isSubdirectoryMode) { try { await ctx.dns.createRecord(config.subdomain, config.ip); log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip }); } catch (dnsError) { await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip }); dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`; log.warn('deploy', 'DNS creation failed during deploy', { error: dnsError.message }); } } // Caddy config generation const caddyOptions = { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [], subpathSupport: template.subpathSupport || 'strip', }; let caddyConfig; if (template.isStaticSite) { const sitePath = platformPaths.sitePath(config.subdomain); if (appId === 'dashca') { caddyOptions.httpAccess = true; caddyOptions.apiProxy = 'host.docker.internal:3001'; } caddyConfig = helpers.generateStaticSiteConfig(config.subdomain, sitePath, caddyOptions); } else { caddyConfig = caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions); } // Write Caddy config (subdirectory: inject into main block; subdomain: append as new block) if (isSubdirectoryMode && !template.isStaticSite) { await helpers.ensureMainDomainBlock(); await helpers.addSubpathConfig(config.subdomain, caddyConfig); } else { await helpers.addCaddyConfig(config.subdomain, caddyConfig); } log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), routingMode: ctx.siteConfig.routingMode, tailscaleOnly: config.tailscaleOnly || false }); // Build service URL based on routing mode const serviceUrl = ctx.buildServiceUrl(config.subdomain); // Build deployment manifest — the full recipe to recreate this container const deploymentManifest = { templateId: appId, config: { subdomain: config.subdomain, port: config.port || template.defaultPort, ip: config.ip, mediaPath: config.mediaPath || undefined, createDns: config.createDns || false, tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [], customVolumes: config.customVolumes || undefined, useExisting: false }, container: template.isStaticSite ? null : { image: processedTemplate.docker.image, ports: processedTemplate.docker.ports, volumes: processedTemplate.docker.volumes || [], environment: (() => { // Strip sensitive values from stored env (claim tokens, secrets) const env = { ...processedTemplate.docker.environment }; for (const key of Object.keys(env)) { if (/claim|secret|password|token|key/i.test(key) && env[key]) { env[key] = ''; // Clear sensitive values — user re-enters on restore } } return env; })(), capabilities: processedTemplate.docker.capabilities || undefined }, caddy: { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [], subpathSupport: template.subpathSupport || 'strip', routingMode: ctx.siteConfig.routingMode } }; await ctx.addServiceToConfig({ id: config.subdomain, name: template.name, logo: template.logo || `/assets/${appId}.png`, url: serviceUrl, containerId, appTemplate: appId, tailscaleOnly: config.tailscaleOnly || false, routingMode: ctx.siteConfig.routingMode, deployedAt: new Date().toISOString(), deploymentManifest }); log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); const response = { success: true, containerId, usedExisting, url: serviceUrl, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, setupInstructions: template.setupInstructions || [], deployedDependencies: deployedDeps }; if (dnsWarning) response.warning = dnsWarning; const notificationMessage = usedExisting ? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}` : `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`; ctx.notification.send('deploymentSuccess', usedExisting ? 'Configuration Complete' : 'Deployment Successful', notificationMessage, 'success'); res.json(response); } catch (error) { await logError('app-deploy', error, { appId, config }); log.error('deploy', 'Deployment failed', { appId, error: error.message }); const template = ctx.APP_TEMPLATES[appId]; ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error'); errorResponse(res, 500, ctx.safeErrorMessage(error)); } }, 'apps-deploy')); return router; };