Compare commits
3
Commits
24f21abe80
...
cd3d0cd8ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3d0cd8ff | ||
|
|
7ebb1b1a01 | ||
|
|
ae54927210 |
@@ -0,0 +1,153 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||
router.get('/log-insights', asyncHandler(async (req, res) => {
|
||||
const hours = parseInt(req.query.hours) || 24;
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// --- Collect data ---
|
||||
const auditEntries = await auditLogger.query({ limit: 10000 });
|
||||
const recentAudit = auditEntries.filter(e => e.timestamp >= since);
|
||||
|
||||
let securityEvents = [];
|
||||
try { securityEvents = securityEventStore.query({ since, limit: 10000 }); } catch {}
|
||||
|
||||
// --- Analyze IPs ---
|
||||
const ipMap = {};
|
||||
recentAudit.forEach(e => {
|
||||
const ip = e.ip || 'unknown';
|
||||
if (!ipMap[ip]) ipMap[ip] = { count: 0, actions: {}, resources: new Set(), first: e.timestamp, last: e.timestamp, failures: 0 };
|
||||
const s = ipMap[ip];
|
||||
s.count++;
|
||||
const cat = (e.action || 'unknown').split('.')[0];
|
||||
s.actions[cat] = (s.actions[cat] || 0) + 1;
|
||||
if (e.resource) s.resources.add(e.resource);
|
||||
if (e.timestamp < s.first) s.first = e.timestamp;
|
||||
if (e.timestamp > s.last) s.last = e.timestamp;
|
||||
if (e.outcome === 'failure' || e.outcome === 'denied') s.failures++;
|
||||
});
|
||||
|
||||
// --- Build plain-English insights ---
|
||||
const insights = [];
|
||||
const ipArray = Object.entries(ipMap).sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
// Heavy users
|
||||
ipArray.slice(0, 3).forEach(([ip, s]) => {
|
||||
const topAction = Object.entries(s.actions).sort((a, b) => b[1] - a[1])[0];
|
||||
insights.push({
|
||||
severity: s.count > 500 ? 'warning' : 'info',
|
||||
title: ip + ' — ' + s.count + ' requests in ' + hours + 'h',
|
||||
plain: ip + ' made ' + s.count + ' requests (mostly ' + (topAction ? topAction[0] : 'unknown') + ')' +
|
||||
(s.failures > 0 ? ', ' + s.failures + ' failed' : '') + '.'
|
||||
});
|
||||
});
|
||||
|
||||
// Auth failures
|
||||
const totalFailures = recentAudit.filter(e => e.outcome === 'failure' || e.outcome === 'denied').length;
|
||||
if (totalFailures > 5) {
|
||||
insights.push({
|
||||
severity: totalFailures > 50 ? 'warning' : 'info',
|
||||
title: totalFailures + ' failed actions',
|
||||
plain: totalFailures + ' requests were denied or failed in the last ' + hours + ' hours.' +
|
||||
(totalFailures > 50 ? ' This could indicate someone trying to brute-force access.' : '')
|
||||
});
|
||||
}
|
||||
|
||||
// Security events
|
||||
const secBySev = {};
|
||||
securityEvents.forEach(e => { secBySev[e.severity] = (secBySev[e.severity] || 0) + 1; });
|
||||
if (secBySev.critical || secBySev.error) {
|
||||
insights.push({
|
||||
severity: 'warning',
|
||||
title: ((secBySev.critical || 0) + (secBySev.error || 0)) + ' security alerts',
|
||||
plain: (secBySev.critical || 0) + ' critical and ' + (secBySev.error || 0) + ' error-level security events were logged.'
|
||||
});
|
||||
}
|
||||
|
||||
// Quiet / nothing
|
||||
if (insights.length === 0) {
|
||||
insights.push({ severity: 'ok', title: 'All quiet', plain: 'No notable activity in the last ' + hours + ' hours.' });
|
||||
}
|
||||
|
||||
// --- Storage info ---
|
||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||
let storage = {};
|
||||
try {
|
||||
const a = await fs.stat(auditPath);
|
||||
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
|
||||
} catch {}
|
||||
try {
|
||||
const s = await fs.stat(secPath);
|
||||
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
|
||||
} catch {}
|
||||
|
||||
ok(res, {
|
||||
period: { hours, since, until: new Date().toISOString() },
|
||||
summary: {
|
||||
totalRequests: recentAudit.length,
|
||||
uniqueIPs: ipArray.length,
|
||||
securityEvents: securityEvents.length,
|
||||
failedActions: totalFailures
|
||||
},
|
||||
topIPs: ipArray.slice(0, 10).map(([ip, s]) => ({
|
||||
ip: ip,
|
||||
count: s.count,
|
||||
failures: s.failures,
|
||||
topActions: Object.entries(s.actions).sort((a, b) => b[1] - a[1]).slice(0, 3),
|
||||
activeFrom: s.first,
|
||||
lastSeen: s.last
|
||||
})),
|
||||
insights: insights,
|
||||
storage: storage
|
||||
});
|
||||
}));
|
||||
|
||||
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
||||
const keepDays = parseInt(req.body.keepDays) || 30;
|
||||
const confirm = req.body.confirm === true;
|
||||
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
||||
|
||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||
|
||||
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
||||
const auditData = JSON.parse(auditRaw);
|
||||
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
||||
|
||||
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
||||
const secLines = secRaw.split('\n').filter(Boolean);
|
||||
const oldSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp < cutoff; } catch (e) { return false; } });
|
||||
|
||||
if (!confirm) {
|
||||
ok(res, {
|
||||
preview: true,
|
||||
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
|
||||
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
cutoffDate: cutoff
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute cleanup
|
||||
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
||||
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
|
||||
|
||||
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
||||
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
||||
|
||||
ok(res, {
|
||||
disposed: true,
|
||||
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
||||
cutoffDate: cutoff
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -93,6 +93,7 @@ const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const securityRoutes = require('../routes/security');
|
||||
const logInsightsRoutes = require('../routes/log-insights');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
@@ -753,6 +754,20 @@ async function createApp() {
|
||||
apiRouter.use('/security', securityRoutes({
|
||||
log: ctx.log,
|
||||
}));
|
||||
|
||||
// Log Insights — plain English activity summary + safe log disposal
|
||||
apiRouter.use(logInsightsRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
auditLogger: ctx.auditLogger,
|
||||
securityEventStore: (function() {
|
||||
try {
|
||||
var getStore = require('./security/event-store').getStore;
|
||||
return getStore();
|
||||
} catch (e) { return null; }
|
||||
})()
|
||||
}));
|
||||
|
||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
|
||||
@@ -19,6 +19,7 @@ const STATS_HOURLY_FILE = process.env.STATS_HOURLY_FILE || path.join(platformPat
|
||||
const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(platformPaths.dataDir, 'container-stats-daily.json');
|
||||
const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(platformPaths.dataDir, 'alert-config.json');
|
||||
const ALERT_HISTORY_FILE = process.env.ALERT_HISTORY_FILE || path.join(platformPaths.dataDir, 'alert-history.json');
|
||||
const MAX_STATS_PER_CONTAINER = parseInt(process.env.STATS_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||
const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw
|
||||
const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly
|
||||
const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily
|
||||
@@ -242,6 +243,11 @@ class ResourceMonitor extends EventEmitter {
|
||||
containerStats.history = containerStats.history.filter(s =>
|
||||
new Date(s.timestamp).getTime() > cutoffTime
|
||||
);
|
||||
|
||||
// Also cap total entries per container (disk explosion fix)
|
||||
if (containerStats.history.length > MAX_STATS_PER_CONTAINER) {
|
||||
containerStats.history = containerStats.history.slice(-MAX_STATS_PER_CONTAINER);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -620,7 +626,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
saveStats() {
|
||||
try {
|
||||
const data = Object.fromEntries(this.stats);
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data)); // Compact JSON to reduce file size
|
||||
} catch (error) {
|
||||
log.error('monitor', error, { operation: 'saveStats' });
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
|
||||
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
||||
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||
|
||||
class HealthChecker extends EventEmitter {
|
||||
@@ -217,7 +218,7 @@ class HealthChecker extends EventEmitter {
|
||||
statusCode: res.statusCode,
|
||||
message: healthy ? 'Service is healthy' : 'Service check failed',
|
||||
details: {
|
||||
headers: res.headers,
|
||||
headers: res.headers ? { server: res.headers.server } : undefined, // Compact: disk explosion fix
|
||||
bodyLength: data.length
|
||||
}
|
||||
});
|
||||
@@ -285,6 +286,11 @@ class HealthChecker extends EventEmitter {
|
||||
}
|
||||
|
||||
this.history[serviceId].push(status);
|
||||
|
||||
// Cap entries to prevent unbounded growth (disk explosion fix)
|
||||
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||
}
|
||||
|
||||
// Emit status event
|
||||
this.emit('status-check', status);
|
||||
@@ -565,6 +571,10 @@ class HealthChecker extends EventEmitter {
|
||||
this.history[serviceId] = this.history[serviceId].filter(h =>
|
||||
new Date(h.timestamp).getTime() > cutoffTime
|
||||
);
|
||||
// Also cap total entries per service
|
||||
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +626,7 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
saveHistory() {
|
||||
try {
|
||||
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history, null, 2));
|
||||
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history)); // Compact JSON (no pretty-print) to reduce file size
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error saving history: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,7 @@
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||
<button id="log-insights-btn" aria-label="Log Insights">🔍 Insights</button>
|
||||
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -950,6 +951,7 @@
|
||||
<script src="/js/xterm-fit.min.js" defer></script>
|
||||
|
||||
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
|
||||
<script src="/js/log-insights.js" defer></script>
|
||||
<script src="/js/tailscale-devices.js" defer></script>
|
||||
|
||||
<!-- Bundled JS (built with: npm run build) -->
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// ========== LOG INSIGHTS PANEL ==========
|
||||
(function() {
|
||||
injectModal('log-insights-modal', `<div id="log-insights-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<h3>🔍 Log Insights</h3>
|
||||
<p class="modal-subtitle">Who's accessing your server and what they're doing — in plain English.</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
||||
<label class="text-muted-sm">Period:</label>
|
||||
<select id="li-period" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="1">Last 1 hour</option>
|
||||
<option value="6">Last 6 hours</option>
|
||||
<option value="24" selected>Last 24 hours</option>
|
||||
<option value="168">Last 7 days</option>
|
||||
</select>
|
||||
<button id="li-refresh" class="btn-sm">🔄 Refresh</button>
|
||||
<span style="flex: 1;"></span>
|
||||
<button id="li-dispose-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--warn-fg, #f0c674); border-color: var(--warn-fg, #f0c674);">🧹 Clean Old Logs</button>
|
||||
</div>
|
||||
|
||||
<!-- Plain English Insights -->
|
||||
<div id="li-insights" style="margin-bottom: 16px;"></div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div id="li-summary" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;"></div>
|
||||
|
||||
<!-- Top IPs Table -->
|
||||
<div id="li-ips-section">
|
||||
<h4 style="margin: 12px 0 8px; font-size: 0.95rem;">Top Visitors</h4>
|
||||
<div id="li-ips-table" class="scroll-container" style="max-height: 300px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Info -->
|
||||
<div id="li-storage" style="margin-top: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"></div>
|
||||
|
||||
<div class="weather-modal-buttons">
|
||||
<button id="li-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('log-insights-modal');
|
||||
const openBtn = document.getElementById('log-insights-btn');
|
||||
const closeBtn = document.getElementById('li-close');
|
||||
const refreshBtn = document.getElementById('li-refresh');
|
||||
const disposeBtn = document.getElementById('li-dispose-btn');
|
||||
const periodSel = document.getElementById('li-period');
|
||||
const insightsDiv = document.getElementById('li-insights');
|
||||
const summaryDiv = document.getElementById('li-summary');
|
||||
const ipsDiv = document.getElementById('li-ips-table');
|
||||
const storageDiv = document.getElementById('li-storage');
|
||||
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => { modal.style.display = 'flex'; loadInsights(); });
|
||||
}
|
||||
closeBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||
refreshBtn.addEventListener('click', loadInsights);
|
||||
periodSel.addEventListener('change', loadInsights);
|
||||
disposeBtn.addEventListener('click', showDisposePreview);
|
||||
|
||||
async function loadInsights() {
|
||||
const hours = periodSel.value;
|
||||
insightsDiv.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Analyzing logs...</div>';
|
||||
summaryDiv.innerHTML = '';
|
||||
ipsDiv.innerHTML = '';
|
||||
storageDiv.innerHTML = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/log-insights?hours=' + hours);
|
||||
const data = await res.json();
|
||||
if (!data.success) { insightsDiv.innerHTML = '<div class="panel-empty">Error: ' + data.error + '</div>'; return; }
|
||||
|
||||
// Render insights as plain English cards
|
||||
let insightsHtml = '';
|
||||
(data.insights || []).forEach(function(ins) {
|
||||
const sevColor = ins.severity === 'warning' ? 'var(--warn-fg, #f0c674)' :
|
||||
ins.severity === 'critical' ? 'var(--bad-fg, #ff6b6b)' :
|
||||
ins.severity === 'ok' ? 'var(--good-fg, #98c379)' : 'var(--muted)';
|
||||
insightsHtml += '<div style="padding: 10px 14px; margin-bottom: 8px; background: var(--bg); border-radius: 6px; border-left: 3px solid ' + sevColor + ';">' +
|
||||
'<strong style="font-size: 0.9rem;">' + ins.title + '</strong><br>' +
|
||||
'<span style="font-size: 0.85rem; color: var(--muted);">' + ins.plain + '</span></div>';
|
||||
});
|
||||
insightsDiv.innerHTML = insightsHtml;
|
||||
|
||||
// Summary stats
|
||||
var s = data.summary;
|
||||
summaryDiv.innerHTML =
|
||||
statCard('Requests', s.totalRequests) +
|
||||
statCard('Unique IPs', s.uniqueIPs) +
|
||||
statCard('Security Events', s.securityEvents) +
|
||||
statCard('Failed Actions', s.failedActions);
|
||||
|
||||
// Top IPs table
|
||||
var ips = data.topIPs || [];
|
||||
if (ips.length === 0) {
|
||||
ipsDiv.innerHTML = '<div class="panel-empty">No activity in this period.</div>';
|
||||
} else {
|
||||
var html = '<table style="width: 100%; font-size: 0.85rem; border-collapse: collapse;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border);"><th style="text-align:left; padding: 6px;">IP Address</th><th style="text-align:right; padding: 6px;">Requests</th><th style="text-align:right; padding: 6px;">Failures</th><th style="text-align:left; padding: 6px;">Top Actions</th><th style="text-align:left; padding: 6px;">Last Seen</th></tr>';
|
||||
ips.forEach(function(ip) {
|
||||
var failStyle = ip.failures > 0 ? 'color: var(--bad-fg, #ff6b6b); font-weight: 600;' : '';
|
||||
var actions = (ip.topActions || []).map(function(a) { return a[0]; }).join(', ');
|
||||
var lastSeen = ip.lastSeen ? new Date(ip.lastSeen).toLocaleString() : '?';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border);">' +
|
||||
'<td style="padding: 6px; font-family: monospace;">' + ip.ip + '</td>' +
|
||||
'<td style="padding: 6px; text-align: right;">' + ip.count + '</td>' +
|
||||
'<td style="padding: 6px; text-align: right; ' + failStyle + '">' + ip.failures + '</td>' +
|
||||
'<td style="padding: 6px;">' + actions + '</td>' +
|
||||
'<td style="padding: 6px; color: var(--muted);">' + lastSeen + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
ipsDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// Storage info
|
||||
var st = data.storage || {};
|
||||
var stHtml = '<strong style="font-size: 0.85rem;">Log Storage</strong><br>';
|
||||
if (st.auditLog) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Audit log: ' + st.auditLog.sizeMB + ' MB (' + st.auditLog.entries + ' entries)</span><br>';
|
||||
if (st.securityEvents) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Security events: ' + st.securityEvents.sizeMB + ' MB (' + st.securityEvents.entries + ' entries)</span>';
|
||||
storageDiv.innerHTML = stHtml;
|
||||
|
||||
} catch (e) {
|
||||
insightsDiv.innerHTML = '<div class="panel-empty">Failed to load: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function statCard(label, value) {
|
||||
return '<div style="text-align: center; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
|
||||
'<div style="font-size: 1.5rem; font-weight: 700;">' + value + '</div>' +
|
||||
'<div style="font-size: 0.75rem; color: var(--muted);">' + label + '</div></div>';
|
||||
}
|
||||
|
||||
async function showDisposePreview() {
|
||||
var keepDays = prompt('Delete logs older than how many days?', '30');
|
||||
if (!keepDays) return;
|
||||
keepDays = parseInt(keepDays);
|
||||
if (isNaN(keepDays) || keepDays < 1) { alert('Invalid number'); return; }
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/v1/log-insights/dispose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keepDays: keepDays })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (!data.success) { alert('Error: ' + data.error); return; }
|
||||
|
||||
var msg = data.message + '\n\n' +
|
||||
'Audit entries to delete: ' + data.wouldDelete.auditEntries + '\n' +
|
||||
'Security events to delete: ' + data.wouldDelete.securityEvents + '\n\n' +
|
||||
'Click OK to confirm deletion.';
|
||||
if (confirm(msg)) {
|
||||
await executeDispose(keepDays);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDispose(keepDays) {
|
||||
try {
|
||||
var res = await fetch('/api/v1/log-insights/dispose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keepDays: keepDays, confirm: true })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (!data.success) { alert('Error: ' + data.error); return; }
|
||||
|
||||
alert('Cleaned up!\n\nDeleted: ' + data.deleted.auditEntries + ' audit entries, ' + data.deleted.securityEvents + ' security events.\nRemaining: ' + data.remaining.auditEntries + ' audit, ' + data.remaining.securityEvents + ' security.');
|
||||
loadInsights();
|
||||
} catch (e) {
|
||||
alert('Failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function injectModal(id, html) {
|
||||
if (document.getElementById(id)) return;
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
document.body.appendChild(div.firstElementChild);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user