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 = ` CA Certificate Distribution

CA Certificate Installation

To trust *${ctx.siteConfig.tld} domains on your device, install the root CA certificate:

Download Certificate

Download Certificate (.crt)

Windows Installation

Run PowerShell as Administrator:

irm http://ca${ctx.siteConfig.tld}/api/ca/install-script?platform=windows | iex

Linux/macOS Installation

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 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 } = 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'); } 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'); } 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 || [] }; 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; };