Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
300
dashcaddy-api/routes/apps/deploy.js
Normal file
300
dashcaddy-api/routes/apps/deploy.js
Normal file
@@ -0,0 +1,300 @@
|
||||
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' }
|
||||
},
|
||||
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();
|
||||
|
||||
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;
|
||||
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');
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
let dnsWarning = null;
|
||||
if (config.createDns) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
const caddyOptions = { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [] };
|
||||
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);
|
||||
}
|
||||
|
||||
await helpers.addCaddyConfig(config.subdomain, caddyConfig);
|
||||
ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), tailscaleOnly: config.tailscaleOnly || false });
|
||||
|
||||
await ctx.addServiceToConfig({
|
||||
id: config.subdomain, name: template.name,
|
||||
logo: template.logo || `/assets/${appId}.png`,
|
||||
containerId, appTemplate: appId,
|
||||
tailscaleOnly: config.tailscaleOnly || false,
|
||||
deployedAt: new Date().toISOString()
|
||||
});
|
||||
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
|
||||
|
||||
const response = {
|
||||
success: true, containerId, usedExisting,
|
||||
url: `https://${ctx.buildDomain(config.subdomain)}`,
|
||||
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: https://${ctx.buildDomain(config.subdomain)}`
|
||||
: `**${template.name}** has been deployed successfully.\nURL: https://${ctx.buildDomain(config.subdomain)}`;
|
||||
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;
|
||||
};
|
||||
278
dashcaddy-api/routes/apps/helpers.js
Normal file
278
dashcaddy-api/routes/apps/helpers.js
Normal file
@@ -0,0 +1,278 @@
|
||||
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
|
||||
};
|
||||
};
|
||||
16
dashcaddy-api/routes/apps/index.js
Normal file
16
dashcaddy-api/routes/apps/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const initHelpers = require('./helpers');
|
||||
const initDeploy = require('./deploy');
|
||||
const initRemoval = require('./removal');
|
||||
const initTemplates = require('./templates');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const router = express.Router();
|
||||
const helpers = initHelpers(ctx);
|
||||
|
||||
router.use(initDeploy(ctx, helpers));
|
||||
router.use(initRemoval(ctx, helpers));
|
||||
router.use(initTemplates(ctx, helpers));
|
||||
|
||||
return router;
|
||||
};
|
||||
104
dashcaddy-api/routes/apps/removal.js
Normal file
104
dashcaddy-api/routes/apps/removal.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const express = require('express');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
|
||||
module.exports = function(ctx, helpers) {
|
||||
const router = express.Router();
|
||||
|
||||
// Remove deployed app
|
||||
router.delete('/apps/:appId', ctx.asyncHandler(async (req, res) => {
|
||||
const { appId } = req.params;
|
||||
const { containerId, subdomain, ip, deleteContainer } = req.query;
|
||||
const shouldDeleteContainer = deleteContainer === 'true';
|
||||
const results = { container: null, dns: null, caddy: null, service: null };
|
||||
|
||||
try {
|
||||
ctx.log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer });
|
||||
|
||||
if (containerId && shouldDeleteContainer) {
|
||||
try {
|
||||
const container = ctx.docker.client.getContainer(containerId);
|
||||
try { await container.stop(); ctx.log.info('docker', 'Container stopped', { containerId }); }
|
||||
catch (stopError) { ctx.log.debug('docker', 'Container stop note', { containerId, note: stopError.message }); }
|
||||
await container.remove({ force: true });
|
||||
results.container = 'removed';
|
||||
ctx.log.info('docker', 'Container removed', { containerId });
|
||||
} catch (error) {
|
||||
results.container = error.message.includes('no such container') ? 'already removed' : error.message;
|
||||
}
|
||||
} else if (containerId && !shouldDeleteContainer) {
|
||||
results.container = 'kept (user choice)';
|
||||
}
|
||||
|
||||
if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) {
|
||||
try {
|
||||
const domain = ctx.buildDomain(subdomain);
|
||||
const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', {
|
||||
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true'
|
||||
});
|
||||
let recordIp = ip || 'localhost';
|
||||
if (getResult.status === 'ok' && getResult.response?.records) {
|
||||
const aRecord = getResult.response.records.find(r => r.type === 'A');
|
||||
if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress;
|
||||
}
|
||||
const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
|
||||
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp
|
||||
});
|
||||
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
|
||||
ctx.log.info('dns', 'DNS record removal', { result: results.dns });
|
||||
} catch (error) {
|
||||
results.dns = error.message;
|
||||
}
|
||||
} else if (!shouldDeleteContainer) {
|
||||
results.dns = 'kept (user choice)';
|
||||
} else {
|
||||
results.dns = 'skipped (no subdomain or token)';
|
||||
}
|
||||
|
||||
if (shouldDeleteContainer && subdomain) {
|
||||
try {
|
||||
const domain = ctx.buildDomain(subdomain);
|
||||
let content = await ctx.caddy.read();
|
||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
|
||||
const originalLength = content.length;
|
||||
content = content.replace(siteBlockRegex, '\n');
|
||||
if (content.length !== originalLength) {
|
||||
content = content.replace(/\n{3,}/g, '\n\n');
|
||||
const caddyResult = await ctx.caddy.modify(() => content);
|
||||
results.caddy = caddyResult.success ? 'removed' : 'removed (reload failed)';
|
||||
} else {
|
||||
results.caddy = 'not found';
|
||||
}
|
||||
ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy });
|
||||
} catch (error) {
|
||||
results.caddy = error.message;
|
||||
}
|
||||
} else if (!shouldDeleteContainer) {
|
||||
results.caddy = 'kept (user choice)';
|
||||
}
|
||||
|
||||
try {
|
||||
if (await exists(ctx.SERVICES_FILE)) {
|
||||
let removed = false;
|
||||
await ctx.servicesStateManager.update(services => {
|
||||
const initialLength = services.length;
|
||||
const filtered = services.filter(s => s.id !== subdomain && s.appTemplate !== appId);
|
||||
removed = filtered.length !== initialLength;
|
||||
return filtered;
|
||||
});
|
||||
results.service = removed ? 'removed' : 'not found';
|
||||
}
|
||||
ctx.log.info('deploy', 'Service config removal', { result: results.service });
|
||||
} catch (error) {
|
||||
results.service = error.message;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `App ${appId} removal completed`, results });
|
||||
} catch (error) {
|
||||
await ctx.logError('app-removal', error);
|
||||
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
|
||||
}
|
||||
}, 'apps-delete'));
|
||||
|
||||
return router;
|
||||
};
|
||||
137
dashcaddy-api/routes/apps/templates.js
Normal file
137
dashcaddy-api/routes/apps/templates.js
Normal file
@@ -0,0 +1,137 @@
|
||||
const express = require('express');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
|
||||
module.exports = function(ctx, helpers) {
|
||||
const router = express.Router();
|
||||
|
||||
// Get available app templates
|
||||
router.get('/apps/templates', ctx.asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
templates: ctx.APP_TEMPLATES,
|
||||
categories: ctx.TEMPLATE_CATEGORIES,
|
||||
difficultyLevels: ctx.DIFFICULTY_LEVELS
|
||||
});
|
||||
}, 'apps-templates'));
|
||||
|
||||
// Get specific app template
|
||||
router.get('/apps/templates/:appId', ctx.asyncHandler(async (req, res) => {
|
||||
const { appId } = req.params;
|
||||
const template = ctx.APP_TEMPLATES[appId];
|
||||
if (!template) {
|
||||
const { NotFoundError } = require('../../errors');
|
||||
throw new NotFoundError('App template');
|
||||
}
|
||||
res.json({ success: true, template });
|
||||
}, 'apps-template-detail'));
|
||||
|
||||
// Check port availability
|
||||
router.get('/apps/ports/:port/check', ctx.asyncHandler(async (req, res) => {
|
||||
const port = req.params.port;
|
||||
const conflicts = await helpers.checkPortConflicts([port]);
|
||||
if (conflicts.length > 0) {
|
||||
const conflict = conflicts[0];
|
||||
res.json({ available: false, port, conflict: { usedBy: conflict.usedBy, app: conflict.app, containerId: conflict.containerId } });
|
||||
} else {
|
||||
res.json({ available: true, port });
|
||||
}
|
||||
}, 'check-port'));
|
||||
|
||||
// Get suggested available port
|
||||
router.get('/apps/ports/:basePort/suggest', ctx.asyncHandler(async (req, res) => {
|
||||
const basePort = parseInt(req.params.basePort) || 8080;
|
||||
const maxAttempts = 100;
|
||||
const usedPorts = await ctx.docker.getUsedPorts();
|
||||
for (let port = basePort; port < basePort + maxAttempts; port++) {
|
||||
if (!usedPorts.has(port)) {
|
||||
res.json({ success: true, suggestedPort: port, basePort });
|
||||
return;
|
||||
}
|
||||
}
|
||||
ctx.errorResponse(res, 400, `No available ports found in range ${basePort}-${basePort + maxAttempts}`);
|
||||
}, 'suggest-port'));
|
||||
|
||||
// Update subdomain for deployed app
|
||||
router.post('/apps/update-subdomain', ctx.asyncHandler(async (req, res) => {
|
||||
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
|
||||
ctx.log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain });
|
||||
const results = { oldDns: null, newDns: null, caddy: null, service: null };
|
||||
|
||||
if (oldSubdomain && ctx.dns.getToken()) {
|
||||
try {
|
||||
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
|
||||
const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
|
||||
token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost'
|
||||
});
|
||||
results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage;
|
||||
ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
|
||||
} catch (error) {
|
||||
results.oldDns = `failed: ${error.message}`;
|
||||
ctx.log.warn('dns', 'Old DNS deletion warning', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (newSubdomain && ctx.dns.getToken()) {
|
||||
try {
|
||||
await ctx.dns.createRecord(newSubdomain, ip || 'localhost');
|
||||
results.newDns = 'created';
|
||||
ctx.log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) });
|
||||
} catch (error) {
|
||||
results.newDns = `failed: ${error.message}`;
|
||||
ctx.log.warn('dns', 'New DNS creation warning', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (await exists(ctx.caddy.filePath)) {
|
||||
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
|
||||
const newDomain = newSubdomain.includes('.') ? newSubdomain : ctx.buildDomain(newSubdomain);
|
||||
const escapedOld = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const oldBlockRegex = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g');
|
||||
const content = await ctx.caddy.read();
|
||||
if (oldBlockRegex.test(content)) {
|
||||
const caddyResult = await ctx.caddy.modify(c => {
|
||||
const re = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g');
|
||||
return c.replace(re, match => match.replace(oldDomain, newDomain));
|
||||
});
|
||||
results.caddy = caddyResult.success ? 'updated' : 'updated (reload failed)';
|
||||
} else {
|
||||
results.caddy = 'old config not found';
|
||||
}
|
||||
} else {
|
||||
results.caddy = 'caddyfile not found';
|
||||
}
|
||||
} catch (error) {
|
||||
results.caddy = `failed: ${error.message}`;
|
||||
ctx.log.error('caddy', 'Caddy update error', { error: error.message });
|
||||
}
|
||||
|
||||
try {
|
||||
if (await exists(ctx.SERVICES_FILE)) {
|
||||
await ctx.servicesStateManager.update(services => {
|
||||
const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.id === serviceId);
|
||||
if (serviceIndex !== -1) {
|
||||
services[serviceIndex].id = newSubdomain;
|
||||
results.service = 'updated';
|
||||
ctx.log.info('deploy', 'Service config updated in services.json');
|
||||
} else {
|
||||
results.service = 'not found';
|
||||
}
|
||||
return services;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.service = `failed: ${error.message}`;
|
||||
ctx.log.warn('deploy', 'Service update warning', { error: error.message || String(error) });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
|
||||
newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
|
||||
results
|
||||
});
|
||||
}, 'update-subdomain'));
|
||||
|
||||
return router;
|
||||
};
|
||||
Reference in New Issue
Block a user