Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa09dcebe | ||
|
|
264de9644c | ||
|
|
e40cb35011 | ||
|
|
7485772427 | ||
|
|
e5d7da6edd | ||
|
|
28f0fa3c10 | ||
|
|
eee32c1eae |
@@ -1 +1 @@
|
||||
1.10.0
|
||||
1.13.0
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Config migration tests
|
||||
*
|
||||
* These tests verify that a config file from any older version of DashCaddy
|
||||
* gets correctly migrated to the current version. Migration MUST be:
|
||||
* - Deterministic (same input always produces same output)
|
||||
* - Idempotent (running migration on already-migrated config is a no-op)
|
||||
* - Safe (no data loss; only adds fields, never removes user values)
|
||||
* - Silent (no exceptions thrown for any version from 0 to CURRENT)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
CURRENT_VERSION,
|
||||
migrations,
|
||||
migrate,
|
||||
loadAndMigrate
|
||||
} = require('../src/config/migrations');
|
||||
|
||||
describe('config/migrations', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mig-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('migrate()', () => {
|
||||
test('null/empty config returns fresh v_current', () => {
|
||||
const result = migrate(null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('undefined config returns fresh v_current', () => {
|
||||
const result = migrate(undefined);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('v0 (no _version) migrates all the way to current', () => {
|
||||
const v0 = { tld: '.home', customValue: 'preserved' };
|
||||
const result = migrate(v0);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data must be preserved
|
||||
expect(result.tld).toBe('.home');
|
||||
expect(result.customValue).toBe('preserved');
|
||||
});
|
||||
|
||||
test('each intermediate version migrates forward to current', () => {
|
||||
for (let v = 0; v < CURRENT_VERSION; v++) {
|
||||
const config = { _version: v, tld: '.test' };
|
||||
const result = migrate(config);
|
||||
// Final version is always CURRENT_VERSION after running all migrations
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data preserved
|
||||
expect(result.tld).toBe('.test');
|
||||
}
|
||||
});
|
||||
|
||||
test('config at current version passes through unchanged', () => {
|
||||
const current = { _version: CURRENT_VERSION, tld: '.home', customField: 'kept' };
|
||||
const result = migrate(current);
|
||||
expect(result).toEqual(current);
|
||||
});
|
||||
|
||||
test('config from FUTURE version is left alone (forward compat)', () => {
|
||||
const future = { _version: 999, tld: '.home', newField: 'unknown' };
|
||||
const result = migrate(future);
|
||||
// We don't touch future configs — let validation catch issues
|
||||
expect(result._version).toBe(999);
|
||||
expect(result.newField).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0 → v1 migration: dns normalization', () => {
|
||||
test('string dns gets converted to object', () => {
|
||||
const result = migrations[1]({ dns: '192.168.1.1' });
|
||||
expect(result.dns).toEqual({ ip: '192.168.1.1', port: 5380 });
|
||||
});
|
||||
|
||||
test('missing dns gets default object', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result.dns).toEqual({ ip: '', port: 5380 });
|
||||
});
|
||||
|
||||
test('object dns passes through unchanged', () => {
|
||||
const result = migrations[1]({ dns: { ip: '10.0.0.1', port: 5380, custom: 'kept' } });
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.custom).toBe('kept');
|
||||
});
|
||||
|
||||
test('_version is set to 1', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result._version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 → v2 migration: dns.provider field', () => {
|
||||
test('adds provider: technitium default', () => {
|
||||
const result = migrations[2]({ dns: { ip: '10.0.0.1', port: 5380 }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
});
|
||||
|
||||
test('respects existing provider if set', () => {
|
||||
const result = migrations[2]({ dns: { provider: 'cloudflare', ip: 'cf' }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('cloudflare');
|
||||
});
|
||||
|
||||
test('_version is set to 2', () => {
|
||||
const result = migrations[2]({ _version: 1 });
|
||||
expect(result._version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAndMigrate()', () => {
|
||||
test('creates fresh config when file does not exist', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// Should NOT write a file when there was nothing to migrate
|
||||
expect(fs.existsSync(configFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('migrates old config and writes back to disk', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
// Write an unversioned config (v0)
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', customField: 'preserve-me' }));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
|
||||
// Returned value is migrated
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
expect(result.tld).toBe('.sami');
|
||||
expect(result.customField).toBe('preserve-me');
|
||||
|
||||
// File on disk is updated
|
||||
const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
expect(written._version).toBe(CURRENT_VERSION);
|
||||
expect(written.tld).toBe('.sami');
|
||||
});
|
||||
|
||||
test('does not rewrite file when already at current version', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const original = JSON.stringify({ _version: CURRENT_VERSION, tld: '.home' }, null, 2);
|
||||
fs.writeFileSync(configFile, original);
|
||||
|
||||
// Record mtime before
|
||||
const mtimeBefore = fs.statSync(configFile).mtimeMs;
|
||||
// Wait a tick
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 50) {} // 50ms busy-wait
|
||||
|
||||
loadAndMigrate(configFile, null);
|
||||
|
||||
// File should not have been rewritten (mtime unchanged)
|
||||
const mtimeAfter = fs.statSync(configFile).mtimeMs;
|
||||
expect(mtimeAfter).toBe(mtimeBefore);
|
||||
});
|
||||
|
||||
test('handles corrupt JSON gracefully (returns defaults, no crash)', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, '{ this is not valid json');
|
||||
|
||||
// Should not throw
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('creates parent directory if missing', () => {
|
||||
const nested = path.join(tmpDir, 'nested', 'subdir', 'config.json');
|
||||
// Pre-create parent dirs (test setup)
|
||||
fs.mkdirSync(path.dirname(nested), { recursive: true });
|
||||
fs.writeFileSync(nested, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const result = loadAndMigrate(nested, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('full chain: v0 file with string dns becomes v2 with provider', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
tld: '.sami',
|
||||
dns: '10.0.0.1'
|
||||
}));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// After full chain, dns is normalized to object AND has provider
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
test('running migration twice produces same result', () => {
|
||||
const v0 = { tld: '.home', customField: 'x' };
|
||||
const first = migrate(v0);
|
||||
const second = migrate(first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
test('loadAndMigrate is idempotent across reloads', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const first = loadAndMigrate(configFile, null);
|
||||
const second = loadAndMigrate(configFile, null);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Health endpoint tests
|
||||
*
|
||||
* Verifies:
|
||||
* - /health/live always returns 200
|
||||
* - /health/ready returns 200 with valid structure when all deps OK
|
||||
* - /health/ready returns 503 when a critical dep is down
|
||||
* - /health/ready does NOT crash with "res.status is not a function"
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock dockerode BEFORE anything else
|
||||
jest.mock('dockerode', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
ping: jest.fn().mockImplementation(() => {
|
||||
if (process.env.MOCK_DOCKER_DOWN === '1') {
|
||||
return Promise.reject(new Error('docker unreachable'));
|
||||
}
|
||||
return Promise.resolve('OK');
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
// Build a minimal Express app with the same health handlers as src/app.js
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
SERVICES_FILE: '/tmp/dc-test-services.json',
|
||||
CADDY_ADMIN_URL: 'http://localhost:2019'
|
||||
};
|
||||
|
||||
// Mock fs
|
||||
const fs = require('fs');
|
||||
const realExistsSync = fs.existsSync;
|
||||
const realReadFileSync = fs.readFileSync;
|
||||
fs.existsSync = (p) => {
|
||||
if (p === config.CONFIG_FILE) return configOk;
|
||||
if (p === config.SERVICES_FILE) return servicesOk;
|
||||
return realExistsSync(p);
|
||||
};
|
||||
fs.readFileSync = (p, ...args) => {
|
||||
if (p === config.CONFIG_FILE) {
|
||||
if (!configOk) throw new Error('config not found');
|
||||
return '{}';
|
||||
}
|
||||
if (p === config.SERVICES_FILE) {
|
||||
if (!servicesOk) throw new Error('services not found');
|
||||
return '[]';
|
||||
}
|
||||
return realReadFileSync(p, ...args);
|
||||
};
|
||||
|
||||
// /health/live (matches src/app.js exactly)
|
||||
app.get('/health/live', (req, res) => {
|
||||
res.json({ status: 'alive', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern)
|
||||
const { asyncHandler } = require('../src/utils/async-handler');
|
||||
const logError = async () => {}; // noop logger
|
||||
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
|
||||
|
||||
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
let allOk = true;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(config.CONFIG_FILE)) {
|
||||
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
||||
checks.configFile = { ok: true };
|
||||
} else {
|
||||
checks.configFile = { ok: false, error: 'Config file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.configFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(config.SERVICES_FILE)) {
|
||||
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
||||
checks.servicesFile = { ok: true };
|
||||
} else {
|
||||
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.servicesFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const docker = require('dockerode')();
|
||||
await docker.ping();
|
||||
checks.docker = { ok: true };
|
||||
} catch (e) {
|
||||
checks.docker = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
checks.caddy = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
const body = {
|
||||
status: allOk ? 'ready' : 'not-ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks
|
||||
};
|
||||
res.status(allOk ? 200 : 503).json(body);
|
||||
}));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Health Endpoints', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.MOCK_DOCKER_DOWN;
|
||||
});
|
||||
|
||||
describe('GET /health/live', () => {
|
||||
it('always returns 200 with status: alive', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/live');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('alive');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
});
|
||||
|
||||
it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => {
|
||||
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false });
|
||||
const res = await request(app).get('/health/live');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /health/ready', () => {
|
||||
it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/ready');
|
||||
// config + services + docker should all be OK
|
||||
expect(res.body.checks.configFile.ok).toBe(true);
|
||||
expect(res.body.checks.servicesFile.ok).toBe(true);
|
||||
expect(res.body.checks.docker.ok).toBe(true);
|
||||
// caddy is tested in sandbox — may be 403 or 200
|
||||
expect(res.body).toHaveProperty('checks');
|
||||
expect(res.body).toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('returns 503 when config file is missing', async () => {
|
||||
const app = buildApp({ configOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.status).toBe('not-ready');
|
||||
expect(res.body.checks.configFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 503 when services file is missing', async () => {
|
||||
const app = buildApp({ servicesOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.checks.servicesFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 503 when Docker is unreachable', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.checks.docker.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT crash with "res.status is not a function" when dependencies fail', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
const bodyStr = JSON.stringify(res.body);
|
||||
expect(bodyStr).not.toMatch(/res\.status is not a function/);
|
||||
// Should always be a valid response object
|
||||
expect(res.body).toHaveProperty('checks');
|
||||
});
|
||||
|
||||
it('responds with all 4 expected check keys', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -277,9 +277,32 @@ module.exports = function configureMiddleware(app, {
|
||||
}
|
||||
|
||||
// ── Public routes (bypass TOTP and JWT auth) ──
|
||||
// Routes here are accessible without authentication. By default the
|
||||
// monitoring/health-check endpoints are public so the dashboard can
|
||||
// render widgets before the user logs in. Set MONITORING_PUBLIC=false
|
||||
// (env var) or `monitoring: { public: false }` (config.json) to require
|
||||
// auth for these — useful for internet-exposed deployments where
|
||||
// CPU/memory/disk data is sensitive.
|
||||
const MONITORING_PUBLIC = (() => {
|
||||
if (process.env.MONITORING_PUBLIC === 'false') return false;
|
||||
if (process.env.MONITORING_PUBLIC === 'true') return true;
|
||||
// Default: check config.json if loaded
|
||||
try {
|
||||
const cfg = require('./src/config/site').siteConfig;
|
||||
if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') {
|
||||
return cfg.monitoring.public;
|
||||
}
|
||||
} catch { /* config not loaded yet, use default */ }
|
||||
return true; // default: public (current behavior, dashboard needs it)
|
||||
})();
|
||||
|
||||
const PUBLIC_ROUTES = [
|
||||
{ path: '/health', exact: true },
|
||||
{ path: '/health/live', exact: true },
|
||||
{ path: '/health/ready', exact: true },
|
||||
{ path: '/api/v1/health', exact: true },
|
||||
{ path: '/api/v1/health/live', exact: true },
|
||||
{ path: '/api/v1/health/ready', exact: true },
|
||||
{ path: '/probe/', prefix: true },
|
||||
{ path: '/api/v1/tailscale/', prefix: true },
|
||||
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
||||
@@ -305,8 +328,12 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
// Monitoring endpoints — only public if MONITORING_PUBLIC is true
|
||||
...(MONITORING_PUBLIC ? [
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
] : []),
|
||||
{ path: '/api/v1/version', exact: true, method: 'GET' },
|
||||
];
|
||||
|
||||
function isPublicRoute(req) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.1",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,7 @@ const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
|
||||
const { resolveServiceUrl } = require('../url-resolver');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
/**
|
||||
* Services route factory
|
||||
|
||||
@@ -627,6 +627,86 @@ async function createApp() {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Liveness probe — "is the process alive?"
|
||||
// Always returns 200 unless the Node.js event loop is completely blocked.
|
||||
// Used by k8s/Docker to decide whether to RESTART the container.
|
||||
// DO NOT add dependency checks here — those belong in /health/ready.
|
||||
app.get('/health/live', (req, res) => {
|
||||
res.json({ status: 'alive', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// Readiness probe — "is the app ready to serve traffic?"
|
||||
// Checks critical dependencies: Docker daemon, Caddy admin API, config file.
|
||||
// Returns 200 with details if all OK, 503 with failed components otherwise.
|
||||
// Used by k8s/Docker to decide whether to ROUTE TRAFFIC to this instance.
|
||||
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
let allOk = true;
|
||||
|
||||
// Check 1: Config file readable
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(config.CONFIG_FILE)) {
|
||||
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
||||
checks.configFile = { ok: true };
|
||||
} else {
|
||||
checks.configFile = { ok: false, error: 'Config file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.configFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// Check 2: Services file readable
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(config.SERVICES_FILE)) {
|
||||
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
||||
checks.servicesFile = { ok: true };
|
||||
} else {
|
||||
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.servicesFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// Check 3: Docker daemon reachable
|
||||
try {
|
||||
const docker = require('dockerode')();
|
||||
await docker.ping();
|
||||
checks.docker = { ok: true };
|
||||
} catch (e) {
|
||||
checks.docker = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// Check 4: Caddy admin API reachable
|
||||
try {
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`${caddyUrl}/config/`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
checks.caddy = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
const body = {
|
||||
status: allOk ? 'ready' : 'not-ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks
|
||||
};
|
||||
res.status(allOk ? 200 : 503).json(body);
|
||||
}));
|
||||
|
||||
// Lightweight probe endpoint
|
||||
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
||||
const id = req.params.id;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Config migration system
|
||||
*
|
||||
* When config.json schema changes between versions, register a migration
|
||||
* function here. On load, the loader detects the stored version, runs all
|
||||
* migrations from that version forward, and writes the result back.
|
||||
*
|
||||
* Migration format:
|
||||
* migrations[<toVersion>] = (rawConfig) => { ...mutations, _version: toVersion }
|
||||
*
|
||||
* Each migration is responsible for transforming the previous version's
|
||||
* shape into the next version's shape. They run sequentially, so v1→v2→v3
|
||||
* all execute in order.
|
||||
*
|
||||
* For first-time users with no config file, the loader creates a fresh
|
||||
* config with CURRENT_VERSION, so they start at the latest schema.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const CURRENT_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Migrations: keys are the version they PRODUCE.
|
||||
* Each migration takes a raw config object and returns the next version.
|
||||
*/
|
||||
const migrations = {
|
||||
// v0 (unversioned) → v1: add _version field, normalize dns structure
|
||||
1: (raw) => {
|
||||
const migrated = { ...raw };
|
||||
if (!migrated._version) migrated._version = 1;
|
||||
// Normalize: older configs may have dns as a string IP, convert to object
|
||||
if (typeof migrated.dns === 'string') {
|
||||
migrated.dns = { ip: migrated.dns, port: 5380 };
|
||||
} else if (!migrated.dns) {
|
||||
migrated.dns = { ip: '', port: 5380 };
|
||||
}
|
||||
return migrated;
|
||||
},
|
||||
|
||||
// v1 → v2: add dns.provider field (default: 'technitium' for backwards compat)
|
||||
2: (raw) => {
|
||||
const migrated = { ...raw };
|
||||
if (migrated.dns && !migrated.dns.provider) {
|
||||
migrated.dns.provider = 'technitium';
|
||||
}
|
||||
migrated._version = 2;
|
||||
return migrated;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION.
|
||||
* @param {object} raw - The raw config object (may or may not have _version)
|
||||
* @returns {object} The migrated config
|
||||
*/
|
||||
function migrate(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
// First-time load: return minimal config at current version
|
||||
return { _version: CURRENT_VERSION };
|
||||
}
|
||||
|
||||
const fromVersion = raw._version || 0;
|
||||
if (fromVersion > CURRENT_VERSION) {
|
||||
// Config from a future version — bail out, don't corrupt it
|
||||
// The validation step will catch any actual issues
|
||||
return raw;
|
||||
}
|
||||
|
||||
let current = { ...raw };
|
||||
for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) {
|
||||
if (migrations[v]) {
|
||||
current = migrations[v](current);
|
||||
} else {
|
||||
// No migration defined for this version, just bump _version
|
||||
current._version = v;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from disk, run migrations if needed, and write back the
|
||||
* migrated version. Safe to call on every startup.
|
||||
* @param {string} configFile - Absolute path to config.json
|
||||
* @param {object} log - Logger instance
|
||||
* @returns {object} The migrated config object
|
||||
*/
|
||||
function loadAndMigrate(configFile, log) {
|
||||
let raw = null;
|
||||
let fileExisted = false;
|
||||
|
||||
if (fs.existsSync(configFile)) {
|
||||
fileExisted = true;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
|
||||
}
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
|
||||
const fromVersion = raw && raw._version ? raw._version : 0;
|
||||
const migrated = migrate(raw);
|
||||
|
||||
// Only write back to disk if:
|
||||
// 1. The file already existed (we don't create configs on fresh installs —
|
||||
// the loader's defaults handle that case), AND
|
||||
// 2. The version actually changed (no point rewriting identical content)
|
||||
if (fileExisted && fromVersion < CURRENT_VERSION) {
|
||||
if (log && log.info) {
|
||||
log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, {
|
||||
from: fromVersion,
|
||||
to: CURRENT_VERSION,
|
||||
path: configFile
|
||||
});
|
||||
}
|
||||
// Write back the migrated config
|
||||
try {
|
||||
// Ensure parent dir exists
|
||||
const dir = path.dirname(configFile);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2));
|
||||
} catch (e) {
|
||||
if (log && log.warn) {
|
||||
log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CURRENT_VERSION,
|
||||
migrations,
|
||||
migrate,
|
||||
loadAndMigrate
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* Site configuration loader
|
||||
* Loads and manages site-wide settings from config.json
|
||||
*
|
||||
* Includes automatic migration from older config versions (see migrations.js).
|
||||
* Users never see the migration — it runs silently on startup, writes the
|
||||
* updated config back, and the rest of the app only ever sees the current
|
||||
* schema.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const { validateConfig } = require('../../config-schema');
|
||||
const { CADDY } = require('../../constants');
|
||||
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
|
||||
|
||||
const siteConfig = {
|
||||
tld: '.home',
|
||||
@@ -21,9 +26,11 @@ const siteConfig = {
|
||||
|
||||
function loadSiteConfig(CONFIG_FILE, log) {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
// Run migrations first — this handles config.json files from older
|
||||
// versions of DashCaddy and writes the migrated version back to disk.
|
||||
const raw = loadAndMigrate(CONFIG_FILE, log);
|
||||
|
||||
if (raw && Object.keys(raw).length > 0) {
|
||||
// Validate config and log any issues
|
||||
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
||||
if (log && log.warn) {
|
||||
@@ -76,4 +83,5 @@ module.exports = {
|
||||
loadSiteConfig,
|
||||
buildDomain,
|
||||
buildServiceUrl,
|
||||
CURRENT_VERSION
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user