Compare commits

..
5 Commits
Author SHA1 Message Date
Hermes 7bbd969fa2 fix: rebuild bundle with widget, restore TOTP across container recreate, integrate auto-updater changes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Three logical changes grouped:

1. Widget bundle rebuild + sami-files logo (from previous session)
   - status/dist/{init,core,features,onboarding}.js rebuilt from latest source
   - status/sw.js cache bumped to dashcaddy-shell-594ec75648 to force SW refresh
   - status/assets/sami-files.png added (Sami Files service card logo)

2. status/build.js: include monitoring-widgets.js in bundle
   - The original build.js was missing monitoring-widgets.js from its JS()
     bundle list — that's why the System Overview widget never showed up
     in the live init.js until we ran the live /var/www/dashcaddy-status/
     build.js. Now consistent.

3. dashcaddy-api/scripts/dashcaddy-update.sh restart_container(): preserve
   TOTP secret across container recreates
   - Was only setting SERVICES_FILE; container fell back to image-local
     /app/credentials.json + /app/.encryption-key (auto-generated fresh
     every recreate), which broke TOTP for the bind-mounted secret at
     /app/data/credentials.json
   - Added CREDENTIALS_FILE + ENCRYPTION_KEY_FILE env vars pointing at
     /app/data/ so the container reads from the bind-mounted host data dir
   - See skill: software-development/dashcaddy/references/totp-and-system-overview-pitfalls.md §9

4. Auto-updater integration (pulled from upstream release):
   - dashcaddy-api/VERSION: dev → c64bbe2
   - dashcaddy-api/health-checker.js, middleware.js, package.json,
     routes/backups.js, src/app.js: new release code (bundled workflows,
     /api/auth/ → /api/v1/ back-compat rewrite, backup storage limits)
2026-06-18 19:23:30 -07:00
Hermes 4f377970d7 chore: ignore runtime data + scratch files, remove dead root routes/
Working tree accumulated 172 untracked/modified files from the auto-updater:
- 19 secret/runtime files in dashcaddy-api/data/ that should never be tracked
- 199 byte-identical duplicates of tracked files dumped at root by an
  outdated rsync/cp step
- 6 scratch debug scripts (cm_check.js, login_test.js, full_test.js, ...)
- 7 .bak-* files from start.sh and dashcaddy-update.sh rollback branches
- Root-level routes/ directory: dead code, container COPYs dashcaddy-api/routes/

