Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d394d882d | ||
|
|
11cfb8c26a | ||
|
|
caa09dcebe | ||
|
|
264de9644c | ||
|
|
e40cb35011 | ||
|
|
7485772427 | ||
|
|
e5d7da6edd | ||
|
|
28f0fa3c10 | ||
|
|
eee32c1eae | ||
|
|
37a3282f98 | ||
|
|
1fbe65f524 | ||
|
|
320f21c113 | ||
|
|
5c76c3df97 | ||
|
|
260575c6bd | ||
|
|
e361d9a328 | ||
|
|
aa25bcc053 | ||
|
|
bda08b592e | ||
|
|
0e408974a0 | ||
|
|
f4b35dcc30 | ||
|
|
1c0d765182 | ||
|
|
2cd62208ac | ||
|
|
7557a6364a | ||
|
|
54c4b049a8 |
@@ -11,6 +11,7 @@ RUN npm install --production
|
|||||||
COPY *.js ./
|
COPY *.js ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY routes/ ./routes/
|
COPY routes/ ./routes/
|
||||||
|
COPY dns-providers/ ./dns-providers/
|
||||||
COPY openapi.yaml ./
|
COPY openapi.yaml ./
|
||||||
|
|
||||||
# VERSION file holds the short git SHA the image was built from. Committed as
|
# VERSION file holds the short git SHA the image was built from. Committed as
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
jest.mock('../error-logger', () => ({
|
// Mock the unified logging module so we can verify logError is called
|
||||||
logError: jest.fn(),
|
// without writing to the actual error.log file
|
||||||
|
jest.mock('../src/utils/logging', () => ({
|
||||||
|
logError: jest.fn().mockResolvedValue(),
|
||||||
|
safeErrorMessage: jest.fn((err) => {
|
||||||
|
if (!err) return 'An internal error occurred';
|
||||||
|
return err.message || String(err);
|
||||||
|
}),
|
||||||
|
createLogger: jest.fn(() => ({
|
||||||
|
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn()
|
||||||
|
})),
|
||||||
|
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler');
|
const { errorMiddleware, notFoundHandler } = require('../error-handler');
|
||||||
const {
|
const {
|
||||||
AppError,
|
AppError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -30,23 +40,6 @@ describe('Error Handler', () => {
|
|||||||
next = jest.fn();
|
next = jest.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('asyncHandler', () => {
|
|
||||||
it('calls the wrapped function', async () => {
|
|
||||||
const fn = jest.fn().mockResolvedValue();
|
|
||||||
const wrapped = asyncHandler(fn);
|
|
||||||
await wrapped(req, res, next);
|
|
||||||
expect(fn).toHaveBeenCalledWith(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls next(err) on rejected promise', async () => {
|
|
||||||
const error = new Error('async fail');
|
|
||||||
const fn = jest.fn().mockRejectedValue(error);
|
|
||||||
const wrapped = asyncHandler(fn);
|
|
||||||
await wrapped(req, res, next);
|
|
||||||
expect(next).toHaveBeenCalledWith(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('errorMiddleware', () => {
|
describe('errorMiddleware', () => {
|
||||||
it('returns 400 for ValidationError', () => {
|
it('returns 400 for ValidationError', () => {
|
||||||
const err = new ValidationError('bad input', 'email');
|
const err = new ValidationError('bad input', 'email');
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -538,7 +538,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('healthy');
|
expect(res.body.caStatus).toBe('healthy');
|
||||||
expect(res.body.daysUntilExpiration).toBeGreaterThan(90);
|
expect(res.body.daysUntilExpiration).toBeGreaterThan(90);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,7 +551,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('warning');
|
expect(res.body.caStatus).toBe('warning');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(90);
|
expect(res.body.daysUntilExpiration).toBeLessThan(90);
|
||||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30);
|
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30);
|
||||||
});
|
});
|
||||||
@@ -565,7 +565,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('critical');
|
expect(res.body.caStatus).toBe('critical');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(30);
|
expect(res.body.daysUntilExpiration).toBeLessThan(30);
|
||||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0);
|
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
@@ -579,7 +579,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('critical');
|
expect(res.body.caStatus).toBe('critical');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(7);
|
expect(res.body.daysUntilExpiration).toBeLessThan(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -592,7 +592,7 @@ describe('Health Routes', () => {
|
|||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.status).toBe('critical');
|
expect(res.body.caStatus).toBe('critical');
|
||||||
expect(res.body.daysUntilExpiration).toBeLessThan(0);
|
expect(res.body.daysUntilExpiration).toBeLessThan(0);
|
||||||
expect(res.body.message).toMatch(/EXPIRED/);
|
expect(res.body.message).toMatch(/EXPIRED/);
|
||||||
});
|
});
|
||||||
@@ -601,9 +601,9 @@ describe('Health Routes', () => {
|
|||||||
exists.mockResolvedValue(false);
|
exists.mockResolvedValue(false);
|
||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body.status).toBe('error');
|
expect(res.body.caStatus).toBe('error');
|
||||||
expect(res.body.message).toMatch(/not found/);
|
expect(res.body.error).toMatch(/not found/);
|
||||||
expect(res.body.daysUntilExpiration).toBeNull();
|
expect(res.body.daysUntilExpiration).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -612,9 +612,9 @@ describe('Health Routes', () => {
|
|||||||
execSync.mockImplementation(() => { throw new Error('openssl not found'); });
|
execSync.mockImplementation(() => { throw new Error('openssl not found'); });
|
||||||
const { app } = createApp();
|
const { app } = createApp();
|
||||||
const res = await request(app).get('/api/health/ca');
|
const res = await request(app).get('/api/health/ca');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.status).toBe('error');
|
expect(res.body.caStatus).toBe('error');
|
||||||
expect(res.body.message).toBe('openssl not found');
|
expect(res.body.error).toBe('openssl not found');
|
||||||
expect(res.body.daysUntilExpiration).toBeNull();
|
expect(res.body.daysUntilExpiration).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jest.mock('../../pagination', () => ({
|
|||||||
parsePaginationParams: jest.fn(() => null),
|
parsePaginationParams: jest.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../response-helpers', () => ({
|
jest.mock('../../src/utils/responses', () => ({
|
||||||
success: jest.fn((res, data, statusCode = 200) => {
|
success: jest.fn((res, data, statusCode = 200) => {
|
||||||
return res.status(statusCode).json({ success: true, ...data });
|
return res.status(statusCode).json({ success: true, ...data });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -342,7 +342,8 @@ const APP_TEMPLATES = {
|
|||||||
volumes: [
|
volumes: [
|
||||||
"/var/run/docker.sock:/var/run/docker.sock",
|
"/var/run/docker.sock:/var/run/docker.sock",
|
||||||
"/opt/portainer/data:/data"
|
"/opt/portainer/data:/data"
|
||||||
]
|
],
|
||||||
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "portainer",
|
subdomain: "portainer",
|
||||||
defaultPort: 9000,
|
defaultPort: 9000,
|
||||||
@@ -393,7 +394,8 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "louislam/uptime-kuma:latest",
|
image: "louislam/uptime-kuma:latest",
|
||||||
ports: ["{{PORT}}:3001"],
|
ports: ["{{PORT}}:3001"],
|
||||||
volumes: ["/opt/uptime-kuma:/app/data"]
|
volumes: ["/opt/uptime-kuma:/app/data"],
|
||||||
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "uptime",
|
subdomain: "uptime",
|
||||||
defaultPort: 3002,
|
defaultPort: 3002,
|
||||||
@@ -549,7 +551,7 @@ const APP_TEMPLATES = {
|
|||||||
},
|
},
|
||||||
subdomain: "dns2",
|
subdomain: "dns2",
|
||||||
defaultPort: 953,
|
defaultPort: 953,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:53",
|
||||||
subpathSupport: 'strip',
|
subpathSupport: 'strip',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Configure zone files in /opt/bind9/config/",
|
"Configure zone files in /opt/bind9/config/",
|
||||||
@@ -640,14 +642,14 @@ const APP_TEMPLATES = {
|
|||||||
],
|
],
|
||||||
docker: {
|
docker: {
|
||||||
image: "coredns/coredns:latest",
|
image: "coredns/coredns:latest",
|
||||||
ports: ["53:53", "53:53/udp"],
|
ports: ["{{PORT}}:53", "53:53", "53:53/udp"],
|
||||||
volumes: ["/opt/coredns/config:/etc/coredns"],
|
volumes: ["/opt/coredns/config:/etc/coredns"],
|
||||||
environment: {},
|
environment: {},
|
||||||
command: ["-conf", "/etc/coredns/Corefile"]
|
command: ["-conf", "/etc/coredns/Corefile"]
|
||||||
},
|
},
|
||||||
subdomain: "dns4",
|
subdomain: "dns4",
|
||||||
defaultPort: 53,
|
defaultPort: 53,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:53",
|
||||||
subpathSupport: 'strip',
|
subpathSupport: 'strip',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Create Corefile in /opt/coredns/config/",
|
"Create Corefile in /opt/coredns/config/",
|
||||||
@@ -1007,7 +1009,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "adminer:latest",
|
image: "adminer:latest",
|
||||||
ports: ["{{PORT}}:8080"],
|
ports: ["{{PORT}}:8080"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/adminer:/var/www/html"
|
||||||
|
],
|
||||||
environment: {
|
environment: {
|
||||||
"ADMINER_DEFAULT_SERVER": "postgres"
|
"ADMINER_DEFAULT_SERVER": "postgres"
|
||||||
}
|
}
|
||||||
@@ -1099,6 +1103,7 @@ const APP_TEMPLATES = {
|
|||||||
popularity: 85,
|
popularity: 85,
|
||||||
difficulty: "Easy",
|
difficulty: "Easy",
|
||||||
isDashboardWidget: true,
|
isDashboardWidget: true,
|
||||||
|
isStaticSite: true,
|
||||||
widgetSelector: ".weather-widget-container",
|
widgetSelector: ".weather-widget-container",
|
||||||
subdomain: null,
|
subdomain: null,
|
||||||
defaultPort: null,
|
defaultPort: null,
|
||||||
@@ -1126,6 +1131,7 @@ const APP_TEMPLATES = {
|
|||||||
popularity: 80,
|
popularity: 80,
|
||||||
difficulty: "Easy",
|
difficulty: "Easy",
|
||||||
isDashboardWidget: true,
|
isDashboardWidget: true,
|
||||||
|
isStaticSite: true,
|
||||||
widgetSelector: ".clock-widget-container",
|
widgetSelector: ".clock-widget-container",
|
||||||
subdomain: null,
|
subdomain: null,
|
||||||
defaultPort: null,
|
defaultPort: null,
|
||||||
@@ -1908,7 +1914,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "traefik/whoami:latest",
|
image: "traefik/whoami:latest",
|
||||||
ports: ["{{PORT}}:80"],
|
ports: ["{{PORT}}:80"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/whoami/config:/config"
|
||||||
|
],
|
||||||
environment: {}
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "whoami",
|
subdomain: "whoami",
|
||||||
@@ -2233,7 +2241,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "excalidraw/excalidraw:latest",
|
image: "excalidraw/excalidraw:latest",
|
||||||
ports: ["{{PORT}}:80"],
|
ports: ["{{PORT}}:80"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/excalidraw/data:/var/lib/excalidraw"
|
||||||
|
],
|
||||||
environment: {}
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "draw",
|
subdomain: "draw",
|
||||||
@@ -2258,7 +2268,9 @@ const APP_TEMPLATES = {
|
|||||||
docker: {
|
docker: {
|
||||||
image: "corentinth/it-tools:latest",
|
image: "corentinth/it-tools:latest",
|
||||||
ports: ["{{PORT}}:80"],
|
ports: ["{{PORT}}:80"],
|
||||||
volumes: [],
|
volumes: [
|
||||||
|
"/opt/it-tools/config:/config"
|
||||||
|
],
|
||||||
environment: {}
|
environment: {}
|
||||||
},
|
},
|
||||||
subdomain: "tools",
|
subdomain: "tools",
|
||||||
@@ -2417,7 +2429,7 @@ const APP_TEMPLATES = {
|
|||||||
},
|
},
|
||||||
subdomain: "mc",
|
subdomain: "mc",
|
||||||
defaultPort: 25565,
|
defaultPort: 25565,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:25565",
|
||||||
subpathSupport: 'none',
|
subpathSupport: 'none',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Server accepts the Minecraft EULA automatically",
|
"Server accepts the Minecraft EULA automatically",
|
||||||
@@ -2451,7 +2463,7 @@ const APP_TEMPLATES = {
|
|||||||
},
|
},
|
||||||
subdomain: "valheim",
|
subdomain: "valheim",
|
||||||
defaultPort: 2456,
|
defaultPort: 2456,
|
||||||
healthCheck: null,
|
healthCheck: "tcp://localhost:2456",
|
||||||
subpathSupport: 'none',
|
subpathSupport: 'none',
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Connect via Steam: Add Server > IP:2456",
|
"Connect via Steam: Add Server > IP:2456",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR',
|
|||||||
// ── Docker ──────────────────────────────────────────────────────
|
// ── Docker ──────────────────────────────────────────────────────
|
||||||
const DOCKER = {
|
const DOCKER = {
|
||||||
CONTAINER_PREFIX: 'sami-',
|
CONTAINER_PREFIX: 'sami-',
|
||||||
TIMEOUT: 30000, // 30s — timeout for docker pull/create operations
|
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
|
||||||
LOG_CONFIG: {
|
LOG_CONFIG: {
|
||||||
Type: 'json-file',
|
Type: 'json-file',
|
||||||
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
|
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* DashCaddy Error Handler Middleware
|
* DashCaddy Error Handler Middleware
|
||||||
* Centralizes error handling logic to eliminate duplicate catch blocks
|
* Centralizes error handling logic to eliminate duplicate catch blocks
|
||||||
|
*
|
||||||
|
* Logging: this middleware uses the unified logError from src/utils/logging.js
|
||||||
|
* (same one src/app.js uses), so all errors go to one log file. The legacy
|
||||||
|
* ./error-logger.js and its ./error.log file have been retired.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
const { AppError } = require('./errors');
|
const { AppError } = require('./errors');
|
||||||
const { logError } = require('./error-logger');
|
const { LIMITS } = require('./constants');
|
||||||
|
const { logError: unifiedLogError, safeErrorMessage } = require('./src/utils/logging');
|
||||||
|
|
||||||
/**
|
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
|
||||||
* Async route handler wrapper
|
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||||||
* Automatically catches errors and passes to error middleware
|
|
||||||
* Usage: app.get('/route', asyncHandler(async (req, res) => { ... }))
|
|
||||||
*/
|
|
||||||
function asyncHandler(fn) {
|
|
||||||
return (req, res, next) => {
|
|
||||||
Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global error handling middleware
|
* Global error handling middleware
|
||||||
* MUST be registered after all routes in server.js
|
* MUST be registered after all routes in server.js
|
||||||
*/
|
*/
|
||||||
function errorMiddleware(err, req, res, next) {
|
function errorMiddleware(err, req, res, next) {
|
||||||
// Log all errors with request context
|
// Log all errors with request context (unified, same file the rest of the app uses)
|
||||||
logError(req.path, err, {
|
unifiedLogError(
|
||||||
|
ERROR_LOG_FILE,
|
||||||
|
MAX_ERROR_LOG_SIZE,
|
||||||
|
req.path,
|
||||||
|
err,
|
||||||
|
{
|
||||||
method: req.method,
|
method: req.method,
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
body: req.body
|
body: req.body
|
||||||
});
|
}
|
||||||
|
).catch(e => console.error('Failed to write to error log:', e.message));
|
||||||
|
|
||||||
// Determine if this is an operational error (AppError) or programming error
|
// Determine if this is an operational error (AppError) or programming error
|
||||||
const isOperational = err.isOperational || err instanceof AppError;
|
const isOperational = err.isOperational || err instanceof AppError;
|
||||||
@@ -42,7 +46,7 @@ function errorMiddleware(err, req, res, next) {
|
|||||||
// Build response
|
// Build response
|
||||||
const response = {
|
const response = {
|
||||||
success: false,
|
success: false,
|
||||||
error: isOperational ? err.message : 'Internal server error',
|
error: isOperational ? safeErrorMessage(err) : 'Internal server error',
|
||||||
code
|
code
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,7 +85,6 @@ function notFoundHandler(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
asyncHandler,
|
|
||||||
errorMiddleware,
|
errorMiddleware,
|
||||||
notFoundHandler
|
notFoundHandler
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
// Error Logger Utility
|
|
||||||
// Centralized error logging with rotation and request context tracking
|
|
||||||
|
|
||||||
const fsp = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const { LIMITS } = require('./constants');
|
|
||||||
|
|
||||||
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
|
|
||||||
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if file exists
|
|
||||||
*/
|
|
||||||
async function exists(filepath) {
|
|
||||||
try {
|
|
||||||
await fsp.access(filepath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log error with context and rotation
|
|
||||||
* @param {string} context - Where the error occurred
|
|
||||||
* @param {Error|string} error - The error to log
|
|
||||||
* @param {Object} additionalInfo - Additional context (req, etc.)
|
|
||||||
*/
|
|
||||||
async function logError(context, error, additionalInfo = {}) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
// Extract request context if a request object is provided
|
|
||||||
const requestContext = extractRequestContext(additionalInfo.req);
|
|
||||||
if (additionalInfo.req) {
|
|
||||||
delete additionalInfo.req; // Remove req to avoid circular refs
|
|
||||||
}
|
|
||||||
|
|
||||||
const logEntry = {
|
|
||||||
timestamp,
|
|
||||||
context,
|
|
||||||
...requestContext,
|
|
||||||
error: {
|
|
||||||
message: error.message || error,
|
|
||||||
stack: error.stack,
|
|
||||||
code: error.code
|
|
||||||
},
|
|
||||||
...additionalInfo
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format log line with request context
|
|
||||||
const contextInfo = Object.keys(requestContext).length > 0
|
|
||||||
? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}`
|
|
||||||
: '';
|
|
||||||
const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Rotate log if it exceeds max size
|
|
||||||
await rotateLogIfNeeded();
|
|
||||||
await fsp.appendFile(ERROR_LOG_FILE, logLine);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to write to error log', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract request context from Express request object
|
|
||||||
*/
|
|
||||||
function extractRequestContext(req) {
|
|
||||||
if (!req) return {};
|
|
||||||
|
|
||||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: req.id,
|
|
||||||
ip: clientIP,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
method: req.method,
|
|
||||||
path: req.path
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate log file if it exceeds max size
|
|
||||||
*/
|
|
||||||
async function rotateLogIfNeeded() {
|
|
||||||
try {
|
|
||||||
const stats = await fsp.stat(ERROR_LOG_FILE);
|
|
||||||
if (stats.size > MAX_ERROR_LOG_SIZE) {
|
|
||||||
const rotated = ERROR_LOG_FILE + '.1';
|
|
||||||
if (await exists(rotated)) {
|
|
||||||
await fsp.unlink(rotated);
|
|
||||||
}
|
|
||||||
await fsp.rename(ERROR_LOG_FILE, rotated);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// File may not exist yet, that's fine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a safe error message to the client without leaking internals
|
|
||||||
*/
|
|
||||||
function safeErrorMessage(error) {
|
|
||||||
const msg = error.message || String(error);
|
|
||||||
|
|
||||||
// Detect port conflict errors from Docker
|
|
||||||
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
|
||||||
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
|
||||||
const port = portMatch ? portMatch[1] : 'requested';
|
|
||||||
return `Port ${port} is already in use. Please choose a different port or stop the conflicting service.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect container not found errors
|
|
||||||
if (msg.includes('No such container')) {
|
|
||||||
return 'Container not found';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect network errors
|
|
||||||
if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) {
|
|
||||||
return 'Service unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic safe message for unknown errors
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return 'An error occurred. Please try again or contact support.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// In development, show the actual error
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
logError,
|
|
||||||
safeErrorMessage
|
|
||||||
};
|
|
||||||
@@ -317,6 +317,9 @@ class LicenseManager {
|
|||||||
*/
|
*/
|
||||||
isExpired() {
|
isExpired() {
|
||||||
if (!this.activation) return true;
|
if (!this.activation) return true;
|
||||||
|
// Lifetime licenses never expire
|
||||||
|
if (this.activation.lifetime || this.activation.durationDays === 0) return false;
|
||||||
|
if (!this.activation.expiresAt) return false; // No expiry set = lifetime
|
||||||
return Date.now() > new Date(this.activation.expiresAt).getTime();
|
return Date.now() > new Date(this.activation.expiresAt).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.10.0",
|
"version": "1.13.3",
|
||||||
"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'));
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
// Response Helpers
|
|
||||||
// Standardize API response format across all routes
|
|
||||||
|
|
||||||
const { HTTP_STATUS } = require('./constants');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success response with data
|
|
||||||
*/
|
|
||||||
function success(res, data, statusCode = HTTP_STATUS.OK) {
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
success: true,
|
|
||||||
...data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success response with message
|
|
||||||
*/
|
|
||||||
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
success: true,
|
|
||||||
message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created response (201)
|
|
||||||
*/
|
|
||||||
function created(res, data) {
|
|
||||||
return res.status(HTTP_STATUS.CREATED).json({
|
|
||||||
success: true,
|
|
||||||
...data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No content response (204)
|
|
||||||
*/
|
|
||||||
function noContent(res) {
|
|
||||||
return res.status(HTTP_STATUS.NO_CONTENT).send();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error response
|
|
||||||
*/
|
|
||||||
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation error response (400)
|
|
||||||
*/
|
|
||||||
function validationError(res, message) {
|
|
||||||
return res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unauthorized response (401)
|
|
||||||
*/
|
|
||||||
function unauthorized(res, message = 'Unauthorized') {
|
|
||||||
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forbidden response (403)
|
|
||||||
*/
|
|
||||||
function forbidden(res, message = 'Forbidden') {
|
|
||||||
return res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Not found response (404)
|
|
||||||
*/
|
|
||||||
function notFound(res, message = 'Not found') {
|
|
||||||
return res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conflict response (409)
|
|
||||||
*/
|
|
||||||
function conflict(res, message) {
|
|
||||||
return res.status(HTTP_STATUS.CONFLICT).json({
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
success,
|
|
||||||
successMessage,
|
|
||||||
created,
|
|
||||||
noContent,
|
|
||||||
error,
|
|
||||||
validationError,
|
|
||||||
unauthorized,
|
|
||||||
forbidden,
|
|
||||||
notFound,
|
|
||||||
conflict
|
|
||||||
};
|
|
||||||
@@ -197,8 +197,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = await docker.client.createContainer(containerConfig);
|
let container;
|
||||||
|
try {
|
||||||
|
container = await docker.client.createContainer(containerConfig);
|
||||||
await container.start();
|
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 {
|
||||||
@@ -306,7 +316,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
} else {
|
} else {
|
||||||
containerId = await deployContainer(appId, config, template);
|
containerId = await deployContainer(appId, config, template);
|
||||||
log.info('deploy', 'Container deployed', { containerId });
|
log.info('deploy', 'Container deployed', { containerId });
|
||||||
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
|
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort, 30);
|
||||||
log.info('deploy', 'Container is healthy', { containerId });
|
log.info('deploy', 'Container is healthy', { containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,10 +430,11 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logError('app-deploy', error, { appId, config });
|
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||||
log.error('deploy', 'Deployment failed', { appId, error: error.message });
|
const msg = error?.message || String(error || 'Unknown error');
|
||||||
|
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
|
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||||
}
|
}
|
||||||
}, 'apps-deploy'));
|
}, 'apps-deploy'));
|
||||||
|
|||||||
@@ -379,9 +379,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success && result.error !== 'No changes to apply') {
|
||||||
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
if (result.error === 'No changes to apply') {
|
||||||
|
log.info('caddy', 'Subpath config already exists, reusing', { subdomain });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ module.exports = function(ctx) {
|
|||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
errorResponse: ctx.errorResponse,
|
errorResponse: ctx.errorResponse,
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
// Additional context properties needed by routes
|
|
||||||
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
||||||
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
||||||
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
||||||
@@ -40,26 +39,27 @@ module.exports = function(ctx) {
|
|||||||
ctx: ctx
|
ctx: ctx
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize helpers with dependencies (ctx is the Koa context)
|
|
||||||
const helpers = initHelpers({ ...deps, ctx });
|
const helpers = initHelpers({ ...deps, ctx });
|
||||||
|
|
||||||
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
|
|
||||||
const subCtx = Object.assign({}, ctx, { helpers });
|
const subCtx = Object.assign({}, ctx, { helpers });
|
||||||
|
|
||||||
try { router.use('/deploy', initDeploy(subCtx)); }
|
// Mount sub-routers at their prefix paths.
|
||||||
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
|
// Sub-modules define routes at '/' (root of their sub-router).
|
||||||
|
// Final paths: /api/v1/apps/deploy, /api/v1/apps/remove, /api/v1/apps/templates, etc.
|
||||||
|
|
||||||
try { router.use('/remove', initRemoval(subCtx)); }
|
try { router.use('/apps', initDeploy(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
|
try { router.use('/apps', initRemoval(subCtx)); }
|
||||||
|
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/apps', initTemplates(subCtx)); }
|
try { router.use('/apps', initTemplates(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
try { router.use('/compose', initCompose(subCtx)); }
|
try { router.use('/apps', initCompose(subCtx)); }
|
||||||
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
|
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); }
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
const { ValidationError, NotFoundError } = require('../errors');
|
const { ValidationError, NotFoundError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
|||||||
+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: [] });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
const { ValidationError, NotFoundError } = require('../errors');
|
const { ValidationError, NotFoundError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const { DOCKER } = require('../constants');
|
const { DOCKER } = require('../constants');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../pagination');
|
||||||
const { NotFoundError } = require('../errors');
|
const { NotFoundError } = require('../errors');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Containers route factory
|
* Containers route factory
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credentials routes factory
|
* Credentials routes factory
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
const { NotFoundError, ValidationError } = require('../errors');
|
const { NotFoundError, ValidationError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
|
|||||||
const validatorLib = require('validator');
|
const validatorLib = require('validator');
|
||||||
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
|
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../fs-helpers');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
|
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -383,9 +383,8 @@ module.exports = function({
|
|||||||
|
|
||||||
const response = await fetchT(technitiumUrl, {
|
const response = await fetchT(technitiumUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Accept': 'text/plain' },
|
headers: { 'Accept': 'text/plain' }
|
||||||
timeout: 10000
|
}, 10000);
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@@ -640,7 +639,7 @@ module.exports = function({
|
|||||||
const dnsPort = siteConfig.dnsServerPort || '5380';
|
const dnsPort = siteConfig.dnsServerPort || '5380';
|
||||||
try {
|
try {
|
||||||
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
|
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
|
||||||
const response = await fetchT(url, { method: 'POST', timeout: 5000 });
|
const response = await fetchT(url, { method: 'POST' }, 5000);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
success(res, { message: 'Restart initiated' });
|
success(res, { message: 'Restart initiated' });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const fs = require('fs');
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../pagination');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error logs routes factory
|
* Error logs routes factory
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { exists } = require('../fs-helpers');
|
|||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../pagination');
|
||||||
const platformPaths = require('../platform-paths');
|
const platformPaths = require('../platform-paths');
|
||||||
const { resolveServiceUrl } = require('../url-resolver');
|
const { resolveServiceUrl } = require('../url-resolver');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -273,9 +273,10 @@ module.exports = function({
|
|||||||
try {
|
try {
|
||||||
// Check if certificate exists
|
// Check if certificate exists
|
||||||
if (!await exists(rootCertPath)) {
|
if (!await exists(rootCertPath)) {
|
||||||
return res.json({
|
return res.status(404).json({
|
||||||
status: 'error',
|
success: false,
|
||||||
message: 'Root CA certificate not found',
|
error: 'Root CA certificate not found',
|
||||||
|
caStatus: 'error',
|
||||||
daysUntilExpiration: null
|
daysUntilExpiration: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,34 +287,36 @@ module.exports = function({
|
|||||||
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
// Alert thresholds
|
// Alert thresholds
|
||||||
let status = 'healthy';
|
let caStatus = 'healthy';
|
||||||
let message = `CA certificate valid for ${daysUntilExpiration} days`;
|
let message = `CA certificate valid for ${daysUntilExpiration} days`;
|
||||||
|
|
||||||
if (daysUntilExpiration < 0) {
|
if (daysUntilExpiration < 0) {
|
||||||
status = 'critical';
|
caStatus = 'critical';
|
||||||
message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`;
|
message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`;
|
||||||
} else if (daysUntilExpiration < 7) {
|
} else if (daysUntilExpiration < 7) {
|
||||||
status = 'critical';
|
caStatus = 'critical';
|
||||||
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
||||||
} else if (daysUntilExpiration < 30) {
|
} else if (daysUntilExpiration < 30) {
|
||||||
status = 'critical';
|
caStatus = 'critical';
|
||||||
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
message = `CA certificate expires in ${daysUntilExpiration} days!`;
|
||||||
} else if (daysUntilExpiration < 90) {
|
} else if (daysUntilExpiration < 90) {
|
||||||
status = 'warning';
|
caStatus = 'warning';
|
||||||
message = `CA certificate expires in ${daysUntilExpiration} days`;
|
message = `CA certificate expires in ${daysUntilExpiration} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: status,
|
success: true,
|
||||||
message: message,
|
caStatus,
|
||||||
daysUntilExpiration: daysUntilExpiration,
|
message,
|
||||||
|
daysUntilExpiration,
|
||||||
expiresAt: notAfter
|
expiresAt: notAfter
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logError('GET /api/health/ca', error);
|
await logError('GET /api/health/ca', error);
|
||||||
res.json({
|
res.status(500).json({
|
||||||
status: 'error',
|
success: false,
|
||||||
message: error.message,
|
error: error.message,
|
||||||
|
caStatus: 'error',
|
||||||
daysUntilExpiration: null
|
daysUntilExpiration: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -322,9 +325,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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitoring routes factory
|
* Monitoring routes factory
|
||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const { exists } = require('../fs-helpers');
|
|||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
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('../src/utils/responses');
|
||||||
|
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 { CADDY, REGEX, LIMITS } = require('../constants');
|
const { CADDY, REGEX, LIMITS } = require('../constants');
|
||||||
const { ValidationError, ConflictError, NotFoundError } = require('../errors');
|
const { ValidationError, ConflictError, NotFoundError } = require('../errors');
|
||||||
const { validateURL } = require('../input-validator');
|
const { validateURL } = require('../input-validator');
|
||||||
|
const { ok } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sites route factory
|
* Sites route factory
|
||||||
@@ -127,7 +128,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
|
|||||||
name: ca.name,
|
name: ca.name,
|
||||||
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name
|
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name
|
||||||
}));
|
}));
|
||||||
res.json({ status: 'success', data: { cas: caList } });
|
ok(res, { cas: caList });
|
||||||
}, 'caddy-get-cas'));
|
}, 'caddy-get-cas'));
|
||||||
|
|
||||||
// Remove a site from Caddyfile
|
// Remove a site from Caddyfile
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse, notFound } = require('../response-helpers');
|
const { success, error: errorResponse, notFound } = require('../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSL Monitor route factory
|
* SSL Monitor route factory
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { success } = require('../response-helpers');
|
const { success } = require('../src/utils/responses');
|
||||||
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,
|
||||||
@@ -73,9 +75,12 @@ process.on('uncaughtException', (error) => {
|
|||||||
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
|
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
|
||||||
|
|
||||||
// Initialize workflow engine if bundled-workflows is available
|
// Initialize workflow engine if bundled-workflows is available
|
||||||
|
// NOTE: createApp() already initializes the workflow engine in src/app.js
|
||||||
|
// This block is kept for backward compat with entry points that don't use createApp()
|
||||||
let workflowEngine = null;
|
let workflowEngine = null;
|
||||||
if (bundledWorkflows) {
|
if (bundledWorkflows) {
|
||||||
try {
|
try {
|
||||||
|
const { fetchT } = require('./src/utils/http');
|
||||||
const { WorkflowEngine } = bundledWorkflows;
|
const { WorkflowEngine } = bundledWorkflows;
|
||||||
// Create a context with needed services
|
// Create a context with needed services
|
||||||
const workflowCtx = {
|
const workflowCtx = {
|
||||||
|
|||||||
+119
-2
@@ -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) => {
|
||||||
@@ -539,7 +576,7 @@ async function createApp() {
|
|||||||
sslMonitor: ctx.sslMonitor,
|
sslMonitor: ctx.sslMonitor,
|
||||||
dnsPropagationChecker: ctx.dnsPropagationChecker
|
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||||
}));
|
}));
|
||||||
apiRouter.use(workflowsRoutes({
|
apiRouter.use('/workflows', workflowsRoutes({
|
||||||
workflowEngine: ctx.workflowEngine,
|
workflowEngine: ctx.workflowEngine,
|
||||||
licenseManager: ctx.licenseManager,
|
licenseManager: ctx.licenseManager,
|
||||||
asyncHandler: ctx.asyncHandler
|
asyncHandler: ctx.asyncHandler
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts
|
|||||||
try {
|
try {
|
||||||
const response = await fetchT(`https://${domain}/`, {
|
const response = await fetchT(`https://${domain}/`, {
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
agent: httpsAgent,
|
agent: httpsAgent
|
||||||
timeout: 5000
|
}, 5000);
|
||||||
});
|
|
||||||
|
|
||||||
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ async function refreshDnsToken(username, password, server, fetchT, log) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
},
|
|
||||||
timeout: 10000
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
10000
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fe
|
|||||||
const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' });
|
const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' });
|
||||||
const response = await fetchT(
|
const response = await fetchT(
|
||||||
`http://${server}:5380/api/user/login?${params.toString()}`,
|
`http://${server}:5380/api/user/login?${params.toString()}`,
|
||||||
{ method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000 }
|
{ method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||||
|
10000
|
||||||
);
|
);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.status === 'ok' && result.token) {
|
if (result.status === 'ok' && result.token) {
|
||||||
|
|||||||
@@ -38,7 +38,15 @@ function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
|||||||
if (!opts.signal) {
|
if (!opts.signal) {
|
||||||
opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) };
|
opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) };
|
||||||
}
|
}
|
||||||
delete opts.timeout;
|
// The `timeout` key in fetch() opts is silently ignored by undici. Callers
|
||||||
|
// should use the third arg of fetchT() (timeoutMs) instead. If a caller
|
||||||
|
// passes `timeout: N` here, it's almost certainly a bug — we used to silently
|
||||||
|
// strip it, which masked the issue. Now we surface it in logs and strip it.
|
||||||
|
if ('timeout' in opts) {
|
||||||
|
console.warn(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}`);
|
||||||
|
const { timeout, ...rest } = opts;
|
||||||
|
opts = rest;
|
||||||
|
}
|
||||||
return fetch(url, opts);
|
return fetch(url, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,8 +94,12 @@ async function logError(ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE, context, error, addi
|
|||||||
* Return a safe error message without leaking internals
|
* Return a safe error message without leaking internals
|
||||||
*/
|
*/
|
||||||
function safeErrorMessage(error) {
|
function safeErrorMessage(error) {
|
||||||
|
if (!error) return 'An internal error occurred';
|
||||||
const msg = error.message || String(error);
|
const msg = error.message || String(error);
|
||||||
|
|
||||||
|
// Always expose DC-prefixed user-facing errors
|
||||||
|
if (/\[DC-\d+\]/.test(msg)) return msg;
|
||||||
|
|
||||||
// Detect port conflict errors
|
// Detect port conflict errors
|
||||||
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
||||||
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
||||||
@@ -103,7 +107,7 @@ function safeErrorMessage(error) {
|
|||||||
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
|
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only expose short, user-facing messages
|
// Only expose short, user-facing messages (no paths, stack traces, or internal details)
|
||||||
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
|
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,124 @@
|
|||||||
/**
|
/**
|
||||||
* Response helpers - Standard API response formats
|
* Response helpers - Standard API response formats
|
||||||
|
*
|
||||||
|
* Single source of truth for HTTP response shapes across DashCaddy.
|
||||||
|
* Standard envelope: { success: true, ...data } or { success: false, error: "..." }.
|
||||||
|
*
|
||||||
|
* All routes should import from this module — do not call res.json/res.status
|
||||||
|
* directly with the response shape, use these helpers instead.
|
||||||
*/
|
*/
|
||||||
|
const { HTTP_STATUS } = require('../../constants');
|
||||||
|
|
||||||
|
// ── Success helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard error response
|
* Standard success response. Use this in route handlers.
|
||||||
|
* Wraps the data object with a `success: true` envelope.
|
||||||
|
* @param {object} res Express response
|
||||||
|
* @param {object} [data={}] fields to include in the response body
|
||||||
|
* @param {number} [statusCode=200] HTTP status code
|
||||||
|
*/
|
||||||
|
function ok(res, data = {}, statusCode = HTTP_STATUS.OK) {
|
||||||
|
return res.status(statusCode).json({ success: true, ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for `ok` — prefer `ok` in new code, but kept for code that imports as `success`.
|
||||||
|
*/
|
||||||
|
function success(res, data, statusCode) {
|
||||||
|
return ok(res, data, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success response with a human-readable message field.
|
||||||
|
* Use when there's no data to return, just confirmation.
|
||||||
|
*/
|
||||||
|
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
|
||||||
|
return res.status(statusCode).json({ success: true, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 201 Created response.
|
||||||
|
*/
|
||||||
|
function created(res, data = {}) {
|
||||||
|
return res.status(HTTP_STATUS.CREATED).json({ success: true, ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 204 No Content response.
|
||||||
|
*/
|
||||||
|
function noContent(res) {
|
||||||
|
return res.status(HTTP_STATUS.NO_CONTENT).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error response. Use this in route handlers.
|
||||||
|
* @param {object} res Express response
|
||||||
|
* @param {number} statusCode HTTP status code
|
||||||
|
* @param {string} message Human-readable error message
|
||||||
|
* @param {object} [extras={}] additional fields to merge into the response
|
||||||
*/
|
*/
|
||||||
function errorResponse(res, statusCode, message, extras = {}) {
|
function errorResponse(res, statusCode, message, extras = {}) {
|
||||||
return res.status(statusCode).json({ success: false, error: message, ...extras });
|
return res.status(statusCode).json({ success: false, error: message, ...extras });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard success response
|
* Alias for `errorResponse` — kept for code that imports as `error`.
|
||||||
*/
|
*/
|
||||||
function ok(res, data = {}) {
|
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
|
||||||
return res.json({ success: true, ...data });
|
return res.status(statusCode).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 400 Bad Request — invalid input from the user.
|
||||||
|
*/
|
||||||
|
function validationError(res, message) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 401 Unauthorized — no valid credentials.
|
||||||
|
*/
|
||||||
|
function unauthorized(res, message = 'Unauthorized') {
|
||||||
|
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 403 Forbidden — credentials valid but permission denied.
|
||||||
|
*/
|
||||||
|
function forbidden(res, message = 'Forbidden') {
|
||||||
|
return res.status(HTTP_STATUS.FORBIDDEN).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 Not Found — resource doesn't exist.
|
||||||
|
*/
|
||||||
|
function notFound(res, message = 'Not found') {
|
||||||
|
return res.status(HTTP_STATUS.NOT_FOUND).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 409 Conflict — request conflicts with current state (e.g. duplicate).
|
||||||
|
*/
|
||||||
|
function conflict(res, message) {
|
||||||
|
return res.status(HTTP_STATUS.CONFLICT).json({ success: false, error: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
errorResponse,
|
// Success helpers
|
||||||
ok,
|
ok,
|
||||||
|
success,
|
||||||
|
successMessage,
|
||||||
|
created,
|
||||||
|
noContent,
|
||||||
|
// Error helpers
|
||||||
|
errorResponse,
|
||||||
|
error,
|
||||||
|
validationError,
|
||||||
|
unauthorized,
|
||||||
|
forbidden,
|
||||||
|
notFound,
|
||||||
|
conflict,
|
||||||
};
|
};
|
||||||
|
|||||||
Executable
+379
@@ -0,0 +1,379 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# DashCaddy Host-Side Updater
|
||||||
|
# Triggered by systemd path unit when the container writes trigger.json.
|
||||||
|
# Reads the trigger, backs up current API + data/, copies new files, rebuilds container.
|
||||||
|
# Writes result.json so the new container knows the outcome.
|
||||||
|
#
|
||||||
|
# This runs on the HOST, outside the container.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly UPDATES_DIR="/opt/dashcaddy/updates"
|
||||||
|
readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json"
|
||||||
|
readonly RESULT_FILE="${UPDATES_DIR}/result.json"
|
||||||
|
readonly BACKUPS_DIR="${UPDATES_DIR}/backups"
|
||||||
|
readonly CONTAINER_NAME="dashcaddy-api"
|
||||||
|
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
|
||||||
|
readonly MAX_BACKUPS=3
|
||||||
|
readonly HEALTH_TIMEOUT=60
|
||||||
|
|
||||||
|
# Data directory backup — stored alongside code backups so everything rolls back together
|
||||||
|
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
||||||
|
readonly DATA_BACKUP_PREFIX="data-backup"
|
||||||
|
|
||||||
|
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
||||||
|
|
||||||
|
write_result() {
|
||||||
|
local success="$1" version="$2" duration="$3"
|
||||||
|
shift 3
|
||||||
|
local error="${1:-}"
|
||||||
|
|
||||||
|
if [[ "$success" == "true" ]]; then
|
||||||
|
cat > "$RESULT_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"version": "${version}",
|
||||||
|
"duration": ${duration},
|
||||||
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
cat > "$RESULT_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"version": "${version}",
|
||||||
|
"duration": ${duration},
|
||||||
|
"error": "${error}",
|
||||||
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_old_backups() {
|
||||||
|
local count
|
||||||
|
count=$(find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
|
||||||
|
if (( count > MAX_BACKUPS )); then
|
||||||
|
log "Cleaning old backups (${count} > ${MAX_BACKUPS})"
|
||||||
|
find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \
|
||||||
|
| sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \
|
||||||
|
| xargs rm -rf
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Data backup (rsync for efficiency + permissions) ──────────────────────────
|
||||||
|
backup_data_dir() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
||||||
|
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
||||||
|
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||||
|
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|
||||||
|
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||||
|
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
|
||||||
|
else
|
||||||
|
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Data restore ──────────────────────────────────────────────────────────────
|
||||||
|
restore_data_dir() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||||
|
if [[ -d "$data_backup" ]]; then
|
||||||
|
log "Restoring data/ from backup..."
|
||||||
|
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|
||||||
|
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
|
||||||
|
log "Data restored successfully"
|
||||||
|
else
|
||||||
|
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_health() {
|
||||||
|
local port="${1:-3001}"
|
||||||
|
local timeout="$HEALTH_TIMEOUT"
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
log "Waiting for health check (timeout: ${timeout}s)..."
|
||||||
|
while (( elapsed < timeout )); do
|
||||||
|
if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then
|
||||||
|
log "Health check passed after ${elapsed}s"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
elapsed=$(( elapsed + 2 ))
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Health check FAILED after ${timeout}s"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Shared rollback: restore code + data ────────────────────────────────────
|
||||||
|
rollback_restore() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
log "Rolling back: restoring code files..."
|
||||||
|
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [[ -d "$backup_dir/routes" ]]; then
|
||||||
|
rm -rf "$api_source_dir/routes"
|
||||||
|
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/src" ]]; then
|
||||||
|
rm -rf "$api_source_dir/src"
|
||||||
|
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||||
|
rm -rf "$api_source_dir/dns-providers"
|
||||||
|
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||||
|
fi
|
||||||
|
restore_data_dir "$backup_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Deployment mode ───────────────────────────────────────────────────────────
|
||||||
|
# Reproduce the SAME container the install created so an auto-update keeps every
|
||||||
|
# volume + env var (docker socket, Caddyfile, config/credentials, updates mount),
|
||||||
|
# not a minimal subset. Standard installs use docker-compose (compose file in the
|
||||||
|
# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a
|
||||||
|
# bare docker run is the last resort. build_image() and restart_container() both
|
||||||
|
# honor the detected mode so build and run stay consistent.
|
||||||
|
deploy_mode() {
|
||||||
|
if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then
|
||||||
|
echo compose
|
||||||
|
elif [[ -x /opt/dashcaddy/start.sh ]]; then
|
||||||
|
echo startsh
|
||||||
|
else
|
||||||
|
echo run
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the API image using whatever the install is wired for. Returns the build
|
||||||
|
# command's exit status so callers can detect failure.
|
||||||
|
build_image() {
|
||||||
|
cd "$api_source_dir" || return 1
|
||||||
|
case "$(deploy_mode)" in
|
||||||
|
compose) docker compose build 2>&1 || docker-compose build 2>&1 ;;
|
||||||
|
*) docker build -t "$IMAGE_TAG" . 2>&1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Shared container restart — recreate with the full, install-defined spec ───
|
||||||
|
# Recreates (rm + run / compose up) so new code AND new env vars take effect.
|
||||||
|
restart_container() {
|
||||||
|
cd "$api_source_dir" 2>/dev/null || true
|
||||||
|
case "$(deploy_mode)" in
|
||||||
|
compose)
|
||||||
|
log "Recreating container via docker compose (full compose spec)..."
|
||||||
|
docker compose up -d 2>&1 || docker-compose up -d 2>&1
|
||||||
|
;;
|
||||||
|
startsh)
|
||||||
|
log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..."
|
||||||
|
bash /opt/dashcaddy/start.sh
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "Recreating container via minimal docker run (fallback)..."
|
||||||
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||||
|
-p 127.0.0.1:3001:3001 \
|
||||||
|
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||||
|
-e SERVICES_FILE=/app/data/services.json \
|
||||||
|
"$IMAGE_TAG"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
log "Container recreated"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
|
||||||
|
code_restore() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
log "Restoring code files..."
|
||||||
|
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [[ -d "$backup_dir/routes" ]]; then
|
||||||
|
rm -rf "$api_source_dir/routes"
|
||||||
|
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/src" ]]; then
|
||||||
|
rm -rf "$api_source_dir/src"
|
||||||
|
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||||
|
fi
|
||||||
|
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||||
|
rm -rf "$api_source_dir/dns-providers"
|
||||||
|
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local start_time
|
||||||
|
start_time=$(date +%s)
|
||||||
|
|
||||||
|
# 1. Read trigger
|
||||||
|
if [[ ! -f "$TRIGGER_FILE" ]]; then
|
||||||
|
log "No trigger file found — nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse trigger.json (uses python3 which is available on all supported distros)
|
||||||
|
local action version from_version staging_dir api_source_dir commit
|
||||||
|
local frontend_staging_dir frontend_target_dir
|
||||||
|
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
||||||
|
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
|
||||||
|
from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])")
|
||||||
|
staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])")
|
||||||
|
api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])")
|
||||||
|
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
|
||||||
|
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
|
||||||
|
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
|
||||||
|
# Handle action=rollback (no new version to deploy)
|
||||||
|
local to_version="${version}"
|
||||||
|
|
||||||
|
log "=== ${action^^}: v${from_version} -> v${to_version} ==="
|
||||||
|
log "Staging: ${staging_dir}"
|
||||||
|
log "API source: ${api_source_dir}"
|
||||||
|
|
||||||
|
# Consume the trigger immediately so we don't re-process on failure
|
||||||
|
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
|
||||||
|
|
||||||
|
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||||
|
if [[ "$action" == "rollback" ]]; then
|
||||||
|
local backup_dir="${BACKUPS_DIR}/${version}"
|
||||||
|
if [[ ! -d "$backup_dir" ]]; then
|
||||||
|
log "ERROR: No backup found for version ${version}"
|
||||||
|
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Performing rollback to v${version}..."
|
||||||
|
rollback_restore "$backup_dir"
|
||||||
|
|
||||||
|
# Rebuild old code
|
||||||
|
log "Rebuilding container..."
|
||||||
|
build_image 2>&1 | tail -3 || true
|
||||||
|
|
||||||
|
restart_container
|
||||||
|
wait_for_health || log "WARNING: Health check failed after rollback"
|
||||||
|
|
||||||
|
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
log "=== Rollback complete ==="
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Handle update ───────────────────────────────────────────────────────────
|
||||||
|
if [[ ! -d "$staging_dir" ]]; then
|
||||||
|
log "ERROR: Staging directory not found: ${staging_dir}"
|
||||||
|
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Backup current API code + data/
|
||||||
|
local backup_dir="${BACKUPS_DIR}/${from_version}"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
log "Backing up current API files to ${backup_dir}"
|
||||||
|
for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
|
||||||
|
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
|
||||||
|
[[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/"
|
||||||
|
|
||||||
|
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
||||||
|
backup_data_dir "$backup_dir"
|
||||||
|
|
||||||
|
cleanup_old_backups
|
||||||
|
|
||||||
|
# 3. Copy new files from staging to API source
|
||||||
|
log "Deploying new API files..."
|
||||||
|
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
|
||||||
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if [[ -d "$staging_dir/routes" ]]; then
|
||||||
|
rm -rf "$api_source_dir/routes"
|
||||||
|
cp -rf "$staging_dir/routes" "$api_source_dir/routes"
|
||||||
|
fi
|
||||||
|
if [[ -d "$staging_dir/src" ]]; then
|
||||||
|
rm -rf "$api_source_dir/src"
|
||||||
|
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
||||||
|
fi
|
||||||
|
if [[ -d "$staging_dir/dns-providers" ]]; then
|
||||||
|
rm -rf "$api_source_dir/dns-providers"
|
||||||
|
cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||||
|
fi
|
||||||
|
if [[ -n "$commit" ]]; then
|
||||||
|
echo "$commit" > "$api_source_dir/VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3b. Sync frontend
|
||||||
|
if [[ -z "$frontend_staging_dir" ]]; then
|
||||||
|
parent_staging=$(dirname "$staging_dir")
|
||||||
|
[[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status"
|
||||||
|
fi
|
||||||
|
if [[ -z "$frontend_target_dir" ]]; then
|
||||||
|
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
|
||||||
|
[[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
|
||||||
|
log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir"
|
||||||
|
mkdir -p "$frontend_target_dir"
|
||||||
|
[[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html"
|
||||||
|
[[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js"
|
||||||
|
for sub in dist css vendor js; do
|
||||||
|
if [[ -d "$frontend_staging_dir/$sub" ]]; then
|
||||||
|
mkdir -p "$frontend_target_dir/$sub"
|
||||||
|
cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -d "$frontend_staging_dir/assets" ]]; then
|
||||||
|
mkdir -p "$frontend_target_dir/assets"
|
||||||
|
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Rebuild container
|
||||||
|
log "Rebuilding container..."
|
||||||
|
local build_ok=false
|
||||||
|
if build_image; then
|
||||||
|
build_ok=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$build_ok" != "true" ]]; then
|
||||||
|
log "ERROR: Docker build failed — rolling back code + data"
|
||||||
|
code_restore "$backup_dir"
|
||||||
|
build_image 2>&1 | tail -3 || true
|
||||||
|
restart_container
|
||||||
|
wait_for_health || true
|
||||||
|
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Restart container (recreate so new code + env vars take effect)
|
||||||
|
restart_container
|
||||||
|
|
||||||
|
# 6. Health check
|
||||||
|
if wait_for_health; then
|
||||||
|
local duration=$(( $(date +%s) - start_time ))
|
||||||
|
log "=== Update successful: v${to_version} in ${duration}s ==="
|
||||||
|
write_result "true" "$to_version" "$duration"
|
||||||
|
else
|
||||||
|
local duration=$(( $(date +%s) - start_time ))
|
||||||
|
log "ERROR: Health check failed after update — rolling back code + data"
|
||||||
|
rollback_restore "$backup_dir"
|
||||||
|
build_image 2>&1 | tail -3 || true
|
||||||
|
restart_container
|
||||||
|
wait_for_health || log "WARNING: Rollback health check also failed"
|
||||||
|
write_result "false" "$to_version" "$duration" "Health check failed after update"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Cleanup
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
|
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
||||||
|
|
||||||
|
log "=== Update process complete ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -14,15 +14,15 @@
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.success) {
|
||||||
const select = document.getElementById('existing-ca-select');
|
const select = document.getElementById('existing-ca-select');
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
|
||||||
if (result.data.cas.length === 0) {
|
if (result.cas.length === 0) {
|
||||||
select.innerHTML = '<option value="">No CAs found in Caddyfile</option>';
|
select.innerHTML = '<option value="">No CAs found in Caddyfile</option>';
|
||||||
} else {
|
} else {
|
||||||
select.innerHTML = '<option value="">Select existing CA...</option>';
|
select.innerHTML = '<option value="">Select existing CA...</option>';
|
||||||
result.data.cas.forEach(ca => {
|
result.cas.forEach(ca => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
if (typeof ca === 'object') {
|
if (typeof ca === 'object') {
|
||||||
option.value = ca.id;
|
option.value = ca.id;
|
||||||
|
|||||||
Reference in New Issue
Block a user