Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa09dcebe | ||
|
|
264de9644c | ||
|
|
e40cb35011 | ||
|
|
7485772427 | ||
|
|
e5d7da6edd | ||
|
|
28f0fa3c10 | ||
|
|
eee32c1eae | ||
|
|
37a3282f98 | ||
|
|
1fbe65f524 | ||
|
|
320f21c113 | ||
|
|
5c76c3df97 | ||
|
|
260575c6bd |
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,26 @@ const lockfile = require('proper-lockfile');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, 'credentials.json');
|
// Resolve credentials file path — supports both standard install (/app/credentials.json)
|
||||||
|
// and custom deployments with consolidated data directory (/app/data/credentials.json)
|
||||||
|
function resolveCredentialsFile() {
|
||||||
|
if (process.env.CREDENTIALS_FILE) {
|
||||||
|
return process.env.CREDENTIALS_FILE;
|
||||||
|
}
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, 'credentials.json'),
|
||||||
|
path.join(__dirname, 'data', 'credentials.json'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No existing file — return standard path so first store() creates it there
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREDENTIALS_FILE = resolveCredentialsFile();
|
||||||
|
|
||||||
class CredentialManager {
|
class CredentialManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -15,8 +15,26 @@ const IV_LENGTH = 16; // 128 bits for GCM
|
|||||||
const AUTH_TAG_LENGTH = 16;
|
const AUTH_TAG_LENGTH = 16;
|
||||||
const SALT_LENGTH = 32;
|
const SALT_LENGTH = 32;
|
||||||
|
|
||||||
// Key file location (should be outside of mounted volumes for security)
|
// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
|
||||||
const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.encryption-key');
|
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
|
||||||
|
function resolveKeyFile() {
|
||||||
|
if (process.env.ENCRYPTION_KEY_FILE) {
|
||||||
|
return process.env.ENCRYPTION_KEY_FILE;
|
||||||
|
}
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, '.encryption-key'),
|
||||||
|
path.join(__dirname, 'data', '.encryption-key'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No existing file — return standard path so first load creates it there
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_FILE = resolveKeyFile();
|
||||||
|
|
||||||
let encryptionKey = null;
|
let encryptionKey = null;
|
||||||
|
|
||||||
|
|||||||
@@ -277,9 +277,32 @@ module.exports = function configureMiddleware(app, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Public routes (bypass TOTP and JWT auth) ──
|
// ── 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 = [
|
const PUBLIC_ROUTES = [
|
||||||
{ path: '/health', exact: true },
|
{ path: '/health', exact: true },
|
||||||
|
{ path: '/health/live', exact: true },
|
||||||
|
{ path: '/health/ready', exact: true },
|
||||||
{ path: '/api/v1/health', 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: '/probe/', prefix: true },
|
||||||
{ path: '/api/v1/tailscale/', prefix: true },
|
{ path: '/api/v1/tailscale/', prefix: true },
|
||||||
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
||||||
@@ -305,6 +328,12 @@ module.exports = function configureMiddleware(app, {
|
|||||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
{ 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) {
|
function isPublicRoute(req) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashcaddy-api",
|
"name": "dashcaddy-api",
|
||||||
"version": "1.11.0",
|
"version": "1.13.1",
|
||||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// All paths can be overridden via environment variables.
|
// All paths can be overridden via environment variables.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
// Base directories
|
// Base directories
|
||||||
@@ -34,6 +35,8 @@ const paths = {
|
|||||||
caCertDir: path.join(CADDY_SITES, 'ca'),
|
caCertDir: path.join(CADDY_SITES, 'ca'),
|
||||||
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
|
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
|
||||||
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
|
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
|
||||||
|
generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'),
|
||||||
|
pkiDir: CADDY_PKI,
|
||||||
|
|
||||||
// Static site base path
|
// Static site base path
|
||||||
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
|
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
|
||||||
@@ -41,6 +44,24 @@ const paths = {
|
|||||||
// Docker data path for app volumes
|
// Docker data path for app volumes
|
||||||
appData: (appName) => path.join(DOCKER_DATA, appName),
|
appData: (appName) => path.join(DOCKER_DATA, appName),
|
||||||
|
|
||||||
|
// In-container paths (used by self-updater and Docker deployments)
|
||||||
|
// Override via env vars for custom Docker layouts
|
||||||
|
containerUpdatesDir: process.env.DASHCADDY_UPDATES_DIR || '/app/updates',
|
||||||
|
containerFrontendDir: process.env.DASHCADDY_FRONTEND_DIR || '/app/dashboard',
|
||||||
|
containerAssetsDir: process.env.ASSETS_DIR || '/app/assets',
|
||||||
|
|
||||||
|
// Asset path resolution — supports both Docker (single file mount) and
|
||||||
|
// consolidated data directory layouts
|
||||||
|
resolveAssetsPath: (envPath) => {
|
||||||
|
if (envPath) return envPath;
|
||||||
|
// Standard Docker mount: /app/assets (volume-mounted)
|
||||||
|
if (fs.existsSync('/app/assets')) return '/app/assets';
|
||||||
|
// Consolidated data directory: /app/data/assets
|
||||||
|
if (fs.existsSync(path.join(CADDY_BASE, 'assets'))) return path.join(CADDY_BASE, 'assets');
|
||||||
|
// Fall back to /app/assets even if it doesn't exist (will create on write)
|
||||||
|
return '/app/assets';
|
||||||
|
},
|
||||||
|
|
||||||
// Log digest directory
|
// Log digest directory
|
||||||
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
|
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,23 @@ const server = http.createServer(async (req, res) => {
|
|||||||
json(res, 404, { error: 'Not found' });
|
json(res, 404, { error: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842;
|
||||||
console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`);
|
const PYLON_HOST = process.env.PYLON_HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
server.listen(PYLON_PORT, PYLON_HOST, () => {
|
||||||
|
console.log(`[Pylon] ${PYLON_NAME} listening on ${PYLON_HOST}:${PYLON_PORT}`);
|
||||||
if (API_KEY) console.log('[Pylon] API key authentication enabled');
|
if (API_KEY) console.log('[Pylon] API key authentication enabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown — drain connections, then exit
|
||||||
|
const shutdown = (signal) => {
|
||||||
|
console.log(`[Pylon] ${signal} received, draining...`);
|
||||||
|
server.close(() => {
|
||||||
|
console.log('[Pylon] HTTP server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
// Force exit after 5s if connections don't drain
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|||||||
@@ -197,8 +197,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = await docker.client.createContainer(containerConfig);
|
let container;
|
||||||
await container.start();
|
try {
|
||||||
|
container = await docker.client.createContainer(containerConfig);
|
||||||
|
await container.start();
|
||||||
|
} catch (createErr) {
|
||||||
|
// If create fails with "no such image", wrap with user-friendly message
|
||||||
|
const errMsg = createErr?.message || String(createErr);
|
||||||
|
if (errMsg.includes('No such image') || errMsg.includes('no such image')) {
|
||||||
|
throw new Error(`[DC-201] Image pull succeeded but container creation failed — image may be corrupted: ${processedTemplate.docker.image}. ${errMsg}`);
|
||||||
|
}
|
||||||
|
throw createErr;
|
||||||
|
}
|
||||||
|
|
||||||
// Prune dangling images to prevent disk bloat
|
// Prune dangling images to prevent disk bloat
|
||||||
try {
|
try {
|
||||||
|
|||||||
+10
-16
@@ -12,14 +12,11 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// Get CA certificate information
|
// Get CA certificate information
|
||||||
router.get('/info', ctx.asyncHandler(async (req, res) => {
|
router.get('/info', ctx.asyncHandler(async (req, res) => {
|
||||||
const certInfoPath = '/app/ca/cert-info.json';
|
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||||
const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
||||||
|
|
||||||
let certInfoFile;
|
let certInfoFile;
|
||||||
if (await exists(certInfoPath)) {
|
if (await exists(certInfoPath)) {
|
||||||
certInfoFile = certInfoPath;
|
certInfoFile = certInfoPath;
|
||||||
} else if (await exists(fallbackCertInfoPath)) {
|
|
||||||
certInfoFile = fallbackCertInfoPath;
|
|
||||||
} else {
|
} else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../errors');
|
||||||
throw new NotFoundError('CA certificate information');
|
throw new NotFoundError('CA certificate information');
|
||||||
@@ -46,13 +43,11 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
// Serve root CA certificate directly (works even without DashCA deployed)
|
// Serve root CA certificate directly (works even without DashCA deployed)
|
||||||
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
|
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
|
||||||
const pkiCertPath = '/app/pki/root.crt';
|
|
||||||
const hostCertPath = platformPaths.pkiRootCert;
|
const hostCertPath = platformPaths.pkiRootCert;
|
||||||
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
||||||
|
|
||||||
let certPath;
|
let certPath;
|
||||||
if (await exists(pkiCertPath)) certPath = pkiCertPath;
|
if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
||||||
else if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
|
||||||
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
||||||
else {
|
else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../errors');
|
||||||
@@ -72,13 +67,12 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load cert info to get the fingerprint
|
// Load cert info to get the fingerprint
|
||||||
const certInfoPath = '/app/ca/cert-info.json';
|
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
|
||||||
const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
||||||
|
|
||||||
let certInfoFile;
|
let certInfoFile;
|
||||||
if (await exists(certInfoPath)) certInfoFile = certInfoPath;
|
if (await exists(certInfoPath)) {
|
||||||
else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2;
|
certInfoFile = certInfoPath;
|
||||||
else {
|
} else {
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../errors');
|
||||||
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
||||||
}
|
}
|
||||||
@@ -100,7 +94,7 @@ module.exports = function(ctx) {
|
|||||||
// Look for template in multiple locations (packaged app vs dev)
|
// Look for template in multiple locations (packaged app vs dev)
|
||||||
const templatePaths = [
|
const templatePaths = [
|
||||||
path.join(__dirname, '..', 'scripts', templateName),
|
path.join(__dirname, '..', 'scripts', templateName),
|
||||||
path.join('/app', 'scripts', templateName)
|
path.join(platformPaths.caddyBase, 'scripts', templateName)
|
||||||
];
|
];
|
||||||
|
|
||||||
let templateContent;
|
let templateContent;
|
||||||
@@ -142,8 +136,8 @@ module.exports = function(ctx) {
|
|||||||
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkiPath = '/app/pki';
|
const pkiPath = platformPaths.pkiDir;
|
||||||
const certsDir = '/app/generated-certs';
|
const certsDir = platformPaths.generatedCertsDir;
|
||||||
const domainDir = path.join(certsDir, domain);
|
const domainDir = path.join(certsDir, domain);
|
||||||
|
|
||||||
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
|
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
|
||||||
@@ -246,7 +240,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
|||||||
|
|
||||||
// List generated certificates
|
// List generated certificates
|
||||||
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
||||||
const certsDir = '/app/generated-certs';
|
const certsDir = platformPaths.generatedCertsDir;
|
||||||
|
|
||||||
if (!await exists(certsDir)) {
|
if (!await exists(certsDir)) {
|
||||||
return res.json({ success: true, certificates: [] });
|
return res.json({ success: true, certificates: [] });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const { LIMITS } = require('../../constants');
|
const { LIMITS } = require('../../constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../errors');
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
/**
|
/**
|
||||||
* Config assets routes factory
|
* Config assets routes factory
|
||||||
* @param {Object} deps - Explicit dependencies
|
* @param {Object} deps - Explicit dependencies
|
||||||
@@ -51,7 +52,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
// Determine assets path (mounted volume)
|
// Determine assets path (mounted volume)
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
@@ -96,7 +97,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
||||||
const buffer = Buffer.from(matches[2], 'base64');
|
const buffer = Buffer.from(matches[2], 'base64');
|
||||||
|
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
await fsp.mkdir(assetsPath, { recursive: true });
|
await fsp.mkdir(assetsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
// Reset all branding to defaults
|
// Reset all branding to defaults
|
||||||
router.delete('/logo', asyncHandler(async (req, res) => {
|
router.delete('/logo', asyncHandler(async (req, res) => {
|
||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
|
|
||||||
// Delete all custom logo files
|
// Delete all custom logo files
|
||||||
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
||||||
@@ -234,7 +235,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const base64Data = matches[2];
|
const base64Data = matches[2];
|
||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
if (!await exists(assetsPath)) {
|
if (!await exists(assetsPath)) {
|
||||||
await fsp.mkdir(assetsPath, { recursive: true });
|
await fsp.mkdir(assetsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -279,7 +280,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
|||||||
const config = await ctx.readConfig();
|
const config = await ctx.readConfig();
|
||||||
|
|
||||||
// Delete custom favicon files
|
// Delete custom favicon files
|
||||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||||
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
||||||
for (const file of filesToDelete) {
|
for (const file of filesToDelete) {
|
||||||
const filePath = `${assetsPath}/${file}`;
|
const filePath = `${assetsPath}/${file}`;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const { CADDY } = require('../../constants');
|
const { CADDY } = require('../../constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
const { ValidationError, AuthenticationError } = require('../../errors');
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config backup routes factory
|
* Config backup routes factory
|
||||||
@@ -115,7 +116,7 @@ module.exports = function(deps) {
|
|||||||
|
|
||||||
// Include custom assets (logo, favicon) as base64
|
// Include custom assets (logo, favicon) as base64
|
||||||
try {
|
try {
|
||||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||||
const configData = backup.files.config?.data || {};
|
const configData = backup.files.config?.data || {};
|
||||||
const assetFiles = [configData.customLogo, configData.customFavicon]
|
const assetFiles = [configData.customLogo, configData.customFavicon]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -346,7 +347,7 @@ module.exports = function(deps) {
|
|||||||
|
|
||||||
// Restore custom assets from base64
|
// Restore custom assets from base64
|
||||||
if (backup.assets && typeof backup.assets === 'object') {
|
if (backup.assets && typeof backup.assets === 'object') {
|
||||||
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
|
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
|
||||||
for (const [name, b64] of Object.entries(backup.assets)) {
|
for (const [name, b64] of Object.entries(backup.assets)) {
|
||||||
try {
|
try {
|
||||||
const safeName = path.basename(name); // prevent path traversal
|
const safeName = path.basename(name); // prevent path traversal
|
||||||
|
|||||||
@@ -322,9 +322,16 @@ module.exports = function({
|
|||||||
// ===== HEALTH CHECK (health-checker module) =====
|
// ===== HEALTH CHECK (health-checker module) =====
|
||||||
|
|
||||||
// Get current status for all services
|
// Get current status for all services
|
||||||
|
// Returns per-service status plus a summary for the System Overview widget:
|
||||||
|
// { status: { ... }, summary: { healthy, unhealthy, total } }
|
||||||
router.get('/health-checks/status', asyncHandler(async (req, res) => {
|
router.get('/health-checks/status', asyncHandler(async (req, res) => {
|
||||||
const status = healthChecker.getCurrentStatus();
|
const status = healthChecker.getCurrentStatus();
|
||||||
success(res, { status });
|
// Build summary for the overview widget
|
||||||
|
const entries = Object.values(status);
|
||||||
|
const healthy = entries.filter(s => s.status === 'up' || s.status === 'healthy').length;
|
||||||
|
const unhealthy = entries.filter(s => s.status === 'down' || s.status === 'unhealthy').length;
|
||||||
|
const total = entries.length;
|
||||||
|
success(res, { status, summary: { healthy, unhealthy, total } });
|
||||||
}, 'health-check-status'));
|
}, 'health-check-status'));
|
||||||
|
|
||||||
// Get service statistics
|
// Get service statistics
|
||||||
|
|||||||
@@ -16,8 +16,22 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
|||||||
// ===== RESOURCE MONITORING ENDPOINTS =====
|
// ===== RESOURCE MONITORING ENDPOINTS =====
|
||||||
|
|
||||||
// Get all container stats (from resource monitor module)
|
// Get all container stats (from resource monitor module)
|
||||||
|
// Returns a flat summary format for the System Overview widget:
|
||||||
|
// { containerId: { cpu: <percent>, memory: <percent>, memoryUsage: <bytes>, name } }
|
||||||
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
|
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
|
||||||
const stats = resourceMonitor.getAllStats();
|
const raw = resourceMonitor.getAllStats();
|
||||||
|
// Transform nested { current: { cpu: { percent }, memory: { percent, usage } } }
|
||||||
|
// into flat { cpu: number, memory: number, memoryUsage: number } for the frontend widget
|
||||||
|
const stats = {};
|
||||||
|
for (const [id, data] of Object.entries(raw)) {
|
||||||
|
const cur = data.current || {};
|
||||||
|
stats[id] = {
|
||||||
|
name: data.name,
|
||||||
|
cpu: typeof cur.cpu === 'object' ? (cur.cpu.percent ?? 0) : (Number(cur.cpu) || 0),
|
||||||
|
memory: typeof cur.memory === 'object' ? (cur.memory.percent ?? 0) : (Number(cur.memory) || 0),
|
||||||
|
memoryUsage: typeof cur.memory === 'object' ? (cur.memory.usage ?? 0) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
success(res, { stats });
|
success(res, { stats });
|
||||||
}, 'monitoring-stats'));
|
}, 'monitoring-stats'));
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { paginate, parsePaginationParams } = require('../pagination');
|
|||||||
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
|
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
|
||||||
const { resolveServiceUrl } = require('../url-resolver');
|
const { resolveServiceUrl } = require('../url-resolver');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../response-helpers');
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Services route factory
|
* Services route factory
|
||||||
@@ -46,7 +47,7 @@ module.exports = function({
|
|||||||
dns
|
dns
|
||||||
}) {
|
}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||||
const PROBE_CONCURRENCY = 6;
|
const PROBE_CONCURRENCY = 6;
|
||||||
let probeHttpsAgent;
|
let probeHttpsAgent;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../response-helpers');
|
||||||
const { ValidationError, NotFoundError } = require('../errors');
|
const { ValidationError, NotFoundError } = require('../errors');
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Themes routes factory
|
* Themes routes factory
|
||||||
@@ -13,7 +14,7 @@ const { ValidationError, NotFoundError } = require('../errors');
|
|||||||
*/
|
*/
|
||||||
module.exports = function({ asyncHandler, log }) {
|
module.exports = function({ asyncHandler, log }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
|
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(platformPaths.servicesFile), 'themes');
|
||||||
|
|
||||||
// Ensure themes directory exists
|
// Ensure themes directory exists
|
||||||
if (!fs.existsSync(THEMES_DIR)) {
|
if (!fs.existsSync(THEMES_DIR)) {
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ const isWindows = platformPaths.isWindows;
|
|||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
||||||
UPDATE_URL: 'https://get.dashcaddy.net/release',
|
UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
|
||||||
MIRROR_URL: 'https://get2.dashcaddy.net/release',
|
MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
|
||||||
UPDATES_DIR: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'updates') : '/app/updates',
|
UPDATES_DIR: platformPaths.containerUpdatesDir,
|
||||||
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
|
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
|
||||||
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
|
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
|
||||||
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
|
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
|
||||||
FRONTEND_DIR: platformPaths.isWindows ? path.join(platformPaths.caddySites, 'status') : '/app/dashboard',
|
FRONTEND_DIR: platformPaths.containerFrontendDir,
|
||||||
MAX_BACKUPS: 3,
|
MAX_BACKUPS: 3,
|
||||||
HEALTH_TIMEOUT: 60000,
|
HEALTH_TIMEOUT: 60000,
|
||||||
DOWNLOAD_TIMEOUT: 120000,
|
DOWNLOAD_TIMEOUT: 120000,
|
||||||
CHANNEL: 'stable',
|
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
|
||||||
INSTANCE_ID_FILE: platformPaths.isWindows
|
INSTANCE_ID_FILE: platformPaths.isWindows
|
||||||
? path.join(platformPaths.caddyBase, 'instance-id')
|
? path.join(platformPaths.caddyBase, 'instance-id')
|
||||||
: '/etc/dashcaddy/instance-id',
|
: '/etc/dashcaddy/instance-id',
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ process.on('uncaughtException', (error) => {
|
|||||||
// Load license
|
// Load license
|
||||||
await licenseManager.load();
|
await licenseManager.load();
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = parseInt(process.env.PORT, 10) || 3001;
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
|
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
|
||||||
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
|
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
|
||||||
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
|
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
|
||||||
@@ -43,9 +44,10 @@ process.on('uncaughtException', (error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
const server = app.listen(PORT, HOST, () => {
|
||||||
log.info('server', 'DashCaddy API server started', {
|
log.info('server', 'DashCaddy API server started', {
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
host: HOST,
|
||||||
caddyfile: CADDYFILE_PATH,
|
caddyfile: CADDYFILE_PATH,
|
||||||
caddyAdmin: CADDY_ADMIN_URL,
|
caddyAdmin: CADDY_ADMIN_URL,
|
||||||
services: SERVICES_FILE,
|
services: SERVICES_FILE,
|
||||||
|
|||||||
+118
-1
@@ -16,6 +16,7 @@ const { asyncHandler } = require('./utils/async-handler');
|
|||||||
|
|
||||||
// Managers and utilities
|
// Managers and utilities
|
||||||
const StateManager = require('../state-manager');
|
const StateManager = require('../state-manager');
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
const { LicenseManager } = require('../license-manager');
|
const { LicenseManager } = require('../license-manager');
|
||||||
const credentialManager = require('../credential-manager');
|
const credentialManager = require('../credential-manager');
|
||||||
const authManager = require('../auth-manager');
|
const authManager = require('../auth-manager');
|
||||||
@@ -96,6 +97,19 @@ const { APP } = require('../constants');
|
|||||||
async function createApp() {
|
async function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Global request timeout (default 5 minutes — covers slow Docker pulls)
|
||||||
|
// Routes that need longer can override per-request with req.setTimeout()
|
||||||
|
const REQUEST_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.setTimeout(REQUEST_TIMEOUT_MS);
|
||||||
|
res.setTimeout(REQUEST_TIMEOUT_MS);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
// Disable x-powered-by header for security (don't advertise framework)
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
// Trust first proxy (Caddy/nginx in front of us) so req.ip works correctly
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
const log = createLogger(config.LOG_LEVEL);
|
const log = createLogger(config.LOG_LEVEL);
|
||||||
|
|
||||||
@@ -111,7 +125,7 @@ async function createApp() {
|
|||||||
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
||||||
|
|
||||||
// HTTPS agent for internal CA
|
// HTTPS agent for internal CA
|
||||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
||||||
let httpsAgent;
|
let httpsAgent;
|
||||||
try {
|
try {
|
||||||
const caCert = fs.readFileSync(CA_CERT_PATH);
|
const caCert = fs.readFileSync(CA_CERT_PATH);
|
||||||
@@ -380,6 +394,29 @@ async function createApp() {
|
|||||||
// Build versioned API router
|
// Build versioned API router
|
||||||
const apiRouter = express.Router();
|
const apiRouter = express.Router();
|
||||||
|
|
||||||
|
// Version endpoint — public, no auth required
|
||||||
|
// Reads version from package.json at startup so the response always matches the running code
|
||||||
|
let appVersion = '0.0.0';
|
||||||
|
let appName = 'dashcaddy-api';
|
||||||
|
try {
|
||||||
|
const pkg = require('../package.json');
|
||||||
|
appVersion = pkg.version || appVersion;
|
||||||
|
appName = pkg.name || appName;
|
||||||
|
} catch { /* package.json unreadable — keep fallback */ }
|
||||||
|
apiRouter.get('/version', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
name: appName,
|
||||||
|
version: appVersion,
|
||||||
|
node: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
|
||||||
|
|
||||||
// Wire up notification listeners for resourceMonitor and backupManager
|
// Wire up notification listeners for resourceMonitor and backupManager
|
||||||
if (ctx.notification && ctx.resourceMonitor) {
|
if (ctx.notification && ctx.resourceMonitor) {
|
||||||
ctx.resourceMonitor.on('alert', (alertData) => {
|
ctx.resourceMonitor.on('alert', (alertData) => {
|
||||||
@@ -590,6 +627,86 @@ async function createApp() {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
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
|
// Lightweight probe endpoint
|
||||||
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
||||||
const id = req.params.id;
|
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
|
* Site configuration loader
|
||||||
* Loads and manages site-wide settings from config.json
|
* 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 { validateConfig } = require('../../config-schema');
|
||||||
const { CADDY } = require('../../constants');
|
const { CADDY } = require('../../constants');
|
||||||
|
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
|
||||||
|
|
||||||
const siteConfig = {
|
const siteConfig = {
|
||||||
tld: '.home',
|
tld: '.home',
|
||||||
@@ -21,9 +26,11 @@ const siteConfig = {
|
|||||||
|
|
||||||
function loadSiteConfig(CONFIG_FILE, log) {
|
function loadSiteConfig(CONFIG_FILE, log) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
// Run migrations first — this handles config.json files from older
|
||||||
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
// 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
|
// Validate config and log any issues
|
||||||
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
||||||
if (log && log.warn) {
|
if (log && log.warn) {
|
||||||
@@ -76,4 +83,5 @@ module.exports = {
|
|||||||
loadSiteConfig,
|
loadSiteConfig,
|
||||||
buildDomain,
|
buildDomain,
|
||||||
buildServiceUrl,
|
buildServiceUrl,
|
||||||
|
CURRENT_VERSION
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user