const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const { REGEX, DOCKER } = require('../../constants'); const { exists } = require('../../fs-helpers'); const platformPaths = require('../../platform-paths'); module.exports = function(ctx) { async function checkPortConflicts(ports, excludeContainerName = null) { const conflicts = []; try { const containers = await ctx.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) { ctx.log.warn('docker', 'Could not check port conflicts', { error: e.message }); } return conflicts; } async function findExistingContainerByImage(template) { try { const containers = await ctx.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) { ctx.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' }; 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; } // 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) 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 = ctx.docker.client.getContainer(containerId); const info = await container.inspect(); if (info.State.Running) { if (info.State.Health) { if (info.State.Health.Status === 'healthy') { ctx.log.info('docker', 'Container is healthy (Docker health check)', { containerId }); return true; } } else if (healthPath && port && httpCheckFailed < 5) { try { const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, { signal: AbortSignal.timeout(3000), redirect: 'manual' }); if (response.ok || (response.status >= 300 && response.status < 400)) { ctx.log.info('docker', 'Health check passed', { containerId, status: response.status }); return true; } } catch (e) { httpCheckFailed++; ctx.log.debug('docker', 'HTTP health check failed', { attempt: httpCheckFailed, error: e.message }); } } else { if (i >= 5) { ctx.log.info('docker', 'Container is running', { containerId, waitedSeconds: i * delay / 1000 }); return true; } } } } catch (e) { ctx.log.debug('docker', 'Health check attempt failed', { attempt: i + 1, error: e.message }); } if (i < maxAttempts - 1) { ctx.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 ctx.caddy.read(); if (existing.includes(`${domain} {`)) { ctx.log.info('caddy', 'Caddy config already exists, skipping add', { domain }); await ctx.caddy.reload(existing); return; } const result = await ctx.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 ctx.caddy.verifySite(domain); } return { checkPortConflicts, findExistingContainerByImage, toDockerDesktopPath, processTemplateVariables, waitForHealthCheck, addCaddyConfig, generateStaticSiteConfig }; };