const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const { REGEX, DOCKER } = require('../../constants'); const { exists } = require('../../fs-helpers'); const platformPaths = require('../../platform-paths'); /** * Apps helpers 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 {Function} deps.fetchT - Timeout-wrapped fetch * @param {Object} deps.log - Logger instance * @returns {Object} Helper functions */ module.exports = function({ docker, caddy, credentialManager, servicesStateManager, fetchT, log }) { async function checkPortConflicts(ports, excludeContainerName = null) { const conflicts = []; try { const containers = await docker.client.listContainers({ all: true }); for (const container of containers) { if (excludeContainerName && container.Names.some(n => n === `/${excludeContainerName}`)) continue; if (container.State !== 'running') continue; for (const portInfo of (container.Ports || [])) { if (portInfo.PublicPort) { const publicPort = portInfo.PublicPort.toString(); if (ports.includes(publicPort)) { const containerName = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12); const appLabel = container.Labels?.['sami.app'] || 'unknown'; conflicts.push({ port: publicPort, usedBy: containerName, app: appLabel, containerId: container.Id.substring(0, 12) }); } } } } } catch (e) { log.warn('docker', 'Could not check port conflicts', { error: e.message }); } return conflicts; } async function findExistingContainerByImage(template) { try { const containers = await docker.client.listContainers({ all: false }); const templateImage = template.docker.image.split(':')[0]; for (const container of containers) { const containerImage = container.Image.split(':')[0]; if (containerImage === templateImage || containerImage.endsWith('/' + templateImage)) { const ports = container.Ports.filter(p => p.PublicPort).map(p => ({ hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type })); return { id: container.Id, shortId: container.Id.slice(0, 12), name: container.Names[0]?.replace(/^\//, '') || 'unknown', image: container.Image, status: container.Status, state: container.State, ports, primaryPort: ports.length > 0 ? ports[0].hostPort : null, labels: container.Labels || {} }; } } return null; } catch (e) { log.warn('docker', 'Could not check for existing containers', { error: e.message }); return null; } } // Convert host path to Docker-compatible mount format (platform-aware) const toDockerDesktopPath = platformPaths.toDockerMountPath; function processTemplateVariables(template, config) { const processed = JSON.parse(JSON.stringify(template)); const mediaPathInput = config.mediaPath || template.mediaMount?.defaultPath || '/media'; const mediaPaths = mediaPathInput.split(',').map(p => p.trim()).filter(p => p).map(p => toDockerDesktopPath(p)); const replacements = { '{{HOST_IP}}': config.ip, '{{SUBDOMAIN}}': config.subdomain, '{{PORT}}': config.port || template.defaultPort, '{{MEDIA_PATH}}': mediaPaths[0] || '/media', '{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC', '{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex') }; function replaceInObject(obj) { for (const key in obj) { if (typeof obj[key] === 'string') { Object.entries(replacements).forEach(([placeholder, value]) => { obj[key] = obj[key].replace(new RegExp(placeholder, 'g'), value); }); } else if (typeof obj[key] === 'object' && obj[key] !== null) { replaceInObject(obj[key]); } } } replaceInObject(processed); // Handle multiple media paths if (mediaPaths.length > 1 && processed.docker?.volumes) { const containerPath = template.mediaMount?.containerPath || '/media'; const newVolumes = []; for (const vol of processed.docker.volumes) { if (vol.includes(mediaPaths[0]) && vol.includes(containerPath)) { for (const p of mediaPaths) { const folderName = p.split(/[/\\]/).filter(p => p).pop() || 'media'; newVolumes.push(`${p}:${containerPath}/${folderName}`); } } else { newVolumes.push(vol); } } processed.docker.volumes = newVolumes; } // Handle Plex claim token if (config.plexClaimToken && processed.docker?.environment?.PLEX_CLAIM !== undefined) { processed.docker.environment.PLEX_CLAIM = config.plexClaimToken; } // Inject URL base env var for subdirectory routing mode if (ctx.siteConfig.routingMode === 'subdirectory' && template.urlBaseEnv) { processed.docker.environment = processed.docker.environment || {}; const basePath = `/${config.subdomain}`; // Some apps need the full URL, not just the path if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) { processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/'; } else { processed.docker.environment[template.urlBaseEnv] = basePath; } } // Apply custom volume overrides if (config.customVolumes?.length && processed.docker?.volumes) { processed.docker.volumes = processed.docker.volumes.map(vol => { const parts = vol.split(':'); const containerPath = parts.slice(1).join(':'); const override = config.customVolumes.find(cv => cv.containerPath === containerPath); if (override && override.hostPath) { // Validate host path is under allowed roots (docker data dir or media paths) const normalizedHost = path.resolve(override.hostPath); const allowedRoots = [path.resolve(platformPaths.dockerData)]; if (config.mediaPath) { config.mediaPath.split(',').map(p => p.trim()).filter(Boolean).forEach(p => allowedRoots.push(path.resolve(p))); } const isAllowed = allowedRoots.some(root => normalizedHost === root || normalizedHost.startsWith(root + path.sep) ); if (!isAllowed) { log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots }); return vol; // Keep original volume, don't apply unsafe override } return `${toDockerDesktopPath(override.hostPath)}:${containerPath}`; } return vol; }); } return processed; } function generateStaticSiteConfig(subdomain, sitePath, options = {}) { const { tailscaleOnly = false, httpAccess = false, apiProxy = null } = options; const domain = ctx.buildDomain(subdomain); // Shared block content used by both HTTPS and HTTP blocks function siteBlockContent() { let c = ''; c += ` root * ${sitePath}\n\n`; if (tailscaleOnly) { c += ` @blocked not remote_ip 100.64.0.0/10\n`; c += ` respond @blocked "Access denied. Tailscale connection required." 403\n\n`; } if (apiProxy) { c += ` handle /api/* {\n`; c += ` reverse_proxy ${apiProxy}\n`; c += ` }\n\n`; } c += ` @crt path *.crt\n`; c += ` handle @crt {\n`; c += ` header Content-Type application/x-x509-ca-cert\n`; c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ` header Cache-Control "public, max-age=86400"\n`; c += ` file_server\n`; c += ` }\n\n`; c += ` @der path *.der\n`; c += ` handle @der {\n`; c += ` header Content-Type application/x-x509-ca-cert\n`; c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ` header Cache-Control "public, max-age=86400"\n`; c += ` file_server\n`; c += ` }\n\n`; c += ` @mobileconfig path *.mobileconfig\n`; c += ` handle @mobileconfig {\n`; c += ` header Content-Type application/x-apple-aspen-config\n`; c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ` header Cache-Control "public, max-age=86400"\n`; c += ` file_server\n`; c += ` }\n\n`; c += ` @ps1 path *.ps1\n`; c += ` handle @ps1 {\n`; c += ` header Content-Type text/plain\n`; c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ` file_server\n`; c += ` }\n\n`; c += ` @sh path *.sh\n`; c += ` handle @sh {\n`; c += ` header Content-Type text/x-shellscript\n`; c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; c += ` file_server\n`; c += ` }\n\n`; c += ` # Static site with SPA fallback\n`; c += ` handle {\n`; c += ` @notFile not file {path}\n`; c += ` rewrite @notFile /index.html\n`; c += ` file_server\n`; c += ` }\n\n`; c += ` # No cache for HTML\n`; c += ` @htmlfiles {\n`; c += ` path *.html\n`; c += ` path /\n`; c += ` }\n`; c += ` header @htmlfiles Cache-Control "no-store"\n`; return c; } // HTTPS block let config = `${domain} {\n`; config += ` tls internal\n\n`; config += siteBlockContent(); config += `}`; // HTTP companion block for devices that haven't trusted the CA yet if (httpAccess) { config += `\n\n# HTTP access for first-time certificate installation\n`; config += `http://${domain} {\n`; config += siteBlockContent(); config += `}`; } return config; } async function waitForHealthCheck(containerId, healthPath, port, maxAttempts = 20) { const delay = 2000; let httpCheckFailed = 0; for (let i = 0; i < maxAttempts; i++) { try { const container = docker.client.getContainer(containerId); const info = await container.inspect(); if (info.State.Running) { if (info.State.Health) { if (info.State.Health.Status === 'healthy') { log.info('docker', 'Container is healthy (Docker health check)', { containerId }); return true; } } else if (healthPath && port && httpCheckFailed < 5) { try { const response = await fetchT(`http://localhost:${port}${healthPath}`, { signal: AbortSignal.timeout(3000), redirect: 'manual' }); if (response.ok || (response.status >= 300 && response.status < 400)) { log.info('docker', 'Health check passed', { containerId, status: response.status }); return true; } } catch (e) { httpCheckFailed++; log.debug('docker', 'HTTP health check failed', { attempt: httpCheckFailed, error: e.message }); } } else { if (i >= 5) { log.info('docker', 'Container is running', { containerId, waitedSeconds: i * delay / 1000 }); return true; } } } } catch (e) { log.debug('docker', 'Health check attempt failed', { attempt: i + 1, error: e.message }); } if (i < maxAttempts - 1) { log.debug('docker', 'Waiting for container to be healthy', { attempt: i + 1, maxAttempts }); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error(`[DC-202] Container failed to become healthy after ${maxAttempts} attempts (${maxAttempts * delay / 1000}s)`); } async function addCaddyConfig(subdomain, config) { const domain = ctx.buildDomain(subdomain); const existing = await caddy.read(); if (existing.includes(`${domain} {`)) { log.info('caddy', 'Caddy config already exists, skipping add', { domain }); await caddy.reload(existing); return; } const result = await caddy.modify(c => c + `\n${config}\n`); if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`); await caddy.verifySite(domain); } // Reserved paths that cannot be used as subpath names in subdirectory mode const RESERVED_SUBPATHS = ['api', 'probe', 'assets', 'health', 'dist', 'js', 'css', 'fonts', 'favicon.ico']; /** Ensure the main domain block with route markers exists in the Caddyfile. * Called before the first subdirectory app is deployed. */ async function ensureMainDomainBlock() { if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return; const content = await caddy.read(); const domain = ctx.siteConfig.domain; const ROUTE_MARKER = '# === DashCaddy App Routes ==='; // Already has markers — nothing to do if (content.includes(ROUTE_MARKER)) return; // Domain block exists but lacks markers — inject them if (content.includes(`${domain} {`)) { const result = await caddy.modify(c => { // Insert markers before the final catch-all handle block inside the domain block const domainStart = c.indexOf(`${domain} {`); // Find standalone "handle {" (catch-all SPA fallback) — match tabs or spaces const searchFrom = domainStart + domain.length; const handleMatch = c.slice(searchFrom).match(/^([ \t]+)handle\s*\{/m); if (!handleMatch) return null; const handleIdx = searchFrom + handleMatch.index; const indent = handleMatch[1]; const markerBlock = `${indent}${ROUTE_MARKER}\n${indent}# === End App Routes ===\n\n`; return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx); }); if (result.success) { log.info('caddy', 'Injected route markers into existing domain block', { domain }); } return; } // No domain block at all — create one with dashboard + markers const dashboardRoot = platformPaths.sitePath('status'); const apiPort = process.env.PORT || 3001; const block = `\n${domain} {\n root * ${dashboardRoot}\n encode gzip\n\n handle /api/* {\n reverse_proxy localhost:${apiPort}\n }\n\n handle /probe/* {\n reverse_proxy localhost:${apiPort}\n }\n\n ${ROUTE_MARKER}\n # === End App Routes ===\n\n handle {\n @notFile not file {path}\n rewrite @notFile /index.html\n file_server\n }\n}\n`; const result = await caddy.modify(c => c + block); if (result.success) { log.info('caddy', 'Created main domain block with route markers', { domain }); } else { throw new Error(`[DC-303] Failed to create main domain block for ${domain}: ${result.error}`); } } /** Inject a subpath config block between route markers in the Caddyfile. */ async function addSubpathConfig(subdomain, configBlock) { const marker = `# --- DashCaddy: ${subdomain} ---`; const endMarker = `# --- End: ${subdomain} ---`; const END_ROUTE_MARKER = '# === End App Routes ==='; const result = await caddy.modify(content => { if (content.includes(marker)) { log.info('caddy', 'Subpath config already exists, skipping', { subdomain }); return null; } const endIdx = content.indexOf(END_ROUTE_MARKER); if (endIdx === -1) { throw new Error(`Route marker "${END_ROUTE_MARKER}" not found in Caddyfile`); } // Detect indentation from the end marker line const lineStart = content.lastIndexOf('\n', endIdx) + 1; const indent = content.slice(lineStart, endIdx).match(/^([ \t]*)/)?.[1] || '\t'; const injection = `${indent}${marker}\n${configBlock}\n${indent}${endMarker}\n`; return content.slice(0, endIdx) + injection + content.slice(endIdx); }); if (!result.success) { throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`); } } /** Remove a subpath config block from between its markers in the Caddyfile. */ async function removeSubpathConfig(subdomain) { const marker = `# --- DashCaddy: ${subdomain} ---`; const endMarker = `# --- End: ${subdomain} ---`; return await caddy.modify(content => { const startIdx = content.indexOf(marker); if (startIdx === -1) return null; const endIdx = content.indexOf(endMarker); if (endIdx === -1) return null; // Remove from the line start of the marker to the line end of the end marker const lineStart = content.lastIndexOf('\n', startIdx); const lineEnd = content.indexOf('\n', endIdx + endMarker.length); const modified = content.slice(0, lineStart) + content.slice(lineEnd); return modified.replace(/\n{3,}/g, '\n\n'); }); } return { checkPortConflicts, findExistingContainerByImage, toDockerDesktopPath, processTemplateVariables, waitForHealthCheck, addCaddyConfig, addSubpathConfig, removeSubpathConfig, ensureMainDomainBlock, RESERVED_SUBPATHS, generateStaticSiteConfig }; };