411 lines
17 KiB
JavaScript
411 lines
17 KiB
JavaScript
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');
|
|
|
|
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',
|
|
'{{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) {
|
|
ctx.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 = 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);
|
|
}
|
|
|
|
// 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 ctx.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 ctx.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) {
|
|
ctx.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 ctx.caddy.modify(c => c + block);
|
|
if (result.success) {
|
|
ctx.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 ctx.caddy.modify(content => {
|
|
if (content.includes(marker)) {
|
|
ctx.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 ctx.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
|
|
};
|
|
};
|