.gitignore now ignores:
  - dashcaddy-api/data/          (runtime: credentials, secrets, history)
  - start.sh.bak*, scripts/*.bak* (auto-updater rollback backups)
  - updates/                      (auto-updater runtime state)
  - cm_check*.js, *_test.js       (scratch debug scripts)

Removed dead code:
  - routes/openclaw.js            (replaced by dashcaddy-api/routes/openclaw.js)

Recreated runtime scripts that were deleted with their duplicates:
  - start.sh                      (canonical container-start, 47-line full config)
  - scripts/dashcaddy-update.sh was already untracked; fixed the tracked
    dashcaddy-api/scripts/dashcaddy-update.sh instead (see next commit)

Net change: 172 → 17 files in working tree.
2026-06-18 19:23:02 -07:00
Hermes 7f0d43943c feat: restore monitoring widget + add sami-files template
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- Recreate status/js/monitoring-widgets.js with robust services count
  (reads from window.APPS, #cards DOM, then live fetch as fallback)
- Add sami-files service to data/services.json (Sami Files card)
- Add sami-files template to app-templates.js under 'Files' category
  with full systemd deployment docs and Caddy snippet
- Bundle monitoring-widgets.js into init.js
2026-06-18 18:52:48 -07:00
Krystie 9ab947a394 feat: enforceStorageLimit - prune oldest backups when maxStorageBytes exceeded 2026-05-28 15:14:59 -07:00
Krystie ad9400490d Merge: resolve conflict in routes/backups.js, keep storage-info + maxStorageBytes 2026-05-28 15:00:41 -07:00
118 changed files with 1433 additions and 7667 deletions
+15
View File
@@ -2,6 +2,8 @@
node_modules/ node_modules/
# Runtime state/config files (generated, not source) # Runtime state/config files (generated, not source)
# Note: data/ subdir contains runtime state (credentials, secrets, history) — never commit
dashcaddy-api/data/
dashcaddy-api/credentials.json dashcaddy-api/credentials.json
dashcaddy-api/.env dashcaddy-api/.env
.env .env
@@ -17,6 +19,19 @@ dashcaddy-api/update-config.json
dashcaddy-api/update-history.json dashcaddy-api/update-history.json
dashcaddy-api/dashcaddy-errors.log dashcaddy-api/dashcaddy-errors.log
# Auto-updater backups (created by dashcaddy-update.sh when rolling back)
start.sh.bak*
scripts/*.bak*
# Auto-updater runtime state (history + secrets + staging)
updates/
# Scratch / debug scripts (left over from past sessions)
cm_check*.js
full_test.js
login_test.js
login_backup_test.js
# Build output # Build output
dashcaddy-installer/build-output/ dashcaddy-installer/build-output/
dashcaddy-installer/dist/ dashcaddy-installer/dist/
-20
View File
@@ -7,26 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.13.4] - 2026-06-12
### Changed
- Standardized all route handler responses to use helpers from `src/utils/responses.js`
(`ok`, `errorResponse`, `successMessage`, `notFound`, `validationError`, `forbidden`,
`unauthorized`, `conflict`). ~160 raw `res.json()` calls converted across 32+ files.
No behavior changes — response shapes are identical. This ensures future schema
changes (e.g., adding a `requestId` envelope) only need to update one module.
- Fixed `error` vs `errorResponse` signature mismatch in `routes/health.js` CA cert
endpoint. The `error` helper takes `(res, message, statusCode)` while `errorResponse`
takes `(res, statusCode, message, extras)` — the wrong alias was being used for
calls that needed the 4-argument form.
- Updated `middleware.js`, `csrf-protection.js`, `error-handler.js`, and
`license-manager.js` to use response helpers for rejection/error responses
instead of inline `res.status().json()`.
### Note
- 4 pre-existing test failures in `services.routes.test.js` (credential storage)
remain from before this release. They are unrelated to the standardization pass.
## [1.5.0] - 2026-05-17 ## [1.5.0] - 2026-05-17
### Changed (BREAKING) ### Changed (BREAKING)
-1
View File
@@ -1 +0,0 @@
1.13.0
-1
View File
@@ -11,7 +11,6 @@ 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
View File
@@ -1 +1 @@
1.13.4 c64bbe2
@@ -1,215 +0,0 @@
/**
* 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);
});
});
});
+20 -13
View File
@@ -1,18 +1,8 @@
// Mock the unified logging module so we can verify logError is called jest.mock('../error-logger', () => ({
// without writing to the actual error.log file logError: jest.fn(),
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 { errorMiddleware, notFoundHandler } = require('../error-handler'); const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler');
const { const {
AppError, AppError,
ValidationError, ValidationError,
@@ -40,6 +30,23 @@ 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');
@@ -1,201 +0,0 @@
/**
* 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.caStatus).toBe('healthy'); expect(res.body.status).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.caStatus).toBe('warning'); expect(res.body.status).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.caStatus).toBe('critical'); expect(res.body.status).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.caStatus).toBe('critical'); expect(res.body.status).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.caStatus).toBe('critical'); expect(res.body.status).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(404); expect(res.status).toBe(200);
expect(res.body.caStatus).toBe('error'); expect(res.body.status).toBe('error');
expect(res.body.error).toMatch(/not found/); expect(res.body.message).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(500); expect(res.status).toBe(200);
expect(res.body.caStatus).toBe('error'); expect(res.body.status).toBe('error');
expect(res.body.error).toBe('openssl not found'); expect(res.body.message).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('../../src/utils/responses', () => ({ jest.mock('../../response-helpers', () => ({
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 });
}), }),
@@ -103,12 +103,12 @@ describe('Services Routes', () => {
}); });
describe('GET /api/services', () => { describe('GET /api/services', () => {
it('returns empty services array (enveloped) when no services file', async () => { it('returns empty array when no services file', async () => {
exists.mockResolvedValue(false); exists.mockResolvedValue(false);
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/services'); const res = await request(app).get('/api/services');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, services: [] }); expect(res.body).toEqual([]);
}); });
it('returns services list', async () => { it('returns services list', async () => {
+79 -23
View File
@@ -342,8 +342,7 @@ 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,
@@ -394,8 +393,7 @@ 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,
@@ -551,7 +549,7 @@ const APP_TEMPLATES = {
}, },
subdomain: "dns2", subdomain: "dns2",
defaultPort: 953, defaultPort: 953,
healthCheck: "tcp://localhost:53", healthCheck: null,
subpathSupport: 'strip', subpathSupport: 'strip',
setupInstructions: [ setupInstructions: [
"Configure zone files in /opt/bind9/config/", "Configure zone files in /opt/bind9/config/",
@@ -642,14 +640,14 @@ const APP_TEMPLATES = {
], ],
docker: { docker: {
image: "coredns/coredns:latest", image: "coredns/coredns:latest",
ports: ["{{PORT}}:53", "53:53", "53:53/udp"], ports: ["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: "tcp://localhost:53", healthCheck: null,
subpathSupport: 'strip', subpathSupport: 'strip',
setupInstructions: [ setupInstructions: [
"Create Corefile in /opt/coredns/config/", "Create Corefile in /opt/coredns/config/",
@@ -1009,9 +1007,7 @@ 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"
} }
@@ -1103,7 +1099,6 @@ 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,
@@ -1131,7 +1126,6 @@ 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,
@@ -1914,9 +1908,7 @@ 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",
@@ -2241,9 +2233,7 @@ 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",
@@ -2268,9 +2258,7 @@ 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",
@@ -2429,7 +2417,7 @@ const APP_TEMPLATES = {
}, },
subdomain: "mc", subdomain: "mc",
defaultPort: 25565, defaultPort: 25565,
healthCheck: "tcp://localhost:25565", healthCheck: null,
subpathSupport: 'none', subpathSupport: 'none',
setupInstructions: [ setupInstructions: [
"Server accepts the Minecraft EULA automatically", "Server accepts the Minecraft EULA automatically",
@@ -2463,7 +2451,7 @@ const APP_TEMPLATES = {
}, },
subdomain: "valheim", subdomain: "valheim",
defaultPort: 2456, defaultPort: 2456,
healthCheck: "tcp://localhost:2456", healthCheck: null,
subpathSupport: 'none', subpathSupport: 'none',
setupInstructions: [ setupInstructions: [
"Connect via Steam: Add Server > IP:2456", "Connect via Steam: Add Server > IP:2456",
@@ -2471,6 +2459,74 @@ const APP_TEMPLATES = {
"World data is persisted in the data volume", "World data is persisted in the data volume",
"Requires at least 4GB RAM for smooth operation" "Requires at least 4GB RAM for smooth operation"
] ]
},
// === FILE MANAGEMENT — HOST-SERVICE TEMPLATES ===
// Sami Files is a host-systemd service, NOT a Docker container. The
// template exists so users get the right metadata + category in the App
// Selector, but the actual deployment is via `deploy/sami-files.service`
// unit + a Caddy reverse_proxy (see README in /opt/sami-files/deploy/).
// Service health is checked by probing the FastAPI /api/health endpoint
// on 127.0.0.1:8765; Caddy proxies the public URL.
"sami-files": {
name: "Sami Files",
description: "Multi-server SSH file manager — browse, edit, upload, and exec across all your machines from one browser tab",
icon: "📂",
logo: "/assets/sami-files.png",
category: "Files",
popularity: 80,
difficulty: "Intermediate",
isSystemdService: true,
systemdUnit: "sami-files.service",
healthCheck: "http://127.0.0.1:8765/api/health",
healthCheckExpect: "ok",
defaultPort: 8765,
subdomain: "files",
proxyPass: "http://127.0.0.1:8765",
subpathSupport: 'none',
externalConfig: {
// Where the source code / config lives on the host. DashCaddy reads
// these paths when generating a fresh setup via "Deploy" in the App
// Selector — they are informational for the systemd variant.
installDir: "/opt/sami-files",
configFile: "/opt/sami-files/config/servers.yaml",
serviceFile: "/opt/sami-files/deploy/sami-files.service",
logFile: "/opt/sami-files/logs/backend.log",
pythonVenv: "/usr/local/lib/hermes-agent/venv",
repo: "git.sami/sami7777/sami-files",
dependencies: [
"python3 >= 3.11 with uvicorn + asyncssh + pyyaml + fastapi",
"systemd >= 245 (for StandardOutput=append: journal syntax)"
],
caddySnippet: [
"files.sami {",
" reverse_proxy 127.0.0.1:8765",
" import dashcaddy_auth",
"}"
]
},
setupInstructions: [
"Clone the repo: git clone http://100.81.59.99:3030/sami7777/sami-files.git /opt/sami-files",
"Create venv and install deps: /usr/local/lib/hermes-agent/venv/bin/pip install fastapi uvicorn asyncssh pyyaml python-multipart",
"Copy deploy/sami-files.service to /etc/systemd/system/ and `systemctl daemon-reload`",
"Enable + start: systemctl enable --now sami-files.service",
"Edit /opt/sami-files/config/servers.yaml to add your SSH targets",
"Add the Caddy snippet (above) to your Caddyfile and reload Caddy",
"Browse to https://files.sami — log in via DashCaddy SSO"
],
troubleshooting: [
{
symptom: "Service fails to start with 'No such file or directory'",
fix: "Verify the python venv path in the .service file matches your installation (use `which python3` and update ExecStart accordingly)."
},
{
symptom: "Backend logs show 'Permission denied' on key file",
fix: "Run `chmod 600 /root/.ssh/<key>` for each key_file listed in servers.yaml — backend refuses to load keys with looser permissions."
},
{
symptom: "Browser shows 'Cannot connect' but systemctl says running",
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
}
]
} }
}; };
-503
View File
@@ -1,503 +0,0 @@
/**
* Auto-Restart Manager - Per-container restart policies with retry tracking
*
* When a container goes down, attempts automatic restart up to N times
* (configurable per-service). Sends notifications on each attempt and
* when max retries are exceeded. Integrates with HealthChecker events.
*
* @module auto-restart-manager
*/
const EventEmitter = require('events');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
/**
* Default policy values applied when a new policy is created.
* @readonly
*/
const DEFAULT_POLICY = {
enabled: true,
maxRetries: 3,
retryIntervalMs: 5000,
windowMinutes: 10,
currentRetries: 0,
lastRestartAt: null,
cooldownUntil: null,
};
/**
* Manages automatic container restart policies and execution.
*
* @extends EventEmitter
*
* @fires AutoRestartManager#auto-restart-attempt
* @fires AutoRestartManager#auto-restart-success
* @fires AutoRestartManager#auto-restart-failed
* @fires AutoRestartManager#auto-restart-max-reached
*/
class AutoRestartManager extends EventEmitter {
/**
* @param {Object} ctx - Shared application context
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
* @param {Object} ctx.healthChecker - HealthChecker singleton
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {Function} ctx.logError - Error logging function
* @param {string} ctx.SERVICES_FILE - Path to services.json (used to derive data dir)
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_ctx, err) => console.error(err));
this.docker = ctx.docker;
this.healthChecker = ctx.healthChecker;
this.notification = ctx.notification;
/** @type {Map<string, Object>} serviceId -> policy */
this.policies = new Map();
/** Path to the JSON file that persists policies */
this.policiesFile = path.join(path.dirname(ctx.SERVICES_FILE), 'auto-restart-policies.json');
/** Track previous health status per service for transition detection */
this._previousHealth = new Map();
/** Bound handlers so we can remove them on stop() */
this._onStatusCheck = this._handleStatusCheck.bind(this);
this._started = false;
}
// ─── Lifecycle ────────────────────────────────────────────────────────
/**
* Load persisted policies, then wire into HealthChecker events.
* @returns {Promise<void>}
*/
async start() {
if (this._started) return;
// Load persisted policies from disk
try {
const data = await readJsonFile(this.policiesFile, {});
for (const [serviceId, policy] of Object.entries(data)) {
this.policies.set(serviceId, { ...DEFAULT_POLICY, ...policy });
}
this.log.info('auto-restart', 'Policies loaded', { count: this.policies.size });
} catch (err) {
this.log.error('auto-restart', 'Failed to load policies', { error: err.message });
}
// Listen to health checker status transitions
if (this.healthChecker) {
this.healthChecker.on('status-check', this._onStatusCheck);
}
this._started = true;
this.log.info('auto-restart', 'Manager started');
}
/**
* Remove event listeners and stop processing health events.
*/
stop() {
if (!this._started) return;
if (this.healthChecker) {
this.healthChecker.removeListener('status-check', this._onStatusCheck);
}
this._started = false;
this.log.info('auto-restart', 'Manager stopped');
}
// ─── Policy CRUD ─────────────────────────────────────────────────────
/**
* Create or update a restart policy for a service.
*
* @param {string} serviceId - Unique service identifier
* @param {Object} policy - Partial policy fields to merge
* @param {boolean} [policy.enabled=true]
* @param {number} [policy.maxRetries=3]
* @param {number} [policy.retryIntervalMs=5000]
* @param {number} [policy.windowMinutes=10]
* @returns {Promise<Object>} The resulting policy
* @throws {Error} If serviceId is invalid
*/
async setPolicy(serviceId, policy) {
if (!serviceId || typeof serviceId !== 'string') {
throw new Error('serviceId is required');
}
const existing = this.policies.get(serviceId) || { ...DEFAULT_POLICY, serviceId };
const merged = {
...existing,
...policy,
serviceId,
// Never allow caller to override runtime counters directly
currentRetries: existing.currentRetries || 0,
lastRestartAt: existing.lastRestartAt,
cooldownUntil: existing.cooldownUntil,
};
this.policies.set(serviceId, merged);
await this._savePolicies();
this.log.info('auto-restart', 'Policy set', { serviceId, enabled: merged.enabled });
return { ...merged };
}
/**
* Retrieve the policy for a service.
*
* @param {string} serviceId
* @returns {Object|null} Policy object or null if none exists
*/
getPolicy(serviceId) {
const policy = this.policies.get(serviceId);
return policy ? { ...policy } : null;
}
/**
* Return all policies as an array.
* @returns {Object[]}
*/
listPolicies() {
return Array.from(this.policies.values()).map(p => ({ ...p }));
}
/**
* Remove a service's restart policy.
*
* @param {string} serviceId
* @returns {Promise<boolean>} true if a policy was removed
*/
async removePolicy(serviceId) {
if (!this.policies.has(serviceId)) return false;
this.policies.delete(serviceId);
await this._savePolicies();
this.log.info('auto-restart', 'Policy removed', { serviceId });
return true;
}
// ─── Core Restart Logic ──────────────────────────────────────────────
/**
* Called when a container is detected as down.
*
* Checks policy, cooldown, and retry count, then either attempts a
* Docker restart or notifies that max retries were exceeded.
*
* @param {string} serviceId - Service identifier
* @param {string} containerId - Docker container ID to restart
* @returns {Promise<Object>} Result of the operation
*/
async handleContainerDown(serviceId, containerId) {
const policy = this.policies.get(serviceId);
if (!policy) {
return { action: 'ignored', reason: 'no-policy' };
}
if (!policy.enabled) {
return { action: 'ignored', reason: 'disabled' };
}
// Check cooldown window
const now = Date.now();
if (policy.cooldownUntil && now < policy.cooldownUntil) {
this.log.info('auto-restart', 'Skipping — cooldown active', {
serviceId,
cooldownUntil: new Date(policy.cooldownUntil).toISOString(),
});
return { action: 'skipped', reason: 'cooldown' };
}
// Max retries exceeded — notify and enter cooldown
if (policy.currentRetries >= policy.maxRetries) {
const cooldownMs = policy.windowMinutes * 60 * 1000;
policy.cooldownUntil = now + cooldownMs;
policy.currentRetries = 0; // Reset so next window can try again
await this._savePolicies();
const eventData = {
serviceId,
containerId,
maxRetries: policy.maxRetries,
cooldownUntil: policy.cooldownUntil,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-max-reached
* @type {Object}
*/
this.emit('auto-restart-max-reached', eventData);
// Send notification
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `⛔ Max auto-restart retries (${policy.maxRetries}) exceeded for "${serviceId}". Cooldown until ${new Date(policy.cooldownUntil).toISOString()}.`,
...eventData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
return { action: 'max-reached', ...eventData };
}
// Wait for the configured retry interval before attempting
if (policy.retryIntervalMs > 0 && policy.lastRestartAt) {
const elapsed = now - new Date(policy.lastRestartAt).getTime();
if (elapsed < policy.retryIntervalMs) {
const waitMs = policy.retryIntervalMs - elapsed;
this.log.info('auto-restart', 'Waiting for retry interval', { serviceId, waitMs });
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
// Attempt restart
policy.currentRetries += 1;
const attemptNum = policy.currentRetries;
const maxRetries = policy.maxRetries;
/**
* @event AutoRestartManager#auto-restart-attempt
* @type {Object}
*/
this.emit('auto-restart-attempt', {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
timestamp: new Date().toISOString(),
});
try {
if (!this.docker?.client) {
throw new Error('Docker client not available');
}
const container = this.docker.client.getContainer(containerId);
await container.start();
policy.lastRestartAt = new Date().toISOString();
await this._savePolicies();
const successData = {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-success
* @type {Object}
*/
this.emit('auto-restart-success', successData);
// Notify
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `🔄 Auto-restart attempt ${attemptNum}/${maxRetries} succeeded for "${serviceId}".`,
...successData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
this.log.info('auto-restart', 'Container restarted', {
serviceId,
attempt: attemptNum,
maxRetries,
});
return { action: 'restarted', ...successData };
} catch (restartErr) {
policy.lastRestartAt = new Date().toISOString();
await this._savePolicies();
const failData = {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
error: restartErr.message,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-failed
* @type {Object}
*/
this.emit('auto-restart-failed', failData);
// Notify
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `❌ Auto-restart attempt ${attemptNum}/${maxRetries} failed for "${serviceId}": ${restartErr.message}`,
...failData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
this.log.error('auto-restart', 'Restart failed', {
serviceId,
attempt: attemptNum,
error: restartErr.message,
});
return { action: 'failed', ...failData };
}
}
/**
* Called when a container recovers to healthy state.
* Resets the retry counter for the associated service.
*
* @param {string} serviceId
* @returns {Promise<void>}
*/
async handleContainerUp(serviceId) {
const policy = this.policies.get(serviceId);
if (!policy) return;
if (policy.currentRetries > 0) {
policy.currentRetries = 0;
policy.cooldownUntil = null;
await this._savePolicies();
this.log.info('auto-restart', 'Retries reset after recovery', { serviceId });
}
}
// ─── Health Event Bridge ─────────────────────────────────────────────
/**
* Internal handler for HealthChecker `status-check` events.
* Detects healthy→unhealthy and unhealthy→healthy transitions for tracked services.
*
* @param {Object} status - HealthChecker status object
* @param {string} status.serviceId
* @param {string} status.status - "up" or "down"
* @private
*/
async _handleStatusCheck(status) {
const { serviceId, status: currentStatus } = status;
if (!serviceId) return;
// Only process services that have a restart policy
if (!this.policies.has(serviceId)) return;
const previousStatus = this._previousHealth.get(serviceId);
this._previousHealth.set(serviceId, currentStatus);
// Transition: healthy → unhealthy
if (previousStatus === 'up' && currentStatus === 'down') {
// Find the containerId from the health checker config or status details
const containerId = this._resolveContainerId(serviceId, status);
if (containerId) {
try {
await this.handleContainerDown(serviceId, containerId);
} catch (err) {
this.logError('auto-restart-health-bridge', err);
}
}
}
// Transition: unhealthy → healthy (recovery)
if (previousStatus === 'down' && currentStatus === 'up') {
try {
await this.handleContainerUp(serviceId);
} catch (err) {
this.logError('auto-restart-health-bridge', err);
}
}
}
/**
* Attempt to find the containerId for a service from various sources.
*
* @param {string} serviceId
* @param {Object} status - The status-check event data
* @returns {string|null}
* @private
*/
_resolveContainerId(serviceId, status) {
// Check if it's in the status details (some health checks embed it)
if (status.details?.containerId) return status.details.containerId;
// Look in the health checker config
const hcService = this.healthChecker?.config?.services?.[serviceId];
if (hcService?.containerId) return hcService.containerId;
// Try to look it up from the services state manager
try {
const servicesStateManager = this.ctx.servicesStateManager;
if (servicesStateManager) {
const readResult = servicesStateManager.read();
if (readResult && typeof readResult.then === 'function') {
// It returns a promise — fire-and-forget lookup
readResult.then(list => {
const found = (list || []).find(s => s.id === serviceId);
return found?.containerId || null;
}).catch(() => null);
} else {
const found = (readResult || []).find(s => s.id === serviceId);
if (found?.containerId) return found.containerId;
}
}
} catch (_) { /* best effort */ }
return null;
}
// ─── Persistence ─────────────────────────────────────────────────────
/**
* Persist current policies to disk.
* @returns {Promise<void>}
* @private
*/
async _savePolicies() {
try {
const obj = {};
for (const [serviceId, policy] of this.policies.entries()) {
obj[serviceId] = { ...policy };
}
await writeJsonFile(this.policiesFile, obj);
} catch (err) {
this.log.error('auto-restart', 'Failed to save policies', { error: err.message });
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
/**
* Send a notification via the notification manager.
*
* @param {string} event - Event type (e.g. 'auto-restart')
* @param {Object} data - Notification payload
* @returns {Promise<Object>}
* @private
*/
async _notify(event, data) {
if (this.notification?.send) {
return this.notification.send(event, data);
}
return { success: false, reason: 'no-notification-manager' };
}
}
module.exports = { AutoRestartManager, DEFAULT_POLICY };
+77 -323
View File
@@ -9,6 +9,15 @@ const { execSync } = require('child_process');
const crypto = require('crypto'); const crypto = require('crypto');
const EventEmitter = require('events'); const EventEmitter = require('events');
// Format bytes to human readable string
function formatBytes(bytes) {
if (bytes === 0 || bytes === undefined || bytes === null) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json'); const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json');
const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json'); const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
@@ -20,14 +29,6 @@ class BackupManager extends EventEmitter {
this.history = this.loadHistory(); this.history = this.loadHistory();
this.scheduledJobs = new Map(); this.scheduledJobs = new Map();
this.running = false; this.running = false;
this.notificationManager = null;
}
/**
* Set the notification manager for sending backup notifications
*/
setNotificationManager(nm) {
this.notificationManager = nm;
} }
/** /**
@@ -83,7 +84,7 @@ class BackupManager extends EventEmitter {
case 'monthly': case 'monthly':
intervalMs = 30 * 24 * 60 * 60 * 1000; intervalMs = 30 * 24 * 60 * 60 * 1000;
break; break;
default: { default:
// Custom interval in minutes // Custom interval in minutes
const minutes = parseInt(backup.schedule, 10); const minutes = parseInt(backup.schedule, 10);
if (!isNaN(minutes) && minutes > 0) { if (!isNaN(minutes) && minutes > 0) {
@@ -93,7 +94,6 @@ class BackupManager extends EventEmitter {
return; return;
} }
} }
}
// Schedule the job // Schedule the job
const job = setInterval(() => { const job = setInterval(() => {
@@ -184,15 +184,12 @@ class BackupManager extends EventEmitter {
await this.cleanupOldBackups(name, backup.retention); await this.cleanupOldBackups(name, backup.retention);
} }
this.emit('backup-complete', historyEntry); // Enforce storage limit (delete oldest until within maxStorageBytes)
if (backup.maxStorageBytes) {
// Send notification if manager is configured await this.enforceStorageLimit(name, backup.maxStorageBytes);
if (this.notificationManager) {
this.notificationManager.sendBackupComplete(historyEntry).catch(err => {
console.error('[BackupManager] Failed to send backup-complete notification:', err.message);
});
} }
this.emit('backup-complete', historyEntry);
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`); console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
return historyEntry; return historyEntry;
@@ -210,13 +207,6 @@ class BackupManager extends EventEmitter {
this.addToHistory(historyEntry); this.addToHistory(historyEntry);
this.emit('backup-failed', historyEntry); this.emit('backup-failed', historyEntry);
// Send notification if manager is configured
if (this.notificationManager) {
this.notificationManager.sendBackupFailed(historyEntry).catch(err => {
console.error('[BackupManager] Failed to send backup-failed notification:', err.message);
});
}
throw error; throw error;
} }
} }
@@ -566,36 +556,11 @@ class BackupManager extends EventEmitter {
switch (destination.type) { switch (destination.type) {
case 'local': case 'local':
return await this.saveToLocal(data, destination, backupId); return await this.saveToLocal(data, destination, backupId);
case 'dropbox':
return await this.saveToDropbox(data, destination, backupId);
case 'webdav':
return await this.saveToWebDAV(data, destination, backupId);
case 'sftp':
return await this.saveToSFTP(data, destination, backupId);
default: default:
throw new Error(`Unsupported destination type: ${destination.type}`); throw new Error(`Unsupported destination type: ${destination.type}`);
} }
} }
/**
* Load encrypted backup blob from a destination location.
* Returns a Buffer that can be passed to decryptBackup/decompressBackup.
*/
async loadFromDestination(location) {
switch (location.type) {
case 'local':
return fs.readFileSync(location.path);
case 'dropbox':
return await this.loadFromDropbox(location);
case 'webdav':
return await this.loadFromWebDAV(location);
case 'sftp':
return await this.loadFromSFTP(location);
default:
throw new Error(`Unsupported destination type: ${location.type}`);
}
}
/** /**
* Save to local filesystem * Save to local filesystem
*/ */
@@ -619,257 +584,6 @@ class BackupManager extends EventEmitter {
}; };
} }
// ==================== CLOUD DESTINATIONS ====================
/**
* Resolve credentials for a given provider via the credentialManager.
* Throws if required fields are missing.
*/
async _getCloudCredentials(provider) {
const credentialManager = require('./credential-manager');
const creds = {};
if (provider === 'dropbox') {
creds.token = await credentialManager.retrieve('backup.dropbox.token');
if (!creds.token) throw new Error('Dropbox token not configured');
} else if (provider === 'webdav') {
creds.url = await credentialManager.retrieve('backup.webdav.url');
creds.username = await credentialManager.retrieve('backup.webdav.username');
creds.password = await credentialManager.retrieve('backup.webdav.password');
if (!creds.url || !creds.username || !creds.password) {
throw new Error('WebDAV credentials incomplete (need url, username, password)');
}
} else if (provider === 'sftp') {
creds.host = await credentialManager.retrieve('backup.sftp.host');
const portStr = await credentialManager.retrieve('backup.sftp.port');
creds.port = parseInt(portStr || '22', 10);
creds.username = await credentialManager.retrieve('backup.sftp.username');
creds.password = await credentialManager.retrieve('backup.sftp.password');
creds.privateKey = await credentialManager.retrieve('backup.sftp.privateKey');
if (!creds.host || !creds.username || (!creds.password && !creds.privateKey)) {
throw new Error('SFTP credentials incomplete (need host, username, and either password or privateKey)');
}
}
return creds;
}
// ----- Dropbox -----
async saveToDropbox(data, destination, backupId) {
const { Dropbox } = require('dropbox');
const creds = await this._getCloudCredentials('dropbox');
const dbx = new Dropbox({ accessToken: creds.token });
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
const remotePath = `${folder}/${backupId}.backup`;
await dbx.filesUpload({
path: remotePath,
contents: data,
mode: { '.tag': 'overwrite' },
autorename: false,
mute: true
});
return {
type: 'dropbox',
path: remotePath,
size: data.length
};
}
async loadFromDropbox(location) {
const { Dropbox } = require('dropbox');
const creds = await this._getCloudCredentials('dropbox');
const dbx = new Dropbox({ accessToken: creds.token });
const result = await dbx.filesDownload({ path: location.path });
// Node SDK returns fileBinary on the result
const fileBinary = result.result.fileBinary || result.result.fileBlob;
if (Buffer.isBuffer(fileBinary)) return fileBinary;
return Buffer.from(fileBinary);
}
// ----- WebDAV -----
async saveToWebDAV(data, destination, backupId) {
const { createClient } = require('webdav');
const creds = await this._getCloudCredentials('webdav');
const client = createClient(creds.url, {
username: creds.username,
password: creds.password
});
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
// Ensure folder exists
try {
const exists = await client.exists(folder);
if (!exists) await client.createDirectory(folder, { recursive: true });
} catch (_) {
// best-effort
}
const remotePath = `${folder}/${backupId}.backup`;
await client.putFileContents(remotePath, data, { overwrite: true });
return {
type: 'webdav',
path: remotePath,
size: data.length
};
}
async loadFromWebDAV(location) {
const { createClient } = require('webdav');
const creds = await this._getCloudCredentials('webdav');
const client = createClient(creds.url, {
username: creds.username,
password: creds.password
});
const data = await client.getFileContents(location.path);
return Buffer.isBuffer(data) ? data : Buffer.from(data);
}
// ----- SFTP -----
async saveToSFTP(data, destination, backupId) {
const SftpClient = require('ssh2-sftp-client');
const creds = await this._getCloudCredentials('sftp');
const client = new SftpClient();
try {
await client.connect({
host: creds.host,
port: creds.port,
username: creds.username,
password: creds.password || undefined,
privateKey: creds.privateKey || undefined
});
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
// Ensure remote dir exists
try {
const exists = await client.exists(folder);
if (!exists) await client.mkdir(folder, true);
} catch (_) {
// best-effort
}
const remotePath = `${folder}/${backupId}.backup`;
await client.put(Buffer.from(data), remotePath);
return {
type: 'sftp',
path: remotePath,
size: data.length
};
} finally {
try { await client.end(); } catch (_) { /* ignore */ }
}
}
async loadFromSFTP(location) {
const SftpClient = require('ssh2-sftp-client');
const creds = await this._getCloudCredentials('sftp');
const client = new SftpClient();
try {
await client.connect({
host: creds.host,
port: creds.port,
username: creds.username,
password: creds.password || undefined,
privateKey: creds.privateKey || undefined
});
const buffer = await client.get(location.path);
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
} finally {
try { await client.end(); } catch (_) { /* ignore */ }
}
}
/**
* Test that a destination is reachable + writable + deletable.
* Performs a small write/read/delete probe.
*/
async testDestination(destination) {
const probeId = `test-${Date.now()}`;
const probeData = Buffer.from(`dashcaddy-test-${probeId}`);
const start = Date.now();
try {
const location = await this.saveToDestination(probeData, destination, probeId);
// Read it back
let readBack = null;
try {
readBack = await this.loadFromDestination(location);
} catch (_) {
// Some providers (e.g. local) we already trust the file system; skip
}
// Delete the probe
try {
await this._deleteFromDestination(location);
} catch (_) { /* ignore */ }
const elapsed = Date.now() - start;
return {
success: true,
type: destination.type,
elapsedMs: elapsed,
verified: readBack ? readBack.equals(probeData) : null
};
} catch (error) {
return {
success: false,
type: destination.type,
error: error.message,
elapsedMs: Date.now() - start
};
}
}
/**
* Delete a backup from a destination location
*/
async _deleteFromDestination(location) {
if (location.type === 'local') {
if (fs.existsSync(location.path)) fs.unlinkSync(location.path);
return;
}
if (location.type === 'dropbox') {
const { Dropbox } = require('dropbox');
const creds = await this._getCloudCredentials('dropbox');
const dbx = new Dropbox({ accessToken: creds.token });
try { await dbx.filesDeleteV2({ path: location.path }); } catch (_) { /* ignore */ }
return;
}
if (location.type === 'webdav') {
const { createClient } = require('webdav');
const creds = await this._getCloudCredentials('webdav');
const client = createClient(creds.url, { username: creds.username, password: creds.password });
try { await client.deleteFile(location.path); } catch (_) { /* ignore */ }
return;
}
if (location.type === 'sftp') {
const SftpClient = require('ssh2-sftp-client');
const creds = await this._getCloudCredentials('sftp');
const client = new SftpClient();
try {
await client.connect({
host: creds.host,
port: creds.port,
username: creds.username,
password: creds.password || undefined,
privateKey: creds.privateKey || undefined
});
try { await client.delete(location.path); } catch (_) { /* ignore */ }
} finally {
try { await client.end(); } catch (_) { /* ignore */ }
}
return;
}
}
/** /**
* Verify backup integrity * Verify backup integrity
*/ */
@@ -904,24 +618,9 @@ class BackupManager extends EventEmitter {
throw new Error(`Backup not found: ${backupId}`); throw new Error(`Backup not found: ${backupId}`);
} }
// Load backup data — try each destination location until one succeeds // Load backup data
const location = backup.locations[0]; // Primary location const location = backup.locations[0]; // Use first location
let data; let data = fs.readFileSync(location.path);
try {
data = await this.loadFromDestination(location);
} catch (loadErr) {
// Fall back to other locations if available
let recovered = false;
for (let i = 1; i < backup.locations.length; i++) {
try {
data = await this.loadFromDestination(backup.locations[i]);
recovered = true;
console.log(`[BackupManager] Loaded backup from fallback location ${backup.locations[i].type}`);
break;
} catch (_) { /* ignore */ }
}
if (!recovered) throw loadErr;
}
// Decrypt if needed // Decrypt if needed
if (backup.encrypted && options.encryptionKey) { if (backup.encrypted && options.encryptionKey) {
@@ -1018,6 +717,63 @@ class BackupManager extends EventEmitter {
console.log('[BackupManager] Stats restored'); console.log('[BackupManager] Stats restored');
} }
/**
* Enforce storage limit by deleting oldest backups until total is within limit
*/
async enforceStorageLimit(name, maxBytes) {
const maxStr = formatBytes(maxBytes);
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
const backups = this.history
.filter(b => b.name === name && b.status === 'success')
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
let totalSize = 0;
const locationsMap = {};
for (const backup of backups) {
for (const loc of backup.locations || []) {
if (loc.type === 'local' && loc.path) {
totalSize += loc.size || 0;
locationsMap[backup.id] = locationsMap[backup.id] || [];
locationsMap[backup.id].push(loc.path);
}
}
}
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
if (totalSize <= maxBytes) {
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
return;
}
let freed = 0;
for (const backup of backups) {
if (totalSize <= maxBytes) break;
const paths = locationsMap[backup.id] || [];
for (const path of paths) {
try {
if (fs.existsSync(path)) {
fs.unlinkSync(path);
const sz = backup.size || 0;
totalSize -= sz;
freed += sz;
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
}
} catch (error) {
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
}
}
this.history = this.history.filter(b => b.id !== backup.id);
}
this.saveHistory();
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
}
/** /**
* Cleanup old backups based on retention policy * Cleanup old backups based on retention policy
*/ */
@@ -1032,12 +788,10 @@ class BackupManager extends EventEmitter {
for (const backup of toDelete) { for (const backup of toDelete) {
try { try {
// Delete from all locations (local + cloud) // Delete from all locations
for (const location of backup.locations) { for (const location of backup.locations) {
try { if (location.type === 'local' && fs.existsSync(location.path)) {
await this._deleteFromDestination(location); fs.unlinkSync(location.path);
} catch (delErr) {
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
} }
} }
-376
View File
@@ -1,376 +0,0 @@
/**
* Config Drift Detector - Compares services.json with live Docker state
*
* Detects discrepancies between the configured service list and what is
* actually running in Docker, including missing containers, unknown
* containers, port mismatches, state mismatches, and stale records.
*
* @module config-drift-detector
*/
const EventEmitter = require('events');
/**
* @typedef {Object} DriftReport
* @property {string} checkedAt - ISO timestamp of the check
* @property {Object[]} missingContainers - Services with containerId but container absent in Docker
* @property {Object[]} unknownContainers - Running Docker containers with sami.managed label but not in services.json
* @property {Object[]} portMismatch - Service port != container mapped port
* @property {Object[]} stateMismatch - Service expected up but container stopped/absent
* @property {Object[]} staleRecords - Services with containerId pointing to removed containers
* @property {boolean} hasDrift - Whether any drift category is non-empty
*/
/**
* Detects and reports configuration drift between services.json and Docker.
*
* @extends EventEmitter
*
* @fires ConfigDriftDetector#drift-detected
*/
class ConfigDriftDetector extends EventEmitter {
/**
* @param {Object} ctx - Shared application context
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
* @param {Object} ctx.servicesStateManager - StateManager for services.json
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {Function} ctx.logError - Error logging function
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_c, err) => console.error(err));
this.docker = ctx.docker;
this.servicesStateManager = ctx.servicesStateManager;
this.notification = ctx.notification;
/** @type {DriftReport|null} Cached report from last detection */
this.lastReport = null;
/** @type {NodeJS.Timeout|null} Polling timer reference */
this._pollTimer = null;
/** Whether polling is currently active */
this._polling = false;
}
// ─── Detection ───────────────────────────────────────────────────────
/**
* Run a full drift detection and return the report.
*
* Reads services from servicesStateManager and live containers from Docker,
* then compares them across five drift categories.
*
* @returns {Promise<DriftReport>}
*/
async detect() {
const checkedAt = new Date().toISOString();
// Gather configured services
let services = [];
try {
const data = await this.servicesStateManager.read();
services = Array.isArray(data) ? data : (data.services || []);
} catch (err) {
this.log.error('drift', 'Failed to read services', { error: err.message });
}
// Gather live Docker containers
let containers = [];
try {
containers = await this.docker.client.listContainers({ all: true });
} catch (err) {
this.log.error('drift', 'Failed to list containers', { error: err.message });
}
// Build lookup maps
const containerById = new Map(); // containerId (short or long) → container info
const containerByName = new Map(); // container name → container info
for (const c of containers) {
// Store by full ID
containerById.set(c.Id, c);
// Store by short ID (first 12 chars)
if (c.Id && c.Id.length >= 12) {
containerById.set(c.Id.substring(0, 12), c);
}
// Store by name (strip leading /)
for (const name of (c.Names || [])) {
containerByName.set(name.replace(/^\//, ''), c);
}
}
// Build set of service containerIds for reverse lookup
const serviceContainerIds = new Set();
const serviceByContainerId = new Map();
for (const svc of services) {
if (svc.containerId) {
serviceContainerIds.add(svc.containerId);
// Index by both full and short ID
serviceByContainerId.set(svc.containerId, svc);
if (svc.containerId.length >= 12) {
serviceByContainerId.set(svc.containerId.substring(0, 12), svc);
}
}
}
const missingContainers = [];
const portMismatch = [];
const stateMismatch = [];
const staleRecords = [];
for (const svc of services) {
if (!svc.containerId) continue;
// Look up the container
const container = containerById.get(svc.containerId)
|| containerById.get(svc.containerId.substring(0, 12));
if (!container) {
// Container ID referenced but not found in Docker at all
staleRecords.push({
serviceId: svc.id,
name: svc.name,
containerId: svc.containerId,
reason: 'Container not found in Docker',
});
continue;
}
// Missing container — service expects it but it's not running
if (container.State !== 'running') {
missingContainers.push({
serviceId: svc.id,
name: svc.name,
containerId: svc.containerId,
containerState: container.State,
containerStatus: container.Status,
});
// Also a state mismatch if the service is expected to be up
stateMismatch.push({
serviceId: svc.id,
name: svc.name,
expectedState: 'running',
actualState: container.State,
containerId: svc.containerId,
});
}
// Port mismatch detection
if (svc.port && container.State === 'running') {
const actualPorts = this._extractContainerPorts(container);
if (actualPorts.length > 0 && !actualPorts.includes(svc.port)) {
portMismatch.push({
serviceId: svc.id,
name: svc.name,
configuredPort: svc.port,
actualPorts,
containerId: svc.containerId,
});
}
}
}
// Unknown managed containers: Docker containers with sami.managed label
// that are NOT in services.json
const unknownContainers = [];
for (const c of containers) {
const isManaged = c.Labels && c.Labels['sami.managed'] === 'true';
if (!isManaged) continue;
const isInServices = serviceByContainerId.has(c.Id)
|| serviceByContainerId.has(c.Id.substring(0, 12));
if (!isInServices) {
unknownContainers.push({
containerId: c.Id,
name: (c.Names && c.Names[0] || '').replace(/^\//, ''),
image: c.Image,
state: c.State,
status: c.Status,
app: c.Labels?.['sami.app'] || null,
subdomain: c.Labels?.['sami.subdomain'] || null,
});
}
}
const report = {
checkedAt,
missingContainers,
unknownContainers,
portMismatch,
stateMismatch,
staleRecords,
hasDrift: missingContainers.length > 0
|| unknownContainers.length > 0
|| portMismatch.length > 0
|| stateMismatch.length > 0
|| staleRecords.length > 0,
};
// Cache for quick API access
this.lastReport = report;
// Emit and notify if drift detected
if (report.hasDrift) {
/**
* @event ConfigDriftDetector#drift-detected
* @type {DriftReport}
*/
this.emit('drift-detected', report);
try {
await this._sendDriftNotification(report);
} catch (notifErr) {
this.log.error('drift', 'Failed to send drift notification', {
error: notifErr.message,
});
}
}
this.log.info('drift', 'Detection complete', {
hasDrift: report.hasDrift,
missing: report.missingContainers.length,
unknown: report.unknownContainers.length,
portMismatch: report.portMismatch.length,
stateMismatch: report.stateMismatch.length,
stale: report.staleRecords.length,
});
return report;
}
// ─── Auto-fix ────────────────────────────────────────────────────────
/**
* Attempt to auto-fix drift:
* - Remove stale records (services referencing removed containers)
* - Flag unknown containers for review
*
* @returns {Promise<{ staleRemoved: number, unknownFlagged: number }>}
*/
async autoFix() {
const report = await this.detect();
let staleRemoved = 0;
// Remove stale records from services.json
if (report.staleRecords.length > 0) {
const staleIds = new Set(report.staleRecords.map(r => r.serviceId));
await this.servicesStateManager.update(services => {
const before = services.length;
const cleaned = services.filter(s => !staleIds.has(s.id));
staleRemoved = before - cleaned.length;
return cleaned;
});
}
const unknownFlagged = report.unknownContainers.length;
this.log.info('drift', 'Auto-fix applied', { staleRemoved, unknownFlagged });
return { staleRemoved, unknownFlagged };
}
// ─── Polling ─────────────────────────────────────────────────────────
/**
* Start periodic drift detection.
*
* @param {number} [intervalMs=300000] - Polling interval in milliseconds (default 5 min)
*/
startPolling(intervalMs = 300000) {
this.stopPolling();
this._polling = true;
this._pollTimer = setInterval(async () => {
try {
await this.detect();
} catch (err) {
this.logError('drift-poll', err);
}
}, intervalMs);
this.log.info('drift', 'Polling started', { intervalMs });
}
/**
* Stop periodic drift detection.
*/
stopPolling() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
this._polling = false;
this.log.info('drift', 'Polling stopped');
}
/**
* Whether polling is currently active.
* @returns {boolean}
*/
isPolling() {
return this._polling;
}
// ─── Helpers ─────────────────────────────────────────────────────────
/**
* Extract mapped host ports from a Docker container info object.
*
* @param {Object} container - Dockerode container info
* @returns {number[]} Array of host port numbers
* @private
*/
_extractContainerPorts(container) {
const ports = [];
if (!container.Ports) return ports;
for (const p of container.Ports) {
if (p.PublicPort) {
ports.push(p.PublicPort);
}
}
return ports;
}
/**
* Send a notification about detected drift.
*
* @param {DriftReport} report
* @returns {Promise<Object>}
* @private
*/
async _sendDriftNotification(report) {
if (!this.notification?.send) {
return { success: false, reason: 'no-notification-manager' };
}
const parts = [];
if (report.missingContainers.length > 0) {
parts.push(`Missing containers: ${report.missingContainers.map(c => c.name).join(', ')}`);
}
if (report.unknownContainers.length > 0) {
parts.push(`Unknown managed containers: ${report.unknownContainers.map(c => c.name).join(', ')}`);
}
if (report.portMismatch.length > 0) {
parts.push(`Port mismatches: ${report.portMismatch.map(c => c.name).join(', ')}`);
}
if (report.staleRecords.length > 0) {
parts.push(`Stale records: ${report.staleRecords.map(c => c.name).join(', ')}`);
}
return this.notification.send('drift-detected', {
text: `⚠️ Configuration drift detected:\n${parts.join('\n')}`,
report,
});
}
}
module.exports = { ConfigDriftDetector };
-9
View File
@@ -59,15 +59,6 @@ function validateConfig(config) {
errors.push('dns.servers must be an object'); errors.push('dns.servers must be an object');
} }
} }
// DNS provider validation
if (config.dns.provider !== undefined) {
const validProviders = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
if (typeof config.dns.provider !== 'string') {
errors.push('dns.provider must be a string');
} else if (!validProviders.includes(config.dns.provider)) {
warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${validProviders.join(', ')}. It may still work if a custom adapter is installed.`);
}
}
} }
} }
+1 -1
View File
@@ -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: 300000, // 300s — timeout for docker pull/create operations TIMEOUT: 30000, // 30s — 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
+1 -20
View File
@@ -10,26 +10,7 @@ const lockfile = require('proper-lockfile');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// Resolve credentials file path — supports both standard install (/app/credentials.json) const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, '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() {
+2 -20
View File
@@ -15,26 +15,8 @@ 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;
// Resolve encryption key file path — supports both standard install (/app/.encryption-key) // Key file location (should be outside of mounted volumes for security)
// and custom deployments with consolidated data directory (/app/data/.encryption-key) const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.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;
+9 -4
View File
@@ -8,7 +8,6 @@
const crypto = require('crypto'); const crypto = require('crypto');
const cryptoUtils = require('./crypto-utils'); const cryptoUtils = require('./crypto-utils');
const { errorResponse } = require('./src/utils/responses');
const CSRF_TOKEN_LENGTH = 32; const CSRF_TOKEN_LENGTH = 32;
const CSRF_COOKIE_NAME = 'dashcaddy_csrf'; const CSRF_COOKIE_NAME = 'dashcaddy_csrf';
@@ -170,14 +169,18 @@ function csrfValidationMiddleware(req, res, next) {
// Validate both values exist // Validate both values exist
if (!cookieNonce) { if (!cookieNonce) {
console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`); console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', { return res.status(403).json({
success: false,
error: '[DC-100] CSRF token missing',
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.'
}); });
} }
if (!headerToken) { if (!headerToken) {
console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`); console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', { return res.status(403).json({
success: false,
error: '[DC-100] CSRF token missing',
message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.' message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.'
}); });
} }
@@ -201,7 +204,9 @@ function csrfValidationMiddleware(req, res, next) {
} catch (err) { } catch (err) {
console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`); console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`);
return errorResponse(res, 403, '[DC-101] CSRF token invalid', { return res.status(403).json({
success: false,
error: '[DC-101] CSRF token invalid',
message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.' message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.'
}); });
} }
+109
View File
@@ -0,0 +1,109 @@
[
{
"id": "router",
"name": "Router UI",
"logo": "/assets/router.png",
"url": "https://router.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "chat",
"name": "Chat",
"logo": "/assets/chat.png",
"url": "https://chat.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "sync",
"name": "Syncthing",
"logo": "/assets/syncthing.png",
"url": "https://sync.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "torrent",
"name": "qBittorrent",
"logo": "/assets/qBittorrent.png",
"url": "https://torrent.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:55.246Z"
},
{
"id": "sonarr",
"name": "Sonarr",
"logo": "/assets/sonarr.png",
"url": "https://sonarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:56.612Z"
},
{
"id": "radarr",
"name": "Radarr",
"logo": "/assets/radarr.png",
"url": "https://radarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:12.359Z"
},
{
"id": "prowlarr",
"name": "Prowlarr",
"logo": "/assets/prowlarr.png",
"url": "https://prowlarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:13.739Z"
},
{
"id": "ca",
"name": "DashCA",
"logo": "/assets/certificate-icon.png",
"containerId": null,
"appTemplate": "dashca",
"tailscaleOnly": false,
"deployedAt": "2026-02-11T11:47:08.383Z",
"url": "https://ca.sami"
},
{
"id": "plex",
"name": "Plex",
"logo": "/assets/plex.png",
"containerId": null,
"appTemplate": "plex",
"tailscaleOnly": false,
"deployedAt": "2026-02-12T02:18:36.067Z",
"url": "https://plex.sami"
},
{
"id": "requests",
"name": "Seerr",
"logo": "/assets/seerr.png",
"url": "https://requests.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "git",
"name": "Gitea",
"logo": "/assets/gitea.png",
"url": "https://git.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "files",
"name": "Sami Files",
"logo": "/assets/sami-files.png",
"url": "https://files.sami",
"ip": "localhost",
"tailscaleOnly": false,
"containerId": null,
"appTemplate": "sami-files",
"deployedAt": "2026-06-19T00:00:00.000Z"
}
]
-605
View File
@@ -1,605 +0,0 @@
/**
* Dependency Manager - Service dependency tracking with ordered restart chains
*
* Manages directed acyclic graph (DAG) of service dependencies. Services can
* declare which other services they depend on, and this manager provides:
* - Full dependency graph inspection
* - Topological ordering for safe restart chains
* - Circular dependency detection
* - Health-aware restart with per-service polling
*
* Dependencies are stored directly on service objects in services.json:
* { id, name, ..., dependsOn: ['service-id-1', 'service-id-2'] }
*
* @module dependency-manager
*/
const EventEmitter = require('events');
/** Maximum seconds to wait for a single container to become healthy after restart */
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
/** Interval between container health polls */
const HEALTH_CHECK_INTERVAL_MS = 1_000;
/**
* @typedef {Object} ServiceNode
* @property {string} serviceId
* @property {string} name
* @property {string|null} containerId
*/
/**
* @typedef {Object} DependencyEdge
* @property {string} from - The service that depends
* @property {string} to - The service being depended upon
*/
/**
* @typedef {Object} DependencyGraph
* @property {ServiceNode[]} nodes
* @property {DependencyEdge[]} edges
*/
/**
* @typedef {Object} DependencyStatusEntry
* @property {string} serviceId
* @property {string} name
* @property {boolean} isUp
* @property {string} [error]
*/
/**
* DependencyManager — tracks service dependencies and orchestrates ordered restarts.
*
* Events emitted:
* - `dependency-restart-start` ({ serviceId, chain: string[] })
* - `dependency-restart-progress` ({ serviceId, currentServiceId, index, total })
* - `dependency-restart-complete` ({ serviceId, chain: string[], results: Array })
* - `dependency-restart-failed` ({ serviceId, failedServiceId, error, chain: string[] })
*
* @extends EventEmitter
*/
class DependencyManager extends EventEmitter {
/**
* @param {Object} ctx - Application context
* @param {Object} ctx.servicesStateManager - StateManager for services.json
* @param {Object} ctx.docker - Docker context ({ client: Dockerode })
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
*/
constructor(ctx) {
super();
/** @private */
this.ctx = ctx;
/** @private */
this._servicesStateManager = ctx.servicesStateManager;
/** @private */
this._docker = ctx.docker;
/** @private */
this._notification = ctx.notification;
/** @private */
this._log = ctx.log || console;
}
// ---------------------------------------------------------------------------
// Core helpers
// ---------------------------------------------------------------------------
/**
* Load all services from the state manager.
* @private
* @returns {Promise<Object[]>}
*/
async _loadServices() {
const data = await this._servicesStateManager.read();
return Array.isArray(data) ? data : (data.services || []);
}
/**
* Find a single service by ID.
* @private
* @param {string} serviceId
* @returns {Promise<Object|null>}
*/
async _findService(serviceId) {
const services = await this._loadServices();
return services.find(s => s.id === serviceId) || null;
}
// ---------------------------------------------------------------------------
// Graph queries
// ---------------------------------------------------------------------------
/**
* Return the full dependency graph for visualisation.
*
* @returns {Promise<DependencyGraph>}
*/
async getDependencyGraph() {
const services = await this._loadServices();
const nodes = services.map(s => ({
serviceId: s.id,
name: s.name,
containerId: s.containerId || null,
}));
const edges = [];
for (const service of services) {
const deps = service.dependsOn || [];
for (const depId of deps) {
edges.push({ from: service.id, to: depId });
}
}
return { nodes, edges };
}
/**
* Return the services that depend on the given service (reverse deps).
*
* @param {string} serviceId
* @returns {Promise<Object[]>} Services whose `dependsOn` includes `serviceId`.
*/
async getDependents(serviceId) {
const services = await this._loadServices();
return services.filter(s => (s.dependsOn || []).includes(serviceId));
}
/**
* Return the direct dependencies for a service.
*
* @param {string} serviceId
* @returns {Promise<Object[]>} Services that `serviceId` depends on.
*/
async getDependencies(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) return [];
const depIds = service.dependsOn || [];
return services.filter(s => depIds.includes(s.id));
}
// ---------------------------------------------------------------------------
// Topological sort
// ---------------------------------------------------------------------------
/**
* Build an adjacency list for the current dependency graph.
* Edge direction: service → its dependencies (i.e. what it depends on).
*
* @private
* @param {Object[]} services
* @returns {Map<string, string[]>}
*/
_buildAdjacencyList(services) {
const adj = new Map();
for (const service of services) {
adj.set(service.id, (service.dependsOn || []).slice());
}
return adj;
}
/**
* DFS-based topological sort with cycle detection (white/gray/black coloring).
*
* Returns services in restart order: dependencies first, dependents last.
* The target service is included at the end.
*
* @private
* @param {string} serviceId - Target service (will be last in the result).
* @param {Object[]} services - All services.
* @param {Map<string, string[]>} adj - Adjacency list (service → deps).
* @returns {string[]} Ordered service IDs for restart.
* @throws {Error} If a circular dependency is detected.
*/
_topologicalSort(serviceId, services, adj) {
// Collect only the reachable sub-graph from serviceId
const visited = new Set();
const reachable = new Set();
const collectReachable = (id) => {
if (reachable.has(id)) return;
reachable.add(id);
for (const dep of (adj.get(id) || [])) {
collectReachable(dep);
}
};
collectReachable(serviceId);
// DFS topological sort on the reachable sub-graph
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map();
for (const id of reachable) color.set(id, WHITE);
const result = [];
const dfs = (id) => {
if (color.get(id) === BLACK) return;
if (color.get(id) === GRAY) {
throw new Error(`Circular dependency detected involving service "${id}"`);
}
color.set(id, GRAY);
for (const dep of (adj.get(id) || [])) {
dfs(dep);
}
color.set(id, BLACK);
result.push(id);
};
// Visit the target last so it ends up at the end of the result
// Actually, we want deps *first* then the target.
// The DFS naturally puts deps before dependents, so starting from
// serviceId will place it last (which is correct for restart order).
dfs(serviceId);
return result;
}
/**
* Get the topologically ordered restart chain for a service.
*
* The returned array lists all services that must be restarted,
* starting with leaf dependencies and ending with the target service.
*
* @param {string} serviceId - The service to build the chain for.
* @returns {Promise<string[]>} Ordered service IDs.
* @throws {Error} If `serviceId` doesn't exist or a circular dependency is found.
*/
async getOrderedRestartChain(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) {
throw new Error(`Service "${serviceId}" not found`);
}
const adj = this._buildAdjacencyList(services);
return this._topologicalSort(serviceId, services, adj);
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/**
* Validate a proposed set of dependencies for a service.
*
* Checks:
* - All referenced service IDs exist.
* - Adding these dependencies would not create a circular dependency.
* - A service cannot depend on itself.
*
* @param {string} serviceId - The service to set dependencies on.
* @param {string[]} dependsOn - Proposed dependency IDs.
* @returns {Promise<{ valid: boolean, errors: string[] }>}
*/
async validateDependencies(serviceId, dependsOn) {
const errors = [];
if (!Array.isArray(dependsOn)) {
return { valid: false, errors: ['dependsOn must be an array'] };
}
const services = await this._loadServices();
const allIds = new Set(services.map(s => s.id));
// Service must exist
if (!allIds.has(serviceId)) {
return { valid: false, errors: [`Service "${serviceId}" not found`] };
}
// Self-dependency
if (dependsOn.includes(serviceId)) {
errors.push(`Service "${serviceId}" cannot depend on itself`);
}
// Existence check
for (const depId of dependsOn) {
if (!allIds.has(depId)) {
errors.push(`Dependency service "${depId}" does not exist`);
}
}
if (errors.length > 0) {
return { valid: false, errors };
}
// Circular dependency check: temporarily set the proposed dependsOn
// and attempt a topological sort.
const tempServices = services.map(s => {
if (s.id === serviceId) {
return { ...s, dependsOn: dependsOn.slice() };
}
return { ...s };
});
const adj = this._buildAdjacencyList(tempServices);
// Check every node for cycles with the new edges
try {
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map();
for (const s of tempServices) color.set(s.id, WHITE);
const dfs = (id) => {
if (color.get(id) === BLACK) return;
if (color.get(id) === GRAY) {
throw new Error(`Circular dependency detected involving service "${id}"`);
}
color.set(id, GRAY);
for (const dep of (adj.get(id) || [])) {
dfs(dep);
}
color.set(id, BLACK);
};
for (const s of tempServices) {
if (color.get(s.id) === WHITE) {
dfs(s.id);
}
}
} catch (err) {
errors.push(err.message);
}
return { valid: errors.length === 0, errors };
}
// ---------------------------------------------------------------------------
// Health status
// ---------------------------------------------------------------------------
/**
* Get the current container status for a service and all its transitive dependencies.
*
* @param {string} serviceId
* @returns {Promise<DependencyStatusEntry[]>}
* @throws {Error} If `serviceId` doesn't exist.
*/
async getDependencyStatus(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) {
throw new Error(`Service "${serviceId}" not found`);
}
// Collect all transitive dependencies via BFS
const serviceMap = new Map(services.map(s => [s.id, s]));
const visited = new Set();
const queue = [serviceId];
const allRelated = [];
while (queue.length > 0) {
const currentId = queue.shift();
if (visited.has(currentId)) continue;
visited.add(currentId);
const svc = serviceMap.get(currentId);
if (!svc) continue;
allRelated.push(svc);
for (const depId of (svc.dependsOn || [])) {
if (!visited.has(depId)) {
queue.push(depId);
}
}
}
// Query container status for each
const results = [];
for (const svc of allRelated) {
const entry = {
serviceId: svc.id,
name: svc.name,
isUp: false,
};
if (!svc.containerId) {
entry.error = 'No container associated with this service';
results.push(entry);
continue;
}
try {
const container = this._docker.client.getContainer(svc.containerId);
const info = await container.inspect();
entry.isUp = info.State?.Running === true;
} catch (err) {
entry.error = err.message || 'Unable to inspect container';
}
results.push(entry);
}
return results;
}
// ---------------------------------------------------------------------------
// Restart with dependencies
// ---------------------------------------------------------------------------
/**
* Wait for a container to report as running after a restart.
*
* @private
* @param {string} containerId
* @param {number} [timeoutMs=30000]
* @returns {Promise<boolean>} `true` if healthy, `false` if timed out.
*/
async _waitForContainerHealthy(containerId, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const container = this._docker.client.getContainer(containerId);
const info = await container.inspect();
if (info.State?.Running === true) {
return true;
}
} catch {
// Container might not be inspectable during restart — keep polling
}
await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
}
return false;
}
/**
* Restart a service and all its dependencies in topological order.
*
* Emits progress events and sends a notification on completion/failure.
* This method is designed to be called from the route handler and
* **does not throw** — errors are reported via events and notifications.
*
* @param {string} serviceId - Target service to restart (with deps).
* @returns {Promise<{ success: boolean, chain: string[], results: Array }>}
*/
async restartWithDependencies(serviceId) {
const service = await this._findService(serviceId);
if (!service) {
const err = new Error(`Service "${serviceId}" not found`);
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: serviceId,
error: err.message,
chain: [],
});
throw err;
}
let chain;
try {
chain = await this.getOrderedRestartChain(serviceId);
} catch (err) {
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: serviceId,
error: err.message,
chain: [],
});
throw err;
}
const services = await this._loadServices();
const serviceMap = new Map(services.map(s => [s.id, s]));
this._log.info('dependency', 'Starting dependency restart chain', {
serviceId,
chain,
});
this.emit('dependency-restart-start', { serviceId, chain });
const results = [];
const total = chain.length;
for (let i = 0; i < total; i++) {
const currentId = chain[i];
const svc = serviceMap.get(currentId);
this.emit('dependency-restart-progress', {
serviceId,
currentServiceId: currentId,
index: i,
total,
});
if (!svc || !svc.containerId) {
const msg = !svc
? `Service "${currentId}" not found in state`
: `Service "${currentId}" has no container — skipping restart`;
this._log.warn('dependency', msg);
results.push({ serviceId: currentId, restarted: false, skipped: true, reason: msg });
continue;
}
try {
const container = this._docker.client.getContainer(svc.containerId);
this._log.info('dependency', `Restarting container for service "${currentId}"`, {
containerId: svc.containerId,
});
await container.restart();
// Wait for it to come back up
const healthy = await this._waitForContainerHealthy(svc.containerId);
if (!healthy) {
const msg = `Container for service "${currentId}" did not become healthy within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
this._log.warn('dependency', msg);
results.push({ serviceId: currentId, restarted: true, healthy: false, error: msg });
// Abort chain — dependency didn't come back
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: currentId,
error: msg,
chain,
});
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
return { success: false, chain, results };
}
this._log.info('dependency', `Service "${currentId}" is healthy after restart`);
results.push({ serviceId: currentId, restarted: true, healthy: true });
} catch (err) {
const msg = err.message || 'Unknown error during restart';
this._log.error('dependency', `Failed to restart service "${currentId}"`, {
error: msg,
});
results.push({ serviceId: currentId, restarted: false, error: msg });
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: currentId,
error: msg,
chain,
});
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
return { success: false, chain, results };
}
}
this.emit('dependency-restart-complete', { serviceId, chain, results });
await this._notifyRestartResult(serviceId, true, chain, results);
return { success: true, chain, results };
}
/**
* Send a notification about the restart result.
*
* @private
* @param {string} serviceId
* @param {boolean} success
* @param {string[]} chain
* @param {Array} results
* @param {string} [failedServiceId]
*/
async _notifyRestartResult(serviceId, success, chain, results, failedServiceId) {
if (!this._notification) return;
try {
if (success) {
await this._notification.send('dependency-restart-complete', {
text: `✅ Dependency restart chain completed for "${serviceId}". Restarted: ${chain.join(' → ')}`,
serviceId,
chain,
results,
});
} else {
await this._notification.send('dependency-restart-failed', {
text: `❌ Dependency restart chain failed for "${serviceId}" at "${failedServiceId}". Chain: ${chain.join(' → ')}`,
serviceId,
failedServiceId,
chain,
results,
});
}
} catch (err) {
this._log.error('dependency', 'Failed to send restart notification', {
error: err.message,
});
}
}
}
module.exports = DependencyManager;
-273
View File
@@ -1,273 +0,0 @@
/**
* DNS Propagation Checker
* Verifies DNS record propagation by querying multiple resolvers.
* Runs as background jobs with configurable timeout and interval.
*
* @module dns-propagation
*/
const dns = require('dns').promises;
const EventEmitter = require('events');
/** Default verification options */
const DEFAULT_OPTIONS = {
timeout: 300000, // 5 minutes
interval: 10000, // 10 seconds
resolvers: ['1.1.1.1', '8.8.8.8', '9.9.9.9']
};
/** Maximum age for stored verification results (1 hour) */
const MAX_RESULT_AGE_MS = 3600000;
class DNSPropagationChecker extends EventEmitter {
/**
* Create a DNSPropagationChecker instance.
* @param {Object} ctx - Shared application context
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
/** @type {Map<string, Object>} domain → verification status */
this.verifications = new Map();
}
/**
* Verify that a DNS record has propagated by querying multiple resolvers.
* Retries every `interval` ms until `timeout` is reached.
*
* @param {string} domain - The domain to check (e.g., 'test.sami')
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @param {number} [options.timeout=300000] - Maximum time to wait (ms)
* @param {number} [options.interval=10000] - Time between retries (ms)
* @param {string[]} [options.resolvers] - DNS resolvers to query
* @returns {Promise<Object>} Verification result
*/
async verifyRecord(domain, expectedIp, options = {}) {
const startTime = Date.now();
const {
timeout = DEFAULT_OPTIONS.timeout,
interval = DEFAULT_OPTIONS.interval,
resolvers = DEFAULT_OPTIONS.resolvers
} = options;
const allResults = [];
let propagated = false;
while (Date.now() - startTime < timeout) {
const roundResults = [];
for (const resolver of resolvers) {
const checkStart = Date.now();
try {
// Use dns.resolve4 with a custom resolver
const resolverInstance = new dns.Resolver();
resolverInstance.setServers([resolver]);
resolverInstance.setTimeout(5000);
const addresses = await resolverInstance.resolve4(domain);
const matched = addresses.includes(expectedIp);
const result = {
resolver,
ips: addresses,
matched,
checkedAt: new Date().toISOString(),
responseTime: Date.now() - checkStart
};
roundResults.push(result);
if (matched) {
propagated = true;
}
} catch (err) {
roundResults.push({
resolver,
ips: [],
matched: false,
checkedAt: new Date().toISOString(),
error: err.code || err.message,
responseTime: Date.now() - checkStart
});
}
}
allResults.push(...roundResults);
// Emit progress event
this.emit('propagation-check', {
domain,
expectedIp,
roundResults,
elapsed: Date.now() - startTime,
propagated
});
if (propagated) {
break;
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, interval));
}
const totalTime = Date.now() - startTime;
return {
domain,
expectedIp,
propagated,
results: allResults,
totalTime,
checkedAt: new Date().toISOString()
};
}
/**
* Start a background DNS propagation verification.
* Does not block — returns immediately with the job reference.
*
* @param {string} domain - The domain to verify
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @returns {Object} Job status object
*/
startVerification(domain, expectedIp, options = {}) {
// If there's already a running verification for this domain, return it
const existing = this.verifications.get(domain);
if (existing && existing.status === 'running') {
return existing;
}
const job = {
domain,
expectedIp,
status: 'running',
startedAt: new Date().toISOString(),
progress: [],
result: null
};
this.verifications.set(domain, job);
// Run verification in background (non-blocking)
this.verifyRecord(domain, expectedIp, options)
.then(result => {
job.status = 'completed';
job.result = result;
job.completedAt = new Date().toISOString();
if (result.propagated) {
this.emit('propagation-complete', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `✅ DNS record for ${domain} propagated successfully to ${expectedIp}`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'success').catch(err => {
this.log.error('dns-propagation', 'Failed to send propagation notification', {
error: err.message
});
});
}
} else {
this.emit('propagation-timeout', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `⏱️ DNS propagation timeout for ${domain} — expected ${expectedIp} not found after ${Math.round(result.totalTime / 1000)}s`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'warning').catch(err => {
this.log.error('dns-propagation', 'Failed to send timeout notification', {
error: err.message
});
});
}
}
})
.catch(err => {
job.status = 'error';
job.error = err.message;
job.completedAt = new Date().toISOString();
this.log.error('dns-propagation', `Verification failed for ${domain}`, {
error: err.message
});
});
return job;
}
/**
* Get the current verification status for a domain.
*
* @param {string} domain - The domain to look up
* @returns {Object|null} Verification status or null if not found
*/
getVerificationStatus(domain) {
const job = this.verifications.get(domain);
if (!job) return null;
return {
domain: job.domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
result: job.result || null,
error: job.error || null
};
}
/**
* Get all recent verifications.
*
* @returns {Object[]} Array of verification statuses
*/
getAllVerifications() {
const results = [];
for (const [domain, job] of this.verifications.entries()) {
results.push({
domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
propagated: job.result?.propagated || null,
totalTime: job.result?.totalTime || null,
error: job.error || null
});
}
return results;
}
/**
* Remove verifications older than 1 hour.
*/
cleanup() {
const now = Date.now();
for (const [domain, job] of this.verifications.entries()) {
const completedAt = job.completedAt ? new Date(job.completedAt).getTime() : null;
const startedAt = new Date(job.startedAt).getTime();
// Clean up completed/error jobs older than 1 hour
// Also clean up stale running jobs that started over 2 hours ago
const age = completedAt ? (now - completedAt) : (now - startedAt);
const maxAge = job.status === 'running' ? MAX_RESULT_AGE_MS * 2 : MAX_RESULT_AGE_MS;
if (age > maxAge) {
this.verifications.delete(domain);
}
}
}
}
module.exports = DNSPropagationChecker;
-69
View File
@@ -1,69 +0,0 @@
/**
* Base DNS Provider Adapter
* All DNS provider adapters must extend this class and implement the required methods.
*
* Each adapter handles the specifics of talking to a particular DNS provider's API.
* The routes layer calls these methods generically — no provider-specific logic in routes.
*/
class BaseDNSProvider {
constructor(config, ctx) {
this.config = config; // Provider-specific config (api token, server url, etc.)
this.ctx = ctx; // Shared app context (log, credentialManager, fetchT, etc.)
this.providerId = 'base';
this.displayName = 'Base DNS Provider';
}
/** Check if this provider supports a given capability */
supportsCapability(cap) {
// Capabilities: 'create-record', 'delete-record', 'resolve', 'list-records',
// 'logs', 'restart', 'update-check', 'credentials', 'zones'
return false;
}
/** Authenticate and return a token/session */
async authenticate() { throw new Error('Not implemented'); }
/** Create a DNS record */
async createRecord({ domain, zone, type, value, ttl, overwrite }) { throw new Error('Not implemented'); }
/** Delete a DNS record */
async deleteRecord({ domain, type, value }) { throw new Error('Not implemented'); }
/** Resolve/query existing records for a domain */
async resolveRecords({ domain, zone, type }) { throw new Error('Not implemented'); }
/** List all records in a zone */
async listRecords({ zone }) { throw new Error('Not implemented'); }
/** Get DNS query logs */
async getLogs({ limit, server }) { throw new Error('Not implemented'); }
/** Restart the DNS server */
async restartServer({ server }) { throw new Error('Not implemented'); }
/** Check for DNS server updates */
async checkUpdate({ server }) { throw new Error('Not implemented'); }
/** Get provider status info */
async getStatus() {
return {
providerId: this.providerId,
displayName: this.displayName,
capabilities: this.getCapabilities(),
authenticated: false
};
}
/** Get list of supported capabilities */
getCapabilities() {
return [];
}
/** Validate provider-specific config */
validateConfig() { return { valid: true, errors: [] }; }
/** Clean up resources on shutdown */
async shutdown() {}
}
module.exports = BaseDNSProvider;
-269
View File
@@ -1,269 +0,0 @@
/**
* Cloudflare DNS Provider Adapter
* Manages DNS records via the Cloudflare API v4.
*/
const BaseDNSProvider = require('./base');
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
class CloudflareDNSProvider extends BaseDNSProvider {
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'cloudflare';
this.displayName = 'Cloudflare DNS';
// Resolve API token: explicit config takes priority, then credential manager
this.apiToken = config.apiToken
|| (ctx.credentialManager && ctx.credentialManager.get('dns.cloudflare.apiToken'))
|| null;
this.zoneId = config.zoneId || null;
this.domain = config.domain || null;
}
// ── Helpers ────────────────────────────────────────────────────────────
/** Build common request headers for Cloudflare API calls */
_headers() {
return {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
};
}
/** Make an authenticated request to the Cloudflare API */
async _cfRequest(method, path, body) {
const url = `${CF_API_BASE}${path}`;
const opts = {
method,
headers: this._headers(),
};
if (body !== undefined) {
opts.body = JSON.stringify(body);
}
return this.ctx.fetchT(url, opts);
}
/** Map a Cloudflare DNS record to the normalised format expected by routes */
_mapRecord(rec) {
return {
id: rec.id,
type: rec.type,
name: rec.name,
value: rec.content,
ttl: rec.ttl,
proxied: rec.proxied || false,
};
}
// ── Capabilities ───────────────────────────────────────────────────────
supportsCapability(cap) {
return this.getCapabilities().includes(cap);
}
getCapabilities() {
return ['create-record', 'delete-record', 'resolve', 'list-records', 'credentials', 'zones'];
}
// ── Authentication ─────────────────────────────────────────────────────
/**
* Validate the API token by calling the Cloudflare verify endpoint.
* Stores basic zone info on success.
*/
async authenticate() {
this.ctx.log('[cloudflare] Authenticating verifying API token…');
if (!this.apiToken) {
return { status: 'error', message: 'No Cloudflare API token provided' };
}
const res = await this._cfRequest('GET', '/user/tokens/verify');
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Token verification failed';
this.ctx.log(`[cloudflare] Authentication failed: ${msg}`);
return { status: 'error', message: msg };
}
this.ctx.log(`[cloudflare] Token verified for status "${data.status}"`);
// Optionally fetch zone info if zoneId is configured
if (this.zoneId) {
try {
const zoneRes = await this._cfRequest('GET', `/zones/${this.zoneId}`);
const zoneData = await zoneRes.json();
if (zoneData.success && zoneData.result) {
this.zoneInfo = zoneData.result;
this.ctx.log(`[cloudflare] Zone loaded: ${zoneData.result.name} (${zoneData.result.id})`);
}
} catch (err) {
this.ctx.log(`[cloudflare] Could not fetch zone info: ${err.message}`);
}
}
return { status: 'ok', response: { status: data.status } };
}
// ── Create Record ──────────────────────────────────────────────────────
/**
* Create a DNS record.
* If overwrite is true, first delete any existing record with the same name+type.
*/
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
const targetDomain = domain || this.domain;
const targetZone = zone || this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
if (overwrite) {
this.ctx.log(`[cloudflare] Overwrite requested deleting existing ${type} record for ${targetDomain}`);
try {
await this.deleteRecord({ domain: targetDomain, type, value });
} catch (err) {
this.ctx.log(`[cloudflare] No existing record to overwrite (or delete failed): ${err.message}`);
}
}
const body = {
type,
name: targetDomain,
content: value,
ttl: ttl || 1, // 1 = automatic TTL in Cloudflare
proxied: false,
};
this.ctx.log(`[cloudflare] Creating ${type} record: ${targetDomain}${value}`);
const res = await this._cfRequest('POST', `/zones/${targetZone}/dns_records`, body);
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Record creation failed';
this.ctx.log(`[cloudflare] Create failed: ${msg}`);
return { status: 'error', message: msg };
}
return { status: 'ok', response: { record: this._mapRecord(data.result) } };
}
// ── Delete Record ──────────────────────────────────────────────────────
/**
* Delete DNS records matching domain+type.
* Lists matching records first, then deletes each one.
*/
async deleteRecord({ domain, type, value }) {
const targetDomain = domain || this.domain;
const targetZone = this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
// List records matching name + type
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
if (type) {
queryPath += `&type=${encodeURIComponent(type)}`;
}
const listRes = await this._cfRequest('GET', queryPath);
const listData = await listRes.json();
if (!listData.success) {
const msg = (listData.errors && listData.errors[0] && listData.errors[0].message) || 'Failed to list records for deletion';
this.ctx.log(`[cloudflare] Delete list failed: ${msg}`);
return { status: 'error', message: msg };
}
const matching = listData.result || [];
if (matching.length === 0) {
this.ctx.log(`[cloudflare] No records found for ${targetDomain} (${type || 'any type'})`);
return { status: 'ok', response: { deleted: 0 } };
}
// If a specific value is given, only delete records matching that value
const toDelete = value
? matching.filter((r) => r.content === value)
: matching;
let deleted = 0;
for (const record of toDelete) {
const delRes = await this._cfRequest('DELETE', `/zones/${targetZone}/dns_records/${record.id}`);
const delData = await delRes.json();
if (delData.success) {
deleted++;
this.ctx.log(`[cloudflare] Deleted record ${record.id} (${record.type} ${record.name})`);
} else {
const msg = (delData.errors && delData.errors[0] && delData.errors[0].message) || 'Delete failed';
this.ctx.log(`[cloudflare] Failed to delete record ${record.id}: ${msg}`);
}
}
return { status: 'ok', response: { deleted } };
}
// ── Resolve Records ───────────────────────────────────────────────────
/**
* Resolve/query existing records for a domain.
* Returns records matching domain (and optionally type).
*/
async resolveRecords({ domain, zone, type }) {
const targetDomain = domain || this.domain;
const targetZone = zone || this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
if (type) {
queryPath += `&type=${encodeURIComponent(type)}`;
}
this.ctx.log(`[cloudflare] Resolving records for ${targetDomain}${type ? ` (${type})` : ''}`);
const res = await this._cfRequest('GET', queryPath);
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Resolve failed';
this.ctx.log(`[cloudflare] Resolve failed: ${msg}`);
return { status: 'error', message: msg };
}
const records = (data.result || []).map(this._mapRecord);
return { status: 'ok', response: { records } };
}
// ── List Records ───────────────────────────────────────────────────────
/**
* List all DNS records in a zone.
*/
async listRecords({ zone }) {
const targetZone = zone || this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
this.ctx.log(`[cloudflare] Listing all records in zone ${targetZone}`);
const res = await this._cfRequest('GET', `/zones/${targetZone}/dns_records`);
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'List failed';
this.ctx.log(`[cloudflare] List failed: ${msg}`);
return { status: 'error', message: msg };
}
const records = (data.result || []).map(this._mapRecord);
return { status: 'ok', response: { records } };
}
}
module.exports = CloudflareDNSProvider;
-93
View File
@@ -1,93 +0,0 @@
/**
* Manual DNS Provider Adapter
* No-op adapter for users who manage DNS externally (manual, cPanel, other control panels).
* Provides propagation checking only — all record operations return helpful instructions.
*/
const BaseDNSProvider = require('./base');
class ManualDNSProvider extends BaseDNSProvider {
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'manual';
this.displayName = 'Manual / External DNS';
this.description = 'Manage DNS records yourself via your provider\'s control panel';
}
supportsCapability(cap) {
return ['credentials'].includes(cap);
}
getCapabilities() {
return ['credentials'];
}
async authenticate() {
return { success: true, message: 'Manual DNS — no authentication needed' };
}
async createRecord({ domain, zone, type, value, ttl }) {
return {
status: 'manual',
message: `Create this record manually in your DNS control panel:`,
instructions: {
name: domain,
type: type || 'A',
value,
ttl: ttl || 300
}
};
}
async deleteRecord({ domain, type, value }) {
return {
status: 'manual',
message: `Delete this record manually from your DNS control panel:`,
instructions: {
name: domain,
type: type || 'A',
value: value || '(any)'
}
};
}
async resolveRecords({ domain, zone, type }) {
// Use Node.js built-in DNS to resolve regardless of provider
const dns = require('dns').promises;
try {
const resolver = new dns.Resolver();
resolver.setServers(['1.1.1.1', '8.8.8.8']);
const records = await resolver.resolve(domain, type || 'A');
return {
status: 'ok',
response: {
records: records.map(r => ({
type: type || 'A',
domain,
rData: { ipAddress: r },
ttl: 0,
manual: true
}))
}
};
} catch (err) {
return { status: 'ok', response: { records: [] } };
}
}
async getStatus() {
return {
providerId: this.providerId,
displayName: this.displayName,
description: this.description,
capabilities: this.getCapabilities(),
authenticated: true,
note: 'DNS records are managed externally. Use propagation checks to verify changes.'
};
}
validateConfig() {
return { valid: true, errors: [] };
}
}
module.exports = ManualDNSProvider;
-101
View File
@@ -1,101 +0,0 @@
/**
* DNS Provider Registry
* Manages available DNS provider adapters.
* Providers register themselves, and the active provider is selected by config.
*/
const path = require('path');
class DNSProviderRegistry {
constructor() {
this.providers = new Map(); // providerId -> adapter class
this.instances = new Map(); // providerId -> adapter instance
}
/** Register a provider adapter class */
register(adapterClass) {
const instance = new adapterClass({}, {});
const id = instance.providerId;
if (this.providers.has(id)) {
console.warn(`DNS provider "${id}" already registered, overwriting`);
}
this.providers.set(id, adapterClass);
}
/** Get list of all registered provider IDs */
getProviderIds() {
return Array.from(this.providers.keys());
}
/** Get metadata for all providers (without instantiating with real config) */
getProviderMeta() {
return this.getProviderIds().map(id => {
const Adapter = this.providers.get(id);
const inst = new Adapter({}, {});
return {
id: inst.providerId,
displayName: inst.displayName,
capabilities: inst.getCapabilities()
};
});
}
/**
* Get or create an adapter instance for the given provider + config
* @param {string} providerId - The provider to instantiate
* @param {Object} config - Provider-specific configuration
* @param {Object} ctx - Shared application context
* @returns {BaseDNSProvider} The provider adapter instance
*/
getProvider(providerId, config, ctx) {
// Re-create if config changed
const cacheKey = providerId;
const Adapter = this.providers.get(providerId);
if (!Adapter) {
throw new Error(`Unknown DNS provider: ${providerId}. Available: ${this.getProviderIds().join(', ')}`);
}
const instance = new Adapter(config, ctx);
this.instances.set(cacheKey, instance);
return instance;
}
/** Auto-discover and register all providers in this directory */
autoDiscover() {
const fs = require('fs');
const dir = __dirname;
const files = fs.readdirSync(dir).filter(f =>
f !== 'base.js' && f !== 'registry.js' && f.endsWith('.js') && !f.startsWith('.')
);
for (const file of files) {
try {
const Loaded = require(path.join(dir, file));
// Support: module.exports = Class, module.exports = { Class }, or plain objects
let cls = null;
if (typeof Loaded === 'function') {
cls = Loaded;
} else if (typeof Loaded === 'object' && Loaded !== null) {
// Try to find a class in the exported object
cls = Object.values(Loaded).find(v => typeof v === 'function');
}
if (cls) {
// Verify it has providerId (on prototype or set in constructor)
try {
const test = new cls({}, {});
if (test.providerId && typeof test.getCapabilities === 'function') {
this.register(cls);
}
} catch {
// Not a valid provider adapter, skip
}
}
} catch (err) {
console.error(`Failed to load DNS provider from ${file}:`, err.message);
}
}
}
}
// Singleton
const registry = new DNSProviderRegistry();
registry.autoDiscover();
module.exports = registry;
-383
View File
@@ -1,383 +0,0 @@
/**
* RFC 2136 Dynamic DNS Provider Adapter
*
* Manages DNS records via RFC 2136 dynamic updates using the nsupdate CLI tool.
* Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server.
*
* Capabilities: create-record, delete-record, resolve, credentials
* Not supported: logs, restart, update-check, list-records, zones
*/
const { execFile } = require('child_process');
const { promisify } = require('util');
const dns = require('dns');
const os = require('os');
const path = require('path');
const fs = require('fs');
const execFileAsync = promisify(execFile);
const BaseDNSProvider = require('./base');
const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'credentials'];
const DEFAULT_PORT = 53;
const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256';
const NSUPDATE_TIMEOUT_MS = 15000;
class RFC2136Provider extends BaseDNSProvider {
static providerId = 'rfc2136';
static displayName = 'RFC 2136 (Dynamic DNS)';
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'rfc2136';
this.displayName = 'RFC 2136 (Dynamic DNS)';
// Core config
this.server = config.server || null;
this.port = config.port || DEFAULT_PORT;
this.zone = config.zone || null;
// TSIG authentication
this.tsigAlgorithm = config.tsigAlgorithm || DEFAULT_TSIG_ALGORITHM;
this.tsigKeyName = config.tsigKeyName || null;
this.tsigSecret = config.tsigSecret || null;
// Resolve credentials from credential manager if available
if (ctx && ctx.credentialManager) {
if (!this.tsigKeyName && ctx.credentialManager.get) {
this.tsigKeyName = ctx.credentialManager.get('rfc2136_tsigKeyName') || null;
}
if (!this.tsigSecret && ctx.credentialManager.get) {
this.tsigSecret = ctx.credentialManager.get('rfc2136_tsigSecret') || null;
}
}
// Logger shorthand
this._log = ctx && ctx.log ? ctx.ctx : null;
}
// ── Logging helper ────────────────────────────────────────────────────────
_log(level, message, meta) {
if (this.ctx && this.ctx.log && typeof this.ctx.log[level] === 'function') {
this.ctx.log[level](`[rfc2136] ${message}`, meta || {});
}
}
// ── Capabilities ──────────────────────────────────────────────────────────
supportsCapability(cap) {
return CAPABILITIES.includes(cap);
}
getCapabilities() {
return [...CAPABILITIES];
}
// ── Config validation ─────────────────────────────────────────────────────
validateConfig() {
const errors = [];
if (!this.server) errors.push('Missing required config: server');
if (!this.zone) errors.push('Missing required config: zone');
return { valid: errors.length === 0, errors };
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Ensure a domain name ends with a trailing dot (FQDN for nsupdate).
*/
_ensureFqdn(domain) {
if (!domain) return domain;
return domain.endsWith('.') ? domain : `${domain}.`;
}
/**
* Build the common nsupdate header lines (server, zone, key).
*/
_buildHeader() {
const lines = [];
lines.push(`server ${this.server} ${this.port}`);
lines.push(`zone ${this.zone}`);
if (this.tsigKeyName && this.tsigSecret) {
lines.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
}
return lines;
}
/**
* Execute an nsupdate script and return { stdout, stderr }.
* Writes commands to a temporary file and runs `nsupdate <file>`.
*/
async _runNsupdate(commands) {
const script = commands.join('\n') + '\n';
const tmpFile = path.join(os.tmpdir(), `nsupdate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cmd`);
try {
await fs.promises.writeFile(tmpFile, script, { mode: 0o600 });
this._log('debug', `Executing nsupdate script`, { script: script.trim() });
const { stdout, stderr } = await execFileAsync('nsupdate', [tmpFile], {
timeout: NSUPDATE_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
});
this._log('debug', 'nsupdate completed', { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() });
if (stderr && stderr.toLowerCase().includes('refused')) {
throw new Error(`nsupdate refused: ${stderr.trim()}`);
}
if (stderr && stderr.toLowerCase().includes('failed')) {
throw new Error(`nsupdate failed: ${stderr.trim()}`);
}
return { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() };
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('nsupdate command not found. Install bind9utils (Debian/Ubuntu) or bind-utils (RHEL/CentOS).');
}
throw err;
} finally {
try { await fs.promises.unlink(tmpFile); } catch (_) { /* ignore */ }
}
}
// ── Authenticate ──────────────────────────────────────────────────────────
/**
* Verify nsupdate is available and optionally test connectivity.
* Runs a minimal nsupdate with just "show" (no-op) to confirm the tool works.
*/
async authenticate() {
const validation = this.validateConfig();
if (!validation.valid) {
throw new Error(`RFC 2136 config invalid: ${validation.errors.join('; ')}`);
}
// Check nsupdate binary is available with a dry-run command set
const commands = [
...this._buildHeader(),
'show',
];
try {
const { stdout } = await this._runNsupdate(commands);
this._log('info', 'Authenticated to RFC 2136 server', { server: this.server, port: this.port });
return { success: true, server: this.server, port: this.port };
} catch (err) {
this._log('error', 'Authentication test failed', { error: err.message });
// If nsupdate is missing, rethrow immediately
if (err.message.includes('not found')) throw err;
// Otherwise, the server might be unreachable but the tool works — return partial
return { success: false, error: err.message, server: this.server };
}
}
// ── Create Record ─────────────────────────────────────────────────────────
/**
* Create (add) a DNS record via RFC 2136 UPDATE.
*
* @param {Object} params
* @param {string} params.domain - Record name (e.g. "www.example.com")
* @param {string} params.zone - Zone name (overrides constructor zone)
* @param {string} params.type - Record type (A, AAAA, CNAME, TXT, etc.)
* @param {string} params.value - Record value
* @param {number} [params.ttl=300] - TTL in seconds
*/
async createRecord({ domain, zone, type, value, ttl }) {
const effectiveZone = zone || this.zone;
const effectiveTtl = ttl || 300;
const fqdn = this._ensureFqdn(domain);
const commands = [
`server ${this.server} ${this.port}`,
`zone ${effectiveZone}`,
];
if (this.tsigKeyName && this.tsigSecret) {
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
}
commands.push(`update add ${fqdn} ${effectiveTtl} ${type} ${value}`);
commands.push('show');
commands.push('send');
this._log('info', 'Creating DNS record', { domain: fqdn, type, value, ttl: effectiveTtl });
const result = await this._runNsupdate(commands);
return {
success: true,
action: 'create-record',
domain: fqdn,
type,
value,
ttl: effectiveTtl,
zone: effectiveZone,
raw: result.stdout,
};
}
// ── Delete Record ─────────────────────────────────────────────────────────
/**
* Delete a DNS record via RFC 2136 UPDATE.
*
* @param {Object} params
* @param {string} params.domain - Record name
* @param {string} params.type - Record type
* @param {string} [params.value] - Optional specific value to match
*/
async deleteRecord({ domain, type, value }) {
const effectiveZone = this.zone;
const fqdn = this._ensureFqdn(domain);
const commands = [
`server ${this.server} ${this.port}`,
`zone ${effectiveZone}`,
];
if (this.tsigKeyName && this.tsigSecret) {
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
}
// "update delete" with value removes that specific RR;
// without value it removes all RRs of that type for the name.
const deleteClause = value
? `update delete ${fqdn} ${type} ${value}`
: `update delete ${fqdn} ${type}`;
commands.push(deleteClause);
commands.push('show');
commands.push('send');
this._log('info', 'Deleting DNS record', { domain: fqdn, type, value: value || '(all)' });
const result = await this._runNsupdate(commands);
return {
success: true,
action: 'delete-record',
domain: fqdn,
type,
value: value || null,
zone: effectiveZone,
raw: result.stdout,
};
}
// ── Resolve Records ───────────────────────────────────────────────────────
/**
* Resolve DNS records for a domain.
* First attempts dig against the configured server, then falls back to Node dns module.
*
* @param {Object} params
* @param {string} params.domain - Domain to resolve
* @param {string} [params.zone] - Zone (unused for resolution, kept for interface consistency)
* @param {string} [params.type='A'] - Record type to query
*/
async resolveRecords({ domain, zone, type }) {
const queryType = type || 'A';
const fqdn = domain.endsWith('.') ? domain : domain;
// Strategy 1: Use dig against the configured RFC 2136 server
try {
const { stdout } = await execFileAsync('dig', [
`@${this.server}`,
'-p', String(this.port),
fqdn,
queryType,
'+short',
'+time=5',
'+tries=1',
], { timeout: 10000 });
const records = stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean);
if (records.length > 0) {
this._log('debug', `Resolved ${fqdn} ${queryType} via dig`, { records });
return {
domain: fqdn,
type: queryType,
records: records.map(r => ({ value: r, type: queryType })),
source: 'dig',
server: this.server,
};
}
} catch (err) {
this._log('warn', 'dig resolution failed, falling back to Node dns', { error: err.message });
}
// Strategy 2: Fallback to Node.js built-in resolver
try {
const resolver = new dns.Resolver();
resolver.setServers([this.server]);
const resolveMethod = this._getResolveMethod(queryType);
const resolveAsync = promisify(resolver[resolveMethod]).bind(resolver);
const results = await resolveAsync(fqdn);
const records = Array.isArray(results) ? results : [results];
this._log('debug', `Resolved ${fqdn} ${queryType} via Node dns`, { records });
return {
domain: fqdn,
type: queryType,
records: records.map(r => ({ value: String(r), type: queryType })),
source: 'node-dns',
server: this.server,
};
} catch (err) {
this._log('warn', 'Node dns resolution also failed', { error: err.message });
return {
domain: fqdn,
type: queryType,
records: [],
source: 'none',
server: this.server,
error: err.message,
};
}
}
/**
* Map record type to the Node dns resolver method name.
*/
_getResolveMethod(type) {
const map = {
A: 'resolve4',
AAAA: 'resolve6',
CNAME: 'resolveCname',
MX: 'resolveMx',
TXT: 'resolveTxt',
NS: 'resolveNs',
SOA: 'resolveSoa',
SRV: 'resolveSrv',
PTR: 'reverse',
};
return map[(type || '').toUpperCase()] || 'resolve4';
}
// ── Shutdown ──────────────────────────────────────────────────────────────
async shutdown() {
this._log('info', 'RFC 2136 provider shutting down');
}
}
// Expose providerId on the prototype so the registry's auto-discover can detect it
RFC2136Provider.prototype.providerId = 'rfc2136';
module.exports = RFC2136Provider;
-507
View File
@@ -1,507 +0,0 @@
/**
* Technitium DNS Server Provider Adapter
*
* Wraps Technitium-specific DNS logic into the standard adapter interface.
* Uses the Technitium HTTP API (default port 5380) for all operations.
*/
const BaseDNSProvider = require('./base');
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24-hour token lifetime
class TechnitiumDNSProvider extends BaseDNSProvider {
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'technitium';
this.displayName = 'Technitium DNS Server';
this.serverIp = config.serverIp;
this.serverPort = config.serverPort || 5380;
this.dnsId = config.dnsId || null;
// Token state
this.token = null;
this.tokenExpiry = null;
}
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
static CAPABILITIES = [
'create-record',
'delete-record',
'resolve',
'list-records',
'logs',
'restart',
'update-check',
'credentials',
'zones'
];
supportsCapability(cap) {
return TechnitiumDNSProvider.CAPABILITIES.includes(cap);
}
getCapabilities() {
return [...TechnitiumDNSProvider.CAPABILITIES];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build the base URL for this server */
_baseUrl() {
return `http://${this.serverIp}:${this.serverPort}`;
}
/** Build a full API URL with query-string params */
_buildUrl(apiPath, params = {}) {
const qs = new URLSearchParams(params).toString();
return `${this._baseUrl()}${apiPath}${qs ? '?' + qs : ''}`;
}
/** Ensure we have a valid token; throws on failure */
async _requireToken() {
// Re-use existing token if still valid
if (this.token && this.tokenExpiry && new Date() < new Date(this.tokenExpiry)) {
return this.token;
}
const result = await this.authenticate();
if (!result.success) {
const err = new Error('No valid DNS token available. ' + (result.error || ''));
err.statusCode = 401;
throw err;
}
return this.token;
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
/**
* Authenticate against the Technitium server.
* Checks per-server credentials first (dns.{dnsId}.readonly.username),
* then falls back to global credentials (dns.username).
*
* Stores token + expiry on success.
*/
async authenticate() {
const { credentialManager, log } = this.ctx;
// Try per-server credentials first
if (this.dnsId) {
for (const role of ['readonly', 'admin']) {
try {
const username = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.username`);
const password = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.password`);
if (username && password) {
const result = await this._doLogin(username, password);
if (result.success) return result;
}
} catch (err) {
log.error('technitium', `Per-server ${role} credential error`, {
dnsId: this.dnsId,
error: err.message
});
}
}
}
// Fall back to global credentials
try {
const username = await credentialManager.retrieve('dns.username');
const password = await credentialManager.retrieve('dns.password');
if (username && password) {
return await this._doLogin(username, password);
}
} catch (err) {
log.error('technitium', 'Global credential error', { error: err.message });
}
return {
success: false,
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials'
};
}
/**
* Perform the actual login POST to Technitium.
* Stores token on success.
*/
async _doLogin(username, password) {
const { fetchT, log } = this.ctx;
try {
const params = new URLSearchParams({
user: username,
pass: password,
includeInfo: 'false'
});
const url = `${this._baseUrl()}/api/user/login?${params.toString()}`;
const response = await fetchT(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const result = await response.json();
if (result.status === 'ok' && result.token) {
this.token = result.token;
this.tokenExpiry = new Date(Date.now() + SESSION_TTL_MS).toISOString();
log.info('technitium', 'DNS token obtained', {
server: this.serverIp,
expires: this.tokenExpiry
});
return { success: true, token: this.token };
}
return { success: false, error: result.errorMessage || 'Login failed' };
} catch (error) {
log.error('technitium', 'Login error', { error: error.message });
return { success: false, error: error.message };
}
}
// ---------------------------------------------------------------------------
// Record Management
// ---------------------------------------------------------------------------
/**
* Create (or overwrite) a DNS record.
* GET /api/zones/records/add?token=...&domain=...&zone=...&type=...&ipAddress=...&ttl=...&overwrite=...
*/
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const params = {
token,
domain,
zone,
type: type || 'A',
ipAddress: value,
ttl: String(ttl || 300),
overwrite: String(overwrite !== false)
};
try {
log.info('technitium', 'Creating DNS record', { domain, type, value });
const url = this._buildUrl('/api/zones/records/add', params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
log.info('technitium', 'DNS record created', { domain, type, value });
return { success: true };
}
// If token expired, re-authenticate and retry once
if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) {
log.info('technitium', 'Token expired, re-authenticating');
this.token = null;
this.tokenExpiry = null;
const retryToken = await this._requireToken();
params.token = retryToken;
const retryUrl = this._buildUrl('/api/zones/records/add', params);
const retryResp = await fetchT(retryUrl, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const retryResult = await retryResp.json();
if (retryResult.status === 'ok') {
return { success: true };
}
throw new Error(retryResult.errorMessage || 'Failed after token refresh');
}
throw new Error(result.errorMessage || 'Unknown error');
} catch (error) {
throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`);
}
}
/**
* Delete a DNS record.
* GET /api/zones/records/delete?token=...&domain=...&type=... (+ ipAddress if value provided)
*/
async deleteRecord({ domain, type, value }) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const params = {
token,
domain,
type: type || 'A'
};
if (value) {
params.ipAddress = value;
}
try {
log.info('technitium', 'Deleting DNS record', { domain, type, value });
const url = this._buildUrl('/api/zones/records/delete', params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
log.info('technitium', 'DNS record deleted', { domain, type, value });
return { success: true };
}
throw new Error(result.errorMessage || 'Unknown error');
} catch (error) {
throw new Error(`Failed to delete DNS record for ${domain}: ${error.message}`);
}
}
/**
* Resolve/query records for a domain in a zone.
* GET /api/zones/records/get?token=...&domain=...&zone=...&listZone=true
* Filters returned records by type if provided.
*/
async resolveRecords({ domain, zone, type }) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const params = {
token,
domain,
zone,
listZone: 'true'
};
try {
log.info('technitium', 'Resolving records', { domain, zone, type });
const url = this._buildUrl('/api/zones/records/get', params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status !== 'ok') {
throw new Error(result.errorMessage || 'Failed to resolve records');
}
let records = (result.response && result.response.records) || [];
// Filter by type if specified
if (type) {
records = records.filter(r => r.type === type);
}
return { success: true, records };
} catch (error) {
throw new Error(`Failed to resolve records for ${domain}: ${error.message}`);
}
}
/**
* List all records in a zone.
* Delegates to resolveRecords with a wildcard domain.
*/
async listRecords({ zone }) {
return this.resolveRecords({ domain: zone, zone, type: null });
}
// ---------------------------------------------------------------------------
// Logs
// ---------------------------------------------------------------------------
/**
* Fetch and parse DNS query logs.
* 1. GET /api/logs/list to discover the latest log file
* 2. GET /api/logs/download?token=...&fileName=... to download it
* 3. Parse text format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
*/
async getLogs({ limit, server } = {}) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const targetIp = server || this.serverIp;
const targetPort = this.serverPort;
const baseUrl = `http://${targetIp}:${targetPort}`;
try {
// Step 1: Get log file list
const listUrl = this._buildUrl('/api/logs/list', { token });
const listResp = await fetchT(listUrl.replace(this._baseUrl(), baseUrl), {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const listResult = await listResp.json();
if (listResult.status !== 'ok' || !listResult.response || !listResult.response.length) {
throw new Error(listResult.errorMessage || 'No log files found');
}
// Pick the latest log file (last entry)
const logFile = listResult.response[listResult.response.length - 1];
const fileName = logFile.name || logFile.fileName || logFile;
// Step 2: Download the log file
const downloadUrl = `${baseUrl}/api/logs/download?${new URLSearchParams({ token, fileName }).toString()}`;
const downloadResp = await fetchT(downloadUrl, {
method: 'GET'
});
const logText = await downloadResp.text();
// Step 3: Parse lines
const parsed = this._parseLogText(logText, limit);
return { success: true, logs: parsed };
} catch (error) {
log.error('technitium', 'Failed to fetch DNS logs', { error: error.message });
throw new Error(`Failed to get DNS logs: ${error.message}`);
}
}
/**
* Parse Technitium DNS log text format.
* Line format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
*/
_parseLogText(text, limit) {
const lines = text.split('\n').filter(l => l.trim());
const parsed = [];
// Process newest first if we need to limit
const iterable = limit ? lines.slice(-limit).reverse() : lines;
for (const line of iterable) {
try {
const entry = {};
// Extract timestamp: [2024-01-15 10:30:45]
const tsMatch = line.match(/\[([^\]]+)\]/);
if (tsMatch) entry.timestamp = tsMatch[1];
// Extract client:port: [192.168.1.100:12345]
const clientMatch = line.match(/\[([^\]]+:\d+)\]/g);
if (clientMatch && clientMatch.length >= 2) {
entry.client = clientMatch[1].replace(/\[|\]/g, '');
}
// Extract protocol: [UDP] or [TCP]
const protoMatch = line.match(/\]\s*\[(UDP|TCP|DoH|DoT|DoH2)\]/i);
if (protoMatch) entry.protocol = protoMatch[1];
// Extract key-value pairs: QNAME: value; QTYPE: value; etc.
const kvPattern = /(\w+):\s*([^;]+)/g;
let match;
while ((match = kvPattern.exec(line)) !== null) {
const key = match[1];
const val = match[2].trim();
if (['QNAME', 'QTYPE', 'QCLASS', 'RCODE'].includes(key)) {
entry[key.toLowerCase()] = val;
} else if (key === 'ANSWER') {
entry.answer = val;
}
}
entry.raw = line;
parsed.push(entry);
} catch {
// Skip unparseable lines
}
}
return parsed;
}
// ---------------------------------------------------------------------------
// Server Management
// ---------------------------------------------------------------------------
/**
* Restart the DNS server.
* POST /api/admin/restart?token=...
* Requires admin credentials.
*/
async restartServer({ server } = {}) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
try {
log.info('technitium', 'Restarting DNS server', { server: this.serverIp });
const url = this._buildUrl('/api/admin/restart', { token });
const response = await fetchT(url, {
method: 'POST',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
log.info('technitium', 'DNS server restart initiated');
return { success: true, message: 'Server restart initiated' };
}
throw new Error(result.errorMessage || 'Restart failed');
} catch (error) {
log.error('technitium', 'DNS restart error', { error: error.message });
throw new Error(`Failed to restart DNS server: ${error.message}`);
}
}
/**
* Check for DNS server updates.
* GET /api/user/checkForUpdate?token=...
*/
async checkUpdate({ server } = {}) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
try {
log.info('technitium', 'Checking for DNS server update', { server: this.serverIp });
const url = this._buildUrl('/api/user/checkForUpdate', { token });
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
return {
success: true,
updateAvailable: !!(result.response && result.response.updateAvailable),
latestVersion: (result.response && result.response.latestVersion) || null,
currentVersion: (result.response && result.response.currentVersion) || null,
response: result.response
};
}
throw new Error(result.errorMessage || 'Update check failed');
} catch (error) {
log.error('technitium', 'Update check error', { error: error.message });
throw new Error(`Failed to check for updates: ${error.message}`);
}
}
// ---------------------------------------------------------------------------
// Config Validation
// ---------------------------------------------------------------------------
validateConfig() {
const errors = [];
if (!this.serverIp) {
errors.push('serverIp is required');
}
if (this.serverPort && (typeof this.serverPort !== 'number' || this.serverPort < 1 || this.serverPort > 65535)) {
errors.push('serverPort must be a valid port number (1-65535)');
}
return { valid: errors.length === 0, errors };
}
}
module.exports = TechnitiumDNSProvider;
+28 -28
View File
@@ -1,39 +1,34 @@
/** /**
* 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 { LIMITS } = require('./constants'); const { logError } = require('./error-logger');
const { logError: unifiedLogError, safeErrorMessage } = require('./src/utils/logging');
const { errorResponse } = require('./src/utils/responses');
const ERROR_LOG_FILE = path.join(__dirname, 'error.log'); /**
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE; * Async route handler wrapper
* 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 (unified, same file the rest of the app uses) // Log all errors with request context
unifiedLogError( logError(req.path, err, {
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;
@@ -44,23 +39,27 @@ function errorMiddleware(err, req, res, next) {
// Error code (DC-XXX format) // Error code (DC-XXX format)
const code = err.code || `DC-${statusCode}`; const code = err.code || `DC-${statusCode}`;
// Build extras for response // Build response
const extras = { code }; const response = {
success: false,
error: isOperational ? err.message : 'Internal server error',
code
};
// Add optional fields if present // Add optional fields if present
if (err.requiresTotp) extras.requiresTotp = true; if (err.requiresTotp) response.requiresTotp = true;
if (err.retryAfter) extras.retryAfter = err.retryAfter; if (err.retryAfter) response.retryAfter = err.retryAfter;
if (err.field) extras.field = err.field; if (err.field) response.field = err.field;
if (err.resource) extras.resource = err.resource; if (err.resource) response.resource = err.resource;
if (err.details && Object.keys(err.details).length > 0) extras.details = err.details; if (err.details && Object.keys(err.details).length > 0) response.details = err.details;
// Development mode: include stack trace // Development mode: include stack trace
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
extras.stack = err.stack; response.stack = err.stack;
} }
// Send response // Send response
errorResponse(res, statusCode, isOperational ? safeErrorMessage(err) : 'Internal server error', extras); res.status(statusCode).json(response);
// For non-operational errors, log as fatal // For non-operational errors, log as fatal
if (!isOperational) { if (!isOperational) {
@@ -82,6 +81,7 @@ function notFoundHandler(req, res, next) {
} }
module.exports = { module.exports = {
asyncHandler,
errorMiddleware, errorMiddleware,
notFoundHandler notFoundHandler
}; };
+135
View File
@@ -0,0 +1,135 @@
// 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
};
+25 -6
View File
@@ -9,9 +9,24 @@ const http = require('http');
const EventEmitter = require('events'); const EventEmitter = require('events');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const paths = require('./platform-paths');
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(__dirname, 'health-config.json'); // Persist health config + history alongside the other state files (services.json,
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(__dirname, 'health-history.json'); // config.json) rather than next to the source. In a container that data dir is the
// mounted /app/data volume, so uptime history survives container recreates/updates;
// previously these defaulted to __dirname (unmounted /app) and every recreate wiped
// the accumulated history, blanking the dashboard uptime bars. Explicit env vars
// still override.
const HEALTH_DATA_DIR = process.env.HEALTH_DATA_DIR || path.dirname(paths.configFile);
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(HEALTH_DATA_DIR, 'health-config.json');
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_DATA_DIR, 'health-history.json');
// Legacy locations (next to the source) used before the data-dir default. Read these
// once on first load if the new files are absent, so upgrading installs migrate their
// accumulated history/config instead of starting empty. The next save() rewrites to
// the new location.
const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10); const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
@@ -541,8 +556,10 @@ class HealthChecker extends EventEmitter {
*/ */
loadConfig() { loadConfig() {
try { try {
if (fs.existsSync(HEALTH_CONFIG_FILE)) { const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE
return JSON.parse(fs.readFileSync(HEALTH_CONFIG_FILE, 'utf8')); : (HEALTH_CONFIG_FILE !== LEGACY_HEALTH_CONFIG_FILE && fs.existsSync(LEGACY_HEALTH_CONFIG_FILE) ? LEGACY_HEALTH_CONFIG_FILE : null);
if (file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} }
} catch (error) { } catch (error) {
this.emit('log', 'error', `Error loading config: ${error.message}`); this.emit('log', 'error', `Error loading config: ${error.message}`);
@@ -566,8 +583,10 @@ class HealthChecker extends EventEmitter {
*/ */
loadHistory() { loadHistory() {
try { try {
if (fs.existsSync(HEALTH_HISTORY_FILE)) { const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE
return JSON.parse(fs.readFileSync(HEALTH_HISTORY_FILE, 'utf8')); : (HEALTH_HISTORY_FILE !== LEGACY_HEALTH_HISTORY_FILE && fs.existsSync(LEGACY_HEALTH_HISTORY_FILE) ? LEGACY_HEALTH_HISTORY_FILE : null);
if (file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} }
} catch (error) { } catch (error) {
this.emit('log', 'error', `Error loading history: ${error.message}`); this.emit('log', 'error', `Error loading history: ${error.message}`);
+3 -5
View File
@@ -15,7 +15,6 @@ const os = require('os');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { verifyCode, parseCode, VALID_DURATIONS } = require('./license-keygen'); const { verifyCode, parseCode, VALID_DURATIONS } = require('./license-keygen');
const { errorResponse } = require('./src/utils/responses');
const LICENSE_CRED_KEY = 'license.activation'; const LICENSE_CRED_KEY = 'license.activation';
const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists
@@ -318,9 +317,6 @@ 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();
} }
@@ -345,7 +341,9 @@ class LicenseManager {
} }
const featureInfo = PREMIUM_FEATURES[feature] || { name: feature }; const featureInfo = PREMIUM_FEATURES[feature] || { name: feature };
return errorResponse(res, 403, `${featureInfo.name} requires a DashCaddy Premium subscription.`, { return res.status(403).json({
success: false,
error: `${featureInfo.name} requires a DashCaddy Premium subscription.`,
premiumRequired: true, premiumRequired: true,
feature, feature,
featureName: featureInfo.name, featureName: featureInfo.name,
+20 -35
View File
@@ -15,7 +15,6 @@ const crypto = require('crypto');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection'); const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection');
const { RATE_LIMITS, LIMITS, APP } = require('./constants'); const { RATE_LIMITS, LIMITS, APP } = require('./constants');
const { errorResponse, unauthorized, forbidden, validationError } = require('./src/utils/responses');
const { CACHE_CONFIGS, createCache } = require('./cache-config'); const { CACHE_CONFIGS, createCache } = require('./cache-config');
/** /**
@@ -34,7 +33,7 @@ module.exports = function configureMiddleware(app, {
// ── Container ID param validation ── // ── Container ID param validation ──
app.param('id', (req, res, next, id) => { app.param('id', (req, res, next, id) => {
if (req.path.includes('/containers/') && !isValidContainerId(id)) { if (req.path.includes('/containers/') && !isValidContainerId(id)) {
return validationError(res, 'Invalid container ID'); return res.status(400).json({ success: false, error: 'Invalid container ID' });
} }
next(); next();
}); });
@@ -128,7 +127,9 @@ module.exports = function configureMiddleware(app, {
const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim())); const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim()));
if (!fromTailscale) { if (!fromTailscale) {
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', { return res.status(403).json({
success: false,
error: '[DC-120] Access denied. This dashboard requires Tailscale connection.',
requiresTailscale: true, requiresTailscale: true,
clientIP: clientIP clientIP: clientIP
}); });
@@ -149,7 +150,9 @@ module.exports = function configureMiddleware(app, {
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip); for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
} }
if (!knownIPs.has(clientTailscaleIP)) { if (!knownIPs.has(clientTailscaleIP)) {
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', { return res.status(403).json({
success: false,
error: '[DC-121] Access denied. Device not in allowed tailnet.',
requiresTailscale: true, requiresTailscale: true,
clientIP clientIP
}); });
@@ -274,32 +277,9 @@ 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' },
@@ -324,13 +304,16 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' }, { path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
{ 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' },
// 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/health-checks/status', exact: true, method: 'GET' },
] : []), // Read-only update/version info shown on the dashboard view (verification
{ path: '/api/v1/version', exact: true, method: 'GET' }, // modal, topbar version, update badges). Mutating actions — update-apply,
// rollback (POST) — are NOT listed here and stay TOTP-protected.
{ path: '/api/v1/system/version', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-status', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-history', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-check', exact: true, method: 'GET' },
{ path: '/api/v1/updates/available', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
]; ];
function isPublicRoute(req) { function isPublicRoute(req) {
@@ -355,7 +338,7 @@ module.exports = function configureMiddleware(app, {
if (isPublicRoute(req)) return next(); if (isPublicRoute(req)) return next();
if (isSessionValid(req)) return next(); if (isSessionValid(req)) return next();
return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true }); return res.status(401).json({ success: false, error: '[DC-110] Authentication required', requiresTotp: true });
}; };
app.use(totpAuthMiddleware); app.use(totpAuthMiddleware);
@@ -403,7 +386,9 @@ module.exports = function configureMiddleware(app, {
} }
// No valid auth — reject // No valid auth — reject
return errorResponse(res, 401, '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', { return res.status(401).json({
success: false,
error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key',
requiresTotp: totpConfig.enabled requiresTotp: totpConfig.enabled
}); });
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.13.4", "version": "1.7.8",
"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": {
-21
View File
@@ -3,7 +3,6 @@
// 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
@@ -35,8 +34,6 @@ 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),
@@ -44,24 +41,6 @@ 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'),
+2 -18
View File
@@ -226,23 +226,7 @@ const server = http.createServer(async (req, res) => {
json(res, 404, { error: 'Not found' }); json(res, 404, { error: 'Not found' });
}); });
const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842; server.listen(PORT, '0.0.0.0', () => {
const PYLON_HOST = process.env.PYLON_HOST || '0.0.0.0'; console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`);
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'));
+114
View File
@@ -0,0 +1,114 @@
// 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
};
+3 -4
View File
@@ -3,7 +3,6 @@ const yaml = require('js-yaml');
const { DOCKER, REGEX } = require('../../constants'); const { DOCKER, REGEX } = require('../../constants');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
const { ok } = require('../../src/utils/responses');
/** /**
* Docker Compose import routes * Docker Compose import routes
@@ -163,7 +162,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
} }
const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack'; const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack';
const result = parseCompose(yamlStr, name); const result = parseCompose(yamlStr, name);
ok(res, { ...result }); res.json({ success: true, ...result });
}, 'compose-import')); }, 'compose-import'));
// POST /deploy-compose — deploy parsed services // POST /deploy-compose — deploy parsed services
@@ -301,7 +300,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason }); results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason });
} }
ok(res, { results, stackName: stackName || prefix }); res.json({ success: true, results, stackName: stackName || prefix });
}, 'compose-deploy')); }, 'compose-deploy'));
// DELETE /compose-stack/:stackName — remove an entire stack // DELETE /compose-stack/:stackName — remove an entire stack
@@ -330,7 +329,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
}); });
await servicesStateManager.update(data => { data.services = updated; }); await servicesStateManager.update(data => { data.services = updated; });
ok(res, { removed, count: removed.length }); res.json({ success: true, removed, count: removed.length });
}, 'compose-stack-delete')); }, 'compose-stack-delete'));
return router; return router;
+8 -20
View File
@@ -8,7 +8,6 @@ const { exists } = require('../../fs-helpers');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const { logError } = require('../../src/utils/logging'); const { logError } = require('../../src/utils/logging');
const { ok } = require('../../src/utils/responses');
/** /**
* Apps deployment routes factory * Apps deployment routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -198,18 +197,8 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
} }
} }
let container; const container = await docker.client.createContainer(containerConfig);
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 {
@@ -244,9 +233,9 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
if (!template) throw new ValidationError('Invalid app template'); if (!template) throw new ValidationError('Invalid app template');
const existingContainer = await helpers.findExistingContainerByImage(template); const existingContainer = await helpers.findExistingContainerByImage(template);
if (existingContainer) { if (existingContainer) {
ok(res, { exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` }); res.json({ success: true, exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` });
} else { } else {
ok(res, { exists: false, message: `No existing ${template.name} container found` }); res.json({ success: true, exists: false, message: `No existing ${template.name} container found` });
} }
}, 'check-existing')); }, 'check-existing'));
@@ -317,7 +306,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, 30); await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
log.info('deploy', 'Container is healthy', { containerId }); log.info('deploy', 'Container is healthy', { containerId });
} }
@@ -327,7 +316,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
let dnsWarning = null; let dnsWarning = null;
if (config.createDns && !isSubdirectoryMode) { if (config.createDns && !isSubdirectoryMode) {
try { try {
await ctx.dns.universalCreateRecord(config.subdomain, config.ip); await ctx.dns.createRecord(config.subdomain, config.ip);
log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip }); log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
} catch (dnsError) { } catch (dnsError) {
await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip }); await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip });
@@ -431,11 +420,10 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
res.json(response); res.json(response);
} catch (error) { } catch (error) {
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ } await logError('app-deploy', error, { appId, config });
const msg = error?.message || String(error || 'Unknown error'); log.error('deploy', 'Deployment failed', { appId, error: error.message });
log.error('deploy', 'Deployment failed', { appId, error: msg });
const template = ctx.APP_TEMPLATES[appId]; const template = ctx.APP_TEMPLATES[appId];
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {} ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
errorResponse(res, 500, ctx.safeErrorMessage(error)); errorResponse(res, 500, ctx.safeErrorMessage(error));
} }
}, 'apps-deploy')); }, 'apps-deploy'));
+1 -4
View File
@@ -379,12 +379,9 @@ 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 && result.error !== 'No changes to apply') { if (!result.success) {
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. */
+13 -13
View File
@@ -25,6 +25,7 @@ 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,
@@ -39,27 +40,26 @@ 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 });
// Mount sub-routers at their prefix paths. try { router.use('/deploy', initDeploy(subCtx)); }
// Sub-modules define routes at '/' (root of their sub-router). catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
// Final paths: /api/v1/apps/deploy, /api/v1/apps/remove, /api/v1/apps/templates, etc.
try { router.use('/apps', initDeploy(subCtx)); } try { router.use('/remove', initRemoval(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message, e.stack); } catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
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, e.stack); } catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); } try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); } catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
try { router.use('/apps', initCompose(subCtx)); } try { router.use('/compose', initCompose(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); } catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
return router; return router;
}; };
+11 -7
View File
@@ -1,7 +1,6 @@
const express = require('express'); const express = require('express');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { logError } = require('../../src/utils/logging'); const { logError } = require('../../src/utils/logging');
const { ok } = require('../../src/utils/responses');
module.exports = function({ module.exports = function({
docker, caddy, servicesStateManager, asyncHandler, log, helpers, docker, caddy, servicesStateManager, asyncHandler, log, helpers,
@@ -72,13 +71,18 @@ module.exports = function({
if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) { if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) {
try { try {
const domain = ctx.buildDomain(subdomain); const domain = ctx.buildDomain(subdomain);
const resolveResult = await ctx.dns.universalResolveRecord(domain, 'A'); const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', {
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true'
});
let recordIp = ip || 'localhost'; let recordIp = ip || 'localhost';
if (resolveResult) { if (getResult.status === 'ok' && getResult.response?.records) {
recordIp = resolveResult; const aRecord = getResult.response.records.find(r => r.type === 'A');
if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress;
} }
await ctx.dns.universalDeleteRecord(domain, recordIp); const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
results.dns = 'deleted'; token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp
});
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
log.info('dns', 'DNS record removal', { result: results.dns }); log.info('dns', 'DNS record removal', { result: results.dns });
} catch (error) { } catch (error) {
results.dns = error.message; results.dns = error.message;
@@ -136,7 +140,7 @@ module.exports = function({
results.service = error.message; results.service = error.message;
} }
ok(res, { message: `App ${appId} removal completed`, results }); res.json({ success: true, message: `App ${appId} removal completed`, results });
} catch (error) { } catch (error) {
await logError('app-removal', error); await logError('app-removal', error);
errorResponse(res, 500, ctx.safeErrorMessage(error), { results }); errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
+17 -13
View File
@@ -2,7 +2,6 @@ const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { ok, validationError, notFound, errorResponse } = require('../../src/utils/responses');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
@@ -48,7 +47,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
} }
const result = await restoreService(service); const result = await restoreService(service);
ok(res, { result }); res.json({ success: true, result });
}, 'apps-restore')); }, 'apps-restore'));
/** /**
@@ -60,7 +59,8 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
const restoreable = services.filter(s => s.deploymentManifest); const restoreable = services.filter(s => s.deploymentManifest);
if (restoreable.length === 0) { if (restoreable.length === 0) {
return ok(res, { return res.json({
success: true,
message: 'No services have deployment manifests to restore', message: 'No services have deployment manifests to restore',
results: [] results: []
}); });
@@ -85,7 +85,8 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
const skipped = results.filter(r => r.status === 'skipped').length; const skipped = results.filter(r => r.status === 'skipped').length;
const failed = results.filter(r => r.status === 'failed').length; const failed = results.filter(r => r.status === 'failed').length;
ok(res, { res.json({
success: true,
message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`, message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`,
results results
}); });
@@ -122,7 +123,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
status.push(entry); status.push(entry);
} }
ok(res, { services: status }); res.json({ success: true, services: status });
}, 'apps-restore-status')); }, 'apps-restore-status'));
// ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ==================== // ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ====================
@@ -173,7 +174,8 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
// Sort by timestamp descending (newest first) // Sort by timestamp descending (newest first)
files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
ok(res, { res.json({
success: true,
appId, appId,
isBackupFile: true, isBackupFile: true,
files, files,
@@ -188,12 +190,12 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
// Security: prevent path traversal // Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return validationError(res, 'Invalid filename'); return res.status(400).json({ success: false, error: 'Invalid filename' });
} }
const filepath = path.join(DEFAULT_BACKUP_DIR, filename); const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
if (!fs.existsSync(filepath)) { if (!fs.existsSync(filepath)) {
return notFound(res, `Backup file not found: ${filename}`); return res.status(404).json({ success: false, error: `Backup file not found: ${filename}` });
} }
try { try {
@@ -205,7 +207,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
try { try {
fileData = await backupManager.decryptBackup(fileData, encryptionKey); fileData = await backupManager.decryptBackup(fileData, encryptionKey);
} catch (err) { } catch (err) {
return validationError(res, 'Failed to decrypt backup: ' + err.message); return res.status(400).json({ success: false, error: 'Failed to decrypt backup: ' + err.message });
} }
} }
@@ -262,7 +264,8 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
// Cleanup temp dir // Cleanup temp dir
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
ok(res, { res.json({
success: true,
isBackupFile: true, isBackupFile: true,
restored: { restored: {
services: !!restoreData.services, services: !!restoreData.services,
@@ -274,7 +277,8 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
} else { } else {
// Preview mode // Preview mode
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
ok(res, { res.json({
success: true,
isBackupFile: true, isBackupFile: true,
preview: true, preview: true,
filename, filename,
@@ -292,7 +296,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
throw err; throw err;
} }
} catch (err) { } catch (err) {
errorResponse(res, 500, err.message); res.status(500).json({ success: false, error: err.message });
} }
}, 'apps-revert')); }, 'apps-revert'));
@@ -454,7 +458,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
// DNS record // DNS record
if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') { if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') {
try { try {
await ctx.dns.universalCreateRecord(manifest.config.subdomain, manifest.config.ip); await ctx.dns.createRecord(manifest.config.subdomain, manifest.config.ip);
log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain }); log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain });
} catch (e) { } catch (e) {
log.warn('restore', `DNS recreation failed: ${e.message}`); log.warn('restore', `DNS recreation failed: ${e.message}`);
+11 -8
View File
@@ -20,7 +20,6 @@ const { exists } = require('../../fs-helpers');
* @returns {express.Router} * @returns {express.Router}
*/ */
const { REGEX } = require('../../constants'); const { REGEX } = require('../../constants');
const { ok } = require('../../src/utils/responses');
module.exports = function({ module.exports = function({
servicesStateManager, asyncHandler, helpers, servicesStateManager, asyncHandler, helpers,
@@ -43,7 +42,8 @@ module.exports = function({
// Get available app templates // Get available app templates
router.get('/templates', asyncHandler(async (req, res) => { router.get('/templates', asyncHandler(async (req, res) => {
ok(res, { res.json({
success: true,
templates: ctx.APP_TEMPLATES, templates: ctx.APP_TEMPLATES,
categories: ctx.TEMPLATE_CATEGORIES, categories: ctx.TEMPLATE_CATEGORIES,
difficultyLevels: ctx.DIFFICULTY_LEVELS difficultyLevels: ctx.DIFFICULTY_LEVELS
@@ -58,7 +58,7 @@ module.exports = function({
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
throw new NotFoundError('App template'); throw new NotFoundError('App template');
} }
ok(res, { template }); res.json({ success: true, template });
}, 'apps-template-detail')); }, 'apps-template-detail'));
// Check port availability // Check port availability
@@ -80,7 +80,7 @@ module.exports = function({
const usedPorts = await docker.getUsedPorts(); const usedPorts = await docker.getUsedPorts();
for (let port = basePort; port < basePort + maxAttempts; port++) { for (let port = basePort; port < basePort + maxAttempts; port++) {
if (!usedPorts.has(port)) { if (!usedPorts.has(port)) {
ok(res, { suggestedPort: port, basePort }); res.json({ success: true, suggestedPort: port, basePort });
return; return;
} }
} }
@@ -107,8 +107,10 @@ module.exports = function({
if (oldSubdomain && ctx.dns.getToken()) { if (oldSubdomain && ctx.dns.getToken()) {
try { try {
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
await ctx.dns.universalDeleteRecord(oldDomain, ip || 'localhost'); const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
results.oldDns = 'deleted'; token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost'
});
results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage;
log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
} catch (error) { } catch (error) {
results.oldDns = `failed: ${error.message}`; results.oldDns = `failed: ${error.message}`;
@@ -118,7 +120,7 @@ module.exports = function({
if (newSubdomain && ctx.dns.getToken()) { if (newSubdomain && ctx.dns.getToken()) {
try { try {
await ctx.dns.universalCreateRecord(newSubdomain, ip || 'localhost'); await ctx.dns.createRecord(newSubdomain, ip || 'localhost');
results.newDns = 'created'; results.newDns = 'created';
log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) }); log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) });
} catch (error) { } catch (error) {
@@ -170,7 +172,8 @@ module.exports = function({
log.warn('deploy', 'Service update warning', { error: error.message || String(error) }); log.warn('deploy', 'Service update warning', { error: error.message || String(error) });
} }
ok(res, { res.json({
success: true,
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`, message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
newUrl: `https://${ctx.buildDomain(newSubdomain)}`, newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
results results
+7 -4
View File
@@ -3,7 +3,6 @@ const { APP_PORTS, ARR_SERVICES } = require('../../constants');
const { validateURL, validateToken } = require('../../input-validator'); const { validateURL, validateToken } = require('../../input-validator');
const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors'); const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors');
const { logError } = require('../../src/utils/logging'); const { logError } = require('../../src/utils/logging');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Arr configuration routes factory * Arr configuration routes factory
@@ -259,7 +258,11 @@ module.exports = function(ctx) {
const version = service === 'plex' ? data.MediaContainer?.version : data.version; const version = service === 'plex' ? data.MediaContainer?.version : data.version;
const appName = service === 'plex' ? 'Plex' : data.appName; const appName = service === 'plex' ? 'Plex' : data.appName;
log.info('arr', 'Service connection successful', { service, appName, version }); log.info('arr', 'Service connection successful', { service, appName, version });
return ok(res, { version, appName }); return res.json({
success: true,
version,
appName
});
} else if (response.status === 401) { } else if (response.status === 401) {
throw new AuthenticationError('Invalid API key'); throw new AuthenticationError('Invalid API key');
} else if (response.status === 404) { } else if (response.status === 404) {
@@ -550,7 +553,7 @@ module.exports = function(ctx) {
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`); const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
const storedProfileId = metadata?.qualityProfileId || null; const storedProfileId = metadata?.qualityProfileId || null;
ok(res, { profiles: mapped, storedProfileId }); res.json({ success: true, profiles: mapped, storedProfileId });
} catch (e) { } catch (e) {
if (e.cause?.code === 'ECONNREFUSED') { if (e.cause?.code === 'ECONNREFUSED') {
return errorResponse(res, 502, 'Connection refused — is the service running?'); return errorResponse(res, 502, 'Connection refused — is the service running?');
@@ -585,7 +588,7 @@ module.exports = function(ctx) {
existing.qualityProfileName = qualityProfileName || null; existing.qualityProfileName = qualityProfileName || null;
await credentialManager.storeMetadata(credKey, existing); await credentialManager.storeMetadata(credKey, existing);
successMessage(res, `Quality profile updated for ${service}`); res.json({ success: true, message: `Quality profile updated for ${service}` });
}, 'arr-quality-profile-save')); }, 'arr-quality-profile-save'));
return router; return router;
+8 -4
View File
@@ -1,7 +1,6 @@
const express = require('express'); const express = require('express');
const { validateURL, validateToken } = require('../../input-validator'); const { validateURL, validateToken } = require('../../input-validator');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Arr credentials routes factory * Arr credentials routes factory
@@ -102,7 +101,12 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false }); log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false });
ok(res, { message: `${service} API key stored`, connectionTest, url: resolvedUrl }); res.json({
success: true,
message: `${service} API key stored`,
connectionTest,
url: resolvedUrl
});
}, 'arr-credentials-store')); }, 'arr-credentials-store'));
// List stored arr credentials (keys only, not values) // List stored arr credentials (keys only, not values)
@@ -127,7 +131,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
// Get seedbox base URL // Get seedbox base URL
const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl'); const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl');
ok(res, { credentials, seedboxBaseUrl: seedboxBaseUrl || null }); res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null });
}, 'arr-credentials-list')); }, 'arr-credentials-list'));
// Delete stored arr credentials // Delete stored arr credentials
@@ -136,7 +140,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`; const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
await credentialManager.delete(credKey); await credentialManager.delete(credKey);
log.info('arr', 'Deleted credentials', { service }); log.info('arr', 'Deleted credentials', { service });
successMessage(res, `${service} credentials removed`); res.json({ success: true, message: `${service} credentials removed` });
}, 'arr-credentials-delete')); }, 'arr-credentials-delete'));
return router; return router;
+3 -3
View File
@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const { APP_PORTS, ARR_SERVICES } = require('../../constants'); const { APP_PORTS, ARR_SERVICES } = require('../../constants');
const { ok } = require('../../src/utils/responses');
/** /**
* Arr service detection routes factory * Arr service detection routes factory
@@ -63,7 +62,8 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet
detected.plex.token = await helpers.getPlexToken(detected.plex.containerName); detected.plex.token = await helpers.getPlexToken(detected.plex.containerName);
} }
ok(res, { res.json({
success: true,
services: detected, services: detected,
summary: { summary: {
plexReady: !!(detected.plex?.token), plexReady: !!(detected.plex?.token),
@@ -287,7 +287,7 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2 readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2
}; };
ok(res, { services: result, seedboxBaseUrl: detectedSeedboxUrl, summary }); res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });
}, 'smart-detect')); }, 'smart-detect'));
return router; return router;
+1 -2
View File
@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const { APP_PORTS } = require('../../constants'); const { APP_PORTS } = require('../../constants');
const { ok } = require('../../src/utils/responses');
/** /**
* Plex routes factory * Plex routes factory
@@ -87,7 +86,7 @@ module.exports = function({ fetchT, asyncHandler, errorResponse, log: _log, help
lastVerified: new Date().toISOString() lastVerified: new Date().toISOString()
}); });
ok(res, { serverName, version, libraries }); res.json({ success: true, serverName, version, libraries });
}, 'plex-libraries')); }, 'plex-libraries'));
return router; return router;
+6 -5
View File
@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors'); const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Auth API keys routes factory * Auth API keys routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -40,7 +39,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
} }
const keys = await authManager.listAPIKeys(); const keys = await authManager.listAPIKeys();
ok(res, { keys }); res.json({ success: true, keys });
}, 'auth-keys-list')); }, 'auth-keys-list'));
// Generate new API key // Generate new API key
@@ -67,7 +66,8 @@ module.exports = function({ authManager, asyncHandler, log }) {
scopes || ['read', 'write'] scopes || ['read', 'write']
); );
ok(res, { res.json({
success: true,
key: keyData.key, key: keyData.key,
id: keyData.id, id: keyData.id,
name: keyData.name, name: keyData.name,
@@ -93,7 +93,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
const success = await authManager.revokeAPIKey(keyId); const success = await authManager.revokeAPIKey(keyId);
if (success) { if (success) {
successMessage(res, 'API key revoked successfully'); res.json({ success: true, message: 'API key revoked successfully' });
} else { } else {
throw new NotFoundError(`API key ${keyId}`); throw new NotFoundError(`API key ${keyId}`);
} }
@@ -126,7 +126,8 @@ module.exports = function({ authManager, asyncHandler, log }) {
const expiresInMs = parseExpiration(expiresIn || '24h'); const expiresInMs = parseExpiration(expiresIn || '24h');
const expiresAt = new Date(Date.now() + expiresInMs).toISOString(); const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
ok(res, { res.json({
success: true,
token, token,
expiresAt, expiresAt,
usage: 'Include in Authorization header as: Bearer <token>' usage: 'Include in Authorization header as: Bearer <token>'
+8 -7
View File
@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const { ValidationError, AuthenticationError } = require('../../errors'); const { ValidationError, AuthenticationError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Auth TOTP routes factory * Auth TOTP routes factory
@@ -28,7 +27,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
// Get current TOTP config (public route) // Get current TOTP config (public route)
router.get('/totp/config', asyncHandler(async (req, res) => { router.get('/totp/config', asyncHandler(async (req, res) => {
ok(res, { res.json({
success: true,
config: { config: {
enabled: ctx.totpConfig.enabled, enabled: ctx.totpConfig.enabled,
sessionDuration: ctx.totpConfig.sessionDuration, sessionDuration: ctx.totpConfig.sessionDuration,
@@ -62,7 +62,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
color: { dark: '#ffffff', light: '#00000000' } color: { dark: '#ffffff', light: '#00000000' }
}); });
ok(res, { qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret }); res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
}, 'totp-setup')); }, 'totp-setup'));
// Verify first code to confirm setup, then activate TOTP // Verify first code to confirm setup, then activate TOTP
@@ -99,7 +99,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
ctx.session.create(req, ctx.totpConfig.sessionDuration); ctx.session.create(req, ctx.totpConfig.sessionDuration);
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration); ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
ok(res, { message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration }); res.json({ success: true, message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration });
}, 'totp-verify-setup')); }, 'totp-verify-setup'));
// Login: verify TOTP code and set session cookie // Login: verify TOTP code and set session cookie
@@ -133,7 +133,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https'); const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size }); log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken }); res.json({ success: true, message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
}, 'totp-verify')); }, 'totp-verify'));
// Check session validity (used by Caddy forward_auth) // Check session validity (used by Caddy forward_auth)
@@ -185,7 +185,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
ctx.session.clear(req); ctx.session.clear(req);
ctx.session.clearCookie(res); ctx.session.clearCookie(res);
successMessage(res, 'TOTP disabled'); res.json({ success: true, message: 'TOTP disabled' });
}, 'totp-disable')); }, 'totp-disable'));
// Update TOTP settings (session duration) // Update TOTP settings (session duration)
@@ -204,7 +204,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
} }
await ctx.saveTotpConfig(); await ctx.saveTotpConfig();
ok(res, { res.json({
success: true,
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp } config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }
}); });
}, 'totp-config')); }, 'totp-config'));
-164
View File
@@ -1,164 +0,0 @@
/**
* Auto-Restart Policy Routes
*
* CRUD endpoints for per-container auto-restart policies.
* Also provides a dry-run test endpoint.
*
* @module routes/auto-restart
*/
const express = require('express');
const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors');
/**
* Auto-restart route factory
*
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.autoRestartManager - AutoRestartManager instance
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @returns {express.Router}
*/
module.exports = function ({ autoRestartManager, asyncHandler, logError }) {
const router = express.Router();
/**
* GET /auto-restart/policies
* List all configured auto-restart policies.
*/
router.get('/policies', asyncHandler(async (_req, res) => {
const policies = autoRestartManager.listPolicies();
success(res, { policies });
}, 'auto-restart-list'));
/**
* GET /auto-restart/policies/:serviceId
* Get the restart policy for a single service.
*/
router.get('/policies/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const policy = autoRestartManager.getPolicy(serviceId);
if (!policy) {
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
}
success(res, { policy });
}, 'auto-restart-get'));
/**
* POST /auto-restart/policies/:serviceId
* Create or update a restart policy.
*
* Body: { enabled, maxRetries, retryIntervalMs, windowMinutes }
*/
router.post('/policies/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const { enabled, maxRetries, retryIntervalMs, windowMinutes } = req.body;
// Validate inputs
if (enabled !== undefined && typeof enabled !== 'boolean') {
throw new ValidationError('enabled must be a boolean');
}
if (maxRetries !== undefined) {
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 100) {
throw new ValidationError('maxRetries must be an integer between 0 and 100');
}
}
if (retryIntervalMs !== undefined) {
if (!Number.isInteger(retryIntervalMs) || retryIntervalMs < 0 || retryIntervalMs > 3600000) {
throw new ValidationError('retryIntervalMs must be an integer between 0 and 3600000');
}
}
if (windowMinutes !== undefined) {
if (!Number.isInteger(windowMinutes) || windowMinutes < 0 || windowMinutes > 1440) {
throw new ValidationError('windowMinutes must be an integer between 0 and 1440');
}
}
const policy = await autoRestartManager.setPolicy(serviceId, {
...(enabled !== undefined && { enabled }),
...(maxRetries !== undefined && { maxRetries }),
...(retryIntervalMs !== undefined && { retryIntervalMs }),
...(windowMinutes !== undefined && { windowMinutes }),
});
success(res, { policy, message: `Policy ${serviceId} saved` });
}, 'auto-restart-set'));
/**
* DELETE /auto-restart/policies/:serviceId
* Remove a restart policy.
*/
router.delete('/policies/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const removed = await autoRestartManager.removePolicy(serviceId);
if (!removed) {
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
}
success(res, { message: `Policy for "${serviceId}" removed` });
}, 'auto-restart-delete'));
/**
* POST /auto-restart/policies/:serviceId/test
* Dry-run: simulate a restart attempt without actually restarting.
* Returns what *would* happen given the current policy state.
*/
router.post('/policies/:serviceId/test', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
throw new ValidationError('Invalid service ID format');
}
const policy = autoRestartManager.getPolicy(serviceId);
if (!policy) {
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
}
const now = Date.now();
const inCooldown = policy.cooldownUntil && now < policy.cooldownUntil;
const wouldRetry = !inCooldown && policy.currentRetries < policy.maxRetries;
const nextAttempt = policy.currentRetries + 1;
success(res, {
dryRun: true,
serviceId,
policy: {
enabled: policy.enabled,
currentRetries: policy.currentRetries,
maxRetries: policy.maxRetries,
cooldownUntil: policy.cooldownUntil,
inCooldown,
},
wouldRestart: policy.enabled && wouldRetry,
wouldMaxOut: !wouldRetry && !inCooldown,
nextAttempt: wouldRetry ? nextAttempt : null,
message: !policy.enabled
? 'Policy is disabled — no restart would occur'
: inCooldown
? `In cooldown until ${new Date(policy.cooldownUntil).toISOString()} — would skip`
: wouldRetry
? `Would attempt restart ${nextAttempt}/${policy.maxRetries}`
: `Max retries (${policy.maxRetries}) already reached — would enter cooldown`,
});
}, 'auto-restart-test'));
return router;
};
+167 -5
View File
@@ -1,9 +1,13 @@
const express = require('express'); const express = require('express');
const { success } = require('../src/utils/responses'); const fsp = require('fs').promises;
const fs = require('fs');
const path = require('path'); const path = require('path');
const fs = require('fs');
const { success } = require('../response-helpers');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
: 0;
/** /**
* Backups routes factory * Backups routes factory
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
runImmediately: backup.runImmediately || false, runImmediately: backup.runImmediately || false,
destination: backup.destination || 'local', destination: backup.destination || 'local',
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR, destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
maxStorageBytes: backup.maxStorageBytes || null,
lastRun: lastRun ? lastRun.toISOString() : null, lastRun: lastRun ? lastRun.toISOString() : null,
nextRun: nextRun ? nextRun.toISOString() : null, nextRun: nextRun ? nextRun.toISOString() : null,
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
@@ -52,7 +57,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
// Create or update a scheduled backup for an app // Create or update a scheduled backup for an app
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => { router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body; const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
if (!appId) { if (!appId) {
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
@@ -62,6 +67,11 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
const config = backupManager.getConfig(); const config = backupManager.getConfig();
if (!config.backups) config.backups = {}; if (!config.backups) config.backups = {};
// Parse maxStorageBytes if provided as string (e.g. "10GB")
const parsedMaxStorage = maxStorageBytes
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
: null;
// Build the backup config for this app // Build the backup config for this app
const backupConfig = { const backupConfig = {
enabled: enabled !== undefined ? enabled : true, enabled: enabled !== undefined ? enabled : true,
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
destination: destination || 'local', destination: destination || 'local',
destinationPath: destinationPath || DEFAULT_BACKUP_DIR, destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
include: ['all'], include: ['all'],
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }] destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }],
maxStorageBytes: parsedMaxStorage
}; };
config.backups[appId] = backupConfig; config.backups[appId] = backupConfig;
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
success(res, { history }); success(res, { history });
}, 'backups-history')); }, 'backups-history'));
// Get storage info for backups destination
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
const storageInfo = await getStorageInfo();
success(res, storageInfo);
}, 'backups-storage-info'));
// Schedule a backup
router.post('/backups/schedule', asyncHandler(async (req, res) => {
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
if (!name || !schedule) {
return res.status(400).json({ error: 'name and schedule are required' });
}
const config = backupManager.getConfig();
// Store maxStorageBytes in the backup config (converted to bytes)
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
? maxStorageBytes
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
config.backups[name] = {
...backupConfig,
enabled: true,
schedule,
maxStorageBytes: maxBytes,
destinations: backupConfig.destinations || [{ type: 'local' }]
};
backupManager.updateConfig(config);
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
}, 'backups-schedule'));
// Restore from backup // Restore from backup
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
const result = await backupManager.restoreBackup(req.params.backupId, req.body); const result = await backupManager.restoreBackup(req.params.backupId, req.body);
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
/**
* Get storage information for the backup directory
*/
async function getStorageInfo() {
const result = {
destination: DEFAULT_BACKUP_DIR,
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
usedBytes: 0,
availableBytes: 0,
usagePercent: 0,
backupCount: 0,
oldestBackup: null,
newestBackup: null
};
try {
// Get disk space info
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
result.availableBytes = diskSpace.available;
// Scan for backup files
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
} else {
result.maxStorageBytes = diskSpace.total || 0;
}
let totalSize = 0;
let oldestTime = null;
let newestTime = null;
try {
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
for (const entry of entries) {
if (entry.endsWith('.backup')) {
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
try {
const stats = await fsp.stat(filePath);
totalSize += stats.size;
result.backupCount++;
const fileTime = new Date(stats.mtime);
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
} catch (e) {
// Skip files we can't stat
}
}
}
} catch (e) {
// Backup directory might not exist yet
}
result.usedBytes = totalSize;
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
result.newestBackup = newestTime ? newestTime.toISOString() : null;
// Calculate available (total limit - used), or from disk space if no limit set
if (result.maxStorageBytes > 0) {
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
} else if (diskSpace.total) {
result.availableBytes = diskSpace.available;
result.usagePercent = diskSpace.total > 0
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
: 0;
}
} catch (error) {
console.error('[BackupsRouter] Error getting storage info:', error.message);
}
return result;
}
/**
* Get disk space info (filesystem-agnostic)
*/
async function getDiskSpaceInfo(dirPath) {
try {
const diskInfo = await fsp.statfs(dirPath);
return {
total: diskInfo.blocks * diskInfo.bsize,
available: diskInfo.bfree * diskInfo.bsize,
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
};
} catch (error) {
// Directory might not exist or be accessible
return { total: 0, available: 0, used: 0 };
}
}
/**
* Parse storage size string like "10GB" to bytes
*/
function parseStorageSize(sizeStr) {
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = (match[2] || 'B').toUpperCase();
const multipliers = {
'B': 1,
'K': 1024,
'KB': 1024,
'M': 1024 * 1024,
'MB': 1024 * 1024,
'G': 1024 * 1024 * 1024,
'GB': 1024 * 1024 * 1024,
'T': 1024 * 1024 * 1024 * 1024,
'TB': 1024 * 1024 * 1024 * 1024
};
return Math.floor(value * (multipliers[unit] || 1));
}
+6 -5
View File
@@ -5,7 +5,6 @@ const path = require('path');
const { exists, isAccessible } = require('../fs-helpers'); const { exists, isAccessible } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, ForbiddenError } = require('../errors'); const { ValidationError, ForbiddenError } = require('../errors');
const { ok } = require('../src/utils/responses');
/** /**
* Browse route factory * Browse route factory
@@ -45,7 +44,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
} }
} }
return ok(res, { roots }); res.json({ success: true, roots });
}, 'browse-roots')); }, 'browse-roots'));
// Browse directory contents // Browse directory contents
@@ -65,7 +64,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
roots.push(r); roots.push(r);
} }
} }
return ok(res, { path: '', items: roots }); return res.json({ success: true, path: '', items: roots });
} }
const matchingRoot = BROWSE_ROOTS.find(r => const matchingRoot = BROWSE_ROOTS.find(r =>
@@ -125,7 +124,8 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
const result = paginate(folders, paginationParams); const result = paginate(folders, paginationParams);
ok(res, { res.json({
success: true,
path: requestedPath, path: requestedPath,
parent: path.dirname(requestedPath).replace(/\\/g, '/') || null, parent: path.dirname(requestedPath).replace(/\\/g, '/') || null,
items: result.data, items: result.data,
@@ -190,7 +190,8 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
} }
} }
ok(res, { res.json({
success: true,
mounts: detectedMounts, mounts: detectedMounts,
message: detectedMounts.length > 0 message: detectedMounts.length > 0
? `Found ${detectedMounts.length} media mount(s) from existing containers` ? `Found ${detectedMounts.length} media mount(s) from existing containers`
+20 -14
View File
@@ -5,7 +5,6 @@ const path = require('path');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
const { ok } = require('../src/utils/responses');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
module.exports = function(ctx) { module.exports = function(ctx) {
@@ -13,11 +12,14 @@ 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 = path.join(platformPaths.caCertDir, 'cert-info.json'); const certInfoPath = '/app/ca/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');
@@ -27,7 +29,8 @@ module.exports = function(ctx) {
const expirationDate = new Date(certInfo.validUntil); const expirationDate = new Date(certInfo.validUntil);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
ok(res, { res.json({
success: true,
certificate: { certificate: {
name: certInfo.name, name: certInfo.name,
fingerprint: certInfo.fingerprint, fingerprint: certInfo.fingerprint,
@@ -43,11 +46,13 @@ 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(dashcaCertPath)) certPath = dashcaCertPath; if (await exists(pkiCertPath)) certPath = pkiCertPath;
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');
@@ -67,12 +72,13 @@ module.exports = function(ctx) {
} }
// Load cert info to get the fingerprint // Load cert info to get the fingerprint
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json'); const certInfoPath = '/app/ca/cert-info.json';
const fallbackCertInfoPath2 = 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(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2;
} 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.');
} }
@@ -94,7 +100,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(platformPaths.caddyBase, 'scripts', templateName) path.join('/app', 'scripts', templateName)
]; ];
let templateContent; let templateContent;
@@ -136,8 +142,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 = platformPaths.pkiDir; const pkiPath = '/app/pki';
const certsDir = platformPaths.generatedCertsDir; const certsDir = '/app/generated-certs';
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');
@@ -240,10 +246,10 @@ ${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 = platformPaths.generatedCertsDir; const certsDir = '/app/generated-certs';
if (!await exists(certsDir)) { if (!await exists(certsDir)) {
return ok(res, { certificates: [] }); return res.json({ success: true, certificates: [] });
} }
const dirEntries = await fsp.readdir(certsDir); const dirEntries = await fsp.readdir(certsDir);
@@ -278,7 +284,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
} }
}))).filter(Boolean); }))).filter(Boolean);
ok(res, { certificates }); res.json({ success: true, certificates });
}, 'ca-certs')); }, 'ca-certs'));
return router; return router;
-92
View File
@@ -1,92 +0,0 @@
/**
* Config Drift Detection Routes
*
* API endpoints for running drift detection, reading cached reports,
* auto-fixing drift, and controlling periodic polling.
*
* @module routes/config-drift
*/
const express = require('express');
const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors');
/**
* Config-drift route factory
*
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.driftDetector - ConfigDriftDetector instance
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @returns {express.Router}
*/
module.exports = function ({ driftDetector, asyncHandler, logError }) {
const router = express.Router();
/**
* GET /config-drift/report
* Run a fresh drift detection and return the full report.
*/
router.get('/report', asyncHandler(async (_req, res) => {
const report = await driftDetector.detect();
success(res, { report });
}, 'drift-report'));
/**
* GET /config-drift/last
* Return the last cached drift report (no re-detection).
*/
router.get('/last', asyncHandler(async (_req, res) => {
if (!driftDetector.lastReport) {
throw new NotFoundError('No cached drift report — run detection first');
}
success(res, { report: driftDetector.lastReport });
}, 'drift-last'));
/**
* POST /config-drift/fix
* Auto-fix detected drift: remove stale records, flag unknown containers.
*/
router.post('/fix', asyncHandler(async (_req, res) => {
const result = await driftDetector.autoFix();
success(res, {
message: 'Auto-fix applied',
staleRemoved: result.staleRemoved,
unknownFlagged: result.unknownFlagged,
});
}, 'drift-fix'));
/**
* POST /config-drift/polling
* Enable or disable periodic drift detection polling.
*
* Body: { enabled: boolean, intervalMs?: number }
*/
router.post('/polling', asyncHandler(async (req, res) => {
const { enabled, intervalMs } = req.body;
if (typeof enabled !== 'boolean') {
throw new ValidationError('enabled must be a boolean');
}
if (intervalMs !== undefined) {
if (!Number.isInteger(intervalMs) || intervalMs < 10000 || intervalMs > 86400000) {
throw new ValidationError('intervalMs must be an integer between 10000 and 86400000 (10s 24h)');
}
}
if (enabled) {
driftDetector.startPolling(intervalMs || 300000);
success(res, {
message: 'Drift polling enabled',
intervalMs: intervalMs || 300000,
});
} else {
driftDetector.stopPolling();
success(res, { message: 'Drift polling disabled' });
}
}, 'drift-polling'));
return router;
};
+23 -14
View File
@@ -4,8 +4,6 @@ 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');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Config assets routes factory * Config assets routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -53,7 +51,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 = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); const assetsPath = process.env.ASSETS_PATH || '/app/assets';
// Ensure directory exists // Ensure directory exists
if (!await exists(assetsPath)) { if (!await exists(assetsPath)) {
@@ -64,7 +62,8 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const filePath = path.join(assetsPath, safeFilename); const filePath = path.join(assetsPath, safeFilename);
await fsp.writeFile(filePath, buffer); await fsp.writeFile(filePath, buffer);
ok(res, { res.json({
success: true,
path: `/assets/${safeFilename}`, path: `/assets/${safeFilename}`,
message: `Logo saved to ${filePath}` message: `Logo saved to ${filePath}`
}); });
@@ -76,7 +75,8 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Get current logo path, position, and title // Get current logo path, position, and title
router.get('/logo', asyncHandler(async (req, res) => { router.get('/logo', asyncHandler(async (req, res) => {
const config = await ctx.readConfig(); const config = await ctx.readConfig();
ok(res, { res.json({
success: true,
// Dark/light variants (new) // Dark/light variants (new)
customLogoDark: config.customLogoDark || null, customLogoDark: config.customLogoDark || null,
customLogoLight: config.customLogoLight || null, customLogoLight: config.customLogoLight || null,
@@ -96,7 +96,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 = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); const assetsPath = process.env.ASSETS_PATH || '/app/assets';
if (!await exists(assetsPath)) { if (!await exists(assetsPath)) {
await fsp.mkdir(assetsPath, { recursive: true }); await fsp.mkdir(assetsPath, { recursive: true });
} }
@@ -155,7 +155,8 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
config.updatedAt = new Date().toISOString(); config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
ok(res, { res.json({
success: true,
pathDark: pathDark, pathDark: pathDark,
pathLight: pathLight, pathLight: pathLight,
// Legacy compat // Legacy compat
@@ -169,7 +170,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 = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); const assetsPath = process.env.ASSETS_PATH || '/app/assets';
// 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);
@@ -193,7 +194,10 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
config.updatedAt = new Date().toISOString(); config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
successMessage(res, 'Branding reset to defaults'); res.json({
success: true,
message: 'Branding reset to defaults'
});
}, 'logo-delete')); }, 'logo-delete'));
// ===== FAVICON ENDPOINTS ===== // ===== FAVICON ENDPOINTS =====
@@ -202,7 +206,8 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Get current favicon // Get current favicon
router.get('/favicon', asyncHandler(async (req, res) => { router.get('/favicon', asyncHandler(async (req, res) => {
const config = await ctx.readConfig(); const config = await ctx.readConfig();
ok(res, { res.json({
success: true,
customFavicon: config.customFavicon || null, customFavicon: config.customFavicon || null,
isDefault: !config.customFavicon isDefault: !config.customFavicon
}); });
@@ -229,7 +234,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 = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); const assetsPath = process.env.ASSETS_PATH || '/app/assets';
if (!await exists(assetsPath)) { if (!await exists(assetsPath)) {
await fsp.mkdir(assetsPath, { recursive: true }); await fsp.mkdir(assetsPath, { recursive: true });
} }
@@ -262,7 +267,8 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Update config // Update config
await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() }); await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() });
ok(res, { res.json({
success: true,
path: '/assets/favicon.ico', path: '/assets/favicon.ico',
message: 'Favicon created successfully' message: 'Favicon created successfully'
}); });
@@ -273,7 +279,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 = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); const assetsPath = process.env.ASSETS_PATH || '/app/assets';
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}`;
@@ -286,7 +292,10 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
config.updatedAt = new Date().toISOString(); config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
successMessage(res, 'Favicon reset to default'); res.json({
success: true,
message: 'Favicon reset to default'
});
}, 'favicon-delete')); }, 'favicon-delete'));
return router; return router;
+8 -14
View File
@@ -4,8 +4,6 @@ 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');
const { ok } = require('../../src/utils/responses');
/** /**
* Config backup routes factory * Config backup routes factory
@@ -117,7 +115,7 @@ module.exports = function(deps) {
// Include custom assets (logo, favicon) as base64 // Include custom assets (logo, favicon) as base64
try { try {
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR); const assetsDir = process.env.ASSETS_DIR || '/app/assets';
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)
@@ -211,7 +209,7 @@ module.exports = function(deps) {
preview.browserStateCount = Object.keys(backup.browserState).length; preview.browserStateCount = Object.keys(backup.browserState).length;
} }
ok(res, { preview }); res.json({ success: true, preview });
}, 'backup-preview')); }, 'backup-preview'));
// Restore configuration from backup // Restore configuration from backup
@@ -348,7 +346,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 = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR); const assetsDir = process.env.ASSETS_DIR || '/app/assets';
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
@@ -392,17 +390,13 @@ module.exports = function(deps) {
const success = results.restored.length > 0 && results.errors.length === 0; const success = results.restored.length > 0 && results.errors.length === 0;
if (success) { res.json({
ok(res, { success,
message: `Restored ${results.restored.length} file(s) successfully`, message: success
? `Restored ${results.restored.length} file(s) successfully`
: `Restore completed with ${results.errors.length} error(s)`,
results results
}); });
} else {
ok(res, {
message: `Restore completed with ${results.errors.length} error(s)`,
results
}, 200);
}
log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length }); log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });
}, 'backup-restore')); }, 'backup-restore'));
+2 -3
View File
@@ -2,7 +2,6 @@ const fsp = require('fs').promises;
const { validateConfig } = require('../../config-schema'); const { validateConfig } = require('../../config-schema');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Config settings routes factory * Config settings routes factory
@@ -72,14 +71,14 @@ module.exports = function({ configStateManager: _configStateManager, asyncHandle
} }
log.info('config', 'Config saved', { path: ctx.CONFIG_FILE }); log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
ok(res, { message: 'Configuration saved', config, warnings }); res.json({ success: true, message: 'Configuration saved', config, warnings });
}, 'config-save')); }, 'config-save'));
router.delete('/config', asyncHandler(async (req, res) => { router.delete('/config', asyncHandler(async (req, res) => {
if (await exists(ctx.CONFIG_FILE)) { if (await exists(ctx.CONFIG_FILE)) {
await fsp.unlink(ctx.CONFIG_FILE); await fsp.unlink(ctx.CONFIG_FILE);
} }
successMessage(res, 'Configuration reset'); res.json({ success: true, message: 'Configuration reset' });
}, 'config-delete')); }, 'config-delete'));
return router; return router;
+1 -1
View File
@@ -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('../src/utils/responses'); const { success } = require('../response-helpers');
/** /**
* Containers route factory * Containers route factory
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../src/utils/responses'); const { success, error: errorResponse } = require('../response-helpers');
/** /**
* Credentials routes factory * Credentials routes factory
-235
View File
@@ -1,235 +0,0 @@
/**
* Dependencies Route REST API for service dependency tracking
*
* Endpoints:
* GET /dependencies/graph Full dependency graph
* GET /dependencies/validate Validate a proposed dep chain
* GET /dependencies/:serviceId Direct deps for one service
* GET /dependencies/:serviceId/chain Ordered restart chain
* GET /dependencies/:serviceId/status Dependency health status
* POST /dependencies/:serviceId Set dependencies
* DELETE /dependencies/:serviceId Remove all dependencies
* POST /dependencies/:serviceId/restart Restart with dependency chain
*
* @module routes/dependencies
*/
const express = require('express');
const { success, error: errorResponse } = require('../src/utils/responses');
const { NotFoundError, ValidationError } = require('../errors');
/**
* Dependencies route factory
*
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.dependencyManager - DependencyManager instance
* @param {Object} deps.servicesStateManager - State manager for services.json
* @param {Object} deps.docker - Docker client wrapper
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @param {Function} deps.resyncHealthChecker - Health checker resync function
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({
dependencyManager,
servicesStateManager,
docker,
asyncHandler,
logError,
resyncHealthChecker,
log,
}) {
const router = express.Router();
// -------------------------------------------------------------------------
// GET /dependencies/graph — Full dependency graph
// -------------------------------------------------------------------------
router.get('/graph', asyncHandler(async (req, res) => {
const graph = await dependencyManager.getDependencyGraph();
success(res, { graph });
}, 'dep-graph'));
// -------------------------------------------------------------------------
// GET /dependencies/validate — Validate a proposed dep chain (query params)
// -------------------------------------------------------------------------
router.get('/validate', asyncHandler(async (req, res) => {
const { serviceId, dependsOn } = req.query;
if (!serviceId) {
throw new ValidationError('serviceId query parameter is required');
}
// dependsOn may be a comma-separated string or already an array
let parsed;
if (Array.isArray(dependsOn)) {
parsed = dependsOn;
} else if (typeof dependsOn === 'string' && dependsOn.length > 0) {
parsed = dependsOn.split(',').map(s => s.trim()).filter(Boolean);
} else {
parsed = [];
}
const result = await dependencyManager.validateDependencies(serviceId, parsed);
success(res, result);
}, 'dep-validate'));
// -------------------------------------------------------------------------
// GET /dependencies/:serviceId — Direct deps for one service
// -------------------------------------------------------------------------
router.get('/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const dependencies = await dependencyManager.getDependencies(serviceId);
const dependents = await dependencyManager.getDependents(serviceId);
// Read the service's current dependsOn array
const services = await servicesStateManager.read();
const allServices = Array.isArray(services) ? services : (services.services || []);
const service = allServices.find(s => s.id === serviceId);
if (!service) {
throw new NotFoundError(`Service "${serviceId}"`);
}
success(res, {
serviceId,
dependsOn: service.dependsOn || [],
dependencies,
dependents: dependents.map(d => ({ id: d.id, name: d.name })),
});
}, 'dep-get'));
// -------------------------------------------------------------------------
// GET /dependencies/:serviceId/chain — Ordered restart chain
// -------------------------------------------------------------------------
router.get('/:serviceId/chain', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const chain = await dependencyManager.getOrderedRestartChain(serviceId);
success(res, { serviceId, chain });
}, 'dep-chain'));
// -------------------------------------------------------------------------
// GET /dependencies/:serviceId/status — Dependency health status
// -------------------------------------------------------------------------
router.get('/:serviceId/status', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const statuses = await dependencyManager.getDependencyStatus(serviceId);
success(res, { serviceId, statuses });
}, 'dep-status'));
// -------------------------------------------------------------------------
// POST /dependencies/:serviceId — Set dependencies
// -------------------------------------------------------------------------
router.post('/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const { dependsOn } = req.body;
if (!Array.isArray(dependsOn)) {
throw new ValidationError('Request body must include dependsOn as an array of service IDs');
}
// Validate first
const validation = await dependencyManager.validateDependencies(serviceId, dependsOn);
if (!validation.valid) {
return errorResponse(res, validation.errors.join('; '), 400);
}
// Update the service
let found = false;
await servicesStateManager.update(services => {
const arr = Array.isArray(services) ? services : [];
return arr.map(s => {
if (s.id === serviceId) {
found = true;
return { ...s, dependsOn: dependsOn.slice() };
}
return s;
});
});
if (!found) {
throw new NotFoundError(`Service "${serviceId}"`);
}
log.info('dependency', 'Dependencies updated', { serviceId, dependsOn });
success(res, {
message: `Dependencies updated for "${serviceId}"`,
serviceId,
dependsOn,
});
}, 'dep-set'));
// -------------------------------------------------------------------------
// DELETE /dependencies/:serviceId — Remove all dependencies for a service
// -------------------------------------------------------------------------
router.delete('/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
let found = false;
await servicesStateManager.update(services => {
const arr = Array.isArray(services) ? services : [];
return arr.map(s => {
if (s.id === serviceId) {
found = true;
const updated = { ...s };
delete updated.dependsOn;
return updated;
}
return s;
});
});
if (!found) {
throw new NotFoundError(`Service "${serviceId}"`);
}
log.info('dependency', 'Dependencies removed', { serviceId });
success(res, {
message: `All dependencies removed for "${serviceId}"`,
serviceId,
});
}, 'dep-delete'));
// -------------------------------------------------------------------------
// POST /dependencies/:serviceId/restart — Restart with dependency chain
// -------------------------------------------------------------------------
router.post('/:serviceId/restart', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
// Verify the service exists
const services = await servicesStateManager.read();
const allServices = Array.isArray(services) ? services : (services.services || []);
if (!allServices.find(s => s.id === serviceId)) {
throw new NotFoundError(`Service "${serviceId}"`);
}
// Get the chain first for the response (before async restart begins)
let chain;
try {
chain = await dependencyManager.getOrderedRestartChain(serviceId);
} catch (err) {
return errorResponse(res, err.message, 400);
}
// Respond immediately with the chain order
success(res, {
message: `Dependency restart initiated for "${serviceId}"`,
serviceId,
chain,
});
// Run the restart chain asynchronously so the client doesn't block
dependencyManager.restartWithDependencies(serviceId).catch(err => {
if (log) {
log.error('dependency', 'Async dependency restart failed', {
serviceId,
error: err.message,
});
}
});
}, 'dep-restart'));
return router;
};
+14 -233
View File
@@ -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('../src/utils/responses'); const { success, error: errorResponse } = require('../response-helpers');
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
/** /**
@@ -26,8 +26,7 @@ module.exports = function({
log, log,
safeErrorMessage, safeErrorMessage,
fetchT, fetchT,
credentialManager, credentialManager
dnsPropagationChecker
}) { }) {
const router = express.Router(); const router = express.Router();
@@ -42,137 +41,7 @@ module.exports = function({
return serverIp; return serverIp;
} }
// ===== DNS PROVIDER ENDPOINTS ===== // DELETE /record — Delete a DNS record from Technitium
// GET /providers — List all available DNS providers
router.get('/providers', asyncHandler(async (req, res) => {
const providers = dns.getAvailableProviders ? dns.getAvailableProviders() : [];
const activeProvider = dns.getProviderId ? dns.getProviderId() : 'technitium';
success(res, { providers, activeProvider });
}, 'dns-providers-list'));
// GET /provider/status — Get active provider status
router.get('/provider/status', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
return success(res, { providerId: 'technitium', capabilities: ['create-record', 'delete-record', 'resolve', 'list-records', 'logs', 'restart', 'update-check', 'credentials', 'zones'] });
}
try {
const provider = dns.getActiveProvider();
const status = await provider.getStatus();
success(res, status);
} catch (err) {
errorResponse(res, safeErrorMessage(err), 500);
}
}, 'dns-provider-status'));
// ===== UNIVERSAL RECORD ENDPOINTS (work with any provider) =====
// POST /universal/record — Create a DNS record via any provider
router.post('/universal/record', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
// Fallback to legacy Technitium route
return res.redirect(307, '/api/dns/record');
}
const { domain, ip, ttl, type, server } = req.body;
if (!domain || !ip) throw new ValidationError('domain and ip are required');
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
if (!validatorLib.isIP(ip)) throw new ValidationError('[DC-210] Invalid IP address');
try {
const provider = dns.getActiveProvider();
if (!provider.supportsCapability('create-record')) {
const result = await provider.createRecord({
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A', value: ip, ttl: ttl || 300, overwrite: true
});
return success(res, {
message: result.message || `DNS record instructions provided`,
manual: true,
instructions: result.instructions
});
}
const result = await provider.createRecord({
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A', value: ip, ttl: ttl || 300, overwrite: true
});
// Start propagation check in background
if (dnsPropagationChecker && ip) {
dnsPropagationChecker.startVerification(domain, ip).catch(err => {
log('DNS propagation check start failed:', err.message);
});
}
success(res, {
message: result.status === 'manual' ? result.message : `DNS record ${domain} -> ${ip} created`,
provider: dns.getProviderId(),
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
});
} catch (error) {
log.error('dns', 'Universal DNS record creation error', { error: error.message });
errorResponse(res, safeErrorMessage(error), 500);
}
}, 'dns-universal-create'));
// DELETE /universal/record — Delete a DNS record via any provider
router.delete('/universal/record', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
return res.redirect(307, '/api/dns/record');
}
const { domain, type, value } = req.query;
if (!domain) throw new ValidationError('domain is required');
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
try {
const provider = dns.getActiveProvider();
const result = await provider.deleteRecord({
domain, type: type || 'A', value
});
success(res, {
message: result.status === 'manual' ? result.message : `DNS record ${domain} deleted`,
provider: dns.getProviderId(),
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
});
} catch (error) {
log.error('dns', 'Universal DNS record deletion error', { error: error.message });
errorResponse(res, safeErrorMessage(error), 500);
}
}, 'dns-universal-delete'));
// GET /universal/resolve — Resolve a domain via any provider
router.get('/universal/resolve', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
return res.redirect(307, '/api/dns/resolve');
}
const { domain, type } = req.query;
if (!domain) throw new ValidationError('domain is required');
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
try {
const provider = dns.getActiveProvider();
const result = await provider.resolveRecords({
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A'
});
if (result.response?.records?.length > 0) {
const ipAddresses = result.response.records
.filter(r => r.type === (type || 'A'))
.map(r => r.rData?.ipAddress || r.content || r.rData?.address)
.filter(Boolean);
success(res, { answer: ipAddresses });
} else {
throw new NotFoundError('No records found for domain');
}
} catch (error) {
log.error('dns', 'Universal DNS resolve error', { error: error.message });
errorResponse(res, safeErrorMessage(error), error.statusCode || 500);
}
}, 'dns-universal-resolve'));
// ===== LEGACY TECHNITIUM-SPECIFIC ROUTES (unchanged) =====
router.delete('/record', asyncHandler(async (req, res) => { router.delete('/record', asyncHandler(async (req, res) => {
const { domain, type, token, server, ipAddress } = req.query; const { domain, type, token, server, ipAddress } = req.query;
@@ -270,14 +139,6 @@ module.exports = function({
}); });
if (result.status === 'ok') { if (result.status === 'ok') {
// Start DNS propagation verification in background
if (dnsPropagationChecker && ip) {
const fullDomain = domain;
dnsPropagationChecker.startVerification(fullDomain, ip).catch(err => {
log('DNS propagation check start failed:', err.message);
});
}
success(res, { message: `DNS record ${domain} -> ${ip} created` }); success(res, { message: `DNS record ${domain} -> ${ip} created` });
} else { } else {
// Error handled by middleware // Error handled by middleware
@@ -333,13 +194,8 @@ module.exports = function({
} }
}, 'dns-resolve')); }, 'dns-resolve'));
// GET /logs — Fetch DNS query logs (Technitium only) // GET /logs — Fetch DNS query logs from Technitium
router.get('/logs', asyncHandler(async (req, res) => { router.get('/logs', asyncHandler(async (req, res) => {
// Capability gate: logs are provider-specific
if (dns.supportsCapability && !dns.supportsCapability('logs')) {
return success(res, { server: 'N/A', count: 0, logs: [], message: 'DNS logs not supported by current provider' });
}
const { server, limit } = req.query; const { server, limit } = req.query;
if (!server) { if (!server) {
@@ -383,8 +239,9 @@ 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' },
}, 10000); timeout: 10000
});
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
@@ -552,7 +409,7 @@ module.exports = function({
} }
} }
return ok(res, { return res.json({
success: anySuccess, success: anySuccess,
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed', message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
results results
@@ -618,13 +475,8 @@ module.exports = function({
success(res, { message: 'DNS credentials removed' }); success(res, { message: 'DNS credentials removed' });
}, 'dns-credentials-delete')); }, 'dns-credentials-delete'));
// POST /restart/:dnsId — Restart a DNS server (Technitium only) // POST /restart/:dnsId — Restart a DNS server (proxied through backend for auth)
router.post('/restart/:dnsId', asyncHandler(async (req, res) => { router.post('/restart/:dnsId', asyncHandler(async (req, res) => {
// Capability gate
if (dns.supportsCapability && !dns.supportsCapability('restart')) {
return errorResponse(res, 'Server restart not supported by current DNS provider', 501);
}
const { dnsId } = req.params; const { dnsId } = req.params;
const serverInfo = siteConfig.dnsServers?.[dnsId]; const serverInfo = siteConfig.dnsServers?.[dnsId];
if (!serverInfo?.ip) { if (!serverInfo?.ip) {
@@ -639,7 +491,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' }, 5000); const response = await fetchT(url, { method: 'POST', timeout: 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' });
@@ -666,13 +518,8 @@ module.exports = function({
} }
}, 'dns-refresh-token')); }, 'dns-refresh-token'));
// GET /check-update — Check for DNS server updates (Technitium only) // GET /check-update — Check for Technitium DNS server updates
router.get('/check-update', asyncHandler(async (req, res) => { router.get('/check-update', asyncHandler(async (req, res) => {
// Capability gate
if (dns.supportsCapability && !dns.supportsCapability('update-check')) {
return success(res, { updateAvailable: false, message: 'Update check not supported by current DNS provider' });
}
try { try {
const { server } = req.query; const { server } = req.query;
if (!server) { if (!server) {
@@ -729,13 +576,10 @@ module.exports = function({
} }
}, 'dns-check-update')); }, 'dns-check-update'));
// POST /update — Update DNS server (Technitium only) // POST /update — Update Technitium DNS server
// Note: Technitium v14+ has no installUpdate API. This endpoint checks for updates
// and returns download info. The frontend handles showing update instructions.
router.post('/update', asyncHandler(async (req, res) => { router.post('/update', asyncHandler(async (req, res) => {
// Capability gate
if (dns.supportsCapability && !dns.supportsCapability('update-check')) {
return errorResponse(res, 'Server update not supported by current DNS provider', 501);
}
try { try {
const { server } = req.query; const { server } = req.query;
if (!server) { if (!server) {
@@ -797,68 +641,5 @@ module.exports = function({
} }
}, 'dns-update')); }, 'dns-update'));
// ===== DNS PROPAGATION =====
// GET /propagation — Get all recent DNS propagation checks
router.get('/propagation', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return success(res, { verifications: [], message: 'DNS propagation checker not available' });
}
// Cleanup old entries
dnsPropagationChecker.cleanup();
const verifications = dnsPropagationChecker.getAllVerifications();
success(res, { verifications });
}, 'dns-propagation-all'));
// POST /propagation/verify — Manually trigger DNS propagation verification
router.post('/propagation/verify', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return errorResponse(res, 'DNS propagation checker not available', 503);
}
const { domain, expectedIp } = req.body;
if (!domain || !expectedIp) {
throw new ValidationError('domain and expectedIp are required');
}
// Validate domain format
if (!REGEX.DOMAIN.test(domain)) {
throw new ValidationError('[DC-301] Invalid domain format');
}
// Validate IP address
const validatorLib = require('validator');
if (!validatorLib.isIP(expectedIp)) {
throw new ValidationError('[DC-210] Invalid IP address');
}
const job = dnsPropagationChecker.startVerification(domain, expectedIp);
success(res, {
message: 'DNS propagation verification started',
domain,
expectedIp,
status: job.status
});
}, 'dns-propagation-verify'));
// GET /propagation/:domain — Get propagation status for a specific domain
router.get('/propagation/:domain', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return success(res, { verification: null, message: 'DNS propagation checker not available' });
}
const { domain } = req.params;
const status = dnsPropagationChecker.getVerificationStatus(domain);
if (!status) {
throw new NotFoundError(`No propagation check found for domain: ${domain}`);
}
success(res, { verification: status });
}, 'dns-propagation-domain'));
return router; return router;
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../src/utils/responses'); const { success } = require('../response-helpers');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -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('../src/utils/responses'); const { success } = require('../response-helpers');
/** /**
* Error logs routes factory * Error logs routes factory
+2 -46
View File
@@ -1,5 +1,4 @@
const express = require('express'); const express = require('express');
const { ok } = require('../src/utils/responses');
/** /**
* Server-Sent Events route factory * Server-Sent Events route factory
@@ -9,10 +8,9 @@ const { ok } = require('../src/utils/responses');
* @param {Object} deps.healthChecker - Health checker * @param {Object} deps.healthChecker - Health checker
* @param {Object} deps.updateManager - Update manager * @param {Object} deps.updateManager - Update manager
* @param {Function} deps.logError - Error logging function * @param {Function} deps.logError - Error logging function
* @param {Object} deps.dependencyManager - Dependency manager for restart chain events
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, dependencyManager, autoRestartManager, driftDetector, sslMonitor, dnsPropagationChecker }) { module.exports = function({ resourceMonitor, healthChecker, updateManager, logError }) {
const router = express.Router(); const router = express.Router();
const clients = new Set(); const clients = new Set();
@@ -76,48 +74,6 @@ module.exports = function({ resourceMonitor, healthChecker, updateManager, logEr
}); });
} }
// Dependency manager events
if (dependencyManager) {
dependencyManager.on('dependency-restart-start', (data) => {
broadcast('dependency-restart-start', data);
});
dependencyManager.on('dependency-restart-progress', (data) => {
broadcast('dependency-restart-progress', data);
});
dependencyManager.on('dependency-restart-complete', (data) => {
broadcast('dependency-restart-complete', data);
});
dependencyManager.on('dependency-restart-failed', (data) => {
broadcast('dependency-restart-failed', data);
});
}
// Auto-restart manager events
if (autoRestartManager) {
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
}
// Config drift detector events
if (driftDetector) {
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
}
// SSL monitor events
if (sslMonitor) {
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
}
// DNS propagation checker events
if (dnsPropagationChecker) {
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
}
// SSE endpoint // SSE endpoint
router.get('/stream', (req, res) => { router.get('/stream', (req, res) => {
res.writeHead(200, { res.writeHead(200, {
@@ -148,7 +104,7 @@ module.exports = function({ resourceMonitor, healthChecker, updateManager, logEr
// Client count (useful for debugging) // Client count (useful for debugging)
router.get('/clients', (req, res) => { router.get('/clients', (req, res) => {
ok(res, { count: clients.size }); res.json({ success: true, count: clients.size });
}); });
return router; return router;
+21 -20
View File
@@ -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, errorResponse: sendError, ok, notFound } = require('../src/utils/responses'); const { success, error: errorResponse } = require('../response-helpers');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
@@ -273,7 +273,11 @@ module.exports = function({
try { try {
// Check if certificate exists // Check if certificate exists
if (!await exists(rootCertPath)) { if (!await exists(rootCertPath)) {
return sendError(res, 404, 'Root CA certificate not found', { caStatus: 'error', daysUntilExpiration: null }); return res.json({
status: 'error',
message: 'Root CA certificate not found',
daysUntilExpiration: null
});
} }
const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString(); const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString();
@@ -282,48 +286,45 @@ 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 caStatus = 'healthy'; let status = 'healthy';
let message = `CA certificate valid for ${daysUntilExpiration} days`; let message = `CA certificate valid for ${daysUntilExpiration} days`;
if (daysUntilExpiration < 0) { if (daysUntilExpiration < 0) {
caStatus = 'critical'; status = '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) {
caStatus = 'critical'; status = 'critical';
message = `CA certificate expires in ${daysUntilExpiration} days!`; message = `CA certificate expires in ${daysUntilExpiration} days!`;
} else if (daysUntilExpiration < 30) { } else if (daysUntilExpiration < 30) {
caStatus = 'critical'; status = 'critical';
message = `CA certificate expires in ${daysUntilExpiration} days!`; message = `CA certificate expires in ${daysUntilExpiration} days!`;
} else if (daysUntilExpiration < 90) { } else if (daysUntilExpiration < 90) {
caStatus = 'warning'; status = 'warning';
message = `CA certificate expires in ${daysUntilExpiration} days`; message = `CA certificate expires in ${daysUntilExpiration} days`;
} }
ok(res, { res.json({
caStatus, status: status,
message, message: message,
daysUntilExpiration, daysUntilExpiration: daysUntilExpiration,
expiresAt: notAfter expiresAt: notAfter
}); });
} catch (error) { } catch (error) {
await logError('GET /api/health/ca', error); await logError('GET /api/health/ca', error);
sendError(res, 500, error.message, { caStatus: 'error', daysUntilExpiration: null }); res.json({
status: 'error',
message: error.message,
daysUntilExpiration: null
});
} }
}, 'health-ca')); }, 'health-ca'));
// ===== 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();
// Build summary for the overview widget success(res, { status });
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 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../src/utils/responses'); const { success, error: errorResponse } = require('../response-helpers');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+13 -12
View File
@@ -5,7 +5,6 @@ const path = require('path');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError, ValidationError, ForbiddenError } = require('../errors'); const { NotFoundError, ValidationError, ForbiddenError } = require('../errors');
const { ok } = require('../src/utils/responses');
/** /**
* Logs route factory * Logs route factory
@@ -32,7 +31,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
const result = paginate(containerList, paginationParams); const result = paginate(containerList, paginationParams);
ok(res, { containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'logs-containers')); }, 'logs-containers'));
// Get logs for a specific container // Get logs for a specific container
@@ -82,7 +81,8 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
offset += 8 + size; offset += 8 + size;
} }
ok(res, { res.json({
success: true,
containerId, containerName, containerId, containerName,
logs: lines, logs: lines,
count: lines.length count: lines.length
@@ -153,23 +153,23 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const digest = await logDigest.getLatestDigest(); const digest = await logDigest.getLatestDigest();
if (!digest) { if (!digest) {
return ok(res, { digest: null, message: 'No digest available yet. First digest is generated at midnight.' }); return res.json({ success: true, digest: null, message: 'No digest available yet. First digest is generated at midnight.' });
} }
ok(res, { digest }); res.json({ success: true, digest });
}, 'logs-digest-latest')); }, 'logs-digest-latest'));
// Get live digest data (today's accumulated stats) // Get live digest data (today's accumulated stats)
router.get('/logs/digest/live', asyncHandler(async (req, res) => { router.get('/logs/digest/live', asyncHandler(async (req, res) => {
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const live = logDigest.getLiveData(); const live = logDigest.getLiveData();
ok(res, { ...live }); res.json({ success: true, ...live });
}, 'logs-digest-live')); }, 'logs-digest-live'));
// List available digest dates // List available digest dates
router.get('/logs/digest/history', asyncHandler(async (req, res) => { router.get('/logs/digest/history', asyncHandler(async (req, res) => {
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const dates = await logDigest.listDigests(); const dates = await logDigest.listDigests();
ok(res, { dates }); res.json({ success: true, dates });
}, 'logs-digest-history')); }, 'logs-digest-history'));
// Generate digest on demand (for today or a specific date) // Generate digest on demand (for today or a specific date)
@@ -177,7 +177,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const date = req.body.date || new Date().toISOString().slice(0, 10); const date = req.body.date || new Date().toISOString().slice(0, 10);
const digest = await logDigest.generateDailyDigest(date); const digest = await logDigest.generateDailyDigest(date);
ok(res, { digest }); res.json({ success: true, digest });
}, 'logs-digest-generate')); }, 'logs-digest-generate'));
// Get digest for a specific date (JSON) // Get digest for a specific date (JSON)
@@ -196,7 +196,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
} }
const digest = await logDigest.getDigestByDate(date); const digest = await logDigest.getDigestByDate(date);
if (!digest) throw new NotFoundError(`Digest for ${date}`); if (!digest) throw new NotFoundError(`Digest for ${date}`);
ok(res, { digest }); res.json({ success: true, digest });
}, 'logs-digest-date')); }, 'logs-digest-date'));
// Get Docker disk usage snapshot // Get Docker disk usage snapshot
@@ -204,14 +204,14 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
if (!dockerMaintenance) throw new Error('Docker maintenance not available'); if (!dockerMaintenance) throw new Error('Docker maintenance not available');
const diskUsage = await dockerMaintenance.getDiskUsage(); const diskUsage = await dockerMaintenance.getDiskUsage();
const status = dockerMaintenance.getStatus(); const status = dockerMaintenance.getStatus();
ok(res, { diskUsage, maintenance: status }); res.json({ success: true, diskUsage, maintenance: status });
}, 'logs-docker-disk')); }, 'logs-docker-disk'));
// Trigger Docker maintenance manually // Trigger Docker maintenance manually
router.post('/logs/docker-maintenance', asyncHandler(async (req, res) => { router.post('/logs/docker-maintenance', asyncHandler(async (req, res) => {
if (!dockerMaintenance) throw new Error('Docker maintenance not available'); if (!dockerMaintenance) throw new Error('Docker maintenance not available');
const result = await dockerMaintenance.runMaintenance(); const result = await dockerMaintenance.runMaintenance();
ok(res, { result }); res.json({ success: true, result });
}, 'logs-docker-maintenance')); }, 'logs-docker-maintenance'));
// Get logs from a file path (for native applications) // Get logs from a file path (for native applications)
@@ -261,7 +261,8 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
timestamp: extractTimestamp(line) timestamp: extractTimestamp(line)
})); }));
ok(res, { res.json({
success: true,
logPath: normalizedPath, logPath: normalizedPath,
logs, logs,
count: logs.length, count: logs.length,
+2 -16
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../src/utils/responses'); const { success } = require('../response-helpers');
/** /**
* Monitoring routes factory * Monitoring routes factory
@@ -16,22 +16,8 @@ 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 raw = resourceMonitor.getAllStats(); const stats = 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'));
+13 -11
View File
@@ -3,7 +3,6 @@ const { validateURL, validateToken } = require('../input-validator');
const validatorLib = require('validator'); const validatorLib = require('validator');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
const { ok, successMessage } = require('../src/utils/responses');
/** /**
* Notifications route factory * Notifications route factory
@@ -45,7 +44,7 @@ module.exports = function({ notification, asyncHandler }) {
events: notificationConfig.events, events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck healthCheck: notificationConfig.healthCheck
}; };
ok(res, { config: safeConfig }); res.json({ success: true, config: safeConfig });
}, 'notifications-config-get')); }, 'notifications-config-get'));
// POST /config — Update notification configuration // POST /config — Update notification configuration
@@ -151,7 +150,7 @@ module.exports = function({ notification, asyncHandler }) {
} }
await notification.saveConfig(); await notification.saveConfig();
successMessage(res, 'Notification config updated'); res.json({ success: true, message: 'Notification config updated' });
}, 'notifications-config-update')); }, 'notifications-config-update'));
// POST /test — Test notification delivery // POST /test — Test notification delivery
@@ -177,11 +176,11 @@ module.exports = function({ notification, asyncHandler }) {
default: default:
throw new ValidationError('Unknown provider'); throw new ValidationError('Unknown provider');
} }
ok(res, { success: result.success, provider, error: result.error }); res.json({ success: result.success, provider, error: result.error });
} else { } else {
// Test all enabled providers // Test all enabled providers
const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info'); const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info');
ok(res, { success: true, ...result }); res.json({ success: true, ...result });
} }
}, 'notifications-test')); }, 'notifications-test'));
@@ -191,10 +190,11 @@ module.exports = function({ notification, asyncHandler }) {
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
if (paginationParams) { if (paginationParams) {
const result = paginate(notificationHistory, paginationParams); const result = paginate(notificationHistory, paginationParams);
ok(res, { history: result.data, total: notificationHistory.length, pagination: result.pagination }); res.json({ success: true, history: result.data, total: notificationHistory.length, pagination: result.pagination });
} else { } else {
const limit = parseInt(req.query.limit) || 50; const limit = parseInt(req.query.limit) || 50;
ok(res, { res.json({
success: true,
history: notificationHistory.slice(0, limit), history: notificationHistory.slice(0, limit),
total: notificationHistory.length total: notificationHistory.length
}); });
@@ -204,14 +204,15 @@ module.exports = function({ notification, asyncHandler }) {
// DELETE /history — Clear notification history // DELETE /history — Clear notification history
router.delete('/history', asyncHandler(async (req, res) => { router.delete('/history', asyncHandler(async (req, res) => {
notification.clearHistory(); notification.clearHistory();
successMessage(res, 'Notification history cleared'); res.json({ success: true, message: 'Notification history cleared' });
}, 'notifications-history-clear')); }, 'notifications-history-clear'));
// POST /health-check — Manually trigger health check // POST /health-check — Manually trigger health check
router.post('/health-check', asyncHandler(async (req, res) => { router.post('/health-check', asyncHandler(async (req, res) => {
await notification.checkHealth(); await notification.checkHealth();
const notificationConfig = notification.getConfig(); const notificationConfig = notification.getConfig();
ok(res, { res.json({
success: true,
lastCheck: notificationConfig.healthCheck.lastCheck, lastCheck: notificationConfig.healthCheck.lastCheck,
containersMonitored: Object.keys(notification.getHealthState()).length containersMonitored: Object.keys(notification.getHealthState()).length
}); });
@@ -222,7 +223,8 @@ module.exports = function({ notification, asyncHandler }) {
const notificationConfig = notification.getConfig(); const notificationConfig = notification.getConfig();
const providers = notificationConfig.providers || {}; const providers = notificationConfig.providers || {};
ok(res, { res.json({
success: true,
enabled: notificationConfig.enabled, enabled: notificationConfig.enabled,
providers: { providers: {
discord: providers.discord?.enabled && !!providers.discord?.webhookUrl, discord: providers.discord?.enabled && !!providers.discord?.webhookUrl,
@@ -250,7 +252,7 @@ module.exports = function({ notification, asyncHandler }) {
// Use 'test' as the event for manual sends // Use 'test' as the event for manual sends
const result = await notification.send(event, data || {}, type || 'info'); const result = await notification.send(event, data || {}, type || 'info');
ok(res, { res.json({
success: result.success, success: result.success,
event, event,
results: result.results results: result.results
+17 -16
View File
@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const http = require('http'); const http = require('http');
const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses');
/** /**
* OpenClaw management routes * OpenClaw management routes
@@ -94,8 +93,8 @@ module.exports = function openClawRoutes(ctx) {
proxyRes.on('data', function(d) { res.write(d); }); proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); }); proxyRes.on('end', function() { res.end(); });
}); });
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); }); proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); }); proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
proxyReq.write(body); proxyReq.write(body);
proxyReq.end(); proxyReq.end();
} else { } else {
@@ -105,8 +104,8 @@ module.exports = function openClawRoutes(ctx) {
proxyRes.on('data', function(d) { res.write(d); }); proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); }); proxyRes.on('end', function() { res.end(); });
}); });
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); }); proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); }); proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
} }
} }
@@ -116,7 +115,7 @@ module.exports = function openClawRoutes(ctx) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) { if (!container) {
return ok(res, { deployed: false }); return res.json({ success: true, deployed: false });
} }
const token = await getGatewayToken(container.Id); const token = await getGatewayToken(container.Id);
@@ -124,7 +123,8 @@ module.exports = function openClawRoutes(ctx) {
const baseUrl = 'http://localhost:' + port; const baseUrl = 'http://localhost:' + port;
const health = await gatewayHealth(baseUrl, token); const health = await gatewayHealth(baseUrl, token);
ok(res, { res.json({
success: true,
deployed: true, deployed: true,
container: { container: {
id: container.Id.slice(0, 12), id: container.Id.slice(0, 12),
@@ -149,7 +149,7 @@ module.exports = function openClawRoutes(ctx) {
router.post('/deploy', asyncHandler(async function(req, res) { router.post('/deploy', asyncHandler(async function(req, res) {
const existing = await findOpenClawContainer(); const existing = await findOpenClawContainer();
if (existing) { if (existing) {
return conflict(res, 'OpenClaw is already deployed'); return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
} }
const image = 'ghcr.io/nousresearch/openclaw:latest'; const image = 'ghcr.io/nousresearch/openclaw:latest';
@@ -170,7 +170,7 @@ module.exports = function openClawRoutes(ctx) {
}); });
} catch(e) { } catch(e) {
log.error('OpenClaw pull failed: ' + e.message); log.error('OpenClaw pull failed: ' + e.message);
return errorResponse(res, 500, 'Failed to pull image: ' + e.message); return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
} }
// Create + start container // Create + start container
@@ -196,7 +196,8 @@ module.exports = function openClawRoutes(ctx) {
await container.start(); await container.start();
log.info('OpenClaw deployed: ' + container.id.slice(0, 12)); log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
ok(res, { res.json({
success: true,
deployed: true, deployed: true,
container: { id: container.id.slice(0, 12), name: name }, container: { id: container.id.slice(0, 12), name: name },
gateway: { gateway: {
@@ -206,7 +207,7 @@ module.exports = function openClawRoutes(ctx) {
}); });
} catch(e) { } catch(e) {
log.error('OpenClaw deploy failed: ' + e.message); log.error('OpenClaw deploy failed: ' + e.message);
errorResponse(res, 500, 'Deploy failed: ' + e.message); res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
} }
})); }));
@@ -214,7 +215,7 @@ module.exports = function openClawRoutes(ctx) {
router.get('/proxy/*', asyncHandler(async function(req, res) { router.get('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) return notFound(res, 'OpenClaw not deployed'); if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id); const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id); const port = await getContainerPort(container.Id);
@@ -228,7 +229,7 @@ module.exports = function openClawRoutes(ctx) {
router.post('/proxy/*', asyncHandler(async function(req, res) { router.post('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) return notFound(res, 'OpenClaw not deployed'); if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id); const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id); const port = await getContainerPort(container.Id);
@@ -242,17 +243,17 @@ module.exports = function openClawRoutes(ctx) {
router.delete('/', asyncHandler(async function(req, res) { router.delete('/', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) return notFound(res, 'OpenClaw not deployed'); if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
try { try {
const c = docker.client.container(container.Id); const c = docker.client.container(container.Id);
await c.stop().catch(function() {}); await c.stop().catch(function() {});
await c.remove({ force: true }); await c.remove({ force: true });
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed'); log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
ok(res, { message: 'OpenClaw removed' }); res.json({ success: true, message: 'OpenClaw removed' });
} catch(e) { } catch(e) {
log.error('Failed to remove OpenClaw: ' + e.message); log.error('Failed to remove OpenClaw: ' + e.message);
errorResponse(res, 500, e.message); res.status(500).json({ success: false, error: e.message });
} }
})); }));
+1 -2
View File
@@ -2,7 +2,6 @@ const express = require('express');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const crypto = require('crypto'); const crypto = require('crypto');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { ok } = require('../../src/utils/responses');
/** /**
* Recipes deployment routes factory * Recipes deployment routes factory
@@ -147,7 +146,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
'success' 'success'
); );
ok(res, response); res.json(response);
} catch (error) { } catch (error) {
log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message }); log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message });
+2 -3
View File
@@ -2,7 +2,6 @@ const express = require('express');
const deployRoutes = require('./deploy'); const deployRoutes = require('./deploy');
const manageRoutes = require('./manage'); const manageRoutes = require('./manage');
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
const { ok } = require('../../src/utils/responses');
/** /**
* Recipes routes aggregator * Recipes routes aggregator
@@ -56,7 +55,7 @@ module.exports = function(ctx) {
setupInstructions: recipe.setupInstructions setupInstructions: recipe.setupInstructions
})); }));
ok(res, { templates, categories: RECIPE_CATEGORIES }); res.json({ success: true, templates, categories: RECIPE_CATEGORIES });
}, 'recipe-templates')); }, 'recipe-templates'));
// GET /api/recipes/templates/:recipeId — get single recipe template detail // GET /api/recipes/templates/:recipeId — get single recipe template detail
@@ -65,7 +64,7 @@ module.exports = function(ctx) {
const recipe = RECIPE_TEMPLATES[req.params.recipeId]; const recipe = RECIPE_TEMPLATES[req.params.recipeId];
if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`); if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`);
ok(res, { recipe: { id: req.params.recipeId, ...recipe } }); res.json({ success: true, recipe: { id: req.params.recipeId, ...recipe } });
}, 'recipe-template-detail')); }, 'recipe-template-detail'));
// Mount deploy and manage sub-routes — pass full ctx for sub-routes that reference ctx.* // Mount deploy and manage sub-routes — pass full ctx for sub-routes that reference ctx.*
+5 -6
View File
@@ -1,7 +1,6 @@
const express = require('express'); const express = require('express');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
const { ok } = require('../../src/utils/responses');
module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) { module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
const router = express.Router(); const router = express.Router();
@@ -99,7 +98,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
} }
ok(res, { recipes: Object.values(recipeGroups) }); res.json({ success: true, recipes: Object.values(recipeGroups) });
}, 'recipe-deployed')); }, 'recipe-deployed'));
/** /**
@@ -130,7 +129,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
log.info('recipe', 'Recipe started', { recipeId, results }); log.info('recipe', 'Recipe started', { recipeId, results });
ok(res, { recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-start')); }, 'recipe-start'));
/** /**
@@ -162,7 +161,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
log.info('recipe', 'Recipe stopped', { recipeId, results }); log.info('recipe', 'Recipe stopped', { recipeId, results });
ok(res, { recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-stop')); }, 'recipe-stop'));
/** /**
@@ -188,7 +187,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
log.info('recipe', 'Recipe restarted', { recipeId, results }); log.info('recipe', 'Recipe restarted', { recipeId, results });
ok(res, { recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-restart')); }, 'recipe-restart'));
/** /**
@@ -260,7 +259,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
); );
log.info('recipe', 'Recipe removed', { recipeId, results }); log.info('recipe', 'Recipe removed', { recipeId, results });
ok(res, { recipeId, results }); res.json({ success: true, recipeId, results });
}, 'recipe-remove')); }, 'recipe-remove'));
// === Helper functions === // === Helper functions ===
+9 -20
View File
@@ -10,8 +10,7 @@ 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('../src/utils/responses'); const { success, error: errorResponse } = require('../response-helpers');
const platformPaths = require('../platform-paths');
/** /**
* Services route factory * Services route factory
@@ -47,7 +46,7 @@ module.exports = function({
dns dns
}) { }) {
const router = express.Router(); const router = express.Router();
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert; const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
const PROBE_CONCURRENCY = 6; const PROBE_CONCURRENCY = 6;
let probeHttpsAgent; let probeHttpsAgent;
@@ -356,11 +355,9 @@ module.exports = function({
}, 'services-status')); }, 'services-status'));
// List all services // List all services
// Always returns the standard envelope. The `services` field is the array
// (paginated if ?page=N&limit=M is in the query, otherwise the full list).
router.get('/services', asyncHandler(async (req, res) => { router.get('/services', asyncHandler(async (req, res) => {
if (!await exists(SERVICES_FILE)) { if (!await exists(SERVICES_FILE)) {
return success(res, { services: [] }); return res.json([]);
} }
const services = await servicesStateManager.read(); const services = await servicesStateManager.read();
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
@@ -368,14 +365,14 @@ module.exports = function({
if (paginationParams) { if (paginationParams) {
success(res, { services: result.data, pagination: result.pagination }); success(res, { services: result.data, pagination: result.pagination });
} else { } else {
success(res, { services: result.data }); res.json(result.data);
} }
}, 'services-list')); }, 'services-list'));
// Add a new service // Add a new service
router.post('/services', asyncHandler(async (req, res) => { router.post('/services', asyncHandler(async (req, res) => {
try { try {
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body; const { id, name, logo } = req.body;
if (!id || !name) { if (!id || !name) {
throw new ValidationError('id and name are required'); throw new ValidationError('id and name are required');
@@ -394,14 +391,7 @@ module.exports = function({
throw new ConflictError(`Service "${id}" already exists`, id); throw new ConflictError(`Service "${id}" already exists`, id);
} }
const newService = { id, name, logo: logo || `/assets/${id}.png` }; services.push({ id, name, logo: logo || `/assets/${id}.png` });
// Persist optional metadata fields if provided
if (category) newService.category = category;
if (containerId) newService.containerId = containerId;
if (port) newService.port = port;
if (ip) newService.ip = ip;
if (typeof tailscaleOnly === 'boolean') newService.tailscaleOnly = tailscaleOnly;
services.push(newService);
return services; return services;
}); });
@@ -523,8 +513,9 @@ module.exports = function({
if (oldSubdomain !== newSubdomain) { if (oldSubdomain !== newSubdomain) {
try { try {
await dns.universalDeleteRecord(oldDomain); const dnsToken = dns.getToken();
await dns.universalCreateRecord(newSubdomain, ip || 'localhost'); await dns.call(siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' });
await dns.createRecord(newSubdomain, ip || 'localhost');
results.dns = 'updated'; results.dns = 'updated';
} catch (e) { } catch (e) {
results.dns = `failed: ${e.message}`; results.dns = `failed: ${e.message}`;
@@ -551,8 +542,6 @@ module.exports = function({
}; };
if (name) services[serviceIndex].name = name; if (name) services[serviceIndex].name = name;
if (logo) services[serviceIndex].logo = logo; if (logo) services[serviceIndex].logo = logo;
// Allow category update via update endpoint too (optional body field)
if (req.body.category !== undefined) services[serviceIndex].category = req.body.category || undefined;
results.services = 'updated'; results.services = 'updated';
} else { } else {
results.services = 'not found'; results.services = 'not found';
+11 -11
View File
@@ -3,7 +3,6 @@ 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, successMessage } = require('../src/utils/responses');
/** /**
* Sites route factory * Sites route factory
@@ -24,14 +23,14 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
// Get Caddyfile contents // Get Caddyfile contents
router.get('/caddyfile', asyncHandler(async (req, res) => { router.get('/caddyfile', asyncHandler(async (req, res) => {
const content = await caddy.read(); const content = await caddy.read();
ok(res, { content }); res.json({ success: true, content });
}, 'caddyfile-get')); }, 'caddyfile-get'));
// Get current Caddy config (from admin API) // Get current Caddy config (from admin API)
router.get('/caddy/config', asyncHandler(async (req, res) => { router.get('/caddy/config', asyncHandler(async (req, res) => {
const response = await fetchT(`${caddy.adminUrl}/config/`); const response = await fetchT(`${caddy.adminUrl}/config/`);
const config = await response.json(); const config = await response.json();
ok(res, { config }); res.json({ success: true, config });
}, 'caddy-config')); }, 'caddy-config'));
// Reload Caddy configuration via admin API // Reload Caddy configuration via admin API
@@ -50,7 +49,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
throw new Error('Caddy reload failed. Check server logs for details.'); throw new Error('Caddy reload failed. Check server logs for details.');
} }
successMessage(res, 'Caddy configuration reloaded successfully'); res.json({ success: true, message: 'Caddy configuration reloaded successfully' });
}, 'caddy-reload')); }, 'caddy-reload'));
// Get Certificate Authorities from Caddyfile // Get Certificate Authorities from Caddyfile
@@ -128,7 +127,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
})); }));
ok(res, { cas: caList }); res.json({ status: 'success', data: { cas: caList } });
}, 'caddy-get-cas')); }, 'caddy-get-cas'));
// Remove a site from Caddyfile // Remove a site from Caddyfile
@@ -153,7 +152,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
throw new NotFoundError(`Site block for "" in Caddyfile`); throw new NotFoundError(`Site block for "" in Caddyfile`);
} }
successMessage(res, `Site "${domain}" removed from Caddyfile and Caddy reloaded`); res.json({ success: true, message: `Site "${domain}" removed from Caddyfile and Caddy reloaded` });
}, 'site-delete')); }, 'site-delete'));
// Add a new site to Caddyfile and reload // Add a new site to Caddyfile and reload
@@ -181,7 +180,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {}); result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {});
} }
successMessage(res, `Site "${domain}" added to Caddyfile and Caddy reloaded successfully`); res.json({ success: true, message: `Site "${domain}" added to Caddyfile and Caddy reloaded successfully` });
}, 'site-add')); }, 'site-add'));
// Add external service reverse proxy to Caddyfile // Add external service reverse proxy to Caddyfile
@@ -206,7 +205,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
if (createDns) { if (createDns) {
try { try {
await dns.universalCreateRecord(subdomain, siteConfig.dnsServerIp); await dns.createRecord(subdomain, siteConfig.dnsServerIp);
log.info('dns', 'DNS record created for external proxy', { domain, ip: siteConfig.dnsServerIp }); log.info('dns', 'DNS record created for external proxy', { domain, ip: siteConfig.dnsServerIp });
} catch (dnsError) { } catch (dnsError) {
dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`; dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`;
@@ -261,11 +260,12 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
} }
} }
const responseData = { const response = {
success: true,
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}` message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`
}; };
if (dnsWarning) responseData.warning = dnsWarning; if (dnsWarning) response.warning = dnsWarning;
ok(res, responseData); res.json(response);
}, 'site-external')); }, 'site-external'));
return router; return router;
-113
View File
@@ -1,113 +0,0 @@
/**
* SSL Monitor Routes
* REST API endpoints for SSL certificate monitoring.
*
* @module routes/ssl-monitor
*/
const express = require('express');
const { success, error: errorResponse, notFound } = require('../src/utils/responses');
/**
* SSL Monitor route factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.sslMonitor - SSLMonitor instance
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @returns {express.Router}
*/
module.exports = function({ sslMonitor, asyncHandler, logError }) {
const router = express.Router();
/**
* GET /ssl/certificates
* Get all SSL certificate statuses
*/
router.get('/certificates', asyncHandler(async (req, res) => {
const status = sslMonitor.getStatus();
success(res, { certificates: status });
}, 'ssl-certificates'));
/**
* GET /ssl/certificates/:serviceId
* Get SSL certificate status for a specific service
*/
router.get('/certificates/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const certStatus = sslMonitor.getServiceCertStatus(serviceId);
if (!certStatus) {
return notFound(res, `No SSL certificate status found for service: ${serviceId}`);
}
success(res, { certificate: certStatus });
}, 'ssl-certificate-service'));
/**
* POST /ssl/check
* Trigger an on-demand check of all SSL certificates
*/
router.post('/check', asyncHandler(async (req, res) => {
const results = await sslMonitor.checkAll();
success(res, { certificates: results, message: 'SSL check completed' });
}, 'ssl-check-all'));
/**
* POST /ssl/check/:serviceId
* Check the SSL certificate for a specific service
*/
router.post('/check/:serviceId', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
// Look up the existing cert status to find the hostname
const existingCert = sslMonitor.getServiceCertStatus(serviceId);
if (!existingCert) {
return notFound(res, `No HTTPS URL found for service: ${serviceId}`);
}
try {
const result = await sslMonitor.checkCert(existingCert.hostname, existingCert.port);
success(res, { certificate: { ...result, serviceId } });
} catch (err) {
errorResponse(res, `Failed to check SSL certificate: ${err.message}`, 500);
}
}, 'ssl-check-service'));
/**
* GET /ssl/config
* Get current SSL monitoring configuration
*/
router.get('/config', asyncHandler(async (req, res) => {
const config = sslMonitor.getConfig();
success(res, { config });
}, 'ssl-config-get'));
/**
* POST /ssl/config
* Update SSL monitoring configuration
* Body: { enabled: boolean, intervalMs: number }
*/
router.post('/config', asyncHandler(async (req, res) => {
const { enabled, intervalMs } = req.body;
// Validate inputs
if (enabled !== undefined && typeof enabled !== 'boolean') {
return errorResponse(res, 'enabled must be a boolean', 400);
}
if (intervalMs !== undefined) {
if (typeof intervalMs !== 'number' || intervalMs < 60000) {
return errorResponse(res, 'intervalMs must be a number >= 60000 (1 minute)', 400);
}
}
const updates = {};
if (enabled !== undefined) updates.enabled = enabled;
if (intervalMs !== undefined) updates.intervalMs = intervalMs;
sslMonitor.updateConfig(updates);
const config = sslMonitor.getConfig();
success(res, { config, message: 'SSL monitoring config updated' });
}, 'ssl-config-update'));
return router;
};
+19 -13
View File
@@ -3,7 +3,6 @@ const fs = require('fs');
const { TAILSCALE } = require('../constants'); const { TAILSCALE } = require('../constants');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
const { ok, successMessage, unauthorized } = require('../src/utils/responses');
/** /**
* Tailscale route factory * Tailscale route factory
@@ -36,7 +35,8 @@ module.exports = function({
const localIP = await tailscale.getLocalIP(); const localIP = await tailscale.getLocalIP();
if (!status) { if (!status) {
return ok(res, { return res.json({
success: true,
installed: false, installed: false,
connected: false, connected: false,
message: 'Tailscale not available or not running' message: 'Tailscale not available or not running'
@@ -58,7 +58,8 @@ module.exports = function({
} }
} }
ok(res, { res.json({
success: true,
installed: true, installed: true,
connected: status.BackendState === 'Running', connected: status.BackendState === 'Running',
backendState: status.BackendState, backendState: status.BackendState,
@@ -84,7 +85,8 @@ module.exports = function({
await tailscale.save(); await tailscale.save();
ok(res, { res.json({
success: true,
message: 'Tailscale configuration updated', message: 'Tailscale configuration updated',
config: tailscale.config config: tailscale.config
}); });
@@ -99,7 +101,8 @@ module.exports = function({
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean); const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
const isTailscale = ipsToCheck.some(ip => tailscale.isTailscaleIP(ip.toString().split(',')[0].trim())); const isTailscale = ipsToCheck.some(ip => tailscale.isTailscaleIP(ip.toString().split(',')[0].trim()));
ok(res, { res.json({
success: true,
isTailscale, isTailscale,
clientIP, clientIP,
forwardedFor: forwardedFor || null, forwardedFor: forwardedFor || null,
@@ -111,7 +114,7 @@ module.exports = function({
router.get('/devices', asyncHandler(async (req, res) => { router.get('/devices', asyncHandler(async (req, res) => {
const status = await tailscale.getStatus(); const status = await tailscale.getStatus();
if (!status || !status.Peer) { if (!status || !status.Peer) {
return ok(res, { devices: [] }); return res.json({ success: true, devices: [] });
} }
const devices = []; const devices = [];
@@ -138,7 +141,7 @@ module.exports = function({
}); });
} }
ok(res, { devices }); res.json({ success: true, devices });
}, 'tailscale-devices')); }, 'tailscale-devices'));
// Toggle Tailscale-only mode for an existing service // Toggle Tailscale-only mode for an existing service
@@ -187,7 +190,8 @@ module.exports = function({
}); });
} }
ok(res, { res.json({
success: true,
message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`, message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`,
tailscaleOnly: tailscaleOnly !== false tailscaleOnly: tailscaleOnly !== false
}); });
@@ -250,7 +254,7 @@ module.exports = function({
log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message }); log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message });
} }
ok(res, { config: tailscale.config }); res.json({ success: true, config: tailscale.config });
}, 'tailscale-oauth-config')); }, 'tailscale-oauth-config'));
// Remove OAuth credentials and disable API sync // Remove OAuth credentials and disable API sync
@@ -265,7 +269,7 @@ module.exports = function({
tailscale.stopSync(); tailscale.stopSync();
successMessage(res, 'Tailscale OAuth credentials removed'); res.json({ success: true, message: 'Tailscale OAuth credentials removed' });
}, 'tailscale-oauth-delete')); }, 'tailscale-oauth-delete'));
// Get enriched device list from Tailscale API // Get enriched device list from Tailscale API
@@ -275,7 +279,8 @@ module.exports = function({
} }
// Return cached devices from last sync // Return cached devices from last sync
ok(res, { res.json({
success: true,
devices: tailscale.config.devices || [], devices: tailscale.config.devices || [],
lastSync: tailscale.config.lastSync lastSync: tailscale.config.lastSync
}); });
@@ -289,7 +294,8 @@ module.exports = function({
const devices = await tailscale.syncAPI(); const devices = await tailscale.syncAPI();
ok(res, { res.json({
success: true,
devices: devices || [], devices: devices || [],
lastSync: tailscale.config.lastSync lastSync: tailscale.config.lastSync
}); });
@@ -319,7 +325,7 @@ module.exports = function({
sshRuleCount: (acl.ssh || []).length sshRuleCount: (acl.ssh || []).length
}; };
ok(res, { acl, summary }); res.json({ success: true, acl, summary });
}, 'tailscale-acl')); }, 'tailscale-acl'));
return router; return router;
+2 -3
View File
@@ -1,9 +1,8 @@
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('../src/utils/responses'); const { success } = require('../response-helpers');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
const platformPaths = require('../platform-paths');
/** /**
* Themes routes factory * Themes routes factory
@@ -14,7 +13,7 @@ const platformPaths = require('../platform-paths');
*/ */
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(platformPaths.servicesFile), 'themes'); const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
// Ensure themes directory exists // Ensure themes directory exists
if (!fs.existsSync(THEMES_DIR)) { if (!fs.existsSync(THEMES_DIR)) {
+21 -20
View File
@@ -1,7 +1,6 @@
const express = require('express'); const express = require('express');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
const { ok, successMessage } = require('../src/utils/responses');
/** /**
* Updates route factory * Updates route factory
@@ -21,7 +20,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
router.post('/updates/check', asyncHandler(async (req, res) => { router.post('/updates/check', asyncHandler(async (req, res) => {
await updateManager.checkForUpdates(); await updateManager.checkForUpdates();
const updates = updateManager.getAvailableUpdates(); const updates = updateManager.getAvailableUpdates();
ok(res, { updates, count: updates.length }); res.json({ success: true, updates, count: updates.length });
}, 'updates-check')); }, 'updates-check'));
// Get available updates // Get available updates
@@ -29,19 +28,19 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
const updates = updateManager.getAvailableUpdates(); const updates = updateManager.getAvailableUpdates();
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
const result = paginate(updates, paginationParams); const result = paginate(updates, paginationParams);
ok(res, { updates: result.data, count: updates.length, ...(result.pagination && { pagination: result.pagination }) }); res.json({ success: true, updates: result.data, count: updates.length, ...(result.pagination && { pagination: result.pagination }) });
}, 'updates-available')); }, 'updates-available'));
// Update a container // Update a container
router.post('/updates/update/:containerId', asyncHandler(async (req, res) => { router.post('/updates/update/:containerId', asyncHandler(async (req, res) => {
const result = await updateManager.updateContainer(req.params.containerId, req.body); const result = await updateManager.updateContainer(req.params.containerId, req.body);
ok(res, { result }); res.json({ success: true, result });
}, 'updates-update')); }, 'updates-update'));
// Rollback update // Rollback update
router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => { router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => {
await updateManager.rollbackUpdate(req.params.containerId); await updateManager.rollbackUpdate(req.params.containerId);
successMessage(res, 'Rollback completed'); res.json({ success: true, message: 'Rollback completed' });
}, 'updates-rollback')); }, 'updates-rollback'));
// Get update history // Get update history
@@ -51,19 +50,19 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50); const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50);
const history = updateManager.getHistory(fetchLimit); const history = updateManager.getHistory(fetchLimit);
const result = paginate(history, paginationParams); const result = paginate(history, paginationParams);
ok(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) }); res.json({ success: true, history: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'updates-history')); }, 'updates-history'));
// Configure auto-update // Configure auto-update
router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => { router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => {
updateManager.configureAutoUpdate(req.params.containerId, req.body); updateManager.configureAutoUpdate(req.params.containerId, req.body);
successMessage(res, 'Auto-update configured'); res.json({ success: true, message: 'Auto-update configured' });
}, 'updates-auto-update')); }, 'updates-auto-update'));
// Get auto-update configuration // Get auto-update configuration
router.get('/updates/auto-update', asyncHandler(async (req, res) => { router.get('/updates/auto-update', asyncHandler(async (req, res) => {
const config = updateManager.getAutoUpdateConfig(); const config = updateManager.getAutoUpdateConfig();
ok(res, { config }); res.json({ success: true, config });
}, 'updates-auto-update-config')); }, 'updates-auto-update-config'));
// Schedule update // Schedule update
@@ -73,7 +72,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
throw new ValidationError('scheduledTime is required'); throw new ValidationError('scheduledTime is required');
} }
updateManager.scheduleUpdate(req.params.containerId, scheduledTime); updateManager.scheduleUpdate(req.params.containerId, scheduledTime);
ok(res, { message: 'Update scheduled', scheduledTime }); res.json({ success: true, message: 'Update scheduled', scheduledTime });
}, 'updates-schedule')); }, 'updates-schedule'));
// ===== DASHCADDY SELF-UPDATE ENDPOINTS ===== // ===== DASHCADDY SELF-UPDATE ENDPOINTS =====
@@ -81,20 +80,20 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
// Get current version // Get current version
router.get('/system/version', asyncHandler(async (req, res) => { router.get('/system/version', asyncHandler(async (req, res) => {
const local = selfUpdater.getLocalVersion(); const local = selfUpdater.getLocalVersion();
ok(res, { name: 'DashCaddy', version: local.version, commit: local.commit }); res.json({ success: true, name: 'DashCaddy', version: local.version, commit: local.commit });
}, 'system-version')); }, 'system-version'));
// Check for DashCaddy update // Check for DashCaddy update
router.get('/system/update-check', asyncHandler(async (req, res) => { router.get('/system/update-check', asyncHandler(async (req, res) => {
const result = await selfUpdater.checkForUpdate(); const result = await selfUpdater.checkForUpdate();
ok(res, result); res.json({ success: true, ...result });
}, 'system-update-check')); }, 'system-update-check'));
// Apply available update // Apply available update
router.post('/system/update-apply', asyncHandler(async (req, res) => { router.post('/system/update-apply', asyncHandler(async (req, res) => {
const check = await selfUpdater.checkForUpdate(); const check = await selfUpdater.checkForUpdate();
if (!check.available) { if (!check.available) {
return successMessage(res, 'Already up to date'); return res.json({ success: true, message: 'Already up to date' });
} }
// Refuse same-version applies. The check.available flag can theoretically be // Refuse same-version applies. The check.available flag can theoretically be
// true with equal versions (commit-mismatch path); applying anyway just // true with equal versions (commit-mismatch path); applying anyway just
@@ -103,13 +102,14 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
const localV = check.local && check.local.version; const localV = check.local && check.local.version;
const remoteV = check.remote && check.remote.version; const remoteV = check.remote && check.remote.version;
if (localV && remoteV && localV === remoteV) { if (localV && remoteV && localV === remoteV) {
return ok(res, { message: 'Already up to date', version: localV }); return res.json({ success: true, message: 'Already up to date', version: localV });
} }
// Start async — container may restart // Start async — container may restart
selfUpdater.applyUpdate(check.remote).catch(err => { selfUpdater.applyUpdate(check.remote).catch(err => {
logError('self-update', err); logError('self-update', err);
}); });
ok(res, { res.json({
success: true,
message: 'Update initiated', message: 'Update initiated',
fromVersion: localV, fromVersion: localV,
toVersion: remoteV, toVersion: remoteV,
@@ -132,15 +132,16 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
presentedBuf.length > 0 && presentedBuf.length > 0 &&
require('crypto').timingSafeEqual(presentedBuf, expectedBuf); require('crypto').timingSafeEqual(presentedBuf, expectedBuf);
if (!ok) { if (!ok) {
return unauthorized(res, 'Invalid notify secret'); return res.status(401).json({ success: false, error: 'Invalid notify secret' });
} }
const result = selfUpdater.notifyAndApply('http-notify'); const result = selfUpdater.notifyAndApply('http-notify');
ok(res, result); res.json({ success: true, ...result });
}, 'system-update-notify')); }, 'system-update-notify'));
// Get update status // Get update status
router.get('/system/update-status', asyncHandler(async (req, res) => { router.get('/system/update-status', asyncHandler(async (req, res) => {
ok(res, { res.json({
success: true,
status: selfUpdater.getStatus(), status: selfUpdater.getStatus(),
lastCheck: selfUpdater.lastCheckTime, lastCheck: selfUpdater.lastCheckTime,
lastResult: selfUpdater.lastCheckResult, lastResult: selfUpdater.lastCheckResult,
@@ -150,13 +151,13 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
// Get self-update history // Get self-update history
router.get('/system/update-history', asyncHandler(async (req, res) => { router.get('/system/update-history', asyncHandler(async (req, res) => {
const history = selfUpdater.getUpdateHistory(); const history = selfUpdater.getUpdateHistory();
ok(res, { history }); res.json({ success: true, history });
}, 'system-update-history')); }, 'system-update-history'));
// List rollback versions // List rollback versions
router.get('/system/rollback-versions', asyncHandler(async (req, res) => { router.get('/system/rollback-versions', asyncHandler(async (req, res) => {
const versions = selfUpdater.getAvailableRollbacks(); const versions = selfUpdater.getAvailableRollbacks();
ok(res, { versions }); res.json({ success: true, versions });
}, 'system-rollback-versions')); }, 'system-rollback-versions'));
// Rollback to a previous version // Rollback to a previous version
@@ -166,7 +167,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
selfUpdater.rollbackToVersion(version).catch(err => { selfUpdater.rollbackToVersion(version).catch(err => {
logError('self-rollback', err); logError('self-rollback', err);
}); });
ok(res, { message: `Rollback to ${version} initiated` }); res.json({ success: true, message: `Rollback to ${version} initiated` });
}, 'system-rollback')); }, 'system-rollback'));
return router; return router;
+6 -7
View File
@@ -1,5 +1,4 @@
const express = require('express'); const express = require('express');
const { ok } = require('../src/utils/responses');
/** /**
* Workflows routes factory * Workflows routes factory
@@ -20,21 +19,21 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
// List all bundled workflows // List all bundled workflows
router.get('/workflows', asyncHandler(async (req, res) => { router.get('/workflows', asyncHandler(async (req, res) => {
const workflows = workflowEngine.listWorkflows(); const workflows = workflowEngine.listWorkflows();
ok(res, { workflows }); res.json({ success: true, workflows });
}, 'workflows-list')); }, 'workflows-list'));
// Enable a workflow // Enable a workflow
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => { router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
const { workflowId } = req.params; const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, true); const result = workflowEngine.setWorkflowEnabled(workflowId, true);
ok(res, result); res.json({ success: true, ...result });
}, 'workflows-enable')); }, 'workflows-enable'));
// Disable a workflow // Disable a workflow
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => { router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
const { workflowId } = req.params; const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, false); const result = workflowEngine.setWorkflowEnabled(workflowId, false);
ok(res, result); res.json({ success: true, ...result });
}, 'workflows-disable')); }, 'workflows-disable'));
// Manually trigger a workflow // Manually trigger a workflow
@@ -44,7 +43,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
triggerData.trigger = 'manual'; triggerData.trigger = 'manual';
const result = await workflowEngine.executeWorkflow(workflowId, triggerData); const result = await workflowEngine.executeWorkflow(workflowId, triggerData);
ok(res, { result }); res.json({ success: true, result });
}, 'workflows-run')); }, 'workflows-run'));
// Get execution history for a workflow // Get execution history for a workflow
@@ -52,14 +51,14 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
const { workflowId } = req.params; const { workflowId } = req.params;
const limit = parseInt(req.query.limit) || 50; const limit = parseInt(req.query.limit) || 50;
const history = workflowEngine.getHistory(workflowId, limit); const history = workflowEngine.getHistory(workflowId, limit);
ok(res, { history }); res.json({ success: true, history });
}, 'workflows-history')); }, 'workflows-history'));
// Get all workflow execution history // Get all workflow execution history
router.get('/workflows/history', asyncHandler(async (req, res) => { router.get('/workflows/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100; const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit); const history = workflowEngine.getHistory(null, limit);
ok(res, { history }); res.json({ success: true, history });
}, 'workflows-all-history')); }, 'workflows-all-history'));
return router; return router;
+6 -1
View File
@@ -134,11 +134,16 @@ restart_container() {
# Stop and remove existing container so new env var is applied # Stop and remove existing container so new env var is applied
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
# Re-create with same volumes and the SERVICES_FILE env var # Re-create with same volumes. CRITICAL: must include CREDENTIALS_FILE +
# ENCRYPTION_KEY_FILE pointing at /app/data/ so the container reads the TOTP
# secret from the bind-mounted host data dir (not image-local /app/credentials.json
# which gets a fresh encryption key on every container recreate = TOTP breaks).
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \ docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
-p 127.0.0.1:3001:3001 \ -p 127.0.0.1:3001:3001 \
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \ -v /opt/dashcaddy/dashcaddy-api/data:/app/data \
-e SERVICES_FILE=/app/data/services.json \ -e SERVICES_FILE=/app/data/services.json \
-e CREDENTIALS_FILE=/app/d...son \
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
"$image" "$image"
log "Container restarted with fresh env" log "Container restarted with fresh env"
} }
+5 -5
View File
@@ -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: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release', UPDATE_URL: 'https://get.dashcaddy.net/release',
MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release', MIRROR_URL: 'https://get2.dashcaddy.net/release',
UPDATES_DIR: platformPaths.containerUpdatesDir, UPDATES_DIR: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'updates') : '/app/updates',
// 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.containerFrontendDir, FRONTEND_DIR: platformPaths.isWindows ? path.join(platformPaths.caddySites, 'status') : '/app/dashboard',
MAX_BACKUPS: 3, MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000, HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000, DOWNLOAD_TIMEOUT: 120000,
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable', 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',
+2 -7
View File
@@ -25,8 +25,7 @@ process.on('uncaughtException', (error) => {
// Load license // Load license
await licenseManager.load(); await licenseManager.load();
const PORT = parseInt(process.env.PORT, 10) || 3001; const PORT = process.env.PORT || 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;
@@ -44,10 +43,9 @@ process.on('uncaughtException', (error) => {
}); });
// Start HTTP server // Start HTTP server
const server = app.listen(PORT, HOST, () => { const server = app.listen(PORT, '0.0.0.0', () => {
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,
@@ -75,12 +73,9 @@ 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 = {
+24 -197
View File
@@ -16,7 +16,6 @@ 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');
@@ -78,15 +77,6 @@ const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources'); const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events'); const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows'); const workflowsRoutes = require('../routes/workflows');
const dependenciesRoutes = require('../routes/dependencies');
const DependencyManager = require('../dependency-manager');
const autoRestartRoutes = require('../routes/auto-restart');
const configDriftRoutes = require('../routes/config-drift');
const sslMonitorRoutes = require('../routes/ssl-monitor');
const { AutoRestartManager } = require('../auto-restart-manager');
const { ConfigDriftDetector } = require('../config-drift-detector');
const SSLMonitor = require('../ssl-monitor');
const DNSPropagationChecker = require('../dns-propagation');
// Constants // Constants
const { APP } = require('../constants'); const { APP } = require('../constants');
@@ -97,19 +87,6 @@ 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);
@@ -125,7 +102,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 || platformPaths.pkiRootCert; const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
let httpsAgent; let httpsAgent;
try { try {
const caCert = fs.readFileSync(CA_CERT_PATH); const caCert = fs.readFileSync(CA_CERT_PATH);
@@ -187,6 +164,21 @@ async function createApp() {
return null; return null;
} }
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
// auto-login pages) historically call these endpoints under the pre-1.5.0
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
// before configureMiddleware() so CSRF/auth see the canonical path. This is
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
app.use((req, res, next) => {
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
}
next();
});
// Configure middleware // Configure middleware
const middlewareResult = configureMiddleware(app, { const middlewareResult = configureMiddleware(app, {
siteConfig: config.siteConfig, siteConfig: config.siteConfig,
@@ -358,64 +350,9 @@ async function createApp() {
} }
} }
// Initialize dependency manager
const dependencyManager = new DependencyManager({
servicesStateManager,
docker: ctx.docker,
notification: ctx.notification,
log,
});
ctx.dependencyManager = dependencyManager;
log.info('app', 'Dependency manager initialized');
// Initialize auto-restart manager
const autoRestartManager = new AutoRestartManager(ctx);
ctx.autoRestartManager = autoRestartManager;
autoRestartManager.start();
log.info('app', 'Auto-restart manager initialized');
// Initialize config drift detector
const driftDetector = new ConfigDriftDetector(ctx);
ctx.driftDetector = driftDetector;
driftDetector.startPolling(300000); // 5 min
log.info('app', 'Config drift detector initialized');
// Initialize SSL monitor
const sslMonitor = new SSLMonitor(ctx);
ctx.sslMonitor = sslMonitor;
sslMonitor.start(3600000); // 1 hour
log.info('app', 'SSL monitor initialized');
// Initialize DNS propagation checker
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
ctx.dnsPropagationChecker = dnsPropagationChecker;
log.info('app', 'DNS propagation checker initialized');
// 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) => {
ok(res, {
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) => {
@@ -453,8 +390,7 @@ async function createApp() {
log: ctx.log, log: ctx.log,
safeErrorMessage: ctx.safeErrorMessage, safeErrorMessage: ctx.safeErrorMessage,
fetchT: ctx.fetchT, fetchT: ctx.fetchT,
credentialManager: ctx.credentialManager, credentialManager: ctx.credentialManager
dnsPropagationChecker: ctx.dnsPropagationChecker
})); }));
apiRouter.use('/notifications', notificationRoutes({ apiRouter.use('/notifications', notificationRoutes({
notification: ctx.notification, notification: ctx.notification,
@@ -568,54 +504,25 @@ async function createApp() {
resourceMonitor: ctx.resourceMonitor, resourceMonitor: ctx.resourceMonitor,
healthChecker: ctx.healthChecker, healthChecker: ctx.healthChecker,
updateManager: ctx.updateManager, updateManager: ctx.updateManager,
logError: ctx.logError, logError: ctx.logError
dependencyManager: ctx.dependencyManager,
autoRestartManager: ctx.autoRestartManager,
driftDetector: ctx.driftDetector,
sslMonitor: ctx.sslMonitor,
dnsPropagationChecker: ctx.dnsPropagationChecker
})); }));
apiRouter.use('/workflows', workflowsRoutes({ apiRouter.use(workflowsRoutes({
workflowEngine: ctx.workflowEngine, workflowEngine: ctx.workflowEngine,
licenseManager: ctx.licenseManager, licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler asyncHandler: ctx.asyncHandler
})); }));
apiRouter.use('/dependencies', dependenciesRoutes({
dependencyManager: ctx.dependencyManager,
servicesStateManager: ctx.servicesStateManager,
docker: ctx.docker,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
resyncHealthChecker: ctx.resyncHealthChecker,
log: ctx.log,
}));
apiRouter.use(autoRestartRoutes({
autoRestartManager: ctx.autoRestartManager,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
apiRouter.use(configDriftRoutes({
driftDetector: ctx.driftDetector,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
apiRouter.use(sslMonitorRoutes({
sslMonitor: ctx.sslMonitor,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
// Inline API routes // Inline API routes
apiRouter.get('/health', (req, res) => { apiRouter.get('/health', (req, res) => {
ok(res, { status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: 'ok', timestamp: new Date().toISOString() });
}); });
apiRouter.get('/csrf-token', (req, res) => { apiRouter.get('/csrf-token', (req, res) => {
ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME }); res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME });
}); });
apiRouter.get('/metrics', (req, res) => { apiRouter.get('/metrics', (req, res) => {
ok(res, { metrics: metrics.getSummary() }); res.json({ success: true, metrics: metrics.getSummary() });
}); });
// Mount at /api/v1 (canonical, single version) // Mount at /api/v1 (canonical, single version)
@@ -623,89 +530,9 @@ async function createApp() {
// Root-level health check // Root-level health check
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
ok(res, { 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) => {
ok(res, { 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
};
ok(res, body, allOk ? 200 : 503);
}));
// 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;
@@ -829,7 +656,7 @@ async function createApp() {
} }
} }
ok(res, result); res.json(result);
} catch (error) { } catch (error) {
errorResponse(res, 500, safeErrorMessage(error)); errorResponse(res, 500, safeErrorMessage(error));
} }
-142
View File
@@ -1,142 +0,0 @@
/**
* 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 v1v2v3
* 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
};
+3 -11
View File
@@ -1,15 +1,10 @@
/** /**
* 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',
@@ -26,11 +21,9 @@ const siteConfig = {
function loadSiteConfig(CONFIG_FILE, log) { function loadSiteConfig(CONFIG_FILE, log) {
try { try {
// Run migrations first — this handles config.json files from older if (fs.existsSync(CONFIG_FILE)) {
// versions of DashCaddy and writes the migrated version back to disk. const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
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) {
@@ -83,5 +76,4 @@ module.exports = {
loadSiteConfig, loadSiteConfig,
buildDomain, buildDomain,
buildServiceUrl, buildServiceUrl,
CURRENT_VERSION
}; };
+3 -2
View File
@@ -93,8 +93,9 @@ 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,
}, 5000); timeout: 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;
+2 -24
View File
@@ -1,14 +1,8 @@
/** /**
* DNS context - Technitium DNS operations and token management * DNS context - Technitium DNS operations and token management
*
* DEPRECATED: This module is kept for backward compatibility.
* New code should use src/context/provider-dns.js which supports multiple providers.
*
* This module now delegates to the provider system internally.
*/ */
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants'); const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants');
const { createCache, CACHE_CONFIGS } = require('../../cache-config'); const { createCache, CACHE_CONFIGS } = require('../../cache-config');
const { createProviderDnsContext } = require('./provider-dns');
// DNS token management // DNS token management
let dnsToken = process.env.DNS_ADMIN_TOKEN || ''; let dnsToken = process.env.DNS_ADMIN_TOKEN || '';
@@ -58,9 +52,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'
}
}, },
10000 timeout: 10000
}
); );
const result = await response.json(); const result = await response.json();
@@ -287,10 +281,6 @@ function invalidateTokenForServer(serverIp) {
} }
function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) { function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) {
// Create the new provider-aware context
const providerCtx = createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
// Legacy Technitium-specific wrappers (kept for backward compat)
const ensureToken = () => ensureValidDnsToken(siteConfig, credentialManager, fetchT, log); const ensureToken = () => ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log); const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log);
const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role); const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role);
@@ -299,7 +289,6 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent); const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent);
return { return {
// Legacy Technitium-specific interface (unchanged)
call, call,
buildUrl: buildDnsUrl, buildUrl: buildDnsUrl,
requireToken: require, requireToken: require,
@@ -313,17 +302,6 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
invalidateTokenForServer, invalidateTokenForServer,
refresh, refresh,
credentialsFile: DNS_CREDENTIALS_FILE, credentialsFile: DNS_CREDENTIALS_FILE,
// Provider-aware methods (new)
getProviderId: providerCtx.getProviderId,
getActiveProvider: providerCtx.getActiveProvider,
getAvailableProviders: providerCtx.getAvailableProviders,
supportsCapability: providerCtx.supportsCapability,
// Universal DNS helpers (delegated to provider context)
universalCreateRecord: providerCtx.universalCreateRecord,
universalDeleteRecord: providerCtx.universalDeleteRecord,
universalResolveRecord: providerCtx.universalResolveRecord,
}; };
} }
-303
View File
@@ -1,303 +0,0 @@
/**
* Provider-aware DNS Context
* Replaces the Technitium-only context with a provider-agnostic layer.
* Delegates to the active DNS provider adapter based on config.
*
* Falls back to legacy Technitium context for backward compatibility
* when no provider is explicitly configured.
*/
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants');
const registry = require('../../dns-providers/registry');
// Per-server token cache (legacy Technitium)
const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens);
let dnsToken = '';
let dnsTokenExpiry = null;
/**
* Create a provider-aware DNS context.
* This wraps both the new provider system and the legacy Technitium context
* for seamless migration.
*/
function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) {
/** Resolve the active provider from config */
function getProviderId() {
// New explicit provider field
if (siteConfig.dns?.provider) return siteConfig.dns.provider;
// Legacy: if dns.ip is set, default to technitium
if (siteConfig.dnsServerIp || siteConfig.dns?.ip) return 'technitium';
// No DNS configured
return 'manual';
}
/** Get provider-specific config from site config */
function getProviderConfig(providerId) {
const dnsConfig = siteConfig.dns || {};
switch (providerId) {
case 'technitium':
return {
serverIp: siteConfig.dnsServerIp || dnsConfig.ip || '',
serverPort: siteConfig.dnsServerPort || dnsConfig.port || '5380',
dnsServers: siteConfig.dnsServers || {},
dnsId: Object.keys(siteConfig.dnsServers || {})[0] || 'dns1'
};
case 'cloudflare':
return {
apiToken: dnsConfig.apiToken || '',
zoneId: dnsConfig.zoneId || '',
domain: siteConfig.domain || ''
};
case 'rfc2136':
return {
server: dnsConfig.server || siteConfig.dnsServerIp || '',
port: dnsConfig.port || 53,
zone: siteConfig.tld?.replace(/^\./, '') || '',
tsigAlgorithm: dnsConfig.tsigAlgorithm || 'hmac-sha256',
tsigKeyName: dnsConfig.tsigKeyName || '',
tsigSecret: dnsConfig.tsigSecret || ''
};
case 'manual':
return {};
default:
return dnsConfig;
}
}
/** Get or create the active provider adapter */
function getActiveProvider() {
const providerId = getProviderId();
const config = getProviderConfig(providerId);
const ctx = { log, credentialManager, fetchT, httpsAgent };
return registry.getProvider(providerId, config, ctx);
}
// ===== Legacy Technitium helpers (kept for backward compat) =====
function buildDnsUrl(server, apiPath, params) {
const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https';
const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : '';
const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString();
return `${protocol}://${server}${port}${apiPath}?${qs}`;
}
async function callDns(server, apiPath, params) {
const url = buildDnsUrl(server, apiPath, params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
agent: httpsAgent
}, TIMEOUTS.HTTP_LONG);
return response.json();
}
async function refreshDnsToken(username, password, server) {
try {
const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' });
const response = await fetchT(
`http://${server}:5380/api/user/login?${params.toString()}`,
{ method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' } },
10000
);
const result = await response.json();
if (result.status === 'ok' && result.token) {
dnsToken = result.token;
dnsTokenExpiry = new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString();
log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry });
return { success: true, token: dnsToken };
}
return { success: false, error: result.errorMessage || 'Login failed' };
} catch (error) {
log.error('dns', 'DNS token refresh error', { error: error.message });
return { success: false, error: error.message };
}
}
function dnsIpToDnsId(serverIp) {
for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
if (info.ip === serverIp) return dnsId;
}
return null;
}
async function ensureValidDnsToken() {
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
return { success: true, token: dnsToken };
}
const primaryIp = siteConfig.dnsServerIp;
if (primaryIp) {
const dnsId = dnsIpToDnsId(primaryIp);
if (dnsId) {
for (const role of ['admin', 'readonly']) {
try {
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
if (username && password) return await refreshDnsToken(username, password, primaryIp);
} catch (err) { /* try next */ }
}
}
}
try {
const username = await credentialManager.retrieve('dns.username');
const password = await credentialManager.retrieve('dns.password');
const server = await credentialManager.retrieve('dns.server');
if (username && password) return await refreshDnsToken(username, password, server || primaryIp);
} catch (err) { /* no global creds */ }
return { success: false, error: 'No DNS credentials configured' };
}
async function getTokenForServer(targetServer, role = 'readonly') {
const cacheKey = `${targetServer}:${role}`;
const cached = dnsServerTokens.get(cacheKey);
if (cached?.token && cached?.expiry && new Date() < new Date(cached.expiry)) {
return { success: true, token: cached.token };
}
const serverPort = siteConfig.dnsServerPort || '5380';
async function authToServer(username, password) {
const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' });
const response = await fetchT(
`http://${targetServer}:${serverPort}/api/user/login?${params.toString()}`,
{ method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const result = await response.json();
if (result.status === 'ok' && result.token) {
dnsServerTokens.set(cacheKey, { token: result.token, expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString() });
log.info('dns', 'DNS token obtained for server', { server: targetServer, role });
return { success: true, token: result.token };
}
return { success: false, error: result.errorMessage || 'Login failed' };
}
const dnsId = dnsIpToDnsId(targetServer);
if (dnsId) {
for (const r of [role, role === 'readonly' ? 'admin' : 'readonly']) {
try {
const username = await credentialManager.retrieve(`dns.${dnsId}.${r}.username`);
const password = await credentialManager.retrieve(`dns.${dnsId}.${r}.password`);
if (username && password) return await authToServer(username, password);
} catch { /* try next */ }
}
}
try {
const username = await credentialManager.retrieve('dns.username');
const password = await credentialManager.retrieve('dns.password');
if (username && password) return await authToServer(username, password);
} catch { /* no global creds */ }
return { success: false, error: 'No DNS credentials configured' };
}
async function requireDnsToken(providedToken) {
if (providedToken) return providedToken;
const result = await ensureValidDnsToken();
if (result.success) return result.token;
const err = new Error('No valid DNS token available. ' + result.error);
err.statusCode = 401;
throw err;
}
function invalidateTokenForServer(serverIp) {
dnsServerTokens.delete(`${serverIp}:readonly`);
dnsServerTokens.delete(`${serverIp}:admin`);
}
// ===== Public context API =====
// This maintains the same interface as the old createDnsContext()
// but adds provider-aware methods on top.
return {
// --- Provider-aware methods ---
/** Get the active provider ID */
getProviderId,
/** Get the active provider adapter instance */
getActiveProvider,
/** Get metadata for all available providers */
getAvailableProviders: () => registry.getProviderMeta(),
/** Check if the active provider supports a capability */
supportsCapability: (cap) => {
try { return getActiveProvider().supportsCapability(cap); }
catch { return false; }
},
// --- Legacy Technitium context (backward compat) ---
call: callDns,
buildUrl: buildDnsUrl,
requireToken: requireDnsToken,
ensureToken: ensureValidDnsToken,
getToken: () => dnsToken,
setToken: (t) => { dnsToken = t; },
getTokenExpiry: () => dnsTokenExpiry,
setTokenExpiry: (e) => { dnsTokenExpiry = e; },
getTokenForServer,
invalidateTokenForServer,
refresh: refreshDnsToken,
credentialsFile: DNS_CREDENTIALS_FILE,
// --- Universal DNS helpers (provider-agnostic) ---
/**
* Create a DNS A record using the active provider.
* Gracefully handles manual adapters that return instructions instead of performing the action.
*/
async universalCreateRecord(subdomain, ip) {
const provider = getActiveProvider();
const result = await provider.createRecord({
domain: buildDomain(subdomain),
zone: siteConfig.tld?.replace(/^\./, '') || '',
type: 'A',
value: ip,
ttl: 300,
overwrite: true,
});
// Manual adapter returns instructions instead of performing the action
if (result?.manual || result?.instructions) {
return { success: true, manual: true, instructions: result.instructions || result };
}
return result;
},
/**
* Delete a DNS A record using the active provider.
* Gracefully handles manual adapters that return instructions instead of performing the action.
*/
async universalDeleteRecord(domain, ip) {
const provider = getActiveProvider();
const result = await provider.deleteRecord({
domain,
type: 'A',
value: ip,
});
if (result?.manual || result?.instructions) {
return { success: true, manual: true, instructions: result.instructions || result };
}
return result;
},
/**
* Resolve DNS records using the active provider.
* Returns parsed IP addresses from the result.
*/
async universalResolveRecord(domain, type) {
const provider = getActiveProvider();
const result = await provider.resolveRecords({
domain,
zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A',
});
// Parse IP addresses from the result
if (Array.isArray(result)) {
return result;
}
if (result?.records) {
return result.records.map(r => r.ipAddress || r.value || r.address || r).filter(Boolean);
}
if (result?.ips) {
return result.ips;
}
return result;
},
};
}
module.exports = { createProviderDnsContext };
+1 -9
View File
@@ -38,15 +38,7 @@ 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) };
} }
// The `timeout` key in fetch() opts is silently ignored by undici. Callers delete opts.timeout;
// 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);
} }
+1 -5
View File
@@ -94,12 +94,8 @@ 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')) {
@@ -107,7 +103,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 (no paths, stack traces, or internal details) // Only expose short, user-facing messages
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;
} }
+5 -107
View File
@@ -1,124 +1,22 @@
/** /**
* 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 success response. Use this in route handlers. * Standard error response
* 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 });
} }
/** /**
* Alias for `errorResponse` kept for code that imports as `error`. * Standard success response
*/ */
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) { function ok(res, data = {}) {
return res.status(statusCode).json({ success: false, error: message }); return res.json({ success: true, ...data });
}
/**
* 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 = {
// Success helpers
ok,
success,
successMessage,
created,
noContent,
// Error helpers
errorResponse, errorResponse,
error, ok,
validationError,
unauthorized,
forbidden,
notFound,
conflict,
}; };
-411
View File
@@ -1,411 +0,0 @@
/**
* SSL Certificate Monitor
* Periodically checks SSL certificates on services with HTTPS URLs.
* Alerts at 30, 14, and 7 days before expiry.
*
* @module ssl-monitor
*/
const tls = require('tls');
const EventEmitter = require('events');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
const { resolveServiceUrl } = require('./url-resolver');
/** Default check interval: 1 hour */
const DEFAULT_INTERVAL_MS = 3600000;
/** Alert thresholds in days */
const THRESHOLDS = {
WARNING: 30,
URGENT: 14,
CRITICAL: 7
};
/** TLS connection timeout in milliseconds */
const TLS_TIMEOUT_MS = 10000;
class SSLMonitor extends EventEmitter {
/**
* Create an SSLMonitor instance.
* @param {Object} ctx - Shared application context
* @param {Object} ctx.servicesStateManager - State manager for reading services
* @param {Function} ctx.buildServiceUrl - URL builder helper
* @param {Object} ctx.siteConfig - Site configuration
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {string} [ctx.SSL_CACHE_FILE] - Path to persist SSL cache
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
/** @type {Map<string, Object>} hostname → last cert check result */
this.certStatus = new Map();
/** @type {Map<string, number>} hostname → last notified threshold level */
this.notifiedThresholds = new Map();
/** @type {Map<string, string>} hostname → service ID mapping */
this.hostnameToServiceId = new Map();
/** @type {NodeJS.Timeout|null} */
this.intervalHandle = null;
/** Current config */
this.config = {
enabled: true,
intervalMs: DEFAULT_INTERVAL_MS
};
/** Cache file path */
this.cacheFile = ctx.SSL_CACHE_FILE ||
path.join(path.dirname(ctx.SERVICES_FILE || './data'), 'ssl-cache.json');
}
/**
* Check the SSL certificate for a given hostname and port.
* Connects via TLS with rejectUnauthorized: false to retrieve certificate info.
*
* @param {string} hostname - The hostname to check
* @param {number} [port=443] - The port to connect to
* @returns {Promise<Object>} Certificate information
*/
async checkCert(hostname, port = 443) {
return new Promise((resolve, reject) => {
const socket = tls.connect({
host: hostname,
port,
rejectUnauthorized: false,
servername: hostname,
timeout: TLS_TIMEOUT_MS
}, () => {
try {
const cert = socket.getPeerCertificate();
if (!cert || Object.keys(cert).length === 0) {
socket.destroy();
return reject(new Error(`No certificate returned for ${hostname}:${port}`));
}
const validFrom = new Date(cert.valid_from);
const validTo = new Date(cert.valid_to);
const now = new Date();
const msRemaining = validTo.getTime() - now.getTime();
const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));
const result = {
hostname,
port,
subject: cert.subject?.CN || cert.subject?.O || 'Unknown',
issuer: cert.issuer?.CN || cert.issuer?.O || 'Unknown',
validFrom: cert.valid_from,
validTo: cert.valid_to,
daysRemaining,
fingerprint: cert.fingerprint || null,
isExpiring: daysRemaining <= THRESHOLDS.WARNING,
checkedAt: new Date().toISOString()
};
socket.destroy();
resolve(result);
} catch (err) {
socket.destroy();
reject(err);
}
});
socket.on('error', (err) => {
reject(new Error(`TLS connect error for ${hostname}:${port}: ${err.message}`));
});
socket.setTimeout(TLS_TIMEOUT_MS, () => {
socket.destroy(new Error(`TLS connection timeout for ${hostname}:${port}`));
reject(new Error(`TLS connection timeout for ${hostname}:${port}`));
});
});
}
/**
* Check SSL certificates for all services that have HTTPS URLs.
* Reads services from ctx.servicesStateManager, resolves URLs, and checks each HTTPS cert.
*
* @returns {Promise<Object>} Map of hostname cert status
*/
async checkAll() {
if (!this.config.enabled) {
this.log.info('ssl-monitor', 'SSL monitoring is disabled, skipping check');
return this.getStatus();
}
let servicesData;
try {
servicesData = await this.ctx.servicesStateManager.read();
} catch (err) {
this.log.error('ssl-monitor', 'Failed to read services', { error: err.message });
return this.getStatus();
}
const services = Array.isArray(servicesData) ? servicesData : (servicesData.services || []);
for (const service of services) {
const serviceId = service.id || service.name?.toLowerCase();
if (!serviceId) continue;
try {
const url = resolveServiceUrl(serviceId, service, this.ctx.siteConfig, this.ctx.buildServiceUrl);
if (!url) continue;
const parsed = new URL(url);
if (parsed.protocol !== 'https:') continue;
const hostname = parsed.hostname;
const port = parseInt(parsed.port) || 443;
// Map hostname back to service ID
this.hostnameToServiceId.set(hostname, serviceId);
const result = await this.checkCert(hostname, port);
// Store result
this.certStatus.set(hostname, result);
// Emit check event
this.emit('cert-check', { serviceId, hostname, result });
// Check alert thresholds
await this._checkAndNotify(hostname, result, serviceId);
} catch (err) {
this.log.warn('ssl-monitor', `Failed to check cert for service ${serviceId}`, {
error: err.message
});
}
}
// Persist results
await this._saveCache();
return this.getStatus();
}
/**
* Start periodic SSL certificate checking.
*
* @param {number} [intervalMs=3600000] - Check interval in milliseconds
*/
start(intervalMs) {
if (intervalMs !== undefined) {
this.config.intervalMs = intervalMs;
}
if (this.intervalHandle) {
this.log.warn('ssl-monitor', 'SSL monitor is already running');
return;
}
this.config.enabled = true;
// Load cached data
this._loadCache().catch(err => {
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
});
// Initial check (non-blocking)
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message });
});
// Schedule periodic checks
this.intervalHandle = setInterval(() => {
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
});
}, this.config.intervalMs);
this.log.info('ssl-monitor', 'SSL monitoring started', {
intervalMs: this.config.intervalMs
});
}
/**
* Stop periodic SSL certificate checking.
*/
stop() {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
}
this.config.enabled = false;
this.log.info('ssl-monitor', 'SSL monitoring stopped');
}
/**
* Get the current SSL certificate status for all checked hostnames.
*
* @returns {Object} Map of hostname cert status
*/
getStatus() {
const status = {};
for (const [hostname, cert] of this.certStatus.entries()) {
status[hostname] = { ...cert };
}
return status;
}
/**
* Get the SSL certificate status for a specific service.
*
* @param {string} serviceId - The service ID to look up
* @returns {Object|null} Certificate status or null if not found
*/
getServiceCertStatus(serviceId) {
// Find hostname mapped to this service
for (const [hostname, id] of this.hostnameToServiceId.entries()) {
if (id === serviceId) {
const cert = this.certStatus.get(hostname);
return cert ? { ...cert, serviceId } : null;
}
}
return null;
}
/**
* Get current monitoring configuration.
*
* @returns {Object} Config with interval and enabled state
*/
getConfig() {
return { ...this.config };
}
/**
* Update monitoring configuration.
*
* @param {Object} updates - Config updates
* @param {boolean} [updates.enabled] - Enable/disable monitoring
* @param {number} [updates.intervalMs] - Check interval in milliseconds
*/
updateConfig(updates) {
if (typeof updates.enabled === 'boolean') {
this.config.enabled = updates.enabled;
if (!updates.enabled && this.intervalHandle) {
this.stop();
}
}
if (typeof updates.intervalMs === 'number' && updates.intervalMs >= 60000) {
this.config.intervalMs = updates.intervalMs;
// Restart interval if running
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = setInterval(() => {
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
});
}, this.config.intervalMs);
}
}
}
// ===== Private Methods =====
/**
* Check alert thresholds and send notifications if thresholds are crossed.
* Only sends one notification per threshold per hostname.
*
* @param {string} hostname
* @param {Object} certResult
* @param {string} serviceId
*/
async _checkAndNotify(hostname, certResult, serviceId) {
const { daysRemaining } = certResult;
const key = hostname;
const lastNotified = this.notifiedThresholds.get(key) || Infinity;
let level = null;
let eventType = null;
let message = null;
if (daysRemaining <= THRESHOLDS.CRITICAL) {
level = THRESHOLDS.CRITICAL;
eventType = 'cert-critical';
message = `🔒 CRITICAL: SSL certificate for ${hostname} expires in ${daysRemaining} days!`;
} else if (daysRemaining <= THRESHOLDS.URGENT) {
level = THRESHOLDS.URGENT;
eventType = 'cert-expiring';
message = `⚠️ URGENT: SSL certificate for ${hostname} expires in ${daysRemaining} days`;
} else if (daysRemaining <= THRESHOLDS.WARNING) {
level = THRESHOLDS.WARNING;
eventType = 'cert-expiring';
message = `⚠️ SSL certificate for ${hostname} expires in ${daysRemaining} days`;
}
if (level !== null && level < lastNotified) {
// New threshold crossed — send notification
this.notifiedThresholds.set(key, level);
this.emit(eventType, { hostname, serviceId, daysRemaining, level });
if (this.ctx.notification) {
try {
await this.ctx.notification.send('ssl-cert-expiry', {
text: message,
hostname,
serviceId,
daysRemaining,
level,
validTo: certResult.validTo
}, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning');
} catch (err) {
this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message });
}
}
} else if (level === null) {
// Cert is healthy — reset notification tracking
this.notifiedThresholds.delete(key);
}
}
/**
* Persist cert status cache to disk.
*/
async _saveCache() {
try {
const data = {
lastChecked: new Date().toISOString(),
certs: {},
hostnameToServiceId: Object.fromEntries(this.hostnameToServiceId)
};
for (const [hostname, cert] of this.certStatus.entries()) {
data.certs[hostname] = cert;
}
await writeJsonFile(this.cacheFile, data);
} catch (err) {
this.log.warn('ssl-monitor', 'Failed to save SSL cache', { error: err.message });
}
}
/**
* Load cert status cache from disk.
*/
async _loadCache() {
try {
const data = await readJsonFile(this.cacheFile, null);
if (data && data.certs) {
for (const [hostname, cert] of Object.entries(data.certs)) {
this.certStatus.set(hostname, cert);
}
if (data.hostnameToServiceId) {
for (const [hostname, serviceId] of Object.entries(data.hostnameToServiceId)) {
this.hostnameToServiceId.set(hostname, serviceId);
}
}
this.log.info('ssl-monitor', 'Loaded SSL cache', {
certCount: this.certStatus.size
});
}
} catch (err) {
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
}
}
}
module.exports = SSLMonitor;
@@ -155,16 +155,47 @@
return b.toFixed(1) + ' ' + units[i]; return b.toFixed(1) + ' ' + units[i];
} }
function setServicesCard() { // ----- Robust services count -----
const total = (window.APPS || []).length; // Read from multiple sources so we always have a number:
let up = 0; // 1. window.APPS (populated by grid.js after loadServices)
document.querySelectorAll('#cards .card').forEach(c => { // 2. #cards .card elements (post-buildGrid)
if (c.dataset.status === 'on') up++; // 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
}); async function fetchServicesCount() {
// Source 1+2: window.APPS / DOM cards
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
return { total: window.APPS.length, up, source: 'APPS' };
}
const cards = document.querySelectorAll('#cards .card');
if (cards.length > 0) {
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
return { total: cards.length, up, source: 'DOM' };
}
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
try {
const r = await fetch('/api/v1/services', { cache: 'no-store' });
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
const body = await r.json();
const list = (body && Array.isArray(body.services)) ? body.services
: (Array.isArray(body)) ? body
: [];
// Persist for the grid so this fallback only fires once
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
return { total: list.length, up, source: 'fetch' };
} catch (_) {
return { total: 0, up: 0, source: 'fetch-error' };
}
}
async function setServicesCard() {
const { total, up } = await fetchServicesCount();
const el = document.getElementById('dc-monitor-services'); const el = document.getElementById('dc-monitor-services');
const sub = document.getElementById('dc-monitor-services-sub'); const sub = document.getElementById('dc-monitor-services-sub');
if (el) el.textContent = `${up} / ${total}`; if (el) el.textContent = `${up} / ${total}`;
if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`; if (sub) sub.textContent = total === 0
? 'no services yet'
: `${up} online · ${total - up} offline`;
} }
function applyHealthSummary(data) { function applyHealthSummary(data) {
+56
View File
@@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
readonly DASHBOARD_DIR="${SITES_DIR}/status" readonly DASHBOARD_DIR="${SITES_DIR}/status"
readonly CONTAINER_NAME="dashcaddy-api" readonly CONTAINER_NAME="dashcaddy-api"
readonly CADDY_ADMIN_PORT=2019 readonly CADDY_ADMIN_PORT=2019
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
readonly DEFAULT_MAX_STORAGE_BYTES=""
# ---- Tunables (overridable via flags) -------------------------------------- # ---- Tunables (overridable via flags) --------------------------------------
API_PORT=3001 API_PORT=3001
LOCAL_PORT=8080 LOCAL_PORT=8080
BACKUP_DIR=""
BACKUP_LIMIT=""
# ---- Runtime state --------------------------------------------------------- # ---- Runtime state ---------------------------------------------------------
DOMAIN_MODE="" # public | custom-tld | local DOMAIN_MODE="" # public | custom-tld | local
@@ -389,6 +393,7 @@ EOF
create_directories() { create_directories() {
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets" mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
mkdir -p "${BACKUP_DIR}"
ok "Directories created" ok "Directories created"
} }
@@ -626,7 +631,41 @@ CEOF
# Docker Compose # Docker Compose
# ============================================================================ # ============================================================================
# Parse size string like "10GB" or "1TB" to bytes
parse_size_to_bytes() {
local size="$1"
local value unit
# Strip whitespace
size=$(echo "$size" | tr -d ' ')
# Extract numeric value and unit
if [[ $size =~ ^([0-9.]+)([kmgtKMGT][bb]?|[bB]?)$ ]]; then
value="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
# Normalize unit to uppercase without 'B' suffix for simplicity
unit=$(echo "$unit" | tr '[:lower:]' '[:upper:]')
case "$unit" in
K|KB) echo $((value * 1024)) ;;
M|MB) echo $((value * 1024 * 1024)) ;;
G|GB) echo $((value * 1024 * 1024 * 1024)) ;;
T|TB) echo $((value * 1024 * 1024 * 1024 * 1024)) ;;
*) echo "$value" ;;
esac
else
# Not recognized, treat as raw bytes
echo "$size"
fi
}
generate_docker_compose() { generate_docker_compose() {
# Convert BACKUP_LIMIT to bytes if set (e.g., "10GB" -> 10737418240)
local backup_limit_bytes=""
if [[ -n "$BACKUP_LIMIT" ]]; then
backup_limit_bytes=$(parse_size_to_bytes "$BACKUP_LIMIT")
fi
cat > "${API_DIR}/docker-compose.yml" <<DCEOF cat > "${API_DIR}/docker-compose.yml" <<DCEOF
services: services:
dashcaddy-api: dashcaddy-api:
@@ -648,6 +687,7 @@ services:
- ${DASHBOARD_DIR}:/app/dashboard:rw - ${DASHBOARD_DIR}:/app/dashboard:rw
- /opt/dashcaddy/updates:/app/updates:rw - /opt/dashcaddy/updates:/app/updates:rw
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- dashcaddy-backups:/app/backups
environment: environment:
- CADDYFILE_PATH=/caddyfile - CADDYFILE_PATH=/caddyfile
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT} - CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
@@ -665,6 +705,10 @@ services:
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates - DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
- DASHCADDY_API_SOURCE_DIR=${API_DIR} - DASHCADDY_API_SOURCE_DIR=${API_DIR}
- DASHCADDY_FRONTEND_DIR=/app/dashboard - DASHCADDY_FRONTEND_DIR=/app/dashboard
- BACKUP_DIR=/app/backups
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
- BACKUP_CONFIG_FILE=/app/backup-config.json
- BACKUP_HISTORY_FILE=/app/backup-history.json
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
@@ -673,6 +717,14 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes:
dashcaddy-backups:
driver: local
driver_opts:
type: none
o: bind
device: ${BACKUP_DIR}
DCEOF DCEOF
ok "docker-compose.yml generated" ok "docker-compose.yml generated"
@@ -880,6 +932,8 @@ parse_args() {
--skip-caddy) SKIP_CADDY=true; shift ;; --skip-caddy) SKIP_CADDY=true; shift ;;
--uninstall) UNINSTALL=true; shift ;; --uninstall) UNINSTALL=true; shift ;;
--keep-config) KEEP_CONFIG=true; shift ;; --keep-config) KEEP_CONFIG=true; shift ;;
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
--yes|-y) AUTO_YES=true; shift ;; --yes|-y) AUTO_YES=true; shift ;;
--help|-h) print_help; exit 0 ;; --help|-h) print_help; exit 0 ;;
*) warn "Unknown option: $1 (ignored)"; shift ;; *) warn "Unknown option: $1 (ignored)"; shift ;;
@@ -914,6 +968,8 @@ print_help() {
--source PATH Use local source files --source PATH Use local source files
--skip-docker Already have Docker --skip-docker Already have Docker
--skip-caddy Already have Caddy --skip-caddy Already have Caddy
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
--uninstall Remove DashCaddy --uninstall Remove DashCaddy
--keep-config Keep configs during uninstall --keep-config Keep configs during uninstall
--yes Skip confirmations --yes Skip confirmations
+20 -3
View File
@@ -226,17 +226,34 @@ class ConfigManager {
* @returns {Promise<Object>} Disk space info * @returns {Promise<Object>} Disk space info
*/ */
async getDiskSpace(testPath) { async getDiskSpace(testPath) {
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
try { try {
const stats = await fs.stat(testPath); const fsPromises = require('fs').promises;
const pathModule = require('path');
// Ensure directory exists
await fsPromises.mkdir(testPath, { recursive: true });
// Use statfs for true disk space (works on all filesystems: ext4, Btrfs, XFS, ZFS, APFS, NTFS)
const stats = await fsPromises.statfs(testPath);
const totalBytes = stats.blocks * stats.bsize;
const freeBytes = stats.bfree * stats.bsize;
const availableBytes = stats.bavail * stats.bsize; // Available to non-root users
const usedBytes = totalBytes - freeBytes;
return { return {
available: true, available: true,
path: testPath path: testPath,
total: totalBytes,
used: usedBytes,
free: freeBytes,
availableBytes: availableBytes,
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
}; };
} catch (error) { } catch (error) {
return { return {
available: false, available: false,
path: testPath,
error: error.message error: error.message
}; };
} }
@@ -62,6 +62,11 @@ const state = {
installPath: '', installPath: '',
health: null health: null
}, },
// Backup configuration
backup: {
maxStorageGB: 10,
backupDir: ''
},
// Uninstall mode // Uninstall mode
uninstallMode: false, uninstallMode: false,
uninstall: { uninstall: {
@@ -373,6 +378,24 @@ function updateBranding(field, value) {
if (field === 'primaryColor') render(); if (field === 'primaryColor') render();
} }
// Backup functions
function updateBackup(field, value) {
state.backup[field] = value;
render();
}
async function selectBackupDir() {
try {
const result = await window.electronAPI.selectFolder();
if (result.success && result.path) {
state.backup.backupDir = result.path;
render();
}
} catch (err) {
console.error('Backup dir selection failed:', err);
}
}
async function selectLogo() { async function selectLogo() {
try { try {
const result = await window.electronAPI.selectFile({ const result = await window.electronAPI.selectFile({
@@ -420,6 +443,10 @@ async function startInstallation() {
password: state.dns.password, password: state.dns.password,
token: state.dns.token token: state.dns.token
} : null, } : null,
backup: {
maxStorageGB: state.backup.maxStorageGB,
backupDir: state.backup.backupDir || null
},
autoStart: true autoStart: true
}); });
} catch (err) { } catch (err) {
@@ -963,6 +990,31 @@ function renderDashboardSetup() {
<p class="hint">Port for the DashCaddy API server (default: 3001)</p> <p class="hint">Port for the DashCaddy API server (default: 3001)</p>
</div> </div>
` : ''} ` : ''}
<div class="folder-input">
<label>Backup Storage Limit (GB)</label>
<div class="input-row">
<input type="number"
value="${state.backup.maxStorageGB}"
min="1" max="10240"
oninput="updateBackup('maxStorageGB', parseInt(this.value) || 10)">
</div>
<p class="hint">Maximum storage for backups in GB (default: 10, max: 10TB)</p>
</div>
${state.tier !== 'basic' ? `
<div class="folder-input">
<label>Backup Directory</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.backup.backupDir)}"
readonly
placeholder="Default: $INSTALL_DIR/backups">
<button class="btn-browse" onclick="selectBackupDir()">Browse...</button>
</div>
<p class="hint">Where backup files are stored on the host</p>
</div>
` : ''}
</div> </div>
</div> </div>
`; `;
@@ -7,15 +7,28 @@ services:
volumes: volumes:
- {{API_PATH}}:/app - {{API_PATH}}:/app
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- dashcaddy-backups:/app/backups
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT={{API_PORT}} - PORT={{API_PORT}}
- SERVICES_FILE=/app/services.json - SERVICES_FILE=/app/services.json
- CADDY_ADMIN_URL=http://host.docker.internal:2019 - CADDY_ADMIN_URL=http://host.docker.internal:2019
- BACKUP_DIR=/app/backups
- BACKUP_MAX_STORAGE_BYTES={{BACKUP_MAX_STORAGE_BYTES}}
- BACKUP_CONFIG_FILE=/app/backup-config.json
- BACKUP_HISTORY_FILE=/app/backup-history.json
restart: unless-stopped restart: unless-stopped
networks: networks:
- dashcaddy - dashcaddy
volumes:
dashcaddy-backups:
driver: local
driver_opts:
type: none
o: bind
device: {{BACKUP_DIR}}
networks: networks:
dashcaddy: dashcaddy:
driver: bridge driver: bridge
-272
View File
@@ -1,272 +0,0 @@
const express = require('express');
const http = require('http');
/**
* OpenClaw management routes
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
*
* GET /openclaw/status container info + gateway health
* POST /openclaw/deploy deploy OpenClaw container
* GET /openclaw/proxy/* proxy GET to gateway
* POST /openclaw/proxy/* proxy POST to gateway
* DELETE /openclaw remove container
*/
module.exports = function openClawRoutes(ctx) {
const router = express.Router();
const docker = ctx.docker;
const asyncHandler = ctx.asyncHandler;
const log = ctx.log || console;
// ── helpers ──────────────────────────────────────────────────────────────
async function findOpenClawContainer() {
const containers = await docker.client.listContainers({ all: true });
return containers.find(function(c) {
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
c.Names.some(function(n) { return n.includes('openclaw'); }));
}) || null;
}
async function getGatewayToken(containerId) {
try {
const info = await docker.client.containerInfo(containerId);
const entry = (info.Config.Env || []).find(function(e) {
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
});
return entry ? entry.split('=')[1] : null;
} catch(err) {
return null;
}
}
async function getContainerPort(containerId) {
try {
const containers = await docker.client.listContainers({ all: true });
const c = containers.find(function(x) {
return x.Id === containerId || x.Id.startsWith(containerId);
});
if (c && c.Ports) {
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
if (p && p.PublicPort) return String(p.PublicPort);
}
return '18792';
} catch(err) {
return '18792';
}
}
async function gatewayHealth(baseUrl, token) {
return new Promise(function(resolve) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
let data = '';
res.on('data', function(d) { data += d; });
res.on('end', function() {
try { resolve({ ok: true, data: JSON.parse(data) }); }
catch(e) { resolve({ ok: true, data: data }); }
});
});
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
});
}
function proxyRequest(req, res, targetBase, path, token) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
headers['X-Forwarded-For'] = req.ip;
headers['X-Forwarded-Proto'] = req.protocol;
const url = targetBase + '/' + path;
const method = req.method;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const body = JSON.stringify(req.body);
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(body);
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
proxyReq.write(body);
proxyReq.end();
} else {
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
}
}
// ── GET /openclaw/status ────────────────────────────────────────────────
router.get('/status', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) {
return res.json({ success: true, deployed: false });
}
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const health = await gatewayHealth(baseUrl, token);
res.json({
success: true,
deployed: true,
container: {
id: container.Id.slice(0, 12),
name: container.Name,
state: container.State,
status: container.Status,
created: container.Created,
image: container.Image
},
gateway: {
url: baseUrl,
port: port,
healthy: health.ok,
healthData: health.data || null,
tokenSet: !!token
}
});
}));
// ── POST /openclaw/deploy ───────────────────────────────────────────────
router.post('/deploy', asyncHandler(async function(req, res) {
const existing = await findOpenClawContainer();
if (existing) {
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
}
const image = 'ghcr.io/nousresearch/openclaw:latest';
const name = 'openclaw-' + Date.now();
const gatewayToken = generateToken();
// Pull image
log.info('Pulling ' + image + '...');
try {
await new Promise(function(resolve, reject) {
docker.client.pull(image, function(err, stream) {
if (err) return reject(err);
docker.client.modem.followProgress(stream, function(err2) {
if (err2) return reject(err2);
resolve();
});
});
});
} catch(e) {
log.error('OpenClaw pull failed: ' + e.message);
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
}
// Create + start container
try {
const container = await docker.client.createContainer({
name: name,
Image: image,
Env: [
'OPENCLAW_GATEWAY_MODE=local',
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
],
HostConfig: {
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
RestartPolicy: { Name: 'unless-stopped' },
Labels: {
'dashcaddy.managed': 'true',
'dashcaddy.app': 'openclaw'
}
},
ExposedPorts: { '18792/tcp': {} }
});
await container.start();
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
res.json({
success: true,
deployed: true,
container: { id: container.id.slice(0, 12), name: name },
gateway: {
url: 'http://localhost:18792',
token: gatewayToken
}
});
} catch(e) {
log.error('OpenClaw deploy failed: ' + e.message);
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
}
}));
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
router.get('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
router.post('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── DELETE /openclaw ───────────────────────────────────────────────────
router.delete('/', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
try {
const c = docker.client.container(container.Id);
await c.stop().catch(function() {});
await c.remove({ force: true });
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
res.json({ success: true, message: 'OpenClaw removed' });
} catch(e) {
log.error('Failed to remove OpenClaw: ' + e.message);
res.status(500).json({ success: false, error: e.message });
}
}));
return router;
};
// ── token generator ──────────────────────────────────────────────────────────
function generateToken() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

Some files were not shown because too many files have changed in this diff Show More