Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
279 lines
11 KiB
JavaScript
279 lines
11 KiB
JavaScript
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
|
|
};
|
|
};
|