Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a3282f98 | ||
|
|
1fbe65f524 | ||
|
|
320f21c113 | ||
|
|
5c76c3df97 | ||
|
|
260575c6bd | ||
|
|
e361d9a328 | ||
|
|
aa25bcc053 | ||
|
|
bda08b592e | ||
|
|
0e408974a0 | ||
|
|
f4b35dcc30 | ||
|
|
1c0d765182 | ||
|
|
2cd62208ac | ||
|
|
7557a6364a | ||
|
|
54c4b049a8 |
@@ -11,6 +11,7 @@ RUN npm install --production
|
|||||||
COPY *.js ./
|
COPY *.js ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY routes/ ./routes/
|
COPY routes/ ./routes/
|
||||||
|
COPY dns-providers/ ./dns-providers/
|
||||||
COPY openapi.yaml ./
|
COPY openapi.yaml ./
|
||||||
|
|
||||||
# VERSION file holds the short git SHA the image was built from. Committed as
|
# VERSION file holds the short git SHA the image was built from. Committed as
|
||||||
|
|||||||
@@ -342,7 +342,8 @@ const APP_TEMPLATES = {
|
|||||||
volumes: [
|
volumes: [
|
||||||
"/var/run/docker.sock:/var/run/docker.sock",
|
"/var/run/docker.sock:/var/run/docker.sock",
|
||||||
"/opt/portainer/data:/data"
|
"/opt/portainer/data:/data"
|
||||||
]
|
],
|
||||||
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "portainer",
|
subdomain: "portainer",
|
||||||
defaultPort: 9000,
|
defaultPort: 9000,
|
||||||
@@ -393,7 +394,8 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "louislam/uptime-kuma:latest",
|
image: "louislam/uptime-kuma:latest",
|
||||||
ports: ["{{PORT}}:3001"],
|
ports: ["{{PORT}}:3001"],
|
||||||
volumes: ["/opt/uptime-kuma:/app/data"]
|
volumes: ["/opt/uptime-kuma:/app/data"],
|
||||||
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "uptime",
|
subdomain: "uptime",
|
||||||
defaultPort: 3002,
|
defaultPort: 3002,
|
||||||
@@ -549,7 +551,7 @@ const APP_TEMPLATES = {
|
|||||||
},
|
},
|
||||||
subdomain: "dns2",
|
subdomain: "dns2",
|
||||||
defaultPort: 953,
|
defaultPort: 953,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:53",
|
||||||
subpathSupport: 'strip',
|
subpathSupport: 'strip',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Configure zone files in /opt/bind9/config/",
|
"Configure zone files in /opt/bind9/config/",
|
||||||
@@ -640,14 +642,14 @@ const APP_TEMPLATES = {
|
|||||||
],
|
],
|
||||||
docker: {
|
docker: {
|
||||||
image: "coredns/coredns:latest",
|
image: "coredns/coredns:latest",
|
||||||
ports: ["53:53", "53:53/udp"],
|
ports: ["{{PORT}}:53", "53:53", "53:53/udp"],
|
||||||
volumes: ["/opt/coredns/config:/etc/coredns"],
|
volumes: ["/opt/coredns/config:/etc/coredns"],
|
||||||
environment: {},
|
environment: {},
|
||||||
command: ["-conf", "/etc/coredns/Corefile"]
|
command: ["-conf", "/etc/coredns/Corefile"]
|
||||||
},
|
},
|
||||||
subdomain: "dns4",
|
subdomain: "dns4",
|
||||||
defaultPort: 53,
|
defaultPort: 53,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:53",
|
||||||
subpathSupport: 'strip',
|
subpathSupport: 'strip',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Create Corefile in /opt/coredns/config/",
|
"Create Corefile in /opt/coredns/config/",
|
||||||
@@ -1007,7 +1009,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "adminer:latest",
|
image: "adminer:latest",
|
||||||
ports: ["{{PORT}}:8080"],
|
ports: ["{{PORT}}:8080"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/adminer:/var/www/html"
|
||||||
|
],
|
||||||
environment: {
|
environment: {
|
||||||
"ADMINER_DEFAULT_SERVER": "postgres"
|
"ADMINER_DEFAULT_SERVER": "postgres"
|
||||||
}
|
}
|
||||||
@@ -1099,6 +1103,7 @@ const APP_TEMPLATES = {
|
|||||||
popularity: 85,
|
popularity: 85,
|
||||||
difficulty: "Easy",
|
difficulty: "Easy",
|
||||||
isDashboardWidget: true,
|
isDashboardWidget: true,
|
||||||
|
isStaticSite: true,
|
||||||
widgetSelector: ".weather-widget-container",
|
widgetSelector: ".weather-widget-container",
|
||||||
subdomain: null,
|
subdomain: null,
|
||||||
defaultPort: null,
|
defaultPort: null,
|
||||||
@@ -1126,6 +1131,7 @@ const APP_TEMPLATES = {
|
|||||||
popularity: 80,
|
popularity: 80,
|
||||||
difficulty: "Easy",
|
difficulty: "Easy",
|
||||||
isDashboardWidget: true,
|
isDashboardWidget: true,
|
||||||
|
isStaticSite: true,
|
||||||
widgetSelector: ".clock-widget-container",
|
widgetSelector: ".clock-widget-container",
|
||||||
subdomain: null,
|
subdomain: null,
|
||||||
defaultPort: null,
|
defaultPort: null,
|
||||||
@@ -1908,7 +1914,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "traefik/whoami:latest",
|
image: "traefik/whoami:latest",
|
||||||
ports: ["{{PORT}}:80"],
|
ports: ["{{PORT}}:80"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/whoami/config:/config"
|
||||||
|
],
|
||||||
environment: {}
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "whoami",
|
subdomain: "whoami",
|
||||||
@@ -2233,7 +2241,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "excalidraw/excalidraw:latest",
|
image: "excalidraw/excalidraw:latest",
|
||||||
ports: ["{{PORT}}:80"],
|
ports: ["{{PORT}}:80"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/excalidraw/data:/var/lib/excalidraw"
|
||||||
|
],
|
||||||
environment: {}
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "draw",
|
subdomain: "draw",
|
||||||
@@ -2258,7 +2268,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "corentinth/it-tools:latest",
|
image: "corentinth/it-tools:latest",
|
||||||
ports: ["{{PORT}}:80"],
|
ports: ["{{PORT}}:80"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/it-tools/config:/config"
|
||||||
|
],
|
||||||
environment: {}
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "tools",
|
subdomain: "tools",
|
||||||
@@ -2417,7 +2429,7 @@ const APP_TEMPLATES = {
|
|||||||
},
|
},
|
||||||
subdomain: "mc",
|
subdomain: "mc",
|
||||||
defaultPort: 25565,
|
defaultPort: 25565,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:25565",
|
||||||
subpathSupport: 'none',
|
subpathSupport: 'none',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Server accepts the Minecraft EULA automatically",
|
"Server accepts the Minecraft EULA automatically",
|
||||||
@@ -2451,7 +2463,7 @@ const APP_TEMPLATES = {
|
|||||||
},
|
},
|
||||||
subdomain: "valheim",
|
subdomain: "valheim",
|
||||||
defaultPort: 2456,
|
defaultPort: 2456,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:2456",
|
||||||
subpathSupport: 'none',
|
subpathSupport: 'none',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Connect via Steam: Add Server > IP:2456",
|
"Connect via Steam: Add Server > IP:2456",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR',
|
|||||||
// ── Docker ──────────────────────────────────────────────────────
|
// ── Docker ──────────────────────────────────────────────────────
|
||||||
const DOCKER = {
|
const DOCKER = {
|
||||||
CONTAINER_PREFIX: 'sami-',
|
CONTAINER_PREFIX: 'sami-',
|
||||||
TIMEOUT: 30000, // 30s — timeout for docker pull/create operations
|
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
|
||||||
LOG_CONFIG: {
|
LOG_CONFIG: {
|
||||||
Type: 'json-file',
|
Type: 'json-file',
|
||||||
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
|
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
|
||||||
|
|||||||
@@ -10,7 +10,26 @@ const lockfile = require('proper-lockfile');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, 'credentials.json');
|
// Resolve credentials file path — supports both standard install (/app/credentials.json)
|
||||||
|
// and custom deployments with consolidated data directory (/app/data/credentials.json)
|
||||||
|
function resolveCredentialsFile() {
|
||||||
|
if (process.env.CREDENTIALS_FILE) {
|
||||||
|
return process.env.CREDENTIALS_FILE;
|
||||||
|
}
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, 'credentials.json'),
|
||||||
|
path.join(__dirname, 'data', 'credentials.json'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No existing file — return standard path so first store() creates it there
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREDENTIALS_FILE = resolveCredentialsFile();
|
||||||
|
|
||||||
class CredentialManager {
|
class CredentialManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -15,8 +15,26 @@ const IV_LENGTH = 16; // 128 bits for GCM
|
|||||||
const AUTH_TAG_LENGTH = 16;
|
const AUTH_TAG_LENGTH = 16;
|
||||||
const SALT_LENGTH = 32;
|
const SALT_LENGTH = 32;
|
||||||
|
|
||||||
// Key file location (should be outside of mounted volumes for security)
|
// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
|
||||||
const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.encryption-key');
|
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
|
||||||
|
function resolveKeyFile() {
|
||||||
|
if (process.env.ENCRYPTION_KEY_FILE) {
|
||||||
|
return process.env.ENCRYPTION_KEY_FILE;
|
||||||
|
}
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, '.encryption-key'),
|
||||||
|
path.join(__dirname, 'data', '.encryption-key'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No existing file — return standard path so first load creates it there
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_FILE = resolveKeyFile();
|
||||||
|
|
||||||
let encryptionKey = null;
|
let encryptionKey = null;
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ class LicenseManager {
|
|||||||
*/
|
*/
|
||||||
isExpired() {
|
isExpired() {
|
||||||
if (!this.activation) return true;
|
if (!this.activation) return true;
|
||||||
|
// Lifetime licenses never expire
|
||||||
|
if (this.activation.lifetime || this.activation.durationDays === 0) return false;
|
||||||
|
if (!this.activation.expiresAt) return false; // No expiry set = lifetime
|
||||||
return Date.now() > new Date(this.activation.expiresAt).getTime();
|
return Date.now() > new Date(this.activation.expiresAt).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ module.exports = function configureMiddleware(app, {
|
|||||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||||
|
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||||
|
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isPublicRoute(req) {
|
function isPublicRoute(req) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashcaddy-api",
|
"name": "dashcaddy-api",
|
||||||
"version": "1.10.0",
|
"version": "1.12.0",
|
||||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// All paths can be overridden via environment variables.
|
// All paths can be overridden via environment variables.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
// Base directories
|
// Base directories
|
||||||
@@ -34,6 +35,8 @@ const paths = {
|
|||||||
caCertDir: path.join(CADDY_SITES, 'ca'),
|
caCertDir: path.join(CADDY_SITES, 'ca'),
|
||||||
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
|
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
|
||||||
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
|
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
|
||||||
|
generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'),
|
||||||
|
pkiDir: CADDY_PKI,
|
||||||
|
|
||||||
// Static site base path
|
// Static site base path
|
||||||
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
|
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
|
||||||
@@ -41,6 +44,24 @@ const paths = {
|
|||||||
// Docker data path for app volumes
|
// Docker data path for app volumes
|
||||||
appData: (appName) => path.join(DOCKER_DATA, appName),
|
appData: (appName) => path.join(DOCKER_DATA, appName),
|
||||||
|
|
||||||
|
// In-container paths (used by self-updater and Docker deployments)
|
||||||
|
// Override via env vars for custom Docker layouts
|
||||||
|
containerUpdatesDir: process.env.DASHCADDY_UPDATES_DIR || '/app/updates',
|
||||||
|
containerFrontendDir: process.env.DASHCADDY_FRONTEND_DIR || '/app/dashboard',
|
||||||
|
containerAssetsDir: process.env.ASSETS_DIR || '/app/assets',
|
||||||
|
|
||||||
|
// Asset path resolution — supports both Docker (single file mount) and
|
||||||
|
// consolidated data directory layouts
|
||||||
|
resolveAssetsPath: (envPath) => {
|
||||||
|
if (envPath) return envPath;
|
||||||
|
// Standard Docker mount: /app/assets (volume-mounted)
|
||||||
|
if (fs.existsSync('/app/assets')) return '/app/assets';
|
||||||
|
// Consolidated data directory: /app/data/assets
|
||||||
|
if (fs.existsSync(path.join(CADDY_BASE, 'assets'))) return path.join(CADDY_BASE, 'assets');
|
||||||
|
// Fall back to /app/assets even if it doesn't exist (will create on write)
|
||||||
|
return '/app/assets';
|
||||||
|
},
|
||||||
|
|
||||||
// Log digest directory
|
// Log digest directory
|
||||||
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
|
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,23 @@ const server = http.createServer(async (req, res) => {
|
|||||||
json(res, 404, { error: 'Not found' });
|
json(res, 404, { error: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842;
|
||||||
console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`);
|
const PYLON_HOST = process.env.PYLON_HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
server.listen(PYLON_PORT, PYLON_HOST, () => {
|
||||||
|
console.log(`[Pylon] ${PYLON_NAME} listening on ${PYLON_HOST}:${PYLON_PORT}`);
|
||||||
if (API_KEY) console.log('[Pylon] API key authentication enabled');
|
if (API_KEY) console.log('[Pylon] API key authentication enabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown — drain connections, then exit
|
||||||
|
const shutdown = (signal) => {
|
||||||
|
console.log(`[Pylon] ${signal} received, draining...`);
|
||||||
|
server.close(() => {
|
||||||
|
console.log('[Pylon] HTTP server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
// Force exit after 5s if connections don't drain
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|||||||
@@ -197,8 +197,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = await docker.client.createContainer(containerConfig);
|
let container;
|
||||||
await container.start();
|
try {
|
||||||
|
container = await docker.client.createContainer(containerConfig);
|
||||||
|
await container.start();
|
||||||
|
} catch (createErr) {
|
||||||
|
// If create fails with "no such image", wrap with user-friendly message
|
||||||
|
const errMsg = createErr?.message || String(createErr);
|
||||||
|
if (errMsg.includes('No such image') || errMsg.includes('no such image')) {
|
||||||
|
throw new Error(`[DC-201] Image pull succeeded but container creation failed — image may be corrupted: ${processedTemplate.docker.image}. ${errMsg}`);
|
||||||
|
}
|
||||||
|
throw createErr;
|
||||||
|
}
|
||||||
|
|
||||||
// Prune dangling images to prevent disk bloat
|
// Prune dangling images to prevent disk bloat
|
||||||
try {
|
try {
|
||||||
@@ -306,7 +316,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
} else {
|
} else {
|
||||||
containerId = await deployContainer(appId, config, template);
|
containerId = await deployContainer(appId, config, template);
|
||||||
log.info('deploy', 'Container deployed', { containerId });
|
log.info('deploy', 'Container deployed', { containerId });
|
||||||
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
|
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort, 30);
|
||||||
log.info('deploy', 'Container is healthy', { containerId });
|
log.info('deploy', 'Container is healthy', { containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,10 +430,11 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logError('app-deploy', error, { appId, config });
|
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||||
log.error('deploy', 'Deployment failed', { appId, error: error.message });
|
const msg = error?.message || String(error || 'Unknown error');
|
||||||
|
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
|
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||||
}
|
}
|
||||||
}, 'apps-deploy'));
|
}, 'apps-deploy'));
|
||||||
|
|||||||
@@ -379,9 +379,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success && result.error !== 'No changes to apply') {
|
||||||
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
if (result.error === 'No changes to apply') {
|
||||||
|
log.info('caddy', 'Subpath config already exists, reusing', { subdomain });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ module.exports = function(ctx) {
|
|||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
errorResponse: ctx.errorResponse,
|
errorResponse: ctx.errorResponse,
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
// Additional context properties needed by routes
|
|
||||||
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
||||||
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
||||||
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
||||||
@@ -40,26 +39,27 @@ module.exports = function(ctx) {
|
|||||||
ctx: ctx
|
ctx: ctx
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize helpers with dependencies (ctx is the Koa context)
|
|
||||||
const helpers = initHelpers({ ...deps, ctx });
|
const helpers = initHelpers({ ...deps, ctx });
|
||||||
|
|
||||||
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
|
|
||||||
const subCtx = Object.assign({}, ctx, { helpers });
|
const subCtx = Object.assign({}, ctx, { helpers });
|
||||||
|
|
||||||
try { router.use('/deploy', initDeploy(subCtx)); }
|
// Mount sub-routers at their prefix paths.
|
||||||
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
|
// Sub-modules define routes at '/' (root of their sub-router).
|
||||||
|
// Final paths: /api/v1/apps/deploy, /api/v1/apps/remove, /api/v1/apps/templates, etc.
|
||||||
|
|
||||||
try { router.use('/remove', initRemoval(subCtx)); }
|
try { router.use('/apps', initDeploy(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
|
try { router.use('/apps', initRemoval(subCtx)); }
|
||||||
|
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/apps', initTemplates(subCtx)); }
|
try { router.use('/apps', initTemplates(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/compose', initCompose(subCtx)); }
|
try { router.use('/apps', initCompose(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-16
@@ -12,14 +12,11 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// Get CA certificate information
|
// Get CA certificate information
|
||||||
router.get('/info', ctx.asyncHandler(async (req, res) => {
|
router.get('/info', ctx.asyncHandler(async (req, res) => {
|
||||||
const certInfoPath = '/app/ca/cert-info.json';
|
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||||
const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
||||||
|
|
||||||
let certInfoFile;
|
let certInfoFile;
|
||||||
if (await exists(certInfoPath)) {
|
if (await exists(certInfoPath)) {
|
||||||
certInfoFile = certInfoPath;
|
certInfoFile = certInfoPath;
|
||||||
} else if (await exists(fallbackCertInfoPath)) {
|
|
||||||
certInfoFile = fallbackCertInfoPath;
|
|
||||||
} else {
|
} else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../errors');
|
||||||
throw new NotFoundError('CA certificate information');
|
throw new NotFoundError('CA certificate information');
|
||||||
@@ -46,13 +43,11 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// Serve root CA certificate directly (works even without DashCA deployed)
|
// Serve root CA certificate directly (works even without DashCA deployed)
|
||||||
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
|
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
|
||||||
const pkiCertPath = '/app/pki/root.crt';
|
|
||||||
const hostCertPath = platformPaths.pkiRootCert;
|
const hostCertPath = platformPaths.pkiRootCert;
|
||||||
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
||||||
|
|
||||||
let certPath;
|
let certPath;
|
||||||
if (await exists(pkiCertPath)) certPath = pkiCertPath;
|
if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
||||||
else if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
|
||||||
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
||||||
else {
|
else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../errors');
|
||||||
@@ -72,13 +67,12 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load cert info to get the fingerprint
|
// Load cert info to get the fingerprint
|
||||||
const certInfoPath = '/app/ca/cert-info.json';
|
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||||
const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
||||||
|
|
||||||
let certInfoFile;
|
let certInfoFile;
|
||||||
if (await exists(certInfoPath)) certInfoFile = certInfoPath;
|
if (await exists(certInfoPath)) {
|
||||||
else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2;
|
certInfoFile = certInfoPath;
|
||||||
else {
|
} else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../errors');
|
||||||
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
||||||
}
|
}
|
||||||
@@ -100,7 +94,7 @@ module.exports = function(ctx) {
|
|||||||
// Look for template in multiple locations (packaged app vs dev)
|
// Look for template in multiple locations (packaged app vs dev)
|
||||||
const templatePaths = [
|
const templatePaths = [
|
||||||
path.join(__dirname, '..', 'scripts', templateName),
|
path.join(__dirname, '..', 'scripts', templateName),
|
||||||
path.join('/app', 'scripts', templateName)
|
path.join(platformPaths.caddyBase, 'scripts', templateName)
|
||||||
];
|
];
|
||||||
|
|
||||||
let templateContent;
|
let templateContent;
|
||||||
@@ -142,8 +136,8 @@ module.exports = function(ctx) {
|
|||||||
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkiPath = '/app/pki';
|
const pkiPath = platformPaths.pkiDir;
|
||||||
const certsDir = '/app/generated-certs';
|
const certsDir = platformPaths.generatedCertsDir;
|
||||||
const domainDir = path.join(certsDir, domain);
|
const domainDir = path.join(certsDir, domain);
|
||||||
|
|
||||||
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
|
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
|
||||||
@@ -246,7 +240,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
|||||||
|
|
||||||
// List generated certificates
|
// List generated certificates
|
||||||
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
||||||
const certsDir = '/app/generated-certs';
|
const certsDir = platformPaths.generatedCertsDir;
|
||||||
|
|
||||||
if (!await exists(certsDir)) {
|
if (!await exists(certsDir)) {
|
||||||
return res.json({ success: true, certificates: [] });
|
return res.json({ success: true, certificates: [] });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const { LIMITS } = require('../../constants');
|
const { LIMITS } = require('../../constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../errors');
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
/**
|
/**
|
||||||
* Config assets routes factory
|
* Config assets routes factory
|
||||||
* @param {Object} deps - Explicit dependencies
|
* @param {Object} deps - Explicit dependencies
|
||||||
@@ -51,7 +52,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
// Determine assets path (mounted volume)
|
// Determine assets path (mounted volume)
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
@@ -96,7 +97,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
||||||
const buffer = Buffer.from(matches[2], 'base64');
|
const buffer = Buffer.from(matches[2], 'base64');
|
||||||
|
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
await fsp.mkdir(assetsPath, { recursive: true });
|
await fsp.mkdir(assetsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
// Reset all branding to defaults
|
// Reset all branding to defaults
|
||||||
router.delete('/logo', asyncHandler(async (req, res) => {
|
router.delete('/logo', asyncHandler(async (req, res) => {
|
||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
|
|
||||||
// Delete all custom logo files
|
// Delete all custom logo files
|
||||||
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
||||||
@@ -234,7 +235,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const base64Data = matches[2];
|
const base64Data = matches[2];
|
||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
await fsp.mkdir(assetsPath, { recursive: true });
|
await fsp.mkdir(assetsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -279,7 +280,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
|
|
||||||
// Delete custom favicon files
|
// Delete custom favicon files
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
||||||
for (const file of filesToDelete) {
|
for (const file of filesToDelete) {
|
||||||
const filePath = `${assetsPath}/${file}`;
|
const filePath = `${assetsPath}/${file}`;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const { CADDY } = require('../../constants');
|
const { CADDY } = require('../../constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
const { ValidationError, AuthenticationError } = require('../../errors');
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config backup routes factory
|
* Config backup routes factory
|
||||||
@@ -115,7 +116,7 @@ module.exports = function(deps) {
|
|||||||
|
|
||||||
// Include custom assets (logo, favicon) as base64
|
// Include custom assets (logo, favicon) as base64
|
||||||
try {
|
try {
|
||||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||||
const configData = backup.files.config?.data || {};
|
const configData = backup.files.config?.data || {};
|
||||||
const assetFiles = [configData.customLogo, configData.customFavicon]
|
const assetFiles = [configData.customLogo, configData.customFavicon]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -346,7 +347,7 @@ module.exports = function(deps) {
|
|||||||
|
|
||||||
// Restore custom assets from base64
|
// Restore custom assets from base64
|
||||||
if (backup.assets && typeof backup.assets === 'object') {
|
if (backup.assets && typeof backup.assets === 'object') {
|
||||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||||
for (const [name, b64] of Object.entries(backup.assets)) {
|
for (const [name, b64] of Object.entries(backup.assets)) {
|
||||||
try {
|
try {
|
||||||
const safeName = path.basename(name); // prevent path traversal
|
const safeName = path.basename(name); // prevent path traversal
|
||||||
|
|||||||
@@ -322,9 +322,16 @@ module.exports = function({
|
|||||||
// ===== HEALTH CHECK (health-checker module) =====
|
// ===== HEALTH CHECK (health-checker module) =====
|
||||||
|
|
||||||
// Get current status for all services
|
// Get current status for all services
|
||||||
|
// Returns per-service status plus a summary for the System Overview widget:
|
||||||
|
// { status: { ... }, summary: { healthy, unhealthy, total } }
|
||||||
router.get('/health-checks/status', asyncHandler(async (req, res) => {
|
router.get('/health-checks/status', asyncHandler(async (req, res) => {
|
||||||
const status = healthChecker.getCurrentStatus();
|
const status = healthChecker.getCurrentStatus();
|
||||||
success(res, { status });
|
// Build summary for the overview widget
|
||||||
|
const entries = Object.values(status);
|
||||||
|
const healthy = entries.filter(s => s.status === 'up' || s.status === 'healthy').length;
|
||||||
|
const unhealthy = entries.filter(s => s.status === 'down' || s.status === 'unhealthy').length;
|
||||||
|
const total = entries.length;
|
||||||
|
success(res, { status, summary: { healthy, unhealthy, total } });
|
||||||
}, 'health-check-status'));
|
}, 'health-check-status'));
|
||||||
|
|
||||||
// Get service statistics
|
// Get service statistics
|
||||||
|
|||||||
@@ -16,8 +16,22 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
|||||||
// ===== RESOURCE MONITORING ENDPOINTS =====
|
// ===== RESOURCE MONITORING ENDPOINTS =====
|
||||||
|
|
||||||
// Get all container stats (from resource monitor module)
|
// Get all container stats (from resource monitor module)
|
||||||
|
// Returns a flat summary format for the System Overview widget:
|
||||||
|
// { containerId: { cpu: <percent>, memory: <percent>, memoryUsage: <bytes>, name } }
|
||||||
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
|
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
|
||||||
const stats = resourceMonitor.getAllStats();
|
const raw = resourceMonitor.getAllStats();
|
||||||
|
// Transform nested { current: { cpu: { percent }, memory: { percent, usage } } }
|
||||||
|
// into flat { cpu: number, memory: number, memoryUsage: number } for the frontend widget
|
||||||
|
const stats = {};
|
||||||
|
for (const [id, data] of Object.entries(raw)) {
|
||||||
|
const cur = data.current || {};
|
||||||
|
stats[id] = {
|
||||||
|
name: data.name,
|
||||||
|
cpu: typeof cur.cpu === 'object' ? (cur.cpu.percent ?? 0) : (Number(cur.cpu) || 0),
|
||||||
|
memory: typeof cur.memory === 'object' ? (cur.memory.percent ?? 0) : (Number(cur.memory) || 0),
|
||||||
|
memoryUsage: typeof cur.memory === 'object' ? (cur.memory.usage ?? 0) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
success(res, { stats });
|
success(res, { stats });
|
||||||
}, 'monitoring-stats'));
|
}, 'monitoring-stats'));
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ module.exports = function({
|
|||||||
dns
|
dns
|
||||||
}) {
|
}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||||
const PROBE_CONCURRENCY = 6;
|
const PROBE_CONCURRENCY = 6;
|
||||||
let probeHttpsAgent;
|
let probeHttpsAgent;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../response-helpers');
|
||||||
const { ValidationError, NotFoundError } = require('../errors');
|
const { ValidationError, NotFoundError } = require('../errors');
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Themes routes factory
|
* Themes routes factory
|
||||||
@@ -13,7 +14,7 @@ const { ValidationError, NotFoundError } = require('../errors');
|
|||||||
*/
|
*/
|
||||||
module.exports = function({ asyncHandler, log }) {
|
module.exports = function({ asyncHandler, log }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
|
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(platformPaths.servicesFile), 'themes');
|
||||||
|
|
||||||
// Ensure themes directory exists
|
// Ensure themes directory exists
|
||||||
if (!fs.existsSync(THEMES_DIR)) {
|
if (!fs.existsSync(THEMES_DIR)) {
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ const isWindows = platformPaths.isWindows;
|
|||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
||||||
UPDATE_URL: 'https://get.dashcaddy.net/release',
|
UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
|
||||||
MIRROR_URL: 'https://get2.dashcaddy.net/release',
|
MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
|
||||||
UPDATES_DIR: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'updates') : '/app/updates',
|
UPDATES_DIR: platformPaths.containerUpdatesDir,
|
||||||
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
|
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
|
||||||
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
|
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
|
||||||
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
|
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
|
||||||
FRONTEND_DIR: platformPaths.isWindows ? path.join(platformPaths.caddySites, 'status') : '/app/dashboard',
|
FRONTEND_DIR: platformPaths.containerFrontendDir,
|
||||||
MAX_BACKUPS: 3,
|
MAX_BACKUPS: 3,
|
||||||
HEALTH_TIMEOUT: 60000,
|
HEALTH_TIMEOUT: 60000,
|
||||||
DOWNLOAD_TIMEOUT: 120000,
|
DOWNLOAD_TIMEOUT: 120000,
|
||||||
CHANNEL: 'stable',
|
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
|
||||||
INSTANCE_ID_FILE: platformPaths.isWindows
|
INSTANCE_ID_FILE: platformPaths.isWindows
|
||||||
? path.join(platformPaths.caddyBase, 'instance-id')
|
? path.join(platformPaths.caddyBase, 'instance-id')
|
||||||
: '/etc/dashcaddy/instance-id',
|
: '/etc/dashcaddy/instance-id',
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ process.on('uncaughtException', (error) => {
|
|||||||
// Load license
|
// Load license
|
||||||
await licenseManager.load();
|
await licenseManager.load();
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = parseInt(process.env.PORT, 10) || 3001;
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
|
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
|
||||||
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
|
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
|
||||||
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
|
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
|
||||||
@@ -43,9 +44,10 @@ process.on('uncaughtException', (error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
const server = app.listen(PORT, HOST, () => {
|
||||||
log.info('server', 'DashCaddy API server started', {
|
log.info('server', 'DashCaddy API server started', {
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
host: HOST,
|
||||||
caddyfile: CADDYFILE_PATH,
|
caddyfile: CADDYFILE_PATH,
|
||||||
caddyAdmin: CADDY_ADMIN_URL,
|
caddyAdmin: CADDY_ADMIN_URL,
|
||||||
services: SERVICES_FILE,
|
services: SERVICES_FILE,
|
||||||
@@ -73,9 +75,12 @@ process.on('uncaughtException', (error) => {
|
|||||||
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
|
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
|
||||||
|
|
||||||
// Initialize workflow engine if bundled-workflows is available
|
// Initialize workflow engine if bundled-workflows is available
|
||||||
|
// NOTE: createApp() already initializes the workflow engine in src/app.js
|
||||||
|
// This block is kept for backward compat with entry points that don't use createApp()
|
||||||
let workflowEngine = null;
|
let workflowEngine = null;
|
||||||
if (bundledWorkflows) {
|
if (bundledWorkflows) {
|
||||||
try {
|
try {
|
||||||
|
const { fetchT } = require('./src/utils/http');
|
||||||
const { WorkflowEngine } = bundledWorkflows;
|
const { WorkflowEngine } = bundledWorkflows;
|
||||||
// Create a context with needed services
|
// Create a context with needed services
|
||||||
const workflowCtx = {
|
const workflowCtx = {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const { asyncHandler } = require('./utils/async-handler');
|
|||||||
|
|
||||||
// Managers and utilities
|
// Managers and utilities
|
||||||
const StateManager = require('../state-manager');
|
const StateManager = require('../state-manager');
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
const { LicenseManager } = require('../license-manager');
|
const { LicenseManager } = require('../license-manager');
|
||||||
const credentialManager = require('../credential-manager');
|
const credentialManager = require('../credential-manager');
|
||||||
const authManager = require('../auth-manager');
|
const authManager = require('../auth-manager');
|
||||||
@@ -96,6 +97,19 @@ const { APP } = require('../constants');
|
|||||||
async function createApp() {
|
async function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Global request timeout (default 5 minutes — covers slow Docker pulls)
|
||||||
|
// Routes that need longer can override per-request with req.setTimeout()
|
||||||
|
const REQUEST_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.setTimeout(REQUEST_TIMEOUT_MS);
|
||||||
|
res.setTimeout(REQUEST_TIMEOUT_MS);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
// Disable x-powered-by header for security (don't advertise framework)
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
// Trust first proxy (Caddy/nginx in front of us) so req.ip works correctly
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
const log = createLogger(config.LOG_LEVEL);
|
const log = createLogger(config.LOG_LEVEL);
|
||||||
|
|
||||||
@@ -111,7 +125,7 @@ async function createApp() {
|
|||||||
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
||||||
|
|
||||||
// HTTPS agent for internal CA
|
// HTTPS agent for internal CA
|
||||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||||
let httpsAgent;
|
let httpsAgent;
|
||||||
try {
|
try {
|
||||||
const caCert = fs.readFileSync(CA_CERT_PATH);
|
const caCert = fs.readFileSync(CA_CERT_PATH);
|
||||||
@@ -380,6 +394,29 @@ async function createApp() {
|
|||||||
// Build versioned API router
|
// Build versioned API router
|
||||||
const apiRouter = express.Router();
|
const apiRouter = express.Router();
|
||||||
|
|
||||||
|
// Version endpoint — public, no auth required
|
||||||
|
// Reads version from package.json at startup so the response always matches the running code
|
||||||
|
let appVersion = '0.0.0';
|
||||||
|
let appName = 'dashcaddy-api';
|
||||||
|
try {
|
||||||
|
const pkg = require('../package.json');
|
||||||
|
appVersion = pkg.version || appVersion;
|
||||||
|
appName = pkg.name || appName;
|
||||||
|
} catch { /* package.json unreadable — keep fallback */ }
|
||||||
|
apiRouter.get('/version', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
name: appName,
|
||||||
|
version: appVersion,
|
||||||
|
node: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
|
||||||
|
|
||||||
// Wire up notification listeners for resourceMonitor and backupManager
|
// Wire up notification listeners for resourceMonitor and backupManager
|
||||||
if (ctx.notification && ctx.resourceMonitor) {
|
if (ctx.notification && ctx.resourceMonitor) {
|
||||||
ctx.resourceMonitor.on('alert', (alertData) => {
|
ctx.resourceMonitor.on('alert', (alertData) => {
|
||||||
@@ -539,7 +576,7 @@ async function createApp() {
|
|||||||
sslMonitor: ctx.sslMonitor,
|
sslMonitor: ctx.sslMonitor,
|
||||||
dnsPropagationChecker: ctx.dnsPropagationChecker
|
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||||
}));
|
}));
|
||||||
apiRouter.use(workflowsRoutes({
|
apiRouter.use('/workflows', workflowsRoutes({
|
||||||
workflowEngine: ctx.workflowEngine,
|
workflowEngine: ctx.workflowEngine,
|
||||||
licenseManager: ctx.licenseManager,
|
licenseManager: ctx.licenseManager,
|
||||||
asyncHandler: ctx.asyncHandler
|
asyncHandler: ctx.asyncHandler
|
||||||
|
|||||||
@@ -94,8 +94,12 @@ async function logError(ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE, context, error, addi
|
|||||||
* Return a safe error message without leaking internals
|
* Return a safe error message without leaking internals
|
||||||
*/
|
*/
|
||||||
function safeErrorMessage(error) {
|
function safeErrorMessage(error) {
|
||||||
|
if (!error) return 'An internal error occurred';
|
||||||
const msg = error.message || String(error);
|
const msg = error.message || String(error);
|
||||||
|
|
||||||
|
// Always expose DC-prefixed user-facing errors
|
||||||
|
if (/\[DC-\d+\]/.test(msg)) return msg;
|
||||||
|
|
||||||
// Detect port conflict errors
|
// Detect port conflict errors
|
||||||
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
||||||
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
||||||
@@ -103,7 +107,7 @@ function safeErrorMessage(error) {
|
|||||||
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
|
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only expose short, user-facing messages
|
// Only expose short, user-facing messages (no paths, stack traces, or internal details)
|
||||||
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
|
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+379
@@ -0,0 +1,379 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# DashCaddy Host-Side Updater
|
||||||
|
# Triggered by systemd path unit when the container writes trigger.json.
|
||||||
|
# Reads the trigger, backs up current API + data/, copies new files, rebuilds container.
|
||||||
|
# Writes result.json so the new container knows the outcome.
|
||||||
|
#
|
||||||
|
# This runs on the HOST, outside the container.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly UPDATES_DIR="/opt/dashcaddy/updates"
|
||||||
|
readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json"
|
||||||
|
readonly RESULT_FILE="${UPDATES_DIR}/result.json"
|
||||||
|
readonly BACKUPS_DIR="${UPDATES_DIR}/backups"
|
||||||
|
readonly CONTAINER_NAME="dashcaddy-api"
|
||||||
|
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
|
||||||
|
readonly MAX_BACKUPS=3
|
||||||
|
readonly HEALTH_TIMEOUT=60
|
||||||
|
|
||||||
|
# Data directory backup — stored alongside code backups so everything rolls back together
|
||||||
|
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
||||||
|
readonly DATA_BACKUP_PREFIX="data-backup"
|
||||||
|
|
||||||
|
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
||||||
|
|
||||||
|
write_result() {
|
||||||
|
local success="$1" version="$2" duration="$3"
|
||||||
|
shift 3
|
||||||
|
local error="${1:-}"
|
||||||
|
|
||||||
|
if [[ "$success" == "true" ]]; then
|
||||||
|
cat > "$RESULT_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"version": "${version}",
|
||||||
|
"duration": ${duration},
|
||||||
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
cat > "$RESULT_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"version": "${version}",
|
||||||
|
"duration": ${duration},
|
||||||
|
"error": "${error}",
|
||||||
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_old_backups() {
|
||||||
|
local count
|
||||||
|
count=$(find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
|
||||||
|
if (( count > MAX_BACKUPS )); then
|
||||||
|
log "Cleaning old backups (${count} > ${MAX_BACKUPS})"
|
||||||
|
find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \
|
||||||
|
| sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \
|
||||||
|
| xargs rm -rf
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Data backup (rsync for efficiency + permissions) ──────────────────────────
|
||||||
|
backup_data_dir() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
||||||
|
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
||||||
|
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||||
|
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|
||||||
|
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||||
|
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
|
||||||
|
else
|
||||||
|
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Data restore ──────────────────────────────────────────────────────────────
|
||||||
|
restore_data_dir() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||||
|
if [[ -d "$data_backup" ]]; then
|
||||||
|
log "Restoring data/ from backup..."
|
||||||
|
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|
||||||
|
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
|
||||||
|
log "Data restored successfully"
|
||||||
|
else
|
||||||
|
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_health() {
|
||||||
|
local port="${1:-3001}"
|
||||||
|
local timeout="$HEALTH_TIMEOUT"
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
log "Waiting for health check (timeout: ${timeout}s)..."
|
||||||
|
while (( elapsed < timeout )); do
|
||||||
|
if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then
|
||||||
|
log "Health check passed after ${elapsed}s"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
elapsed=$(( elapsed + 2 ))
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Health check FAILED after ${timeout}s"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Shared rollback: restore code + data ────────────────────────────────────
|
||||||
|
rollback_restore() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
log "Rolling back: restoring code files..."
|
||||||
|
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [[ -d "$backup_dir/routes" ]]; then
|
||||||
|
rm -rf "$api_source_dir/routes"
|
||||||
|
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/src" ]]; then
|
||||||
|
rm -rf "$api_source_dir/src"
|
||||||
|
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||||
|
rm -rf "$api_source_dir/dns-providers"
|
||||||
|
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||||
|
fi
|
||||||
|
restore_data_dir "$backup_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Deployment mode ───────────────────────────────────────────────────────────
|
||||||
|
# Reproduce the SAME container the install created so an auto-update keeps every
|
||||||
|
# volume + env var (docker socket, Caddyfile, config/credentials, updates mount),
|
||||||
|
# not a minimal subset. Standard installs use docker-compose (compose file in the
|
||||||
|
# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a
|
||||||
|
# bare docker run is the last resort. build_image() and restart_container() both
|
||||||
|
# honor the detected mode so build and run stay consistent.
|
||||||
|
deploy_mode() {
|
||||||
|
if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then
|
||||||
|
echo compose
|
||||||
|
elif [[ -x /opt/dashcaddy/start.sh ]]; then
|
||||||
|
echo startsh
|
||||||
|
else
|
||||||
|
echo run
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the API image using whatever the install is wired for. Returns the build
|
||||||
|
# command's exit status so callers can detect failure.
|
||||||
|
build_image() {
|
||||||
|
cd "$api_source_dir" || return 1
|
||||||
|
case "$(deploy_mode)" in
|
||||||
|
compose) docker compose build 2>&1 || docker-compose build 2>&1 ;;
|
||||||
|
*) docker build -t "$IMAGE_TAG" . 2>&1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Shared container restart — recreate with the full, install-defined spec ───
|
||||||
|
# Recreates (rm + run / compose up) so new code AND new env vars take effect.
|
||||||
|
restart_container() {
|
||||||
|
cd "$api_source_dir" 2>/dev/null || true
|
||||||
|
case "$(deploy_mode)" in
|
||||||
|
compose)
|
||||||
|
log "Recreating container via docker compose (full compose spec)..."
|
||||||
|
docker compose up -d 2>&1 || docker-compose up -d 2>&1
|
||||||
|
;;
|
||||||
|
startsh)
|
||||||
|
log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..."
|
||||||
|
bash /opt/dashcaddy/start.sh
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "Recreating container via minimal docker run (fallback)..."
|
||||||
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||||
|
-p 127.0.0.1:3001:3001 \
|
||||||
|
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||||
|
-e SERVICES_FILE=/app/data/services.json \
|
||||||
|
"$IMAGE_TAG"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
log "Container recreated"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
|
||||||
|
code_restore() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
log "Restoring code files..."
|
||||||
|
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [[ -d "$backup_dir/routes" ]]; then
|
||||||
|
rm -rf "$api_source_dir/routes"
|
||||||
|
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/src" ]]; then
|
||||||
|
rm -rf "$api_source_dir/src"
|
||||||
|
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||||
|
rm -rf "$api_source_dir/dns-providers"
|
||||||
|
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local start_time
|
||||||
|
start_time=$(date +%s)
|
||||||
|
|
||||||
|
# 1. Read trigger
|
||||||
|
if [[ ! -f "$TRIGGER_FILE" ]]; then
|
||||||
|
log "No trigger file found — nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse trigger.json (uses python3 which is available on all supported distros)
|
||||||
|
local action version from_version staging_dir api_source_dir commit
|
||||||
|
local frontend_staging_dir frontend_target_dir
|
||||||
|
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
||||||
|
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
|
||||||
|
from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])")
|
||||||
|
staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])")
|
||||||
|
api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])")
|
||||||
|
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
|
||||||
|
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
|
||||||
|
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
|
||||||
|
# Handle action=rollback (no new version to deploy)
|
||||||
|
local to_version="${version}"
|
||||||
|
|
||||||
|
log "=== ${action^^}: v${from_version} -> v${to_version} ==="
|
||||||
|
log "Staging: ${staging_dir}"
|
||||||
|
log "API source: ${api_source_dir}"
|
||||||
|
|
||||||
|
# Consume the trigger immediately so we don't re-process on failure
|
||||||
|
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
|
||||||
|
|
||||||
|
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||||
|
if [[ "$action" == "rollback" ]]; then
|
||||||
|
local backup_dir="${BACKUPS_DIR}/${version}"
|
||||||
|
if [[ ! -d "$backup_dir" ]]; then
|
||||||
|
log "ERROR: No backup found for version ${version}"
|
||||||
|
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Performing rollback to v${version}..."
|
||||||
|
rollback_restore "$backup_dir"
|
||||||
|
|
||||||
|
# Rebuild old code
|
||||||
|
log "Rebuilding container..."
|
||||||
|
build_image 2>&1 | tail -3 || true
|
||||||
|
|
||||||
|
restart_container
|
||||||
|
wait_for_health || log "WARNING: Health check failed after rollback"
|
||||||
|
|
||||||
|
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
log "=== Rollback complete ==="
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Handle update ───────────────────────────────────────────────────────────
|
||||||
|
if [[ ! -d "$staging_dir" ]]; then
|
||||||
|
log "ERROR: Staging directory not found: ${staging_dir}"
|
||||||
|
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Backup current API code + data/
|
||||||
|
local backup_dir="${BACKUPS_DIR}/${from_version}"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
log "Backing up current API files to ${backup_dir}"
|
||||||
|
for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
|
||||||
|
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
|
||||||
|
[[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/"
|
||||||
|
|
||||||
|
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
||||||
|
backup_data_dir "$backup_dir"
|
||||||
|
|
||||||
|
cleanup_old_backups
|
||||||
|
|
||||||
|
# 3. Copy new files from staging to API source
|
||||||
|
log "Deploying new API files..."
|
||||||
|
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [[ -d "$staging_dir/routes" ]]; then
|
||||||
|
rm -rf "$api_source_dir/routes"
|
||||||
|
cp -rf "$staging_dir/routes" "$api_source_dir/routes"
|
||||||
|
fi
|
||||||
|
if [[ -d "$staging_dir/src" ]]; then
|
||||||
|
rm -rf "$api_source_dir/src"
|
||||||
|
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
||||||
|
fi
|
||||||
|
if [[ -d "$staging_dir/dns-providers" ]]; then
|
||||||
|
rm -rf "$api_source_dir/dns-providers"
|
||||||
|
cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||||
|
fi
|
||||||
|
if [[ -n "$commit" ]]; then
|
||||||
|
echo "$commit" > "$api_source_dir/VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3b. Sync frontend
|
||||||
|
if [[ -z "$frontend_staging_dir" ]]; then
|
||||||
|
parent_staging=$(dirname "$staging_dir")
|
||||||
|
[[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status"
|
||||||
|
fi
|
||||||
|
if [[ -z "$frontend_target_dir" ]]; then
|
||||||
|
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
|
||||||
|
[[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
|
||||||
|
log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir"
|
||||||
|
mkdir -p "$frontend_target_dir"
|
||||||
|
[[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html"
|
||||||
|
[[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js"
|
||||||
|
for sub in dist css vendor js; do
|
||||||
|
if [[ -d "$frontend_staging_dir/$sub" ]]; then
|
||||||
|
mkdir -p "$frontend_target_dir/$sub"
|
||||||
|
cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -d "$frontend_staging_dir/assets" ]]; then
|
||||||
|
mkdir -p "$frontend_target_dir/assets"
|
||||||
|
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Rebuild container
|
||||||
|
log "Rebuilding container..."
|
||||||
|
local build_ok=false
|
||||||
|
if build_image; then
|
||||||
|
build_ok=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$build_ok" != "true" ]]; then
|
||||||
|
log "ERROR: Docker build failed — rolling back code + data"
|
||||||
|
code_restore "$backup_dir"
|
||||||
|
build_image 2>&1 | tail -3 || true
|
||||||
|
restart_container
|
||||||
|
wait_for_health || true
|
||||||
|
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Restart container (recreate so new code + env vars take effect)
|
||||||
|
restart_container
|
||||||
|
|
||||||
|
# 6. Health check
|
||||||
|
if wait_for_health; then
|
||||||
|
local duration=$(( $(date +%s) - start_time ))
|
||||||
|
log "=== Update successful: v${to_version} in ${duration}s ==="
|
||||||
|
write_result "true" "$to_version" "$duration"
|
||||||
|
else
|
||||||
|
local duration=$(( $(date +%s) - start_time ))
|
||||||
|
log "ERROR: Health check failed after update — rolling back code + data"
|
||||||
|
rollback_restore "$backup_dir"
|
||||||
|
build_image 2>&1 | tail -3 || true
|
||||||
|
restart_container
|
||||||
|
wait_for_health || log "WARNING: Rollback health check also failed"
|
||||||
|
write_result "false" "$to_version" "$duration" "Health check failed after update"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Cleanup
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
||||||
|
|
||||||
|
log "=== Update process complete ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user