/** * VM Disk Provisioner — creates a bounded virtual disk for DashCaddy. * * PLATFORM STRATEGY: * Windows: Dedicated WSL2 distro with a fixed-size VHDX. * Docker runs inside WSL2, all data lives in the VHDX. * Uninstall = wsl --unregister (deletes VHDX instantly). * * macOS: Lima VM with a fixed disk image. * Docker runs inside Lima, all data lives in the disk image. * Uninstall = limactl delete (removes VM + disk). * * Linux: Sparse ext4 loopback image mounted at /opt/dashcaddy-data. * Docker --data-root pointed at the mount. * Uninstall = unmount + rm image file. * * The user picks a disk size (default 20GB). DashCaddy is physically * unable to exceed it — the OS enforces the limit, not our code. */ const { exec } = require('child_process'); const { promisify } = require('util'); const fs = require('fs').promises; const path = require('path'); const platformUtils = require('../shared/platform-utils'); const execAsync = promisify(exec); // Presets users pick from in the wizard const DISK_PRESETS = { minimal: { sizeGB: 10, label: 'Minimal (10GB)', desc: 'DashCaddy only, a few small apps' }, balanced: { sizeGB: 30, label: 'Balanced (30GB)', desc: 'DashCaddy + media tools + containers' }, power: { sizeGB: 100, label: 'Power (100GB)', desc: 'DashCaddy + heavy apps + lots of containers' }, custom: { sizeGB: 0, label: 'Custom', desc: 'Pick your own size' }, }; /** * Main provisioner class. */ class VMDiskProvisioner { constructor() { this.platform = platformUtils.detectOS(); } /** * Provision the full sandboxed environment. * * @param {Object} opts * @param {number} opts.diskSizeGB — virtual disk size * @param {string} opts.installPath — where DashCaddy app files live (host) * @param {number} opts.apiPort * @param {Object} opts.domain — { mode, domain, tld, email } * @param {function} [opts.onProgress] — callback(statusMsg, pct) * @returns {Object} { success, dockerContext, dashboardUrl, vmInfo } */ async provision(opts) { const { diskSizeGB = 30, onProgress = () => {} } = opts; onProgress('Checking prerequisites', 5); await this._checkPrerequisites(); onProgress('Creating virtual disk (' + diskSizeGB + 'GB)', 15); const diskInfo = await this._createDisk(opts); onProgress('Starting sandbox environment', 40); const envInfo = await this._startEnvironment(diskInfo, opts); onProgress('Installing Docker in sandbox', 60); await this._ensureDocker(envInfo); onProgress('Deploying DashCaddy into sandbox', 75); const deployInfo = await this._deployDashCaddy(envInfo, opts); onProgress('Configuring services', 90); await this._configureServices(envInfo, opts); onProgress('Complete', 100); return { success: true, platform: this.platform, diskSizeGB, dockerContext: envInfo.dockerContext, dashboardUrl: deployInfo.dashboardUrl, vmInfo: { type: envInfo.type, name: envInfo.name, diskPath: diskInfo.path, diskSizeGB, dockerDataRoot: envInfo.dockerDataRoot, }, }; } /** * Remove the sandboxed environment completely. * @param {Object} vmInfo — from provision() * @param {Object} opts — { exportDataPath: null } */ async destroy(vmInfo, opts = {}) { // Export data first if requested if (opts.exportDataPath) { await this._exportData(vmInfo, opts.exportDataPath); } switch (this.platform) { case 'windows': return this._destroyWSL2(vmInfo); case 'macos': return this._destroyLima(vmInfo); case 'linux': return this._destroyLoopback(vmInfo); default: throw new Error('Unsupported platform: ' + this.platform); } } // ========================================================================= // PREREQUISITES // ========================================================================= async _checkPrerequisites() { const checks = []; switch (this.platform) { case 'windows': checks.push(this._checkCommand('wsl', '--status', 'WSL2')); break; case 'macos': checks.push(this._checkCommand('limactl', 'version', 'Lima')); break; case 'linux': // Need root or sudo for loopback mount if (process.getuid && process.getuid() !== 0) { // Check if we can sudo try { await execAsync('sudo -n true', { timeout: 5000 }); } catch { throw new Error('Linux install needs root or passwordless sudo for loopback mount'); } } break; } const results = await Promise.all(checks); const failed = results.filter(r => !r.ok); if (failed.length) { throw new Error('Missing: ' + failed.map(f => f.name).join(', ') + '. Install instructions: https://dashcaddy.net/docs/installation'); } } async _checkCommand(cmd, versionArg, friendlyName) { try { await execAsync(`${cmd} ${versionArg}`, { timeout: 10000 }); return { ok: true, name: friendlyName }; } catch { return { ok: false, name: friendlyName }; } } // ========================================================================= // WINDOWS: WSL2 Dedicated Distro // ========================================================================= async _createDisk(opts) { if (this.platform === 'windows') return this._createWSL2Disk(opts); if (this.platform === 'macos') return this._createLimaDisk(opts); return this._createLoopbackDisk(opts); } async _createWSL2Disk(opts) { const distroName = 'dashcaddy'; const { diskSizeGB = 30 } = opts; const wslPath = opts.installPath || path.join(process.env.LOCALAPPDATA || 'C:\\DashCaddy', 'DashCaddy'); const vhdxPath = path.join(wslPath, 'data.vhdx'); // Check if distro already exists try { const { stdout } = await execAsync('wsl -l -q', { timeout: 10000 }); if (stdout.includes(distroName)) { return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: true }; } } catch {} // Download a minimal rootfs (Alpine for smallest footprint) await fs.mkdir(wslPath, { recursive: true }); const rootfsUrl = 'https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.0-x86_64.tar.gz'; const rootfsPath = path.join(wslPath, 'rootfs.tar.gz'); await execAsync(`curl -L -o "${rootfsPath}" "${rootfsUrl}"`, { timeout: 120000 }); // Import as a new WSL2 distro — the VHDX is created automatically // and capped by .wslconfig max disk size await execAsync(`wsl --import ${distroName} "${wslPath}" "${rootfsPath}" --version 2`, { timeout: 60000 }); // Set disk size limit via wsl config const wslconfigPath = path.join(wslPath, '.wslconfig'); await fs.writeFile(wslconfigPath, [ `[wsl2]`, `vmDiskSize=${diskSizeGB}GB`, `memory=2GB`, `processors=2`, ].join('\n')); // Clean up rootfs download await fs.unlink(rootfsPath).catch(() => {}); return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: false }; } async _startEnvironment(diskInfo, opts) { if (this.platform === 'windows') return this._startWSL2(diskInfo, opts); if (this.platform === 'macos') return this._startLima(diskInfo, opts); return this._startLoopback(diskInfo, opts); } async _startWSL2(diskInfo, opts) { const { distroName } = diskInfo; // Start the distro and install Docker inside const wslExec = (cmd) => execAsync(`wsl -d ${distroName} -- sh -c "${cmd}"`, { timeout: 60000 }); // Update apk and install Docker + dependencies await wslExec('apk update && apk add docker docker-cli-compose openrc ca-certificates curl'); await wslExec('rc-update add docker default && service docker start'); // Create Docker data directory inside the VM await wslExec('mkdir -p /var/lib/docker /opt/dashcaddy'); return { type: 'wsl2', name: distroName, dockerContext: 'dashcaddy-wsl', dockerDataRoot: '/var/lib/docker', exec: wslExec, }; } // ========================================================================= // macOS: Lima VM // ========================================================================= async _createLimaDisk(opts) { const { diskSizeGB = 30 } = opts; const vmName = 'dashcaddy'; const limaDir = path.join(process.env.HOME, '.lima', vmName); // Check if VM already exists try { await execAsync(`limactl list ${vmName}`, { timeout: 10000 }); return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: true }; } catch {} // Create Lima config with fixed disk const config = { vmType: 'qemu', arch: 'x86_64', images: [{ location: 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img', arch: 'x86_64', }], cpus: 2, memory: '2GiB', disk: diskSizeGB + 'GiB', mounts: [], containerd: { system: false, user: false }, provision: { mode: 'system', script: 'apt-get update && apt-get install -y docker.io docker-compose-plugin', }, // Forward the API port portForwards: [{ guestSocket: '/var/run/docker.sock', hostSocket: path.join(limaDir, 'sock', 'docker.sock'), }], }; const configPath = path.join(limaDir, 'lima.yaml'); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, require('yaml').stringify ? require('yaml').stringify(config) : JSON.stringify(config, null, 2)); await execAsync(`limactl start --name=${vmName} ${configPath}`, { timeout: 300000 }); return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: false }; } async _startLima(diskInfo, opts) { const { vmName } = diskInfo; // Ensure VM is running try { await execAsync(`limactl start ${vmName}`, { timeout: 60000 }); } catch {} const limaExec = (cmd) => execAsync(`limactl shell ${vmName} -- bash -c "${cmd}"`, { timeout: 60000 }); // Ensure Docker is running await limaExec('service docker start || true'); return { type: 'lima', name: vmName, dockerContext: 'dashcaddy-lima', dockerDataRoot: '/var/lib/docker', exec: limaExec, }; } // ========================================================================= // LINUX: Loopback ext4 image // ========================================================================= async _createLoopbackDisk(opts) { const { diskSizeGB = 30 } = opts; const imagePath = '/opt/dashcaddy-data.raw'; const mountPoint = '/opt/dashcaddy-data'; // Check if already mounted try { const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo mounted', { timeout: 5000 }); if (stdout.includes('mounted')) { return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: true }; } } catch {} // Create sparse image (only uses space as data fills — starts at ~0 bytes) const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo'; await execAsync(`truncate -s ${diskSizeGB}G "${imagePath}"`, { timeout: 30000 }); // Format as ext4 await execAsync(`${sudo} mkfs.ext4 -F -L dashcaddy "${imagePath}"`, { timeout: 60000 }); // Mount await execAsync(`${sudo} mkdir -p "${mountPoint}"`, { timeout: 5000 }); await execAsync(`${sudo} mount -o loop "${imagePath}" "${mountPoint}"`, { timeout: 10000 }); // Add to fstab for persistence across reboots const fstabEntry = `${imagePath} ${mountPoint} ext4 loop,defaults 0 0`; await execAsync(`grep -q '${imagePath}' /etc/fstab || echo '${fstabEntry}' | ${sudo} tee -a /etc/fstab`, { timeout: 5000 }); // Point Docker data-root at the mounted volume await this._configureDockerDataRoot(mountPoint + '/docker', sudo); return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: false }; } async _startLoopback(diskInfo, opts) { const { mountPoint } = diskInfo; const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo'; // Ensure mounted try { await execAsync(`mountpoint -q ${mountPoint} || ${sudo} mount -o loop ${diskInfo.imagePath} ${mountPoint}`, { timeout: 10000 }); } catch {} // Restart Docker to pick up new data-root await execAsync(`${sudo} systemctl restart docker`, { timeout: 30000 }).catch(() => {}); return { type: 'loopback', name: 'dashcaddy-loopback', dockerContext: 'default', dockerDataRoot: mountPoint + '/docker', exec: (cmd) => execAsync(cmd, { timeout: 60000 }), }; } async _configureDockerDataRoot(dataRoot, sudo) { const daemonJsonPath = '/etc/docker/daemon.json'; let daemonJson = {}; try { daemonJson = JSON.parse(await fs.readFile(daemonJsonPath, 'utf8')); } catch {} daemonJson['data-root'] = dataRoot; await execAsync(`${sudo} mkdir -p ${dataRoot}`, { timeout: 5000 }); await execAsync(`${sudo} bash -c 'cat > ${daemonJsonPath} << EOF\n${JSON.stringify(daemonJson, null, 2)}\nEOF'`, { timeout: 5000 }); } // ========================================================================= // DEPLOY + CONFIGURE (shared across platforms) // ========================================================================= async _ensureDocker(envInfo) { // Docker was installed during VM creation per-platform. // Verify it's actually running. if (envInfo.exec) { try { await envInfo.exec('docker info > /dev/null 2>&1'); return; } catch { // Try starting if (this.platform === 'windows') await envInfo.exec('service docker start || true'); if (this.platform === 'macos') await envInfo.exec('service docker start || true'); } } } async _deployDashCaddy(envInfo, opts) { const apiPort = opts.apiPort || 3001; const dashboardPort = opts.domain?.mode === 'public' ? null : (opts.dashboardPort || 8080); // Inside the VM, download and run DashCaddy // The VM has Docker running — we deploy the same container image const deployScript = ` mkdir -p /opt/dashcaddy && cd /opt/dashcaddy curl -fsSL https://get.dashcaddy.net/release/latest.tar.gz | tar xz cd dashcaddy-api && docker build -t dashcaddy-api . docker run -d --name dashcaddy-api --restart unless-stopped \\ -p ${apiPort}:${apiPort} \\ -v /opt/dashcaddy/data:/app/data \\ -v /opt/dashcaddy/status:/app/status \\ -v /var/run/docker.sock:/var/run/docker.sock \\ -e NODE_ENV=production \\ -e PORT=${apiPort} \\ dashcaddy-api `; if (envInfo.exec) { await envInfo.exec(deployScript.replace(/\n/g, ' && ')); } let url; if (opts.domain?.mode === 'public') { url = `https://${opts.domain.domain}`; } else if (opts.domain?.mode === 'custom-tld') { url = `https://dashcaddy${opts.domain.tld}`; } else { url = `http://localhost:${dashboardPort || 8080}`; } return { success: true, dashboardUrl: url }; } async _configureServices(envInfo, opts) { // Port forwarding from host to VM if (this.platform === 'windows') { // WSL2 auto-forwards localhost ports to the host return; } if (this.platform === 'macos') { // Lima forwards are configured in the VM config return; } // Linux: container is directly accessible } // ========================================================================= // DESTROY (uninstall) // ========================================================================= async _destroyWSL2(vmInfo) { await execAsync(`wsl --unregister ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 }); // VHDX is deleted by WSL on unregister return { success: true, message: 'WSL2 distro deleted — all data removed' }; } async _destroyLima(vmInfo) { await execAsync(`limactl delete -f ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 }); return { success: true, message: 'Lima VM deleted — all data removed' }; } async _destroyLoopback(vmInfo) { const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo'; await execAsync(`${sudo} umount ${vmInfo.mountPoint || '/opt/dashcaddy-data'}`, { timeout: 10000 }).catch(() => {}); await execAsync(`rm -f ${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}`, { timeout: 5000 }); // Remove from fstab await execAsync(`${sudo} sed -i '\\#${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}#d' /etc/fstab`, { timeout: 5000 }).catch(() => {}); return { success: true, message: 'Virtual disk unmounted and deleted — all data removed' }; } async _exportData(vmInfo, exportPath) { // Export DashCaddy config + service definitions before destroy if (vmInfo.type === 'wsl2') { await execAsync(`wsl -d ${vmInfo.name} -- tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data /opt/dashcaddy/status`, { timeout: 60000 }); await execAsync(`wsl -d ${vmInfo.name} -- cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 }); } else if (vmInfo.type === 'lima') { await execAsync(`limactl shell ${vmInfo.name} -- sudo tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data`, { timeout: 60000 }); await execAsync(`limactl shell ${vmInfo.name} -- sudo cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 }); } else if (vmInfo.type === 'loopback') { await execAsync(`tar czf "${exportPath}" -C ${vmInfo.mountPoint} data`, { timeout: 60000 }); } return { success: true, exportPath }; } // ========================================================================= // STATUS // ========================================================================= async getStatus() { const info = { platform: this.platform, running: false }; try { switch (this.platform) { case 'windows': { const { stdout } = await execAsync('wsl -l -v', { timeout: 10000 }); info.running = stdout.includes('dashcaddy') && stdout.includes('Running'); break; } case 'macos': { const { stdout } = await execAsync('limactl list --json', { timeout: 10000 }); const vms = JSON.parse(stdout); info.running = vms.some(v => v.name === 'dashcaddy' && v.status === 'Running'); break; } case 'linux': { const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo yes', { timeout: 5000 }); info.running = stdout.includes('yes'); break; } } } catch {} return info; } } module.exports = { VMDiskProvisioner, DISK_PRESETS };