- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards) - Live dashboard updates via SSE (resource alerts, health changes, update notices) - Docker Compose import with YAML parsing, preview, and dependency-ordered deploy - Volume & network management modal with disk usage overview - CPU/memory resource limits on deploy and live update - Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy - Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly) New deps: ws, js-yaml, nodemailer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
433 lines
20 KiB
JavaScript
433 lines
20 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');
|
|
const { ValidationError } = require('../../errors');
|
|
const { logError } = require('../../src/utils/logging');
|
|
/**
|
|
* Apps deployment routes 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 {Object} deps.portLockManager - Port lock manager
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @param {Function} deps.errorResponse - Error response helper
|
|
* @param {Object} deps.log - Logger instance
|
|
* @param {Object} deps.helpers - Apps helpers module
|
|
* @returns {express.Router}
|
|
*/
|
|
|
|
module.exports = function({ docker, caddy, credentialManager, servicesStateManager, portLockManager, asyncHandler, errorResponse, log, helpers, APP_TEMPLATES, siteConfig, buildDomain, buildServiceUrl, addServiceToConfig, dns, notification, safeErrorMessage }) {
|
|
const router = express.Router();
|
|
|
|
// Ctx shim for backward compatibility with existing route code
|
|
const ctx = {
|
|
APP_TEMPLATES,
|
|
siteConfig,
|
|
buildDomain,
|
|
buildServiceUrl,
|
|
addServiceToConfig,
|
|
dns,
|
|
notification,
|
|
safeErrorMessage
|
|
};
|
|
|
|
async function deployDashCAStaticSite(template, userConfig) {
|
|
const destPath = platformPaths.caCertDir;
|
|
try {
|
|
log.info('deploy', 'DashCA: Starting static site deployment');
|
|
if (!await exists(destPath)) {
|
|
await fsp.mkdir(destPath, { recursive: true });
|
|
log.info('deploy', 'DashCA: Created destination directory', { path: destPath });
|
|
}
|
|
|
|
log.info('deploy', 'DashCA: Verifying certificate files');
|
|
const rootCertExists = await exists(`${destPath}/root.crt`);
|
|
const intermediateCertExists = await exists(`${destPath}/intermediate.crt`);
|
|
if (rootCertExists) log.info('deploy', 'DashCA: Root certificate found');
|
|
else log.warn('deploy', 'DashCA: Root certificate not found', { expected: path.join(destPath, 'root.crt') });
|
|
if (intermediateCertExists) log.info('deploy', 'DashCA: Intermediate certificate found');
|
|
|
|
const indexPath = path.join(destPath, 'index.html');
|
|
if (!await exists(indexPath)) {
|
|
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);
|
|
log.info('deploy', 'DashCA: Created minimal landing page');
|
|
} else {
|
|
log.info('deploy', 'DashCA: Using existing index.html');
|
|
}
|
|
|
|
log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
|
|
log.info('deploy', 'DashCA: Static site deployment completed successfully');
|
|
} catch (error) {
|
|
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 {
|
|
log.info('deploy', 'Acquiring port locks', { ports: requestedPorts });
|
|
lockId = await portLockManager.acquirePorts(requestedPorts);
|
|
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 = docker.client.getContainer(containerName);
|
|
const info = await existingContainer.inspect();
|
|
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.`);
|
|
}
|
|
|
|
// Translate volume paths for cross-platform compatibility
|
|
const translatedVolumes = (processedTemplate.docker.volumes || []).map(volume => {
|
|
const [hostPath, ...rest] = volume.split(':');
|
|
const translatedHost = platformPaths.toDockerMountPath(hostPath);
|
|
return rest.length > 0 ? `${translatedHost}:${rest.join(':')}` : translatedHost;
|
|
});
|
|
|
|
const containerConfig = {
|
|
Image: processedTemplate.docker.image,
|
|
name: containerName,
|
|
ExposedPorts: {},
|
|
HostConfig: {
|
|
PortBindings: {},
|
|
Binds: translatedVolumes,
|
|
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;
|
|
}
|
|
|
|
// Resource limits (CPU and memory)
|
|
if (userConfig.resources) {
|
|
if (userConfig.resources.memory) {
|
|
const memBytes = Math.round(userConfig.resources.memory * 1024 * 1024); // MB to bytes
|
|
containerConfig.HostConfig.Memory = memBytes;
|
|
containerConfig.HostConfig.MemoryReservation = Math.round(memBytes * 0.5); // soft limit = 50%
|
|
}
|
|
if (userConfig.resources.cpus) {
|
|
containerConfig.HostConfig.NanoCpus = Math.round(userConfig.resources.cpus * 1e9);
|
|
}
|
|
}
|
|
|
|
try {
|
|
log.info('docker', 'Pulling image', { image: processedTemplate.docker.image });
|
|
await docker.pull(processedTemplate.docker.image);
|
|
log.info('docker', 'Image pulled successfully', { image: processedTemplate.docker.image });
|
|
} catch (e) {
|
|
log.warn('docker', 'Image pull failed, checking if local image exists', { image: processedTemplate.docker.image, error: e.message });
|
|
try {
|
|
const images = await 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}`);
|
|
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 docker.client.createContainer(containerConfig);
|
|
await container.start();
|
|
|
|
// Prune dangling images to prevent disk bloat
|
|
try {
|
|
const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } });
|
|
if (pruneResult.SpaceReclaimed > 0) {
|
|
log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
|
|
}
|
|
} catch (pruneErr) {
|
|
log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message });
|
|
}
|
|
|
|
await portLockManager.releasePorts(lockId);
|
|
log.info('deploy', 'Port locks released', { lockId });
|
|
return container.id;
|
|
} catch (deployError) {
|
|
if (lockId) {
|
|
try {
|
|
await portLockManager.releasePorts(lockId);
|
|
log.info('deploy', 'Port locks released after error', { lockId });
|
|
} catch (releaseError) {
|
|
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', asyncHandler(async (req, res) => {
|
|
const { appId } = req.body;
|
|
const template = ctx.APP_TEMPLATES[appId];
|
|
if (!template) throw new ValidationError('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', asyncHandler(async (req, res) => {
|
|
const { appId, config } = req.body;
|
|
if (!appId || typeof appId !== 'string') {
|
|
throw new ValidationError('appId is required');
|
|
}
|
|
if (!config || typeof config !== 'object') {
|
|
throw new ValidationError('config object is required');
|
|
}
|
|
if (!config.subdomain || typeof config.subdomain !== 'string') {
|
|
throw new ValidationError('config.subdomain is required');
|
|
}
|
|
try {
|
|
log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
|
|
const template = ctx.APP_TEMPLATES[appId];
|
|
if (!template) {
|
|
await logError('app-deploy', new Error('Invalid app template'), { appId, config });
|
|
throw new ValidationError('Invalid app template');
|
|
}
|
|
|
|
if (config.subdomain) {
|
|
if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
|
|
throw new ValidationError('[DC-301] Invalid subdomain format');
|
|
}
|
|
// Block reserved path names in subdirectory mode
|
|
if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) {
|
|
return errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`);
|
|
}
|
|
}
|
|
if (config.port && !isValidPort(config.port)) {
|
|
throw new ValidationError('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 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;
|
|
|
|
// Process template variables for manifest (only needed for Docker containers)
|
|
const processedTemplate = template.isStaticSite ? null : helpers.processTemplateVariables(template, config);
|
|
|
|
if (template.isStaticSite) {
|
|
log.info('deploy', 'Deploying static site', { appId });
|
|
if (appId === 'dashca') {
|
|
await deployDashCAStaticSite(template, config);
|
|
containerId = null;
|
|
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;
|
|
log.info('deploy', 'Using existing container', { containerId });
|
|
if (config.existingPort && !config.port) config.port = config.existingPort;
|
|
} else {
|
|
containerId = await deployContainer(appId, config, template);
|
|
log.info('deploy', 'Container deployed', { containerId });
|
|
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
|
|
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);
|
|
log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
|
|
} catch (dnsError) {
|
|
await 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.`;
|
|
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 = 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);
|
|
}
|
|
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
|
|
});
|
|
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 logError('app-deploy', error, { appId, config });
|
|
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');
|
|
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
|
}
|
|
}, 'apps-deploy'));
|
|
|
|
return router;
|
|
};
|