feat: VM disk sandboxing — bounded virtual disk per platform
ARCHITECTURE: - Windows: dedicated WSL2 distro with fixed VHDX, Docker inside - macOS: Lima VM with fixed disk, Docker inside - Linux: sparse ext4 loopback image, Docker data-root inside NEW FILES: - vm-provisioner.js: core provisioning engine (create/start/destroy/export) - Disk presets: Minimal(10GB), Balanced(30GB), Power(100GB), Custom - Sparse images that grow on demand (start at ~0 bytes) - Full lifecycle: provision → deploy DashCaddy → destroy (clean removal) - Data export before uninstall for users who want to migrate - vm-ipc.js: Electron IPC handlers connecting wizard to provisioner - vm:provision, vm:destroy, vm:get-status, vm:export-data, vm:get-presets - disk-budget-step.js: wizard UI step with preset cards + custom slider - Real-time free space check against selected disk size - Plain English description of what each tier handles UPDATED: - caddyfile-generator.js: docker-compose now includes disk safety env vars (health retention, stats caps, memory limits) as defense-in-depth even inside the VM sandbox GUARANTEE: DashCaddy physically cannot exceed the storage budget. The OS enforces the limit at the disk/image level, not our code.
This commit is contained in:
@@ -7,6 +7,10 @@ const { DEFAULT_PORTS } = require('../shared/constants');
|
||||
*
|
||||
* Generates production-grade configs that match the patterns used by the
|
||||
* running DashCaddy deployment (CORS snippets, admin origins, PKI, etc.)
|
||||
*
|
||||
* DISK SAFETY: All generated configs include sensible defaults for storage
|
||||
* limits — health retention, stats caps, and memory limits — so a fresh
|
||||
* install will never silently fill a user's disk.
|
||||
*/
|
||||
class CaddyfileGenerator {
|
||||
/**
|
||||
@@ -279,19 +283,43 @@ class CaddyfileGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate docker-compose.yml for running the API server
|
||||
* Generate docker-compose.yml for running the API server.
|
||||
*
|
||||
* DISK SAFETY: Includes env vars for health retention, stats caps, and
|
||||
* memory limits derived from the disk budget the user selected during
|
||||
* install. These prevent the disk-explosion bugs seen in early versions.
|
||||
*
|
||||
* @param {string} installPath - Installation directory
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.apiPort - API server port
|
||||
* @param {string} options.lanIP - Host LAN IP address
|
||||
* @param {string} options.tailscaleIP - Host Tailscale IP address
|
||||
* @param {string} options.domainMode - Domain mode (local, public, custom-tld)
|
||||
* @param {Object} [options.disk] - Disk budget settings
|
||||
* @param {number} [options.disk.healthRetentionDays=14] - Health history retention
|
||||
* @param {number} [options.disk.healthMaxEntries=500] - Max health entries per service
|
||||
* @param {number} [options.disk.healthCheckInterval=30000] - Health check interval (ms)
|
||||
* @param {number} [options.disk.statsMaxEntries=2000] - Max container stats entries
|
||||
* @param {number} [options.disk.auditMaxEntries=1000] - Max audit log entries
|
||||
* @param {number} [options.disk.backupLimitGB=10] - Backup storage limit
|
||||
* @param {string} [options.dockerDataPath] - Docker data root override
|
||||
* @param {number} [options.memoryLimitMB=1024] - Container memory limit
|
||||
*/
|
||||
generateDockerCompose(installPath, options = {}) {
|
||||
const apiPort = options.apiPort || DEFAULT_PORTS.API;
|
||||
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
|
||||
const p = this._p.bind(this);
|
||||
|
||||
// Disk budget settings with safe defaults
|
||||
const disk = options.disk || {};
|
||||
const healthRetentionDays = disk.healthRetentionDays || 14;
|
||||
const healthMaxEntries = disk.healthMaxEntries || 500;
|
||||
const healthCheckInterval = disk.healthCheckInterval || 30000;
|
||||
const statsMaxEntries = disk.statsMaxEntries || 2000;
|
||||
const auditMaxEntries = disk.auditMaxEntries || 1000;
|
||||
const backupLimitGB = disk.backupLimitGB || 10;
|
||||
const memoryLimitMB = options.memoryLimitMB || 1024;
|
||||
|
||||
// Core volume mounts
|
||||
let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw
|
||||
- ${p(installPath)}/services.json:/app/services.json:rw
|
||||
@@ -308,12 +336,19 @@ class CaddyfileGenerator {
|
||||
volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`;
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
// Environment variables — disk safety baked in
|
||||
let envVars = ` - CADDYFILE_PATH=/caddyfile
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:${adminPort}
|
||||
- ASSETS_PATH=/app/assets
|
||||
- CREDENTIALS_FILE=/app/credentials.json
|
||||
- NODE_ENV=production`;
|
||||
- NODE_ENV=production
|
||||
# --- Disk Safety ---
|
||||
- HEALTH_HISTORY_RETENTION=${healthRetentionDays}
|
||||
- HEALTH_MAX_ENTRIES=${healthMaxEntries}
|
||||
- HEALTH_CHECK_INTERVAL=${healthCheckInterval}
|
||||
- CONTAINER_STATS_MAX_ENTRIES=${statsMaxEntries}
|
||||
- AUDIT_MAX_ENTRIES=${auditMaxEntries}
|
||||
- BACKUP_MAX_STORAGE_BYTES=${backupLimitGB * 1024 * 1024 * 1024}`;
|
||||
|
||||
if (options.domainMode === 'custom-tld') {
|
||||
envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`;
|
||||
@@ -339,6 +374,9 @@ ${envVars}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
# Memory limit prevents OOM during startup when all managers init
|
||||
mem_limit: ${memoryLimitMB}m
|
||||
memswap_limit: ${(memoryLimitMB * 2)}m
|
||||
`;
|
||||
|
||||
return dockerCompose;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* VM Provisioner IPC Handler
|
||||
* Wires the Electron wizard to VMDiskProvisioner.
|
||||
* Add to src/main/index.js alongside the existing IPC handlers.
|
||||
*/
|
||||
|
||||
const { ipcMain } = require('electron');
|
||||
const { VMDiskProvisioner, DISK_PRESETS } = require('./vm-provisioner');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
function registerVMHandlers(mainWindow) {
|
||||
const provisioner = new VMDiskProvisioner();
|
||||
|
||||
// --- Get disk presets for wizard UI ---
|
||||
ipcMain.handle('vm:get-presets', async () => {
|
||||
return DISK_PRESETS;
|
||||
});
|
||||
|
||||
// --- Get current VM status ---
|
||||
ipcMain.handle('vm:get-status', async () => {
|
||||
try {
|
||||
const status = await provisioner.getStatus();
|
||||
// Also check for saved vmInfo from previous install
|
||||
try {
|
||||
const configPath = path.join(getInstallBase(), '.dashcaddy-config.json');
|
||||
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||
if (config.vmInfo) {
|
||||
status.vmInfo = config.vmInfo;
|
||||
status.diskSizeGB = config.vmInfo.diskSizeGB;
|
||||
}
|
||||
} catch {}
|
||||
return status;
|
||||
} catch (e) {
|
||||
return { platform: process.platform, running: false, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Provision the VM sandbox ---
|
||||
ipcMain.handle('vm:provision', async (event, opts) => {
|
||||
try {
|
||||
const result = await provisioner.provision({
|
||||
...opts,
|
||||
onProgress: (msg, pct) => {
|
||||
mainWindow.webContents.send('vm:progress', { message: msg, percent: pct });
|
||||
},
|
||||
});
|
||||
|
||||
// Save vmInfo for uninstall
|
||||
if (result.vmInfo) {
|
||||
try {
|
||||
const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json');
|
||||
let config = {};
|
||||
try { config = JSON.parse(await fs.readFile(configPath, 'utf8')); } catch {}
|
||||
config.vmInfo = result.vmInfo;
|
||||
config.diskBudgetGB = opts.diskSizeGB;
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('vm:complete', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
mainWindow.webContents.send('vm:error', { error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Destroy the VM sandbox (uninstall) ---
|
||||
ipcMain.handle('vm:destroy', async (event, opts) => {
|
||||
try {
|
||||
// Load saved vmInfo
|
||||
let vmInfo = opts.vmInfo;
|
||||
if (!vmInfo) {
|
||||
try {
|
||||
const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json');
|
||||
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||
vmInfo = config.vmInfo;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!vmInfo) {
|
||||
return { success: false, error: 'No VM info found. Already uninstalled?' };
|
||||
}
|
||||
|
||||
const result = await provisioner.destroy(vmInfo, {
|
||||
exportDataPath: opts.exportDataPath || null,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Export data from VM (before uninstall) ---
|
||||
ipcMain.handle('vm:export-data', async (event, opts) => {
|
||||
try {
|
||||
const result = await provisioner._exportData(opts.vmInfo, opts.exportPath);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getInstallBase() {
|
||||
const { getPlatformInfo } = require('../shared/platform-utils');
|
||||
return getPlatformInfo().defaultInstallPath;
|
||||
}
|
||||
|
||||
module.exports = { registerVMHandlers };
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* VM Disk Budget Step — rendered inside the Electron wizard.
|
||||
* Shows disk size presets, a custom slider, and real-time space check.
|
||||
* Add to wizard.js as a new render step between 'folder' and 'tier'.
|
||||
*
|
||||
* Exported function: renderDiskBudgetStep()
|
||||
* State updates: state.diskBudget.preset, state.diskBudget.customSizeGB
|
||||
*/
|
||||
|
||||
function renderDiskBudgetStep() {
|
||||
const presets = [
|
||||
{ id: 'minimal', icon: '💽', sizeGB: 10, label: 'Minimal', desc: 'DashCaddy only, a few small apps' },
|
||||
{ id: 'balanced', icon: '💿', sizeGB: 30, label: 'Balanced', desc: 'DashCaddy + media tools + containers' },
|
||||
{ id: 'power', icon: '🧊', sizeGB: 100, label: 'Power', desc: 'DashCaddy + heavy apps + lots of containers' },
|
||||
{ id: 'custom', icon: '⚙️', sizeGB: 0, label: 'Custom', desc: 'Pick your own size' },
|
||||
];
|
||||
|
||||
const selectedPreset = state.diskBudget?.preset || 'balanced';
|
||||
const selectedSize = state.diskBudget?.customSizeGB || presets.find(p => p.id === selectedPreset)?.sizeGB || 30;
|
||||
|
||||
return `
|
||||
<div>
|
||||
<h2>Storage Budget</h2>
|
||||
<p>DashCaddy creates a <strong>sandboxed virtual disk</strong> for all its data.
|
||||
It can never exceed this limit — your main drive stays safe.</p>
|
||||
<p class="hint" style="margin-bottom: 20px;">
|
||||
💡 The disk starts nearly empty and only grows as you add apps and data.
|
||||
Deleting DashCaddy removes the entire disk instantly.
|
||||
</p>
|
||||
|
||||
<div class="disk-presets" style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px;">
|
||||
${presets.map(p => `
|
||||
<div class="disk-preset-card ${selectedPreset === p.id ? 'selected' : ''}"
|
||||
onclick="selectDiskPreset('${p.id}', ${p.sizeGB})"
|
||||
style="padding: 16px; border: 2px solid ${selectedPreset === p.id ? '#6366f1' : 'var(--border, #333)'}; border-radius: 10px; cursor: pointer; transition: all 0.2s; ${selectedPreset === p.id ? 'background: rgba(99, 102, 241, 0.1);' : ''}">
|
||||
<div style="font-size: 2rem; margin-bottom: 8px;">${p.icon}</div>
|
||||
<div style="font-weight: 600; font-size: 1.05rem;">${p.label}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted, #888); margin-top: 4px;">
|
||||
${p.sizeGB > 0 ? p.sizeGB + 'GB' : 'Custom'} — ${p.desc}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
${selectedPreset === 'custom' ? `
|
||||
<div class="folder-input" style="margin-bottom: 16px;">
|
||||
<label>Custom Disk Size</label>
|
||||
<div class="input-row" style="display: flex; align-items: center; gap: 12px;">
|
||||
<input type="range" id="disk-slider"
|
||||
min="5" max="500" step="5"
|
||||
value="${selectedSize}"
|
||||
oninput="updateDiskSize(this.value)"
|
||||
style="flex: 1;">
|
||||
<span id="disk-size-display" style="font-size: 1.3rem; font-weight: 700; min-width: 80px; text-align: right;">
|
||||
${selectedSize}GB
|
||||
</span>
|
||||
</div>
|
||||
<p class="hint">Min 5GB, Max 500GB. DashCaddy uses a sparse image — it only consumes real disk space as data fills.</p>
|
||||
</div>
|
||||
` : `
|
||||
<div style="padding: 12px 16px; background: rgba(99, 102, 241, 0.08); border-radius: 8px; border: 1px solid rgba(99, 102, 241, 0.2); margin-bottom: 16px;">
|
||||
<strong>${selectedSize}GB</strong> virtual disk will be created.
|
||||
The sandbox isolates Docker, all containers, and all DashCaddy data inside it.
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div id="disk-space-check" style="margin-top: 12px;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// State management helpers — call from wizard.js
|
||||
function selectDiskPreset(presetId, sizeGB) {
|
||||
if (!state.diskBudget) state.diskBudget = {};
|
||||
state.diskBudget.preset = presetId;
|
||||
if (presetId !== 'custom') {
|
||||
state.diskBudget.diskSizeGB = sizeGB;
|
||||
}
|
||||
checkDiskSpace(sizeGB);
|
||||
render(); // re-render the step
|
||||
}
|
||||
|
||||
function updateDiskSize(val) {
|
||||
const sizeGB = parseInt(val);
|
||||
if (!state.diskBudget) state.diskBudget = {};
|
||||
state.diskBudget.diskSizeGB = sizeGB;
|
||||
state.diskBudget.customSizeGB = sizeGB;
|
||||
document.getElementById('disk-size-display').textContent = sizeGB + 'GB';
|
||||
checkDiskSpace(sizeGB);
|
||||
}
|
||||
|
||||
async function checkDiskSpace(sizeGB) {
|
||||
const el = document.getElementById('disk-space-check');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const info = await window.electronAPI.getDiskSpace(state.paths.install || '');
|
||||
const freeGB = Math.round(info.free / 1024 / 1024 / 1024);
|
||||
const neededGB = sizeGB + 2; // 2GB buffer for DashCaddy itself
|
||||
|
||||
if (freeGB < neededGB) {
|
||||
el.innerHTML = `<div style="padding: 10px 14px; background: rgba(239, 68, 68, 0.1); border-radius: 6px; border: 1px solid rgba(239, 68, 68, 0.3); color: #f87171; font-size: 0.85rem;">
|
||||
⚠️ Not enough free space. You have ${freeGB}GB free, but need ${neededGB}GB.
|
||||
</div>`;
|
||||
} else {
|
||||
el.innerHTML = `<div style="padding: 10px 14px; background: rgba(34, 197, 94, 0.1); border-radius: 6px; border: 1px solid rgba(34, 197, 94, 0.2); color: #4ade80; font-size: 0.85rem;">
|
||||
✓ You have ${freeGB}GB free — plenty of room for a ${sizeGB}GB disk.
|
||||
</div>`;
|
||||
}
|
||||
} catch {
|
||||
el.innerHTML = '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user