Prevents Docker disk bloat by adding log rotation (10MB max, 3 files) to all container creation and update paths, auto-pruning dangling images after deploy/remove/update, and a daily maintenance module that cleans build cache and warns on disk thresholds. Saves a deployment manifest in services.json at deploy time so users can restore all their apps after a Docker purge. Adds restore-all and restore-single endpoints that recreate containers, Caddy config, and DNS records from the saved manifests. Adds an hourly log collector and daily digest generator that summarizes errors, warnings, and events across all services into a single human-readable report with guidance on where to investigate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
383 lines
18 KiB
JavaScript
383 lines
18 KiB
JavaScript
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');
|
|
|
|
module.exports = function(ctx, helpers) {
|
|
const router = express.Router();
|
|
|
|
async function deployDashCAStaticSite(template, userConfig) {
|
|
const destPath = platformPaths.caCertDir;
|
|
try {
|
|
ctx.log.info('deploy', 'DashCA: Starting static site deployment');
|
|
if (!await exists(destPath)) {
|
|
await fsp.mkdir(destPath, { recursive: true });
|
|
ctx.log.info('deploy', 'DashCA: Created destination directory', { path: destPath });
|
|
}
|
|
|
|
ctx.log.info('deploy', 'DashCA: Verifying certificate files');
|
|
const rootCertExists = await exists(`${destPath}/root.crt`);
|
|
const intermediateCertExists = await exists(`${destPath}/intermediate.crt`);
|
|
if (rootCertExists) ctx.log.info('deploy', 'DashCA: Root certificate found');
|
|
else ctx.log.warn('deploy', 'DashCA: Root certificate not found', { expected: path.join(destPath, 'root.crt') });
|
|
if (intermediateCertExists) ctx.log.info('deploy', 'DashCA: Intermediate certificate found');
|
|
|
|
const indexPath = path.join(destPath, 'index.html');
|
|
if (!await exists(indexPath)) {
|
|
ctx.log.info('deploy', 'DashCA: Creating minimal landing page');
|
|
const minimalHtml = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CA Certificate Distribution</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
|
h1 { color: #00d9ff; }
|
|
.download { display: inline-block; padding: 12px 24px; margin: 10px; background: #00d9ff; color: #000; text-decoration: none; border-radius: 6px; font-weight: bold; }
|
|
.download:hover { background: #00b8d4; }
|
|
code { background: #2a2a3e; padding: 2px 6px; border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>CA Certificate Installation</h1>
|
|
<p>To trust *${ctx.siteConfig.tld} domains on your device, install the root CA certificate:</p>
|
|
<h2>Download Certificate</h2>
|
|
<a href="/root.crt" class="download" download>Download Certificate (.crt)</a>
|
|
<h2>Windows Installation</h2>
|
|
<p>Run PowerShell as Administrator:</p>
|
|
<pre><code>irm http://ca${ctx.siteConfig.tld}/api/ca/install-script?platform=windows | iex</code></pre>
|
|
<h2>Linux/macOS Installation</h2>
|
|
<pre><code>curl -fsSk http://ca${ctx.siteConfig.tld}/api/ca/install-script?platform=linux | sudo bash</code></pre>
|
|
<p><em>Note: Full DashCA interface requires manual deployment of certificate files.</em></p>
|
|
</body>
|
|
</html>`;
|
|
await fsp.writeFile(indexPath, minimalHtml);
|
|
ctx.log.info('deploy', 'DashCA: Created minimal landing page');
|
|
} else {
|
|
ctx.log.info('deploy', 'DashCA: Using existing index.html');
|
|
}
|
|
|
|
ctx.log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
|
|
ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully');
|
|
} catch (error) {
|
|
ctx.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 {
|
|
ctx.log.info('deploy', 'Acquiring port locks', { ports: requestedPorts });
|
|
lockId = await ctx.portLockManager.acquirePorts(requestedPorts);
|
|
ctx.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 = ctx.docker.client.getContainer(containerName);
|
|
const info = await existingContainer.inspect();
|
|
ctx.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.`);
|
|
}
|
|
|
|
const containerConfig = {
|
|
Image: processedTemplate.docker.image,
|
|
name: containerName,
|
|
ExposedPorts: {},
|
|
HostConfig: {
|
|
PortBindings: {},
|
|
Binds: processedTemplate.docker.volumes || [],
|
|
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;
|
|
}
|
|
|
|
try {
|
|
ctx.log.info('docker', 'Pulling image', { image: processedTemplate.docker.image });
|
|
await ctx.docker.pull(processedTemplate.docker.image);
|
|
ctx.log.info('docker', 'Image pulled successfully', { image: processedTemplate.docker.image });
|
|
} catch (e) {
|
|
ctx.log.warn('docker', 'Image pull failed, checking if local image exists', { image: processedTemplate.docker.image, error: e.message });
|
|
try {
|
|
const images = await ctx.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}`);
|
|
ctx.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 ctx.docker.client.createContainer(containerConfig);
|
|
await container.start();
|
|
|
|
// Prune dangling images to prevent disk bloat
|
|
try {
|
|
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
|
|
if (pruneResult.SpaceReclaimed > 0) {
|
|
ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
|
|
}
|
|
} catch (pruneErr) {
|
|
ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message });
|
|
}
|
|
|
|
await ctx.portLockManager.releasePorts(lockId);
|
|
ctx.log.info('deploy', 'Port locks released', { lockId });
|
|
return container.id;
|
|
} catch (deployError) {
|
|
if (lockId) {
|
|
try {
|
|
await ctx.portLockManager.releasePorts(lockId);
|
|
ctx.log.info('deploy', 'Port locks released after error', { lockId });
|
|
} catch (releaseError) {
|
|
ctx.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', ctx.asyncHandler(async (req, res) => {
|
|
const { appId } = req.body;
|
|
const template = ctx.APP_TEMPLATES[appId];
|
|
if (!template) return ctx.errorResponse(res, 400, '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', ctx.asyncHandler(async (req, res) => {
|
|
const { appId, config } = req.body;
|
|
if (!appId || typeof appId !== 'string') {
|
|
return ctx.errorResponse(res, 400, 'appId is required');
|
|
}
|
|
if (!config || typeof config !== 'object') {
|
|
return ctx.errorResponse(res, 400, 'config object is required');
|
|
}
|
|
if (!config.subdomain || typeof config.subdomain !== 'string') {
|
|
return ctx.errorResponse(res, 400, 'config.subdomain is required');
|
|
}
|
|
try {
|
|
ctx.log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
|
|
const template = ctx.APP_TEMPLATES[appId];
|
|
if (!template) {
|
|
await ctx.logError('app-deploy', new Error('Invalid app template'), { appId, config });
|
|
return ctx.errorResponse(res, 400, 'Invalid app template');
|
|
}
|
|
|
|
if (config.subdomain) {
|
|
if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
|
|
return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format');
|
|
}
|
|
// Block reserved path names in subdirectory mode
|
|
if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) {
|
|
return ctx.errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`);
|
|
}
|
|
}
|
|
if (config.port && !isValidPort(config.port)) {
|
|
return ctx.errorResponse(res, 400, '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 ctx.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;
|
|
|
|
if (template.isStaticSite) {
|
|
ctx.log.info('deploy', 'Deploying static site', { appId });
|
|
if (appId === 'dashca') {
|
|
await deployDashCAStaticSite(template, config);
|
|
containerId = null;
|
|
ctx.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;
|
|
ctx.log.info('deploy', 'Using existing container', { containerId });
|
|
if (config.existingPort && !config.port) config.port = config.existingPort;
|
|
} else {
|
|
containerId = await deployContainer(appId, config, template);
|
|
ctx.log.info('deploy', 'Container deployed', { containerId });
|
|
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
|
|
ctx.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);
|
|
ctx.log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
|
|
} catch (dnsError) {
|
|
await ctx.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.`;
|
|
ctx.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 = ctx.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);
|
|
}
|
|
ctx.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
|
|
});
|
|
ctx.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 ctx.logError('app-deploy', error, { appId, config });
|
|
ctx.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');
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
|
}
|
|
}, 'apps-deploy'));
|
|
|
|
return router;
|
|
};
|