// DashCaddy Platform Paths // Provides cross-platform path resolution for Windows and Linux deployments. // All paths can be overridden via environment variables. const path = require('path'); const fs = require('fs'); const isWindows = process.platform === 'win32'; // Base directories const CADDY_BASE = process.env.CADDY_BASE || (isWindows ? 'C:/caddy' : '/etc/dashcaddy'); const DOCKER_DATA = process.env.DOCKER_DATA || (isWindows ? 'E:/dockerdata' : '/opt/dockerdata'); const CADDY_SITES = process.env.CADDY_SITES || path.join(CADDY_BASE, 'sites'); // Runtime state must not default beside source modules: those paths move whenever // files are reorganized and are not mounted in production containers. Derive a // stable data directory from the canonical services file instead. This supports // both current /app/data mounts and legacy /app single-file mounts without // requiring per-module environment variables. const SERVICES_FILE = process.env.SERVICES_FILE || path.join(CADDY_BASE, 'services.json'); const DATA_DIR = process.env.DATA_DIR || path.dirname(SERVICES_FILE); const CONFIG_FILE = process.env.CONFIG_FILE || path.join(DATA_DIR, 'config.json'); const DNS_CREDENTIALS_FILE = process.env.DNS_CREDENTIALS_FILE || path.join(DATA_DIR, 'dns-credentials.json'); // Caddy PKI certificates const CADDY_PKI = process.env.CADDY_PKI || (isWindows ? 'C:/caddy/certs/pki/authorities/local' : '/var/lib/caddy/.local/share/caddy/pki/authorities/local'); const paths = { // Base directories caddyBase: CADDY_BASE, caddySites: CADDY_SITES, dockerData: DOCKER_DATA, // Caddy configuration caddyfile: process.env.CADDYFILE_PATH || path.join(CADDY_BASE, 'Caddyfile'), caddyAdminUrl: process.env.CADDY_ADMIN_URL || (isWindows ? 'http://host.docker.internal:2019' : 'http://localhost:2019'), // Service config files servicesFile: SERVICES_FILE, configFile: CONFIG_FILE, dnsCredentialsFile: DNS_CREDENTIALS_FILE, dataDir: DATA_DIR, // CA certificate paths caCertDir: path.join(CADDY_SITES, 'ca'), pkiRootCert: path.join(CADDY_PKI, 'root.crt'), pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'), generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'), pkiDir: CADDY_PKI, // Static site base path sitePath: (subdomain) => path.join(CADDY_SITES, subdomain), // Docker data path for app volumes 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 digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'), // Log paths (for allowed log file access) allowedLogPaths: isWindows ? [ process.env.LOCALAPPDATA || 'C:\\Users', process.env.APPDATA || 'C:\\Users', 'C:\\ProgramData', '/var/log', '/opt' ] : [ '/var/log', '/opt', '/home' ], // Platform detection helpers isWindows, isLinux: process.platform === 'linux', }; // Convert host paths to Docker-compatible mount paths // On Windows Docker Desktop: C:/foo → //mnt/host/c/foo // On Linux: paths pass through unchanged (native Docker) paths.toDockerMountPath = function(hostPath) { if (!isWindows) return hostPath; if (hostPath.startsWith('//mnt/host/') || hostPath.startsWith('/')) return hostPath; const match = hostPath.match(/^([A-Za-z]):[/\\](.*)$/); if (match) { const driveLetter = match[1].toLowerCase(); const restOfPath = match[2].replace(/\\/g, '/'); return `//mnt/host/${driveLetter}/${restOfPath}`; } return hostPath; }; // ============================================================================ // dataDir safety guard — DC-046 follow-up to DC-039 // ============================================================================ // The DC-039 fix routed every runtime-data default through `platformPaths.dataDir` // (derived from SERVICES_FILE → path.dirname(SERVICES_FILE)). That worked because // /opt/dashcaddy/dashcaddy-api/data is bind-mounted at /app/data in production. // // The silent failure mode that survived: if SERVICES_FILE isn't set as an env // var AND no `services.json` exists in the production bind-mount path, the // resolution falls back to `path.join(CADDY_BASE, 'services.json')` — and on // Linux that resolves to `/etc/dashcaddy/services.json` → dataDir = `/etc/dashcaddy` // which is the IMAGE LAYER, not a bind mount. Audit-log / error-log / license // files would silently land in the image and vanish on the next recreate. // // `assertSafe()` is the structural guard. Called once from server.js startup // in production mode (NODE_ENV=production). Throws → container refuses to boot // loudly, instead of running with a path that loses data silently. // // Forbidden zones (Docker image layer; recovered only by rebuild): // /app/src/, /app/routes/, /app/scripts/, /app/*.js (literally /app itself // when no subdir — the WORKDIR in Dockerfile is /app and a misdirected write // to /app/audit-log.json would be the same problem) // // Permitted zones (bind-mounted in production, mount-relative in dev): // /app/data, any non-/app or non-/etc path that resolves onto a real fs // // On non-Linux platforms, the guard only checks the Linux-style image zones. // Windows installs use the E:/ + C:/ ETree and never run inside the Docker image. const FORBIDDEN_DATA_DIRS = (process.platform === 'linux' && !process.env.SKIP_DATA_DIR_GUARD) ? [ // DC-039-era broken defaults. Hits only when SERVICES_FILE is unset AND no // bind mount at /app/data resolves. '/app/src', '/app/routes', '/app/scripts', '/app/utils', '/app/managers', '/app/security', // system dirs that should never be a dataDir '/etc', '/etc/caddy', '/etc/dashcaddy', '/usr', '/usr/local', '/var', '/var/lib/caddy', ] : []; paths.isMountedCheck = function(dir) { // Heuristic: a "mounted" dir on Linux is reachable AND writable AND not the // Docker image layer. Returning `false` lets start.sh skip migration cleanly // rather than crashing. if (!fs.existsSync(dir)) return false; try { fs.accessSync(dir, fs.constants.W_OK); } catch { return false; } // On Linux Docker, /app is a baked image layer; /app/data is bind-mounted. // Detect /app without /app/data being a separate mountpoint. if (process.platform === 'linux' && dir === '/app') { return fs.existsSync('/app/data') && fs.statSync('/app/data').dev !== fs.statSync('/app').dev; } return true; }; paths.assertSafe = function({ mode = 'production' } = {}) { if (mode !== 'production') return; // dev / test pass-through const dataDirResolved = path.resolve(paths.dataDir); const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, ''); // Zone membership is by first segment, not arbitrary substring matches. // `/app/data` is allowed because `/app/data` is the bind mount; `/app/src` // is forbidden because that's where the source tree lives. for (const forbidden of FORBIDDEN_DATA_DIRS) { if (norm(dataDirResolved) === norm(forbidden) || norm(dataDirResolved).startsWith(norm(forbidden) + '/')) { throw new Error( `[platform-paths] FATAL: dataDir resolved to forbidden image-layer path ` + `"${dataDirResolved}". This is a DC-039-class regression: runtime state would ` + `be written into the Docker image and lost on next container recreate. ` + `Set SERVICES_FILE=/app/data/services.json (or equivalent bind-mounted path) ` + `in your container env. To bypass during local dev, set SKIP_DATA_DIR_GUARD=1.` ); } } // Second check: dataDir should be on a writable, persistent mount. if (!paths.isMountedCheck(dataDirResolved)) { // Not fatal — but loud. Some Windows + dev workflows have ambiguous // writability. Warn instead of throw so we don't break the install path // for fresh users on Windows. console.warn( `[platform-paths] WARNING: dataDir "${dataDirResolved}" is not writable ` + `or doesn't exist. Runtime writes may fail or land in unexpected places.` ); } }; module.exports = paths;