diff --git a/BACKLOG.md b/BACKLOG.md index bda2458..becc605 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -47,11 +47,15 @@ - **status:** in-progress - **owner:** krystie - **details:** 40+ JS files at `dashcaddy-api/` root level (auth-manager.js, credential-manager.js, etc.). Move into organized subdirs under `src/` (e.g., `src/managers/`, `src/security/`, `src/docker/`). Update all require() paths. This is a big refactor — run tests after. +- **latent bug (discovered during DC-006, NOT yet fixed):** The DC-005 refactor's path-rewrite script left depth-2 route files (`routes/auth/*.js`, `routes/recipes/*.js`, `routes/apps/*.js`, `routes/arr/*.js`, `routes/config/*.js`) with `'../../../src/...'` — **3 levels up instead of 2**, which goes above `dashcaddy-api/` entirely. Required path should be `'../../src/...'` for depth-2 routes. Tests didn't catch this because no test previously imported any depth-2 route (only depth-1 routes like `routes/services.js` were tested). Confirmed-broken imports (with file → offending line): `routes/auth/totp.js:2` (FIXED in DC-006 commit), `routes/auth/keys.js:2`, `routes/auth/sso-gate.js:2-3`, `routes/auth/session-handlers.js:2-3`, `routes/recipes/manage.js:2-3`, `routes/recipes/deploy.js:2-3`, `routes/recipes/index.js:2-3`, `routes/config/assets.js:2-4`, `routes/config/settings.js:2-4`, `routes/config/backup.js:2-4`, `routes/apps/restore.js:2`, `routes/apps/compose.js:2-3`, `routes/apps/deploy.js:2-5`, `routes/apps/helpers.js:2-3`, `routes/apps/templates.js:2-3`, `routes/apps/removal.js:2-3`, `routes/arr/detect.js:2`, `routes/arr/smart-connect.js:2`, `routes/arr/credentials.js:2-3`, `routes/arr/helpers.js:2`, `routes/arr/config.js:2-5`, `routes/arr/plex.js:2`. The fix is mechanical (3 → 2 levels) but touches ~22 files — should be its own PR/commit for clean review. +- **branch state:** Work is complete on `krystie-improvements` (HEAD `7bc2a20`) with 879/879 tests passing on the branch. **NOT YET ON MAIN** — `origin/main` has since moved past the refactor with ~28 newer commits (DC-008/009/010/011, TOTP 4-part recovery, monitoring widget, unified logger, response-shape standardization). `git diff origin/main..HEAD` is 187 files / 10823 insertions / 3187 deletions — large enough to need careful coordination, not silent fast-forward. **MERGED into main via this commit** — 25 conflicts resolved (route files I touched, status dist files, infra docs). See merge commit for full resolution list. ### DC-006: Add integration test for TOTP auth flow -- **status:** in-progress +- **status:** done - **owner:** krystie - **details:** End-to-end test: no token → 401, wrong token → 403, valid TOTP → session token → authenticated request succeeds. Cover the full `/api/auth/check` → session → endpoint flow. +- **result:** Added `dashcaddy-api/__tests__/routes/auth.totp.routes.test.js` — 25 tests, all passing. Covers: GET `/api/totp/config`, POST `/api/totp/setup` (generate + normalize + reject invalid Base32), POST `/api/totp/verify-setup` (missing/bad/no-pending/valid-code paths), POST `/api/totp/verify` (login — 400/400/401/200), GET `/api/totp/check-session` (passthrough when disabled + 401 no-session + 200 valid-session — the BACKLOG "no token → 401 / authenticated request succeeds" pair), POST `/api/totp/disable` (400/401/200), POST `/api/totp/config` (valid/invalid/never-disables), plus the full end-to-end flow setup→login→check-session→disable and an otplib-not-stubbed sanity check. Uses real `otplib` for code generation (real TOTP math), mocks `credentialManager`/`session`/`totpConfig`/`saveTotpConfig` only. Full suite: 904/904 pass (879 baseline + 25 new). ESLint clean for the new file. +- **side-effect (DC-005 latent bug fix):** While writing the test I discovered `routes/auth/totp.js` had broken require paths from the DC-005 refactor (`'../../../src/utilities/errors'` was 3 levels up from `routes/auth/` — wrong by 1). The test couldn't even load the route without this fix. Fixed in this commit (`'../../src/utilities/errors'` and `'../../src/utils/responses'`). **Same depth bug exists in other depth-2 route files — see DC-005 note above.** ### DC-007: Add tests for untested modules - **status:** done diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f9bb0..5b248a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stale root-level files: `*.bak`, `server-old.js`, and ad-hoc reports (`DEPLOYMENT-SUCCESS.md`, `FINAL-DEPLOYMENT-REPORT.md`, `DESLOPIFICATION-ROADMAP.md`, etc.) — disk-only cleanup, already gitignored. - Dead `routes/` directory at API root (replaced by `src/routes/`). +### Security (TOTP integration) +- TOTP integration tests now cover the full `/api/auth/check` → session → endpoint flow (DC-006). 25 new tests including: `setup` (generate + normalize + reject invalid Base32), `verify-setup` (missing/bad/no-pending/valid-code paths), `verify` login (400/400/401/200), `check-session` (passthrough when disabled + 401 no-session + 200 valid-session), `disable`, `config` (valid/invalid/never-disables), and full end-to-end setup→login→check-session→disable. + +### Fixed (from merge) +- **routes/updates.js** — krystie's branch had `if (!ok)` referencing the helper function instead of the `secretOk` boolean. Would have 500'd every `/system/update-notify` request. Caught during merge, kept my version with the correct boolean check. +- **routes/notifications.js** — two places where she replaced `res.json({success: result.success, ...})` with `ok(...)` would have forced `success: true` for partial-failure delivery. Kept my version with explicit `res.json` to preserve the semantic. + +## [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 ### Changed (BREAKING) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..80138e7 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.13.4 diff --git a/dashcaddy-api/Dockerfile b/dashcaddy-api/Dockerfile index 6d33bc0..e0cf165 100644 --- a/dashcaddy-api/Dockerfile +++ b/dashcaddy-api/Dockerfile @@ -11,6 +11,7 @@ RUN npm install --production COPY *.js ./ COPY src/ ./src/ COPY routes/ ./routes/ +COPY dns-providers/ ./dns-providers/ COPY openapi.yaml ./ # VERSION file holds the short git SHA the image was built from. Committed as diff --git a/dashcaddy-api/VERSION b/dashcaddy-api/VERSION index e3492e2..80138e7 100644 --- a/dashcaddy-api/VERSION +++ b/dashcaddy-api/VERSION @@ -1 +1 @@ -a372d62 +1.13.4 diff --git a/dashcaddy-api/__tests__/app-templates.test.js b/dashcaddy-api/__tests__/app-templates.test.js index 1f83a91..10e329b 100644 --- a/dashcaddy-api/__tests__/app-templates.test.js +++ b/dashcaddy-api/__tests__/app-templates.test.js @@ -1,4 +1,4 @@ -const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); +const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../src/docker/app-templates'); describe('App Templates', () => { const templates = Object.values(APP_TEMPLATES); diff --git a/dashcaddy-api/__tests__/auth-manager.test.js b/dashcaddy-api/__tests__/auth-manager.test.js index 577fe39..37a4fad 100644 --- a/dashcaddy-api/__tests__/auth-manager.test.js +++ b/dashcaddy-api/__tests__/auth-manager.test.js @@ -1,11 +1,11 @@ // Must mock crypto-utils BEFORE auth-manager is required, // because auth-manager.js line 13: const JWT_SECRET = cryptoUtils.loadOrCreateKey() const mockFixedKey = Buffer.alloc(32, 'jwt-test-key-pad'); -jest.mock('../crypto-utils', () => ({ +jest.mock('../src/security/crypto-utils', () => ({ loadOrCreateKey: jest.fn(() => mockFixedKey), })); -jest.mock('../credential-manager', () => ({ +jest.mock('../src/managers/credential-manager', () => ({ store: jest.fn().mockResolvedValue(true), retrieve: jest.fn().mockResolvedValue(null), delete: jest.fn().mockResolvedValue(true), @@ -13,8 +13,8 @@ jest.mock('../credential-manager', () => ({ })); const crypto = require('crypto'); -const authManager = require('../auth-manager'); -const credentialManager = require('../credential-manager'); +const authManager = require('../src/managers/auth-manager'); +const credentialManager = require('../src/managers/credential-manager'); describe('AuthManager', () => { beforeEach(() => { diff --git a/dashcaddy-api/__tests__/auto-restart-manager.test.js b/dashcaddy-api/__tests__/auto-restart-manager.test.js new file mode 100644 index 0000000..a1624d2 --- /dev/null +++ b/dashcaddy-api/__tests__/auto-restart-manager.test.js @@ -0,0 +1,367 @@ +/** + * Smoke tests for auto-restart-manager.js + * Verifies the AutoRestartManager class: + * - Policy CRUD (set/get/list/remove) + * - handleContainerDown: cooldown, max-retries, restart attempt, failure + * - handleContainerUp: retry counter reset + * - _handleStatusCheck: healthy→unhealthy and unhealthy→healthy transitions + * - _resolveContainerId: lookup precedence + */ + +const EventEmitter = require('events'); +const { AutoRestartManager, DEFAULT_POLICY } = require('../src/managers/auto-restart-manager'); + +jest.mock('../src/utilities/fs-helpers', () => ({ + readJsonFile: jest.fn().mockResolvedValue({}), + writeJsonFile: jest.fn().mockResolvedValue(undefined), +})); + +const fsHelpers = require('../src/utilities/fs-helpers'); + +function makeManager(overrides = {}) { + const servicesStateManager = { + read: jest.fn().mockResolvedValue([]), + ...(overrides.servicesStateManager || {}), + }; + + const docker = { + client: { + getContainer: jest.fn(), + ...(overrides.dockerClient || {}), + }, + }; + + const healthChecker = new EventEmitter(); + if (overrides.healthChecker) { + Object.assign(healthChecker, overrides.healthChecker); + } + + const notification = { + send: jest.fn().mockResolvedValue({ success: true }), + ...(overrides.notification || {}), + }; + + const ctx = { + docker, + healthChecker, + notification, + servicesStateManager, + SERVICES_FILE: '/tmp/dc-test/services.json', + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + logError: jest.fn(), + }; + + const manager = new AutoRestartManager(ctx); + return { manager, ctx, docker, healthChecker, notification, servicesStateManager }; +} + +describe('AutoRestartManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + fsHelpers.readJsonFile.mockResolvedValue({}); + fsHelpers.writeJsonFile.mockResolvedValue(undefined); + }); + + describe('constants & construction', () => { + test('DEFAULT_POLICY has the documented fields and sensible defaults', () => { + expect(DEFAULT_POLICY).toEqual({ + enabled: true, + maxRetries: 3, + retryIntervalMs: 5000, + windowMinutes: 10, + currentRetries: 0, + lastRestartAt: null, + cooldownUntil: null, + }); + }); + + test('manager extends EventEmitter and stores ctx deps', () => { + const { manager, ctx } = makeManager(); + expect(manager).toBeInstanceOf(EventEmitter); + expect(manager.docker).toBe(ctx.docker); + expect(manager.healthChecker).toBe(ctx.healthChecker); + expect(manager.notification).toBe(ctx.notification); + expect(manager.policies).toBeInstanceOf(Map); + }); + }); + + describe('lifecycle', () => { + test('start() loads persisted policies from fs-helpers', async () => { + fsHelpers.readJsonFile.mockResolvedValue({ + 'svc-1': { enabled: false, maxRetries: 7 }, + }); + const { manager } = makeManager(); + await manager.start(); + expect(manager.policies.has('svc-1')).toBe(true); + const policy = manager.getPolicy('svc-1'); + expect(policy.maxRetries).toBe(7); + expect(policy.enabled).toBe(false); + }); + + test('start() is idempotent (second call does nothing new)', async () => { + const { manager, healthChecker } = makeManager(); + await manager.start(); + const listenerCount = healthChecker.listenerCount('status-check'); + await manager.start(); + expect(healthChecker.listenerCount('status-check')).toBe(listenerCount); + }); + + test('stop() removes the status-check listener', async () => { + const { manager, healthChecker } = makeManager(); + await manager.start(); + expect(healthChecker.listenerCount('status-check')).toBe(1); + manager.stop(); + expect(healthChecker.listenerCount('status-check')).toBe(0); + }); + }); + + describe('policy CRUD', () => { + test('setPolicy throws on missing serviceId', async () => { + const { manager } = makeManager(); + await expect(manager.setPolicy('', { enabled: true })).rejects.toThrow(/serviceId/); + await expect(manager.setPolicy(null, {})).rejects.toThrow(/serviceId/); + }); + + test('setPolicy merges fields with existing policy', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { maxRetries: 5 }); + await manager.setPolicy('svc-1', { enabled: false }); + const policy = manager.getPolicy('svc-1'); + expect(policy.maxRetries).toBe(5); // preserved from earlier + expect(policy.enabled).toBe(false); // updated by second call + }); + + test('setPolicy persists via fs-helpers.writeJsonFile', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { maxRetries: 4 }); + expect(fsHelpers.writeJsonFile).toHaveBeenCalled(); + const [filePath, payload] = fsHelpers.writeJsonFile.mock.calls[0]; + expect(filePath).toMatch(/auto-restart-policies\.json$/); + expect(payload['svc-1'].maxRetries).toBe(4); + }); + + test('getPolicy returns a copy, not the internal reference', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { maxRetries: 2 }); + const a = manager.getPolicy('svc-1'); + a.maxRetries = 999; + const b = manager.getPolicy('svc-1'); + expect(b.maxRetries).toBe(2); + }); + + test('getPolicy returns null for unknown service', () => { + const { manager } = makeManager(); + expect(manager.getPolicy('does-not-exist')).toBeNull(); + }); + + test('listPolicies returns array of all policies', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { maxRetries: 1 }); + await manager.setPolicy('svc-2', { maxRetries: 2 }); + const list = manager.listPolicies(); + expect(Array.isArray(list)).toBe(true); + expect(list).toHaveLength(2); + const ids = list.map(p => p.serviceId); + expect(ids).toEqual(expect.arrayContaining(['svc-1', 'svc-2'])); + }); + + test('removePolicy returns true and deletes the policy', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { maxRetries: 1 }); + expect(await manager.removePolicy('svc-1')).toBe(true); + expect(manager.getPolicy('svc-1')).toBeNull(); + }); + + test('removePolicy returns false for unknown service', async () => { + const { manager } = makeManager(); + expect(await manager.removePolicy('does-not-exist')).toBe(false); + }); + }); + + describe('handleContainerDown', () => { + test('returns ignored/no-policy when no policy exists', async () => { + const { manager } = makeManager(); + const result = await manager.handleContainerDown('unknown', 'cid'); + expect(result.action).toBe('ignored'); + expect(result.reason).toBe('no-policy'); + }); + + test('returns ignored/disabled when policy.enabled is false', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { enabled: false }); + const result = await manager.handleContainerDown('svc-1', 'cid'); + expect(result.action).toBe('ignored'); + expect(result.reason).toBe('disabled'); + }); + + test('returns skipped/cooldown when cooldownUntil is in the future', async () => { + const { manager } = makeManager(); + // setPolicy() intentionally guards runtime fields; we have to set + // cooldownUntil via the internal map to simulate an in-progress cooldown + await manager.setPolicy('svc-1', { maxRetries: 3 }); + manager.policies.get('svc-1').cooldownUntil = Date.now() + 60_000; + const result = await manager.handleContainerDown('svc-1', 'cid'); + expect(result.action).toBe('skipped'); + expect(result.reason).toBe('cooldown'); + }); + + test('increments currentRetries and calls docker.start on a successful restart', async () => { + const { manager, docker } = makeManager(); + docker.client.getContainer.mockReturnValue({ + start: jest.fn().mockResolvedValue(undefined), + }); + await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 }); + + const onAttempt = jest.fn(); + const onSuccess = jest.fn(); + manager.on('auto-restart-attempt', onAttempt); + manager.on('auto-restart-success', onSuccess); + + const result = await manager.handleContainerDown('svc-1', 'cid-abc'); + expect(result.action).toBe('restarted'); + expect(result.attempt).toBe(1); + expect(result.serviceId).toBe('svc-1'); + expect(docker.client.getContainer).toHaveBeenCalledWith('cid-abc'); + expect(onAttempt).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(manager.getPolicy('svc-1').currentRetries).toBe(1); + }); + + test('emits auto-restart-failed and increments currentRetries when docker.start throws', async () => { + const { manager, docker } = makeManager(); + docker.client.getContainer.mockReturnValue({ + start: jest.fn().mockRejectedValue(new Error('docker daemon down')), + }); + await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 }); + + const onFailed = jest.fn(); + manager.on('auto-restart-failed', onFailed); + + const result = await manager.handleContainerDown('svc-1', 'cid-abc'); + expect(result.action).toBe('failed'); + expect(result.error).toMatch(/docker daemon down/); + expect(onFailed).toHaveBeenCalledTimes(1); + expect(manager.getPolicy('svc-1').currentRetries).toBe(1); + }); + + test('emits auto-restart-max-reached and sets cooldown when maxRetries exceeded', async () => { + const { manager, docker } = makeManager(); + docker.client.getContainer.mockReturnValue({ + start: jest.fn().mockResolvedValue(undefined), + }); + await manager.setPolicy('svc-1', { maxRetries: 2, retryIntervalMs: 0 }); + + const onMax = jest.fn(); + manager.on('auto-restart-max-reached', onMax); + + // First attempt: currentRetries=0 -> succeeds, increments to 1 + await manager.handleContainerDown('svc-1', 'cid'); + // Second: 1 -> succeeds, increments to 2 + await manager.handleContainerDown('svc-1', 'cid'); + // Third: 2 >= maxRetries(2) -> max-reached, currentRetries reset to 0 + const result = await manager.handleContainerDown('svc-1', 'cid'); + + expect(result.action).toBe('max-reached'); + expect(onMax).toHaveBeenCalledTimes(1); + const policy = manager.getPolicy('svc-1'); + expect(policy.currentRetries).toBe(0); + expect(policy.cooldownUntil).toBeGreaterThan(Date.now()); + }); + }); + + describe('handleContainerUp', () => { + test('resets currentRetries and cooldownUntil when service is tracked', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { currentRetries: 2, cooldownUntil: Date.now() + 10000 }); + // Mutate via internal map (bypassing the setter guard) + manager.policies.get('svc-1').currentRetries = 2; + manager.policies.get('svc-1').cooldownUntil = Date.now() + 10000; + + await manager.handleContainerUp('svc-1'); + const policy = manager.getPolicy('svc-1'); + expect(policy.currentRetries).toBe(0); + expect(policy.cooldownUntil).toBeNull(); + }); + + test('is a no-op when service is not tracked', async () => { + const { manager } = makeManager(); + await expect(manager.handleContainerUp('unknown')).resolves.toBeUndefined(); + }); + }); + + describe('_handleStatusCheck', () => { + test('triggers handleContainerDown on healthy→unhealthy transition', async () => { + const { manager, docker } = makeManager(); + docker.client.getContainer.mockReturnValue({ + start: jest.fn().mockResolvedValue(undefined), + }); + await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 }); + // Pre-set previous health + manager._previousHealth.set('svc-1', 'up'); + + const handleDownSpy = jest.spyOn(manager, 'handleContainerDown'); + await manager._handleStatusCheck({ + serviceId: 'svc-1', + status: 'down', + details: { containerId: 'cid-1' }, + }); + expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-1'); + }); + + test('triggers handleContainerUp on unhealthy→healthy transition', async () => { + const { manager } = makeManager(); + await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 }); + manager._previousHealth.set('svc-1', 'down'); + + const handleUpSpy = jest.spyOn(manager, 'handleContainerUp'); + await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'up' }); + expect(handleUpSpy).toHaveBeenCalledWith('svc-1'); + }); + + test('does nothing for services without a policy', async () => { + const { manager } = makeManager(); + const handleDownSpy = jest.spyOn(manager, 'handleContainerDown'); + const handleUpSpy = jest.spyOn(manager, 'handleContainerUp'); + await manager._handleStatusCheck({ serviceId: 'untracked', status: 'down' }); + expect(handleDownSpy).not.toHaveBeenCalled(); + expect(handleUpSpy).not.toHaveBeenCalled(); + }); + + test('ignores status with no serviceId', async () => { + const { manager } = makeManager(); + const handleDownSpy = jest.spyOn(manager, 'handleContainerDown'); + await manager._handleStatusCheck({ status: 'down' }); + expect(handleDownSpy).not.toHaveBeenCalled(); + }); + }); + + describe('_resolveContainerId', () => { + test('returns containerId from status.details when present', () => { + const { manager } = makeManager(); + const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } }); + expect(cid).toBe('cid-details'); + }); + + test('falls back to healthChecker.config.services[serviceId].containerId', () => { + const { manager, healthChecker } = makeManager(); + healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } }; + const cid = manager._resolveContainerId('svc-1', { details: {} }); + expect(cid).toBe('cid-hc'); + }); + + test('falls back to servicesStateManager.read when sync list is returned', () => { + const { manager, servicesStateManager } = makeManager(); + servicesStateManager.read.mockReturnValue([ + { id: 'svc-1', containerId: 'cid-state' }, + ]); + const cid = manager._resolveContainerId('svc-1', { details: {} }); + expect(cid).toBe('cid-state'); + }); + + test('returns null when no source has a containerId', () => { + const { manager } = makeManager(); + const cid = manager._resolveContainerId('svc-unknown', { details: {} }); + expect(cid).toBeNull(); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/backup-manager.test.js b/dashcaddy-api/__tests__/backup-manager.test.js index 3ebe08e..425ee5a 100644 --- a/dashcaddy-api/__tests__/backup-manager.test.js +++ b/dashcaddy-api/__tests__/backup-manager.test.js @@ -3,19 +3,19 @@ jest.mock('fs'); jest.mock('child_process'); -jest.mock('../credential-manager', () => ({ +jest.mock('../src/managers/credential-manager', () => ({ exportBackup: jest.fn().mockReturnValue({ encrypted: 'cred-data' }), importBackup: jest.fn() })); -jest.mock('../resource-monitor', () => ({ +jest.mock('../src/managers/resource-monitor', () => ({ exportStats: jest.fn().mockReturnValue({ stats: [{ cpu: 10 }] }), importStats: jest.fn() })); const fs = require('fs'); const crypto = require('crypto'); -const credentialManager = require('../credential-manager'); -const resourceMonitor = require('../resource-monitor'); +const credentialManager = require('../src/managers/credential-manager'); +const resourceMonitor = require('../src/managers/resource-monitor'); // Setup defaults BEFORE requiring singleton (constructor calls loadConfig/loadHistory) fs.existsSync.mockReturnValue(false); @@ -24,7 +24,7 @@ fs.writeFileSync.mockReturnValue(undefined); fs.mkdirSync.mockReturnValue(undefined); fs.unlinkSync.mockReturnValue(undefined); -const backupManager = require('../backup-manager'); +const backupManager = require('../src/utilities/backup-manager'); beforeEach(() => { jest.clearAllMocks(); diff --git a/dashcaddy-api/__tests__/config-drift-detector.test.js b/dashcaddy-api/__tests__/config-drift-detector.test.js new file mode 100644 index 0000000..2c8e118 --- /dev/null +++ b/dashcaddy-api/__tests__/config-drift-detector.test.js @@ -0,0 +1,335 @@ +/** + * Smoke tests for config-drift-detector.js + * Verifies the ConfigDriftDetector class detects drift across all categories, + * exposes polling control, extracts container ports, and dispatches + * drift notifications. + */ + +const EventEmitter = require('events'); +const { ConfigDriftDetector } = require('../src/managers/config-drift-detector'); + +function makeContainer(overrides = {}) { + return { + Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + Names: ['/dashcaddy-test'], + Image: 'nginx:latest', + State: 'running', + Status: 'Up 5 minutes', + Ports: [], + Labels: {}, + ...overrides, + }; +} + +function makeDetector(overrides = {}) { + const servicesStateManager = { + read: jest.fn().mockResolvedValue([]), + update: jest.fn().mockImplementation(async (updater) => { + const data = await servicesStateManager.read(); + const list = Array.isArray(data) ? data : (data?.services || []); + const next = updater(list); + return next; + }), + ...(overrides.servicesStateManager || {}), + }; + + const docker = { + client: { + listContainers: jest.fn().mockResolvedValue([]), + ...(overrides.dockerClient || {}), + }, + }; + + const notification = { + send: jest.fn().mockResolvedValue({ success: true }), + ...(overrides.notification || {}), + }; + + const ctx = { + docker, + servicesStateManager, + notification, + log: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + logError: jest.fn(), + }; + + const detector = new ConfigDriftDetector(ctx); + return { detector, ctx, docker, servicesStateManager, notification }; +} + +describe('ConfigDriftDetector', () => { + describe('constructor', () => { + test('extends EventEmitter and stores ctx dependencies', () => { + const { detector, ctx } = makeDetector(); + expect(detector).toBeInstanceOf(EventEmitter); + expect(detector.ctx).toBe(ctx); + expect(detector.docker).toBe(ctx.docker); + expect(detector.servicesStateManager).toBe(ctx.servicesStateManager); + expect(detector.notification).toBe(ctx.notification); + expect(detector.lastReport).toBeNull(); + expect(detector.isPolling()).toBe(false); + }); + }); + + describe('detect()', () => { + test('returns a clean report when services and containers are empty', async () => { + const { detector } = makeDetector(); + const report = await detector.detect(); + expect(report).toHaveProperty('checkedAt'); + expect(report.missingContainers).toEqual([]); + expect(report.unknownContainers).toEqual([]); + expect(report.portMismatch).toEqual([]); + expect(report.stateMismatch).toEqual([]); + expect(report.staleRecords).toEqual([]); + expect(report.hasDrift).toBe(false); + }); + + test('flags missing containers when service containerId is not in Docker', async () => { + const services = [{ + id: 'svc-1', + name: 'svc-1', + containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef0000000000000000', + }]; + const { detector, servicesStateManager, docker } = makeDetector(); + servicesStateManager.read.mockResolvedValue(services); + docker.client.listContainers.mockResolvedValue([]); + + const report = await detector.detect(); + expect(report.staleRecords).toHaveLength(1); + expect(report.staleRecords[0].serviceId).toBe('svc-1'); + expect(report.hasDrift).toBe(true); + }); + + test('flags port mismatches between service config and container', async () => { + const services = [{ + id: 'svc-1', + name: 'svc-1', + port: 8080, + containerId: 'abcdef012345', + }]; + const containers = [makeContainer({ + Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + Ports: [{ PublicPort: 9090, PrivatePort: 80, Type: 'tcp' }], + })]; + + const { detector, servicesStateManager, docker } = makeDetector(); + servicesStateManager.read.mockResolvedValue(services); + docker.client.listContainers.mockResolvedValue(containers); + + const report = await detector.detect(); + expect(report.portMismatch).toHaveLength(1); + expect(report.portMismatch[0].configuredPort).toBe(8080); + expect(report.portMismatch[0].actualPorts).toEqual([9090]); + }); + + test('flags state mismatch when service is not running', async () => { + const services = [{ + id: 'svc-1', + name: 'svc-1', + containerId: 'abcdef012345', + }]; + const containers = [makeContainer({ State: 'exited', Status: 'Exited (1) 5 minutes ago' })]; + + const { detector, servicesStateManager, docker } = makeDetector(); + servicesStateManager.read.mockResolvedValue(services); + docker.client.listContainers.mockResolvedValue(containers); + + const report = await detector.detect(); + expect(report.missingContainers).toHaveLength(1); + expect(report.stateMismatch).toHaveLength(1); + expect(report.stateMismatch[0].actualState).toBe('exited'); + }); + + test('flags unknown managed containers not in services.json', async () => { + const containers = [makeContainer({ + Labels: { 'sami.managed': 'true', 'sami.app': 'whoami' }, + })]; + + const { detector, docker, servicesStateManager } = makeDetector(); + docker.client.listContainers.mockResolvedValue(containers); + servicesStateManager.read.mockResolvedValue([]); + + const report = await detector.detect(); + expect(report.unknownContainers).toHaveLength(1); + expect(report.unknownContainers[0].name).toBe('dashcaddy-test'); + expect(report.unknownContainers[0].app).toBe('whoami'); + }); + + test('emits drift-detected and sends notification when drift exists', async () => { + const services = [{ + id: 'svc-1', + name: 'svc-1', + containerId: 'missingcontainer00', + }]; + const { detector, servicesStateManager, docker, notification } = makeDetector(); + servicesStateManager.read.mockResolvedValue(services); + docker.client.listContainers.mockResolvedValue([]); + + const onDrift = jest.fn(); + detector.on('drift-detected', onDrift); + await detector.detect(); + + expect(onDrift).toHaveBeenCalledTimes(1); + expect(notification.send).toHaveBeenCalledTimes(1); + expect(notification.send.mock.calls[0][0]).toBe('drift-detected'); + const payload = notification.send.mock.calls[0][1]; + expect(payload.text).toMatch(/drift/i); + expect(payload.report).toBeDefined(); + }); + + test('caches the report on the instance', async () => { + const { detector } = makeDetector(); + const report = await detector.detect(); + expect(detector.lastReport).toBe(report); + }); + + test('handles services as a wrapper object with .services field', async () => { + const { detector, servicesStateManager } = makeDetector(); + servicesStateManager.read.mockResolvedValue({ services: [] }); + const report = await detector.detect(); + expect(report).toBeDefined(); + expect(report.hasDrift).toBe(false); + }); + + test('tolerates Docker listContainers failure (logs and continues)', async () => { + const { detector, docker, ctx } = makeDetector(); + docker.client.listContainers.mockRejectedValue(new Error('docker daemon down')); + const report = await detector.detect(); + expect(report).toBeDefined(); + expect(report.hasDrift).toBe(false); + expect(ctx.log.error).toHaveBeenCalled(); + }); + }); + + describe('autoFix()', () => { + test('removes stale records via servicesStateManager.update', async () => { + const services = [ + { id: 'svc-good', name: 'svc-good', containerId: 'liveid0000000000000000000000000000' }, + { id: 'svc-stale', name: 'svc-stale', containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef00000000' }, + ]; + const containers = [makeContainer({ + Id: 'liveid0000000000000000000000000000000000000000000000000000000000', + })]; + + const { detector, servicesStateManager, docker } = makeDetector(); + servicesStateManager.read.mockResolvedValue(services); + servicesStateManager.update.mockImplementation(async (updater) => { + const next = updater(services); + return next; + }); + docker.client.listContainers.mockResolvedValue(containers); + + const result = await detector.autoFix(); + expect(result.staleRemoved).toBe(1); + expect(result.unknownFlagged).toBe(0); + expect(servicesStateManager.update).toHaveBeenCalledTimes(1); + }); + }); + + describe('polling', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test('startPolling/stopPolling toggles isPolling', () => { + const { detector } = makeDetector(); + expect(detector.isPolling()).toBe(false); + detector.startPolling(60000); + expect(detector.isPolling()).toBe(true); + detector.stopPolling(); + expect(detector.isPolling()).toBe(false); + }); + + test('startPolling clears any existing timer before starting a new one', () => { + const { detector } = makeDetector(); + detector.startPolling(60000); + const firstTimer = detector._pollTimer; + detector.startPolling(120000); + expect(detector._pollTimer).not.toBe(firstTimer); + detector.stopPolling(); + }); + + test('stopPolling is a safe no-op when not started', () => { + const { detector } = makeDetector(); + expect(() => detector.stopPolling()).not.toThrow(); + expect(detector.isPolling()).toBe(false); + }); + + test('runs detect on the polling interval', async () => { + jest.useFakeTimers(); + const { detector } = makeDetector(); + const detectSpy = jest.spyOn(detector, 'detect').mockResolvedValue({ + checkedAt: new Date().toISOString(), + missingContainers: [], + unknownContainers: [], + portMismatch: [], + stateMismatch: [], + staleRecords: [], + hasDrift: false, + }); + + detector.startPolling(1000); + jest.advanceTimersByTime(3500); + // 3 intervals should have fired (1000, 2000, 3000) + expect(detectSpy.mock.calls.length).toBeGreaterThanOrEqual(3); + detector.stopPolling(); + detectSpy.mockRestore(); + }); + }); + + describe('_extractContainerPorts', () => { + test('returns mapped public ports', () => { + const { detector } = makeDetector(); + const ports = detector._extractContainerPorts({ + Ports: [ + { PublicPort: 8080, PrivatePort: 80, Type: 'tcp' }, + { PublicPort: 8443, PrivatePort: 443, Type: 'tcp' }, + { PrivatePort: 53, Type: 'udp' }, // No PublicPort → not exposed + ], + }); + expect(ports).toEqual([8080, 8443]); + }); + + test('returns [] when container has no Ports field', () => { + const { detector } = makeDetector(); + expect(detector._extractContainerPorts({})).toEqual([]); + expect(detector._extractContainerPorts({ Ports: null })).toEqual([]); + }); + }); + + describe('_sendDriftNotification', () => { + test('returns early when no notification manager is present', async () => { + const { detector } = makeDetector({ notification: null }); + // Replace the field with null/undefined to simulate missing + detector.notification = null; + const result = await detector._sendDriftNotification({ hasDrift: true }); + expect(result.success).toBe(false); + expect(result.reason).toMatch(/no-notification-manager/i); + }); + + test('formats message with one line per drift category', async () => { + const { detector, notification } = makeDetector(); + const report = { + missingContainers: [{ name: 'app-a' }], + unknownContainers: [{ name: 'app-b' }], + portMismatch: [{ name: 'app-c' }], + stateMismatch: [], + staleRecords: [{ name: 'app-d' }], + hasDrift: true, + }; + await detector._sendDriftNotification(report); + expect(notification.send).toHaveBeenCalledTimes(1); + const payload = notification.send.mock.calls[0][1]; + expect(payload.text).toMatch(/Missing containers: app-a/); + expect(payload.text).toMatch(/Unknown managed containers: app-b/); + expect(payload.text).toMatch(/Port mismatches: app-c/); + expect(payload.text).toMatch(/Stale records: app-d/); + expect(payload.report).toBe(report); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/config-migrations.test.js b/dashcaddy-api/__tests__/config-migrations.test.js new file mode 100644 index 0000000..6a762b7 --- /dev/null +++ b/dashcaddy-api/__tests__/config-migrations.test.js @@ -0,0 +1,215 @@ +/** + * Config migration tests + * + * These tests verify that a config file from any older version of DashCaddy + * gets correctly migrated to the current version. Migration MUST be: + * - Deterministic (same input always produces same output) + * - Idempotent (running migration on already-migrated config is a no-op) + * - Safe (no data loss; only adds fields, never removes user values) + * - Silent (no exceptions thrown for any version from 0 to CURRENT) + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + CURRENT_VERSION, + migrations, + migrate, + loadAndMigrate +} = require('../src/config/migrations'); + +describe('config/migrations', () => { + let tmpDir; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mig-test-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('migrate()', () => { + test('null/empty config returns fresh v_current', () => { + const result = migrate(null); + expect(result._version).toBe(CURRENT_VERSION); + }); + + test('undefined config returns fresh v_current', () => { + const result = migrate(undefined); + expect(result._version).toBe(CURRENT_VERSION); + }); + + test('v0 (no _version) migrates all the way to current', () => { + const v0 = { tld: '.home', customValue: 'preserved' }; + const result = migrate(v0); + expect(result._version).toBe(CURRENT_VERSION); + // User data must be preserved + expect(result.tld).toBe('.home'); + expect(result.customValue).toBe('preserved'); + }); + + test('each intermediate version migrates forward to current', () => { + for (let v = 0; v < CURRENT_VERSION; v++) { + const config = { _version: v, tld: '.test' }; + const result = migrate(config); + // Final version is always CURRENT_VERSION after running all migrations + expect(result._version).toBe(CURRENT_VERSION); + // User data preserved + expect(result.tld).toBe('.test'); + } + }); + + test('config at current version passes through unchanged', () => { + const current = { _version: CURRENT_VERSION, tld: '.home', customField: 'kept' }; + const result = migrate(current); + expect(result).toEqual(current); + }); + + test('config from FUTURE version is left alone (forward compat)', () => { + const future = { _version: 999, tld: '.home', newField: 'unknown' }; + const result = migrate(future); + // We don't touch future configs — let validation catch issues + expect(result._version).toBe(999); + expect(result.newField).toBe('unknown'); + }); + }); + + describe('v0 → v1 migration: dns normalization', () => { + test('string dns gets converted to object', () => { + const result = migrations[1]({ dns: '192.168.1.1' }); + expect(result.dns).toEqual({ ip: '192.168.1.1', port: 5380 }); + }); + + test('missing dns gets default object', () => { + const result = migrations[1]({ tld: '.home' }); + expect(result.dns).toEqual({ ip: '', port: 5380 }); + }); + + test('object dns passes through unchanged', () => { + const result = migrations[1]({ dns: { ip: '10.0.0.1', port: 5380, custom: 'kept' } }); + expect(result.dns.ip).toBe('10.0.0.1'); + expect(result.dns.custom).toBe('kept'); + }); + + test('_version is set to 1', () => { + const result = migrations[1]({ tld: '.home' }); + expect(result._version).toBe(1); + }); + }); + + describe('v1 → v2 migration: dns.provider field', () => { + test('adds provider: technitium default', () => { + const result = migrations[2]({ dns: { ip: '10.0.0.1', port: 5380 }, _version: 1 }); + expect(result.dns.provider).toBe('technitium'); + expect(result.dns.ip).toBe('10.0.0.1'); + expect(result.dns.port).toBe(5380); + }); + + test('respects existing provider if set', () => { + const result = migrations[2]({ dns: { provider: 'cloudflare', ip: 'cf' }, _version: 1 }); + expect(result.dns.provider).toBe('cloudflare'); + }); + + test('_version is set to 2', () => { + const result = migrations[2]({ _version: 1 }); + expect(result._version).toBe(2); + }); + }); + + describe('loadAndMigrate()', () => { + test('creates fresh config when file does not exist', () => { + const configFile = path.join(tmpDir, 'config.json'); + const result = loadAndMigrate(configFile, null); + expect(result._version).toBe(CURRENT_VERSION); + // Should NOT write a file when there was nothing to migrate + expect(fs.existsSync(configFile)).toBe(false); + }); + + test('migrates old config and writes back to disk', () => { + const configFile = path.join(tmpDir, 'config.json'); + // Write an unversioned config (v0) + fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', customField: 'preserve-me' })); + + const result = loadAndMigrate(configFile, null); + + // Returned value is migrated + expect(result._version).toBe(CURRENT_VERSION); + expect(result.tld).toBe('.sami'); + expect(result.customField).toBe('preserve-me'); + + // File on disk is updated + const written = JSON.parse(fs.readFileSync(configFile, 'utf8')); + expect(written._version).toBe(CURRENT_VERSION); + expect(written.tld).toBe('.sami'); + }); + + test('does not rewrite file when already at current version', () => { + const configFile = path.join(tmpDir, 'config.json'); + const original = JSON.stringify({ _version: CURRENT_VERSION, tld: '.home' }, null, 2); + fs.writeFileSync(configFile, original); + + // Record mtime before + const mtimeBefore = fs.statSync(configFile).mtimeMs; + // Wait a tick + const start = Date.now(); + while (Date.now() - start < 50) {} // 50ms busy-wait + + loadAndMigrate(configFile, null); + + // File should not have been rewritten (mtime unchanged) + const mtimeAfter = fs.statSync(configFile).mtimeMs; + expect(mtimeAfter).toBe(mtimeBefore); + }); + + test('handles corrupt JSON gracefully (returns defaults, no crash)', () => { + const configFile = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configFile, '{ this is not valid json'); + + // Should not throw + const result = loadAndMigrate(configFile, null); + expect(result._version).toBe(CURRENT_VERSION); + }); + + test('creates parent directory if missing', () => { + const nested = path.join(tmpDir, 'nested', 'subdir', 'config.json'); + // Pre-create parent dirs (test setup) + fs.mkdirSync(path.dirname(nested), { recursive: true }); + fs.writeFileSync(nested, JSON.stringify({ tld: '.home' })); + + const result = loadAndMigrate(nested, null); + expect(result._version).toBe(CURRENT_VERSION); + }); + + test('full chain: v0 file with string dns becomes v2 with provider', () => { + const configFile = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configFile, JSON.stringify({ + tld: '.sami', + dns: '10.0.0.1' + })); + + const result = loadAndMigrate(configFile, null); + expect(result._version).toBe(CURRENT_VERSION); + // After full chain, dns is normalized to object AND has provider + expect(result.dns.ip).toBe('10.0.0.1'); + expect(result.dns.port).toBe(5380); + expect(result.dns.provider).toBe('technitium'); + }); + }); + + describe('idempotency', () => { + test('running migration twice produces same result', () => { + const v0 = { tld: '.home', customField: 'x' }; + const first = migrate(v0); + const second = migrate(first); + expect(second).toEqual(first); + }); + + test('loadAndMigrate is idempotent across reloads', () => { + const configFile = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configFile, JSON.stringify({ tld: '.home' })); + + const first = loadAndMigrate(configFile, null); + const second = loadAndMigrate(configFile, null); + expect(second).toEqual(first); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/credential-manager.test.js b/dashcaddy-api/__tests__/credential-manager.test.js index 6c80430..ca19bb7 100644 --- a/dashcaddy-api/__tests__/credential-manager.test.js +++ b/dashcaddy-api/__tests__/credential-manager.test.js @@ -1,12 +1,12 @@ // Mock dependencies before requiring the module -jest.mock('../keychain-manager', () => ({ +jest.mock('../src/security/keychain-manager', () => ({ available: false, store: jest.fn().mockResolvedValue(false), retrieve: jest.fn().mockResolvedValue(null), delete: jest.fn().mockResolvedValue(true), })); -jest.mock('../crypto-utils', () => ({ +jest.mock('../src/security/crypto-utils', () => ({ encrypt: jest.fn(data => `enc:tag:${Buffer.from(String(data)).toString('base64')}`), decrypt: jest.fn(data => { const parts = data.split(':'); @@ -40,8 +40,8 @@ describe('CredentialManager', () => { // Re-get mocked modules fs = require('fs'); lockfile = require('proper-lockfile'); - keychainManager = require('../keychain-manager'); - cryptoUtils = require('../crypto-utils'); + keychainManager = require('../src/security/keychain-manager'); + cryptoUtils = require('../src/security/crypto-utils'); // Reset mock implementations fs.existsSync.mockReturnValue(true); @@ -50,7 +50,7 @@ describe('CredentialManager', () => { lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); keychainManager.available = false; - credentialManager = require('../credential-manager'); + credentialManager = require('../src/managers/credential-manager'); credentialManager.cache.clear(); }); @@ -72,10 +72,10 @@ describe('CredentialManager', () => { fs.writeFileSync.mockImplementation(() => {}); lockfile = require('proper-lockfile'); lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); - keychainManager = require('../keychain-manager'); + keychainManager = require('../src/security/keychain-manager'); keychainManager.available = true; keychainManager.store.mockResolvedValue(true); - credentialManager = require('../credential-manager'); + credentialManager = require('../src/managers/credential-manager'); const result = await credentialManager.store('test.key', 'value'); expect(result).toBe(true); @@ -91,11 +91,11 @@ describe('CredentialManager', () => { fs.writeFileSync.mockImplementation(() => {}); lockfile = require('proper-lockfile'); lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); - keychainManager = require('../keychain-manager'); + keychainManager = require('../src/security/keychain-manager'); keychainManager.available = true; keychainManager.store.mockResolvedValue(false); - cryptoUtils = require('../crypto-utils'); - credentialManager = require('../credential-manager'); + cryptoUtils = require('../src/security/crypto-utils'); + credentialManager = require('../src/managers/credential-manager'); const result = await credentialManager.store('test.key', 'value'); expect(result).toBe(true); diff --git a/dashcaddy-api/__tests__/crypto-utils.test.js b/dashcaddy-api/__tests__/crypto-utils.test.js index 2a4c9cb..70ab40f 100644 --- a/dashcaddy-api/__tests__/crypto-utils.test.js +++ b/dashcaddy-api/__tests__/crypto-utils.test.js @@ -11,7 +11,7 @@ const TEST_KEY_HEX = TEST_KEY.toString('hex'); // Load the module once — no jest.resetModules() needed // We control key state via clearCachedKey() + env vars process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX; -const cryptoUtils = require('../crypto-utils'); +const cryptoUtils = require('../src/security/crypto-utils'); describe('Crypto Utils', () => { beforeEach(() => { diff --git a/dashcaddy-api/__tests__/csrf-protection.test.js b/dashcaddy-api/__tests__/csrf-protection.test.js index 4943d84..9708600 100644 --- a/dashcaddy-api/__tests__/csrf-protection.test.js +++ b/dashcaddy-api/__tests__/csrf-protection.test.js @@ -2,7 +2,7 @@ const crypto = require('crypto'); // Mock crypto-utils to provide a predictable signing key const mockFixedKey = Buffer.alloc(32, 'test-key-material'); -jest.mock('../crypto-utils', () => ({ +jest.mock('../src/security/crypto-utils', () => ({ loadOrCreateKey: jest.fn(() => mockFixedKey), })); @@ -16,7 +16,7 @@ const { csrfCookieMiddleware, csrfValidationMiddleware, renewCSRFToken -} = require('../csrf-protection'); +} = require('../src/security/csrf-protection'); const { createMockReqRes } = require('./helpers/test-utils'); describe('CSRF Protection', () => { diff --git a/dashcaddy-api/__tests__/dns-propagation.test.js b/dashcaddy-api/__tests__/dns-propagation.test.js new file mode 100644 index 0000000..5e3c62c --- /dev/null +++ b/dashcaddy-api/__tests__/dns-propagation.test.js @@ -0,0 +1,106 @@ +/** + * Smoke tests for dns-propagation.js + * Verifies DNS propagation checker module loads, exposes the expected + * interface, and basic methods (verifyRecord, startVerification, + * getVerificationStatus, getAllVerifications, cleanup) work without throwing. + */ + +// The module does `const dns = require('dns').promises;` then `new dns.Resolver()`. +// We mock the dns module so that .promises exposes our Resolver class. +jest.mock('dns', () => { + class MockResolver { + setServers() { return this; } + setTimeout() { return this; } + resolve4(domain) { + if (domain === 'propagated.sami') { + return Promise.resolve(['1.2.3.4']); + } + return Promise.resolve(['9.9.9.9']); + } + } + return { + promises: { Resolver: MockResolver }, + Resolver: MockResolver, + }; +}); + +const DNSPropagationChecker = require('../src/dns/dns-propagation'); + +describe('DNSPropagationChecker', () => { + let checker; + + beforeEach(() => { + const ctx = { + log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + notification: { send: jest.fn().mockResolvedValue({ success: true }) }, + }; + checker = new DNSPropagationChecker(ctx); + }); + + test('is an EventEmitter', () => { + expect(typeof checker.on).toBe('function'); + expect(typeof checker.emit).toBe('function'); + }); + + test('starts with an empty verifications map', () => { + expect(checker.verifications).toBeInstanceOf(Map); + expect(checker.verifications.size).toBe(0); + }); + + test('verifyRecord returns expected shape and detects propagated domain', async () => { + const result = await checker.verifyRecord('propagated.sami', '1.2.3.4', { + timeout: 5000, + interval: 100, + resolvers: ['1.1.1.1'], + }); + expect(result).toHaveProperty('domain', 'propagated.sami'); + expect(result).toHaveProperty('expectedIp', '1.2.3.4'); + expect(result).toHaveProperty('propagated', true); + expect(Array.isArray(result.results)).toBe(true); + expect(result.results.length).toBeGreaterThan(0); + expect(typeof result.totalTime).toBe('number'); + expect(typeof result.checkedAt).toBe('string'); + }); + + test('verifyRecord reports not-propagated when IP does not match', async () => { + const result = await checker.verifyRecord('notpropagated.sami', '5.6.7.8', { + timeout: 200, + interval: 50, + resolvers: ['1.1.1.1'], + }); + expect(result.propagated).toBe(false); + }); + + test('startVerification returns a job object with running status', () => { + const job = checker.startVerification('job.sami', '1.1.1.1', { + timeout: 100, + interval: 50, + resolvers: ['1.1.1.1'], + }); + expect(job).toMatchObject({ + domain: 'job.sami', + expectedIp: '1.1.1.1', + status: 'running', + }); + expect(job.startedAt).toBeDefined(); + }); + + test('startVerification returns the same job when called twice for one domain', () => { + const a = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 }); + const b = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 }); + expect(a).toBe(b); + }); + + test('getVerificationStatus returns null for unknown domain', () => { + expect(checker.getVerificationStatus('nope.sami')).toBeNull(); + }); + + test('getAllVerifications returns an array', () => { + expect(Array.isArray(checker.getAllVerifications())).toBe(true); + }); + + test('cleanup is a no-op on empty verifications', () => { + expect(() => checker.cleanup()).not.toThrow(); + expect(checker.verifications.size).toBe(0); + }); +}); diff --git a/dashcaddy-api/__tests__/docker-security.test.js b/dashcaddy-api/__tests__/docker-security.test.js index 5757ef4..8198f36 100644 --- a/dashcaddy-api/__tests__/docker-security.test.js +++ b/dashcaddy-api/__tests__/docker-security.test.js @@ -27,7 +27,7 @@ describe('DockerSecurity Module', () => { // Reset modules to get fresh instance jest.resetModules(); - dockerSecurity = require('../docker-security'); + dockerSecurity = require('../src/security/docker-security'); }); afterEach(() => { @@ -58,7 +58,7 @@ describe('DockerSecurity Module', () => { // Force module reload jest.resetModules(); - const freshInstance = require('../docker-security'); + const freshInstance = require('../src/security/docker-security'); const status = freshInstance.getStatus(); expect(status.trustedImagesCount).toBe(1); @@ -77,7 +77,7 @@ describe('DockerSecurity Module', () => { fs.writeFileSync(TEST_CONFIG_FILE, 'INVALID JSON{{{'); jest.resetModules(); - const freshInstance = require('../docker-security'); + const freshInstance = require('../src/security/docker-security'); const status = freshInstance.getStatus(); // Should fall back to default config @@ -89,7 +89,7 @@ describe('DockerSecurity Module', () => { process.env.DOCKER_SECURITY_CONFIG = '/nonexistent/path/config.json'; jest.resetModules(); - const freshInstance = require('../docker-security'); + const freshInstance = require('../src/security/docker-security'); const status = freshInstance.getStatus(); // Should fall back to default config diff --git a/dashcaddy-api/__tests__/error-handler.test.js b/dashcaddy-api/__tests__/error-handler.test.js index 1179c3b..f7f54b8 100644 --- a/dashcaddy-api/__tests__/error-handler.test.js +++ b/dashcaddy-api/__tests__/error-handler.test.js @@ -1,8 +1,18 @@ -jest.mock('../error-logger', () => ({ - logError: jest.fn(), +// Mock the unified logging module so we can verify logError is called +// without writing to the actual error.log file +jest.mock('../src/utils/logging', () => ({ + logError: jest.fn().mockResolvedValue(), + safeErrorMessage: jest.fn((err) => { + if (!err) return 'An internal error occurred'; + return err.message || String(err); + }), + createLogger: jest.fn(() => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() + })), + LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 } })); -const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler'); +const { errorMiddleware, notFoundHandler } = require('../src/utilities/error-handler'); const { AppError, ValidationError, @@ -10,7 +20,7 @@ const { NotFoundError, RateLimitError, DockerError, -} = require('../errors'); +} = require('../src/utilities/errors'); describe('Error Handler', () => { let req, res, next; @@ -30,23 +40,6 @@ describe('Error Handler', () => { 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', () => { it('returns 400 for ValidationError', () => { const err = new ValidationError('bad input', 'email'); diff --git a/dashcaddy-api/__tests__/errors.test.js b/dashcaddy-api/__tests__/errors.test.js index 51b861f..e6c29b7 100644 --- a/dashcaddy-api/__tests__/errors.test.js +++ b/dashcaddy-api/__tests__/errors.test.js @@ -10,7 +10,7 @@ const { CaddyError, DNSError, ServiceUnavailableError -} = require('../errors'); +} = require('../src/utilities/errors'); describe('Error Classes', () => { describe('AppError', () => { diff --git a/dashcaddy-api/__tests__/health-checker.test.js b/dashcaddy-api/__tests__/health-checker.test.js index 60cfa71..3b0fc24 100644 --- a/dashcaddy-api/__tests__/health-checker.test.js +++ b/dashcaddy-api/__tests__/health-checker.test.js @@ -17,7 +17,7 @@ describe('HealthChecker', () => { fs.writeFileSync.mockImplementation(() => {}); // Fresh instance each test - HealthChecker = require('../health-checker').constructor; + HealthChecker = require('../src/monitoring/health-checker').constructor; healthChecker = new HealthChecker(); }); @@ -41,7 +41,7 @@ describe('HealthChecker', () => { services: { svc1: { url: 'http://test.local', enabled: true } } })); - HealthChecker = require('../health-checker').constructor; + HealthChecker = require('../src/monitoring/health-checker').constructor; const hc = new HealthChecker(); expect(hc.config.services.svc1).toBeDefined(); }); @@ -52,7 +52,7 @@ describe('HealthChecker', () => { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue('invalid json'); - HealthChecker = require('../health-checker').constructor; + HealthChecker = require('../src/monitoring/health-checker').constructor; const hc = new HealthChecker(); expect(hc.config).toEqual({ services: {} }); }); diff --git a/dashcaddy-api/__tests__/health-endpoints.test.js b/dashcaddy-api/__tests__/health-endpoints.test.js new file mode 100644 index 0000000..5e01b8e --- /dev/null +++ b/dashcaddy-api/__tests__/health-endpoints.test.js @@ -0,0 +1,201 @@ +/** + * Health endpoint tests + * + * Verifies: + * - /health/live always returns 200 + * - /health/ready returns 200 with valid structure when all deps OK + * - /health/ready returns 503 when a critical dep is down + * - /health/ready does NOT crash with "res.status is not a function" + */ +const express = require('express'); +const request = require('supertest'); + +// Mock dockerode BEFORE anything else +jest.mock('dockerode', () => { + return jest.fn().mockImplementation(() => ({ + ping: jest.fn().mockImplementation(() => { + if (process.env.MOCK_DOCKER_DOWN === '1') { + return Promise.reject(new Error('docker unreachable')); + } + return Promise.resolve('OK'); + }) + })); +}); + +// Build a minimal Express app with the same health handlers as src/app.js +function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) { + process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1'; + + const app = express(); + const config = { + CONFIG_FILE: '/tmp/dc-test-config.json', + SERVICES_FILE: '/tmp/dc-test-services.json', + CADDY_ADMIN_URL: 'http://localhost:2019' + }; + + // Mock fs + const fs = require('fs'); + const realExistsSync = fs.existsSync; + const realReadFileSync = fs.readFileSync; + fs.existsSync = (p) => { + if (p === config.CONFIG_FILE) return configOk; + if (p === config.SERVICES_FILE) return servicesOk; + return realExistsSync(p); + }; + fs.readFileSync = (p, ...args) => { + if (p === config.CONFIG_FILE) { + if (!configOk) throw new Error('config not found'); + return '{}'; + } + if (p === config.SERVICES_FILE) { + if (!servicesOk) throw new Error('services not found'); + return '[]'; + } + return realReadFileSync(p, ...args); + }; + + // /health/live (matches src/app.js exactly) + app.get('/health/live', (req, res) => { + res.json({ status: 'alive', uptime: process.uptime() }); + }); + + // /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern) + const { asyncHandler } = require('../src/utils/async-handler'); + const logError = async () => {}; // noop logger + const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test'); + + app.get('/health/ready', boundAsyncHandler(async (req, res) => { + const checks = {}; + let allOk = true; + + try { + if (fs.existsSync(config.CONFIG_FILE)) { + fs.readFileSync(config.CONFIG_FILE, 'utf8'); + checks.configFile = { ok: true }; + } else { + checks.configFile = { ok: false, error: 'Config file not found' }; + allOk = false; + } + } catch (e) { + checks.configFile = { ok: false, error: e.message }; + allOk = false; + } + + try { + if (fs.existsSync(config.SERVICES_FILE)) { + fs.readFileSync(config.SERVICES_FILE, 'utf8'); + checks.servicesFile = { ok: true }; + } else { + checks.servicesFile = { ok: false, error: 'Services file not found' }; + allOk = false; + } + } catch (e) { + checks.servicesFile = { ok: false, error: e.message }; + allOk = false; + } + + try { + const docker = require('dockerode')(); + await docker.ping(); + checks.docker = { ok: true }; + } catch (e) { + checks.docker = { ok: false, error: e.message }; + allOk = false; + } + + try { + const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019'; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal }); + clearTimeout(timeout); + checks.caddy = { ok: response.ok, status: response.status }; + if (!response.ok) allOk = false; + } catch (e) { + checks.caddy = { ok: false, error: e.message }; + allOk = false; + } + + const body = { + status: allOk ? 'ready' : 'not-ready', + timestamp: new Date().toISOString(), + checks + }; + res.status(allOk ? 200 : 503).json(body); + })); + + return app; +} + +describe('Health Endpoints', () => { + beforeEach(() => { + delete process.env.MOCK_DOCKER_DOWN; + }); + + describe('GET /health/live', () => { + it('always returns 200 with status: alive', async () => { + const app = buildApp(); + const res = await request(app).get('/health/live'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('alive'); + expect(typeof res.body.uptime).toBe('number'); + }); + + it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => { + const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false }); + const res = await request(app).get('/health/live'); + expect(res.status).toBe(200); + }); + }); + + describe('GET /health/ready', () => { + it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => { + const app = buildApp(); + const res = await request(app).get('/health/ready'); + // config + services + docker should all be OK + expect(res.body.checks.configFile.ok).toBe(true); + expect(res.body.checks.servicesFile.ok).toBe(true); + expect(res.body.checks.docker.ok).toBe(true); + // caddy is tested in sandbox — may be 403 or 200 + expect(res.body).toHaveProperty('checks'); + expect(res.body).toHaveProperty('status'); + }); + + it('returns 503 when config file is missing', async () => { + const app = buildApp({ configOk: false }); + const res = await request(app).get('/health/ready'); + expect(res.status).toBe(503); + expect(res.body.status).toBe('not-ready'); + expect(res.body.checks.configFile.ok).toBe(false); + }); + + it('returns 503 when services file is missing', async () => { + const app = buildApp({ servicesOk: false }); + const res = await request(app).get('/health/ready'); + expect(res.status).toBe(503); + expect(res.body.checks.servicesFile.ok).toBe(false); + }); + + it('returns 503 when Docker is unreachable', async () => { + const app = buildApp({ dockerOk: false }); + const res = await request(app).get('/health/ready'); + expect(res.status).toBe(503); + expect(res.body.checks.docker.ok).toBe(false); + }); + + it('does NOT crash with "res.status is not a function" when dependencies fail', async () => { + const app = buildApp({ dockerOk: false }); + const res = await request(app).get('/health/ready'); + const bodyStr = JSON.stringify(res.body); + expect(bodyStr).not.toMatch(/res\.status is not a function/); + // Should always be a valid response object + expect(res.body).toHaveProperty('checks'); + }); + + it('responds with all 4 expected check keys', async () => { + const app = buildApp(); + const res = await request(app).get('/health/ready'); + expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/helpers/test-utils.js b/dashcaddy-api/__tests__/helpers/test-utils.js index 28b0bf7..b0c1f27 100644 --- a/dashcaddy-api/__tests__/helpers/test-utils.js +++ b/dashcaddy-api/__tests__/helpers/test-utils.js @@ -90,7 +90,7 @@ function buildTestApp(routeFactory, deps, prefix = '/api') { const router = routeFactory(deps); app.use(prefix, router); // Error handler - const { errorMiddleware } = require('../../error-handler'); + const { errorMiddleware } = require('../../../src/utilities/error-handler'); app.use(errorMiddleware); return app; } diff --git a/dashcaddy-api/__tests__/input-validator.test.js b/dashcaddy-api/__tests__/input-validator.test.js index 309748c..b74f9be 100644 --- a/dashcaddy-api/__tests__/input-validator.test.js +++ b/dashcaddy-api/__tests__/input-validator.test.js @@ -11,7 +11,7 @@ const { isValidPort, isPrivateIP, validateSecurePath -} = require('../input-validator'); +} = require('../src/security/input-validator'); describe('Input Validator', () => { function fail(message) { @@ -480,7 +480,7 @@ describe('Input Validator', () => { // Re-require after mocking fs function getValidateSecurePath() { - return require('../input-validator').validateSecurePath; + return require('../src/security/input-validator').validateSecurePath; } it('resolves valid path within allowed roots', async () => { diff --git a/dashcaddy-api/__tests__/log-digest.test.js b/dashcaddy-api/__tests__/log-digest.test.js new file mode 100644 index 0000000..84ffc6b --- /dev/null +++ b/dashcaddy-api/__tests__/log-digest.test.js @@ -0,0 +1,187 @@ +/** + * Smoke tests for log-digest.js + * Verifies the singleton LogDigest exposes the expected interface, parses + * Docker multiplexed log streams, formats digests, and supports on-demand + * daily digest generation with mocked Docker. + */ + +const fsReal = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.mock('dockerode', () => { + const listContainers = jest.fn().mockResolvedValue([]); + const getContainer = jest.fn(() => ({ + logs: jest.fn().mockResolvedValue(Buffer.from([])), + })); + function Docker() {} + Docker.prototype.listContainers = listContainers; + Docker.prototype.getContainer = getContainer; + return Docker; +}); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn().mockReturnValue(true), + mkdirSync: jest.fn(), + }; +}); + +jest.mock('../src/docker/docker-maintenance', () => ({ + getDiskUsage: jest.fn().mockResolvedValue(null), +})); + +const Docker = require('dockerode'); +const fs = require('fs'); +const logDigest = require('../src/security/log-digest'); + +describe('LogDigest (singleton)', () => { + let dockerInstance; + let tempDir; + + beforeEach(() => { + // Each test gets a fresh Docker() mock instance + jest.clearAllMocks(); + fs.existsSync.mockReturnValue(true); + // Use a real, writable temp directory so writeFile inside generateDailyDigest + // does not blow up. Each test gets a fresh dir to avoid cross-test pollution. + tempDir = fsReal.mkdtempSync(path.join(os.tmpdir(), 'dc-digest-test-')); + logDigest.hourlySummaries = []; + logDigest.lastCollect = null; + logDigest.running = false; + logDigest.digestDir = null; + if (logDigest.collectInterval) { + clearInterval(logDigest.collectInterval); + logDigest.collectInterval = null; + } + if (logDigest.digestTimeout) { + clearTimeout(logDigest.digestTimeout); + logDigest.digestTimeout = null; + } + dockerInstance = new Docker(); + }); + + afterEach(() => { + logDigest.stop(); + if (tempDir && fsReal.existsSync(tempDir)) { + fsReal.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('is an EventEmitter and exposes the documented API', () => { + expect(typeof logDigest.on).toBe('function'); + expect(typeof logDigest.emit).toBe('function'); + expect(typeof logDigest.start).toBe('function'); + expect(typeof logDigest.stop).toBe('function'); + expect(typeof logDigest.generateDailyDigest).toBe('function'); + expect(typeof logDigest.getLatestDigest).toBe('function'); + expect(typeof logDigest.getDigestByDate).toBe('function'); + expect(typeof logDigest.getDigestText).toBe('function'); + expect(typeof logDigest.listDigests).toBe('function'); + expect(typeof logDigest.getLiveData).toBe('function'); + expect(typeof logDigest.getStatus).toBe('function'); + }); + + test('getStatus returns current state', () => { + const status = logDigest.getStatus(); + expect(status).toEqual({ + running: false, + lastCollect: null, + hourlySummaries: 0, + digestDir: null, + }); + }); + + test('start sets running and digestDir', () => { + logDigest.start(tempDir); + expect(logDigest.running).toBe(true); + expect(logDigest.digestDir).toBe(tempDir); + }); + + test('start is idempotent — second call does nothing new', () => { + logDigest.start(tempDir); + const firstInterval = logDigest.collectInterval; + logDigest.start(tempDir); + expect(logDigest.collectInterval).toBe(firstInterval); + }); + + test('_parseDockerLogs decodes multiplexed log frames into lines', () => { + // Stream type byte: 0=stdin, 1=stdout, 2=stderr + // Header: [type, 0, 0, 0, size-BE-uint32] + function frame(streamType, text) { + const buf = Buffer.from(text, 'utf8'); + const header = Buffer.alloc(8); + header[0] = streamType; + header.writeUInt32BE(buf.length, 4); + return Buffer.concat([header, buf]); + } + + const multiplexed = Buffer.concat([ + frame(1, 'hello world\n'), + frame(2, '2026-03-13T12:00:00.000Z an error happened\n'), + ]); + + const lines = logDigest._parseDockerLogs(multiplexed); + expect(lines).toHaveLength(2); + expect(lines[0]).toEqual({ + stream: 'stdout', + text: 'hello world', + timestamp: null, + }); + expect(lines[1].stream).toBe('stderr'); + expect(lines[1].text).toBe('an error happened'); + expect(lines[1].timestamp).toBe('2026-03-13T12:00:00'); + }); + + test('generateDailyDigest with empty summaries produces minimal digest', async () => { + logDigest.start(tempDir); + const digest = await logDigest.generateDailyDigest('2099-01-01'); + expect(digest.date).toBe('2099-01-01'); + expect(digest.services).toEqual({}); + expect(digest.summary.totalServices).toBe(0); + expect(digest.summary.totalErrors).toBe(0); + expect(Array.isArray(digest.notableEvents)).toBe(true); + + // Confirm the file was actually written + const writtenPath = path.join(tempDir, 'digest-2099-01-01.log'); + expect(fsReal.existsSync(writtenPath)).toBe(true); + const jsonPath = path.join(tempDir, 'digest-2099-01-01.json'); + expect(fsReal.existsSync(jsonPath)).toBe(true); + }); + + test('getLiveData returns shape with date, hoursCollected, services', () => { + const data = logDigest.getLiveData(); + expect(data).toHaveProperty('date'); + expect(data).toHaveProperty('hoursCollected'); + expect(data).toHaveProperty('services'); + expect(data).toHaveProperty('lastCollect'); + }); + + test('getLatestDigest returns null when digestDir is null', async () => { + logDigest.digestDir = null; + const result = await logDigest.getLatestDigest(); + expect(result).toBeNull(); + }); + + test('getDigestByDate returns null when no file exists', async () => { + logDigest.digestDir = '/nonexistent/path'; + const result = await logDigest.getDigestByDate('2020-01-01'); + expect(result).toBeNull(); + }); + + test('listDigests returns empty array when digestDir is null', async () => { + logDigest.digestDir = null; + const result = await logDigest.listDigests(); + expect(result).toEqual([]); + }); + + test('stop clears intervals and timeouts', () => { + logDigest.start(tempDir); + logDigest.stop(); + expect(logDigest.running).toBe(false); + expect(logDigest.collectInterval).toBeNull(); + expect(logDigest.digestTimeout).toBeNull(); + }); +}); diff --git a/dashcaddy-api/__tests__/metrics.test.js b/dashcaddy-api/__tests__/metrics.test.js new file mode 100644 index 0000000..5f293b6 --- /dev/null +++ b/dashcaddy-api/__tests__/metrics.test.js @@ -0,0 +1,207 @@ +/** + * Smoke tests for metrics.js + * Verifies the Metrics singleton exposes the expected interface, accumulates + * request/error/business counters, normalizes paths, formats uptime, and resets. + * + * The module exports a singleton instance, so we import it once and mutate its + * state in beforeEach. + */ + +const metrics = require('../src/monitoring/metrics'); + +describe('Metrics (singleton)', () => { + beforeEach(() => { + metrics.reset(); + }); + + test('exposes the documented public API', () => { + expect(typeof metrics.recordRequest).toBe('function'); + expect(typeof metrics.recordError).toBe('function'); + expect(typeof metrics.recordBusinessEvent).toBe('function'); + expect(typeof metrics.normalizePath).toBe('function'); + expect(typeof metrics.getSummary).toBe('function'); + expect(typeof metrics.formatUptime).toBe('function'); + expect(typeof metrics.reset).toBe('function'); + }); + + describe('recordRequest', () => { + test('increments total request count', () => { + metrics.recordRequest('GET', '/api/services', 200, 12); + metrics.recordRequest('GET', '/api/services', 200, 8); + expect(metrics.requests.total).toBe(2); + }); + + test('aggregates by status code', () => { + metrics.recordRequest('GET', '/a', 200, 5); + metrics.recordRequest('GET', '/b', 200, 5); + metrics.recordRequest('POST', '/c', 500, 5); + expect(metrics.requests.byStatus[200]).toBe(2); + expect(metrics.requests.byStatus[500]).toBe(1); + }); + + test('aggregates by HTTP method', () => { + metrics.recordRequest('GET', '/a', 200, 1); + metrics.recordRequest('GET', '/b', 200, 1); + metrics.recordRequest('DELETE', '/c', 200, 1); + expect(metrics.requests.byMethod.GET).toBe(2); + expect(metrics.requests.byMethod.DELETE).toBe(1); + }); + + test('aggregates by normalized path with totalDuration', () => { + // Real-looking UUID and long hex hash; both should normalize to /:id + const id1 = '550e8400-e29b-41d4-a716-446655440000'; + const id2 = 'abcdef0123456789abcdef0123456789'; + metrics.recordRequest('GET', `/api/services/${id1}`, 200, 10); + metrics.recordRequest('GET', `/api/services/${id2}`, 200, 20); + const entry = metrics.requests.byPath['/api/services/:id']; + expect(entry).toBeDefined(); + expect(entry.count).toBe(2); + expect(entry.totalDuration).toBe(30); + }); + }); + + describe('recordError', () => { + test('increments total error count and per-type counts', () => { + metrics.recordError('ValidationError'); + metrics.recordError('ValidationError'); + metrics.recordError('DockerError'); + expect(metrics.errors.total).toBe(3); + expect(metrics.errors.byType.ValidationError).toBe(2); + expect(metrics.errors.byType.DockerError).toBe(1); + }); + }); + + describe('recordBusinessEvent', () => { + test('increments known business counters', () => { + metrics.recordBusinessEvent('containersDeployed'); + metrics.recordBusinessEvent('containersDeployed'); + metrics.recordBusinessEvent('dnsRecordsCreated'); + expect(metrics.business.containersDeployed).toBe(2); + expect(metrics.business.dnsRecordsCreated).toBe(1); + }); + + test('ignores unknown event types without throwing', () => { + expect(() => metrics.recordBusinessEvent('not-a-real-event')).not.toThrow(); + expect(metrics.business.notARealEvent).toBeUndefined(); + }); + }); + + describe('normalizePath', () => { + test('replaces UUIDs with /:id', () => { + const normalized = metrics.normalizePath('/api/services/550e8400-e29b-41d4-a716-446655440000'); + expect(normalized).toBe('/api/services/:id'); + }); + + test('replaces long hex segments with /:id', () => { + expect(metrics.normalizePath('/api/containers/abc123def4567890')) + .toBe('/api/containers/:id'); + }); + + test('replaces numeric path segments with /:n', () => { + expect(metrics.normalizePath('/api/services/42/edit')) + .toBe('/api/services/:n/edit'); + }); + + test('leaves static paths unchanged', () => { + expect(metrics.normalizePath('/api/health')).toBe('/api/health'); + expect(metrics.normalizePath('/')).toBe('/'); + }); + }); + + describe('getSummary', () => { + test('returns an object with the documented top-level shape', () => { + const summary = metrics.getSummary(); + expect(summary).toHaveProperty('uptime'); + expect(summary.uptime).toHaveProperty('ms'); + expect(summary.uptime).toHaveProperty('human'); + expect(summary).toHaveProperty('requests'); + expect(summary.requests).toHaveProperty('total'); + expect(summary.requests).toHaveProperty('perSecond'); + expect(summary.requests).toHaveProperty('byStatus'); + expect(summary.requests).toHaveProperty('byMethod'); + expect(summary.requests).toHaveProperty('topEndpoints'); + expect(Array.isArray(summary.requests.topEndpoints)).toBe(true); + expect(summary).toHaveProperty('errors'); + expect(summary.errors).toHaveProperty('total'); + expect(summary.errors).toHaveProperty('rate'); + expect(summary.errors).toHaveProperty('byType'); + expect(summary).toHaveProperty('business'); + expect(summary).toHaveProperty('process'); + expect(summary.process).toHaveProperty('pid'); + }); + + test('reflects recorded activity', () => { + metrics.recordRequest('GET', '/api/foo', 200, 10); + metrics.recordError('BoomError'); + const summary = metrics.getSummary(); + expect(summary.requests.total).toBe(1); + expect(summary.requests.byStatus[200]).toBe(1); + expect(summary.errors.total).toBe(1); + expect(summary.errors.byType.BoomError).toBe(1); + // 1 error / 1 request = 100% error rate + expect(summary.errors.rate).toBe(100); + }); + + test('topEndpoints is sorted by count descending and capped at 15', () => { + // /a gets 3 hits, /b gets 1, /c gets 2 + metrics.recordRequest('GET', '/a', 200, 1); + metrics.recordRequest('GET', '/a', 200, 2); + metrics.recordRequest('GET', '/a', 200, 3); + metrics.recordRequest('GET', '/b', 200, 1); + metrics.recordRequest('GET', '/c', 200, 1); + metrics.recordRequest('GET', '/c', 200, 2); + const top = metrics.getSummary().requests.topEndpoints; + expect(top[0].path).toBe('/a'); + expect(top[0].count).toBe(3); + expect(top[0].avgMs).toBe(2); + }); + }); + + describe('formatUptime', () => { + test('formats seconds-only when under a minute', () => { + expect(metrics.formatUptime(0)).toBe('0s'); + expect(metrics.formatUptime(45)).toBe('45s'); + }); + + test('formats minutes and seconds when under an hour', () => { + expect(metrics.formatUptime(60)).toBe('1m 0s'); + expect(metrics.formatUptime(125)).toBe('2m 5s'); + }); + + test('formats hours/minutes/seconds when under a day', () => { + expect(metrics.formatUptime(3600)).toBe('1h 0m 0s'); + expect(metrics.formatUptime(3725)).toBe('1h 2m 5s'); + }); + + test('formats days/hours/minutes when over a day', () => { + expect(metrics.formatUptime(86400)).toBe('1d 0h 0m'); + // 1 day, 2 hours, 5 minutes, 0 seconds + expect(metrics.formatUptime(86400 + 2 * 3600 + 5 * 60)).toBe('1d 2h 5m'); + }); + }); + + describe('reset', () => { + test('clears request counters and error counters', () => { + metrics.recordRequest('GET', '/x', 200, 1); + metrics.recordError('E'); + metrics.reset(); + expect(metrics.requests.total).toBe(0); + expect(metrics.errors.total).toBe(0); + expect(metrics.requests.byStatus).toEqual({}); + expect(metrics.requests.byMethod).toEqual({}); + expect(metrics.requests.byPath).toEqual({}); + expect(metrics.errors.byType).toEqual({}); + }); + + test('resets startTime so uptime is small after reset', () => { + const before = metrics.startTime; + // Sleep a tick so Date.now() moves forward + const start = Date.now(); + while (Date.now() - start < 5) {} // ~5ms busy-wait + metrics.reset(); + expect(metrics.startTime).toBeGreaterThanOrEqual(before); + const summary = metrics.getSummary(); + expect(summary.uptime.ms).toBeLessThan(5000); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/notification-manager.test.js b/dashcaddy-api/__tests__/notification-manager.test.js new file mode 100644 index 0000000..24f5939 --- /dev/null +++ b/dashcaddy-api/__tests__/notification-manager.test.js @@ -0,0 +1,217 @@ +/** + * Smoke tests for notification-manager.js + * Verifies the NotificationManager loads, exposes the expected interface, + * handles config loading/saving, sends notifications via providers, and + * correctly tracks history. + */ + +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockReturnValue('{}'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), +})); + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ + sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }), + })), +})); + +const fs = require('fs'); +const nodemailer = require('nodemailer'); +const NotificationManager = require('../src/managers/notification-manager'); + +describe('NotificationManager', () => { + let nm; + const NOTIF_FILE = '/tmp/dc-notif-test.json'; + + beforeEach(() => { + jest.clearAllMocks(); + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue('{}'); + fs.writeFileSync.mockReturnValue(undefined); + fs.mkdirSync.mockReturnValue(undefined); + + nm = new NotificationManager({ + NOTIFICATIONS_FILE: NOTIF_FILE, + log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + fetchT: jest.fn(), + docker: null, + }); + }); + + afterEach(() => { + nm.stopHealthDaemon(); + }); + + test('initializes with default config', () => { + const cfg = nm.getConfig(); + expect(cfg.enabled).toBe(true); + expect(cfg.providers).toHaveProperty('discord'); + expect(cfg.providers).toHaveProperty('telegram'); + expect(cfg.providers).toHaveProperty('ntfy'); + expect(cfg.providers).toHaveProperty('email'); + }); + + test('starts with empty history and null lastSent', () => { + expect(nm.getHistory()).toEqual([]); + expect(nm.lastSent).toBeNull(); + }); + + test('saveConfig writes the config to disk and creates parent dir', async () => { + fs.existsSync.mockReturnValue(false); + await nm.saveConfig(); + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + const callArgs = fs.writeFileSync.mock.calls[0]; + expect(callArgs[0]).toBe(NOTIF_FILE); + expect(callArgs[1]).toContain('enabled'); + }); + + test('loadConfig merges file content with defaults', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify({ enabled: false })); + const loaded = new NotificationManager({ + NOTIFICATIONS_FILE: NOTIF_FILE, + log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + }); + expect(loaded.getConfig().enabled).toBe(false); + }); + + test('clearHistory empties the history array', () => { + nm.history.push({ event: 'test', timestamp: new Date().toISOString() }); + expect(nm.getHistory().length).toBe(1); + nm.clearHistory(); + expect(nm.getHistory().length).toBe(0); + }); + + test('send returns disabled when notifications are off', async () => { + nm.config.enabled = false; + const result = await nm.send('alert', { text: 'hi' }); + expect(result.success).toBe(false); + expect(result.error).toMatch(/disabled/i); + }); + + test('send returns event-not-enabled for unknown events', async () => { + nm.config.events['some-disabled-event'] = false; + const result = await nm.send('some-disabled-event', { text: 'hi' }); + expect(result.success).toBe(false); + expect(result.error).toMatch(/not enabled/i); + }); + + test('send with no providers enabled records history and returns success:false', async () => { + const result = await nm.send('alert', { text: 'hello' }); + expect(result).toHaveProperty('results'); + expect(Array.isArray(result.results)).toBe(true); + expect(nm.getHistory().length).toBe(1); + expect(nm.getHistory()[0].event).toBe('alert'); + }); + + test('sendDiscord calls ctx.fetchT and returns success on 2xx', async () => { + nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' }; + nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true }); + const result = await nm.sendDiscord('msg', { title: 'T' }); + expect(result.success).toBe(true); + expect(nm.ctx.fetchT).toHaveBeenCalledWith( + 'https://hook.test/x', + expect.objectContaining({ method: 'POST' }) + ); + }); + + test('sendDiscord throws on non-2xx response', async () => { + nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' }; + nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: false, status: 500 }); + await expect(nm.sendDiscord('msg', null)).rejects.toThrow(/Discord/); + }); + + test('sendTelegram calls Telegram API', async () => { + nm.config.providers.telegram = { enabled: true, botToken: 'TOK', chatId: '123' }; + nm.ctx.fetchT = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }) }); + const result = await nm.sendTelegram('hello'); + expect(result.success).toBe(true); + expect(nm.ctx.fetchT).toHaveBeenCalledWith( + expect.stringContaining('api.telegram.org'), + expect.objectContaining({ method: 'POST' }) + ); + }); + + test('sendNtfy posts to the configured serverUrl + topic', async () => { + nm.config.providers.ntfy = { enabled: true, topic: 'dashcaddy', serverUrl: 'https://ntfy.sh' }; + nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true }); + const result = await nm.sendNtfy('body', 'title'); + expect(result.success).toBe(true); + expect(nm.ctx.fetchT).toHaveBeenCalledWith( + 'https://ntfy.sh/dashcaddy', + expect.objectContaining({ method: 'POST' }) + ); + }); + + test('sendEmail uses nodemailer transporter', async () => { + nm.config.providers.email = { + enabled: true, + host: 'smtp.test', + port: 587, + to: 'me@test', + from: 'from@test', + username: 'u', + password: 'p', + }; + const result = await nm.sendEmail('subject', 'body'); + expect(result.success).toBe(true); + expect(nodemailer.createTransport).toHaveBeenCalled(); + }); + + test('sendAlert, sendBackupComplete, sendServiceEvent do not throw', async () => { + const alertResult = await nm.sendAlert({ + containerName: 'web', + alerts: [{ type: 'cpu', severity: 'warning', message: 'high' }], + timestamp: new Date().toISOString(), + }); + expect(alertResult).toBeDefined(); + + const backupResult = await nm.sendBackupComplete({ + name: 'daily', + status: 'success', + }); + expect(backupResult).toBeDefined(); + + const serviceResult = await nm.sendServiceEvent('container-down', { + name: 'web', + containerName: 'sami-web', + }); + expect(serviceResult).toBeDefined(); + }); + + test('checkHealth returns checked:false when no docker client', async () => { + nm.ctx.docker = null; + const r = await nm.checkHealth(); + expect(r.checked).toBe(false); + }); + + test('checkHealth with mocked docker returns checked:true', async () => { + nm.ctx.docker = { + listContainers: jest.fn().mockResolvedValue([ + { Id: 'aaaabbbbcccc', Names: ['/web'], State: 'running', Status: 'Up' }, + { Id: 'ddddeeeeffff', Names: ['/api'], State: 'exited', Status: 'Exited' }, + ]), + }; + nm.config.healthCheck = { enabled: true, intervalMinutes: 5 }; + const r = await nm.checkHealth(); + expect(r.checked).toBe(true); + expect(r.containersMonitored).toBe(2); + }); + + test('formatTitle returns a string for known events', () => { + expect(typeof nm._formatTitle('alert')).toBe('string'); + expect(typeof nm._formatTitle('unknown')).toBe('string'); + }); + + test('startHealthDaemon and stopHealthDaemon are idempotent', () => { + nm.startHealthDaemon(); + nm.startHealthDaemon(); // should not double-schedule + nm.stopHealthDaemon(); + nm.stopHealthDaemon(); + expect(nm.healthDaemonInterval).toBeNull(); + }); +}); diff --git a/dashcaddy-api/__tests__/pagination.test.js b/dashcaddy-api/__tests__/pagination.test.js index 26ab26f..0bfdf0f 100644 --- a/dashcaddy-api/__tests__/pagination.test.js +++ b/dashcaddy-api/__tests__/pagination.test.js @@ -1,4 +1,4 @@ -const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../pagination'); +const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../src/utilities/pagination'); describe('Pagination — DashCaddy list endpoints', () => { diff --git a/dashcaddy-api/__tests__/port-lock-manager.test.js b/dashcaddy-api/__tests__/port-lock-manager.test.js index 2b2df49..4db50a1 100644 --- a/dashcaddy-api/__tests__/port-lock-manager.test.js +++ b/dashcaddy-api/__tests__/port-lock-manager.test.js @@ -16,7 +16,7 @@ fs.unlinkSync.mockReturnValue(undefined); lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); lockfile.check.mockResolvedValue(false); -const portLockManager = require('../port-lock-manager'); +const portLockManager = require('../src/managers/port-lock-manager'); beforeEach(() => { jest.clearAllMocks(); diff --git a/dashcaddy-api/__tests__/resource-monitor.test.js b/dashcaddy-api/__tests__/resource-monitor.test.js index 27417d4..9c738f0 100644 --- a/dashcaddy-api/__tests__/resource-monitor.test.js +++ b/dashcaddy-api/__tests__/resource-monitor.test.js @@ -12,7 +12,7 @@ fs.existsSync.mockReturnValue(false); fs.readFileSync.mockReturnValue('{}'); fs.writeFileSync.mockReturnValue(undefined); -const resourceMonitor = require('../resource-monitor'); +const resourceMonitor = require('../src/managers/resource-monitor'); function makeStat(overrides = {}) { return { diff --git a/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js b/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js new file mode 100644 index 0000000..a5d93fa --- /dev/null +++ b/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js @@ -0,0 +1,483 @@ +/** + * Integration tests for routes/auth/totp.js — the full TOTP auth flow. + * + * Covers the BACKLOG.md DC-006 acceptance criteria: + * - no code → 400 (ValidationError) + * - wrong code → 401 (AuthenticationError) + * - valid TOTP → 200 + session cookie + CSRF token + * - check-session with valid session → 200 { authenticated: true } + * - check-session without session → 401 (AuthenticationError) + * + * Uses real otplib for code generation (so we exercise the actual TOTP math) + * but mocks credentialManager, session, totpConfig, and saveTotpConfig — + * because those modules own their own state machines (disk, cookies, file) + * that don't belong in a routes-level test. + * + * NOTE: this test exercises the src/ refactored module layout (DC-005). + * It depends on routes/auth/totp.js requiring ../../src/utilities/errors and + * ../../src/utils/responses — fix the relative paths in totp.js if they + * regress (see commit log for DC-006). + */ + +const express = require('express'); +const request = require('supertest'); +const { authenticator } = require('otplib'); + +// Quiet otplib's "Unescaped left brace" warning on Node 20+ +const origWarn = console.warn; +beforeAll(() => { + console.warn = (...args) => { + const msg = args.join(' '); + if (msg.includes('Unescaped left brace')) return; + origWarn.apply(console, args); + }; +}); +afterAll(() => { + console.warn = origWarn; +}); + +// Minimal asyncHandler that catches errors into the express error chain +function asyncHandler(fn) { + return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next); +} + +function createApp(depsOverride = {}) { + // In-memory secret store so credentialManager stays deterministic + const storedSecrets = new Map(); + const credentialManager = { + store: jest.fn((key, value) => { + storedSecrets.set(key, value); + return Promise.resolve(true); + }), + retrieve: jest.fn((key) => Promise.resolve(storedSecrets.has(key) ? storedSecrets.get(key) : null)), + delete: jest.fn((key) => { + storedSecrets.delete(key); + return Promise.resolve(true); + }), + list: jest.fn(() => Promise.resolve(Array.from(storedSecrets.keys()))), + }; + + // Mutable TOTP config — tests mutate this to model setup → enable → disable + const totpConfig = { + enabled: false, + isSetUp: false, + sessionDuration: '24h', + secret: null, // matches main's optional backup-secret field + }; + + // Mock session context mirroring src/context/session.js + // isValid() is the knob — toggle it to test the auth-gate behavior + const sessionStore = new Map(); // ip → { expiresAt } + const session = { + create: jest.fn((req, duration) => { + const ip = session.getClientIP(req); + sessionStore.set(ip, { expiresAt: Date.now() + (duration === 'never' ? Number.MAX_SAFE_INTEGER : 3600000) }); + }), + setCookie: jest.fn(), + clear: jest.fn((req) => { + const ip = session.getClientIP(req); + sessionStore.delete(ip); + }), + clearCookie: jest.fn(), + isValid: jest.fn((req) => { + const ip = session.getClientIP(req); + const entry = sessionStore.get(ip); + if (!entry) return false; + return entry.expiresAt > Date.now(); + }), + // Test helper — pretend an IP has a valid session, regardless of req.ip + _grantSession: (ip = '127.0.0.1') => sessionStore.set(ip, { expiresAt: Date.now() + 3600000 }), + getClientIP: jest.fn((req) => req.ip || req.connection?.remoteAddress || '127.0.0.1'), + ipSessions: sessionStore, + durations: { '1h': 3600000, '24h': 86400000, '7d': 604800000, 'never': 0 }, + }; + + const saveTotpConfig = jest.fn(() => Promise.resolve(true)); + const renewCSRFToken = jest.fn(() => 'mock-csrf-token'); + const log = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }; + + const deps = { + authManager: {}, // unused by totp.js but required by the factory signature + credentialManager, + totpConfig, + saveTotpConfig, + session, + asyncHandler, + errorResponse: jest.fn(), + log, + renewCSRFToken, + ...depsOverride, + }; + + // Clear store between tests + deps._resetStore = () => { + storedSecrets.clear(); + sessionStore.clear(); + totpConfig.enabled = false; + totpConfig.isSetUp = false; + totpConfig.sessionDuration = '24h'; + delete totpConfig.secret; + }; + + const totpRoutes = require('../../routes/auth/totp'); + const app = express(); + app.set('trust proxy', true); // so req.ip populates from X-Forwarded-For + app.use(express.json()); + app.use('/api', totpRoutes(deps)); + // Express error handler — surface status from thrown AppError + app.use((err, req, res, _next) => { + const status = err.statusCode || 500; + res.status(status).json({ success: false, error: err.message }); + }); + + return { app, deps }; +} + +describe('TOTP Auth Routes — DC-006 Integration Test', () => { + let app; + let deps; + + beforeEach(() => { + jest.clearAllMocks(); + ({ app, deps } = createApp()); + authenticator.options = { window: 1 }; + }); + + // Helper: derive a fresh secret + a valid current TOTP code for it + function freshSecret() { + const secret = authenticator.generateSecret(); + const token = authenticator.generate(secret); + return { secret, token }; + } + + // ──────────────────────────────────────────────────────────────────── + // GET /api/totp/config + // ──────────────────────────────────────────────────────────────────── + describe('GET /api/totp/config', () => { + it('returns current config (enabled=false, isSetUp=false by default)', async () => { + const res = await request(app).get('/api/totp/config'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.config).toEqual({ + enabled: false, + sessionDuration: '24h', + isSetUp: false, + }); + }); + + it('reflects state changes after setup completes', async () => { + deps.totpConfig.isSetUp = true; + deps.totpConfig.enabled = true; + const res = await request(app).get('/api/totp/config'); + expect(res.status).toBe(200); + expect(res.body.config.isSetUp).toBe(true); + expect(res.body.config.enabled).toBe(true); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // POST /api/totp/setup + // ──────────────────────────────────────────────────────────────────── + describe('POST /api/totp/setup', () => { + it('generates a fresh secret + QR code when none is provided', async () => { + const res = await request(app).post('/api/totp/setup').send({}); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/); + expect(res.body.manualKey).toMatch(/^[A-Z2-7]{16,}$/); + expect(res.body.issuer).toBe('DashCaddy'); + expect(res.body.imported).toBe(false); + // pending_secret should be stashed but totp.secret should NOT be active yet + expect(deps.credentialManager.store).toHaveBeenCalledWith('totp.pending_secret', res.body.manualKey); + expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull(); + }); + + it('accepts and normalizes a user-provided Base32 secret (0→O, 1→L, 8→B, lowercase→uppercase)', async () => { + const raw = 'JBSWY3DPEHPK3PXP'; // canonical example + const userInput = ' jbswy3dpehpk3pxp '; // spaces + lowercase + const res = await request(app).post('/api/totp/setup').send({ secret: userInput }); + expect(res.status).toBe(200); + expect(res.body.manualKey).toBe(raw); + expect(res.body.imported).toBe(true); + }); + + it('rejects an obviously invalid secret (wrong alphabet)', async () => { + const res = await request(app).post('/api/totp/setup').send({ secret: 'NOT-VALID-BASE32!' }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toMatch(/Invalid secret key format/); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // POST /api/totp/verify-setup (activates TOTP after setup) + // ──────────────────────────────────────────────────────────────────── + describe('POST /api/totp/verify-setup', () => { + it('returns 400 when code is missing or malformed', async () => { + const res = await request(app).post('/api/totp/verify-setup').send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid code format/); + }); + + it('returns 400 when no pending setup exists', async () => { + const { token } = freshSecret(); + const res = await request(app).post('/api/totp/verify-setup').send({ code: token }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/No pending TOTP setup/); + }); + + it('returns 401 when code is wrong', async () => { + const { secret } = freshSecret(); + await request(app).post('/api/totp/setup').send({ secret }); + const res = await request(app).post('/api/totp/verify-setup').send({ code: '000000' }); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/DC-111/); + }); + + it('returns 200 + activates TOTP + creates session on valid code', async () => { + const { secret, token } = freshSecret(); + await request(app).post('/api/totp/setup').send({ secret }); + const res = await request(app).post('/api/totp/verify-setup').send({ code: token }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toMatch(/TOTP enabled successfully/); + + // TOTP config activated + persisted + expect(deps.totpConfig.isSetUp).toBe(true); + expect(deps.totpConfig.enabled).toBe(true); + expect(deps.saveTotpConfig).toHaveBeenCalled(); + + // pending_secret → totp.secret promotion, pending cleared + expect(await deps.credentialManager.retrieve('totp.secret')).toBe(secret); + expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull(); + + // Session established + expect(deps.session.create).toHaveBeenCalled(); + expect(deps.session.setCookie).toHaveBeenCalled(); + // Note: renewCSRFToken is only called on /totp/verify (login), not /totp/verify-setup + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // POST /api/totp/verify (login flow — TOTP already configured) + // ──────────────────────────────────────────────────────────────────── + describe('POST /api/totp/verify (login)', () => { + async function setupTOTP() { + const { secret, token } = freshSecret(); + await request(app).post('/api/totp/setup').send({ secret }); + await request(app).post('/api/totp/verify-setup').send({ code: token }); + // Reset mocks but keep config/secret state for the test + jest.clearAllMocks(); + return secret; + } + + it('returns 400 when code is missing', async () => { + const res = await request(app).post('/api/totp/verify').send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid code format/); + }); + + it('returns 400 when TOTP is not enabled', async () => { + const res = await request(app).post('/api/totp/verify').send({ code: '123456' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/TOTP is not enabled/); + }); + + it('returns 401 when code is wrong (TOTP active)', async () => { + await setupTOTP(); + const res = await request(app).post('/api/totp/verify').send({ code: '000000' }); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/DC-111/); + }); + + it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => { + const secret = await setupTOTP(); + const token = authenticator.generate(secret); + const res = await request(app).post('/api/totp/verify').send({ code: token }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toMatch(/Authenticated successfully/); + expect(res.body.csrfToken).toBe('mock-csrf-token'); + expect(deps.session.create).toHaveBeenCalled(); + expect(deps.session.setCookie).toHaveBeenCalled(); + expect(deps.renewCSRFToken).toHaveBeenCalled(); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // GET /api/totp/check-session (the auth gate Caddy calls) + // ──────────────────────────────────────────────────────────────────── + describe('GET /api/totp/check-session', () => { + it('always returns 200 when TOTP is not enabled (passthrough)', async () => { + const res = await request(app).get('/api/totp/check-session'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authenticated: true }); + }); + + it('always returns 200 when sessionDuration is "never" (passthrough)', async () => { + deps.totpConfig.enabled = true; + deps.totpConfig.isSetUp = true; + deps.totpConfig.sessionDuration = 'never'; + const res = await request(app).get('/api/totp/check-session'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authenticated: true }); + }); + + it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => { + deps.totpConfig.enabled = true; + deps.totpConfig.isSetUp = true; + deps.totpConfig.sessionDuration = '24h'; + // session.isValid returns false because sessionStore is empty + const res = await request(app).get('/api/totp/check-session'); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/Session expired or invalid/); + // Cache-control headers must be set to avoid Caddy auth loops + expect(res.headers['cache-control']).toMatch(/no-store/); + }); + + it('returns 200 { authenticated: true } when session is valid (BACKLOG: "authenticated request succeeds")', async () => { + deps.totpConfig.enabled = true; + deps.totpConfig.isSetUp = true; + deps.totpConfig.sessionDuration = '24h'; + // Pre-populate the session store as if verify already ran + deps.session._grantSession('127.0.0.1'); + const res = await request(app).get('/api/totp/check-session').set('X-Forwarded-For', '127.0.0.1'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authenticated: true }); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // POST /api/totp/disable + // ──────────────────────────────────────────────────────────────────── + describe('POST /api/totp/disable', () => { + async function setupTOTP() { + const { secret, token } = freshSecret(); + await request(app).post('/api/totp/setup').send({ secret }); + await request(app).post('/api/totp/verify-setup').send({ code: token }); + jest.clearAllMocks(); + return secret; + } + + it('returns 400 when TOTP is active but no code is provided', async () => { + await setupTOTP(); + const res = await request(app).post('/api/totp/disable').send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/valid TOTP code is required/); + }); + + it('returns 401 when code is wrong', async () => { + await setupTOTP(); + const res = await request(app).post('/api/totp/disable').send({ code: '000000' }); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/DC-111/); + }); + + it('returns 200 + clears TOTP state on valid code', async () => { + const secret = await setupTOTP(); + const code = authenticator.generate(secret); + const res = await request(app).post('/api/totp/disable').send({ code }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + // TOTP disabled, secrets cleared, session cleared + expect(deps.totpConfig.enabled).toBe(false); + expect(deps.totpConfig.isSetUp).toBe(false); + expect(deps.totpConfig.sessionDuration).toBe('never'); + expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull(); + expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull(); + expect(deps.session.clear).toHaveBeenCalled(); + expect(deps.session.clearCookie).toHaveBeenCalled(); + expect(deps.saveTotpConfig).toHaveBeenCalled(); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // POST /api/totp/config (session duration change) + // ──────────────────────────────────────────────────────────────────── + describe('POST /api/totp/config (update settings)', () => { + it('updates sessionDuration with a valid value', async () => { + const res = await request(app).post('/api/totp/config').send({ sessionDuration: '7d' }); + expect(res.status).toBe(200); + expect(res.body.config.sessionDuration).toBe('7d'); + expect(deps.saveTotpConfig).toHaveBeenCalled(); + }); + + it('rejects an invalid sessionDuration', async () => { + const res = await request(app).post('/api/totp/config').send({ sessionDuration: '99y' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid session duration/); + }); + + it('setting sessionDuration to "never" disables TOTP', async () => { + deps.totpConfig.enabled = true; + deps.totpConfig.isSetUp = true; + const res = await request(app).post('/api/totp/config').send({ sessionDuration: 'never' }); + expect(res.status).toBe(200); + expect(deps.totpConfig.sessionDuration).toBe('never'); + expect(deps.totpConfig.enabled).toBe(false); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // End-to-end flow (BACKLOG: "Cover the full /api/auth/check → session → endpoint flow") + // ──────────────────────────────────────────────────────────────────── + describe('End-to-end: setup → login → check-session → disable', () => { + it('walks the full BACKLOG DC-006 flow', async () => { + // 1. Setup — generate a fresh secret + const setupRes = await request(app).post('/api/totp/setup').send({}); + expect(setupRes.status).toBe(200); + const secret = setupRes.body.manualKey; + const setupCode = authenticator.generate(secret); + + // 2. Verify-setup — activate TOTP + const verifySetupRes = await request(app).post('/api/totp/verify-setup').send({ code: setupCode }); + expect(verifySetupRes.status).toBe(200); + expect(deps.totpConfig.isSetUp).toBe(true); + + // 3. Simulate session expiry by clearing the store + deps.session.ipSessions.clear(); + + // 4. Re-login via /totp/verify (the "login" path) + const loginCode = authenticator.generate(secret); + const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode }); + expect(loginRes.status).toBe(200); + expect(loginRes.body.csrfToken).toBeDefined(); + + // 5. Check-session — should now be authenticated (the BACKLOG "→ endpoint succeeds" step) + const checkRes = await request(app).get('/api/totp/check-session'); + expect(checkRes.status).toBe(200); + expect(checkRes.body).toEqual({ authenticated: true }); + + // 6. Logout / disable + const disableCode = authenticator.generate(secret); + const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode }); + expect(disableRes.status).toBe(200); + + // 7. After disable, check-session should be passthrough (TOTP off) + const afterRes = await request(app).get('/api/totp/check-session'); + expect(afterRes.status).toBe(200); + expect(afterRes.body).toEqual({ authenticated: true }); + }); + + it('proves otplib is real (not stubbed) by using a totally bogus code', async () => { + // Sanity check that the test harness is using real otplib, not a stub. + // otplib 12.0.1's authenticator.generate(secret) does not accept a {time} option + // (the signature is fixed to current-time TOTP), so a "stale code" test isn't + // reproducible across runs. Instead, we verify otplib rejects a code that is + // syntactically valid (6 digits) but doesn't match the live TOTP slot. + const secret = authenticator.generateSecret(); + await request(app).post('/api/totp/setup').send({ secret }); + // Generate the real current code, then mutate it — must be rejected + const realCode = authenticator.generate(secret); + const tampered = realCode === '000000' ? '111111' : '000000'; + const res = await request(app).post('/api/totp/verify-setup').send({ code: tampered }); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/routes/containers.routes.test.js b/dashcaddy-api/__tests__/routes/containers.routes.test.js index 0f85da0..65521cf 100644 --- a/dashcaddy-api/__tests__/routes/containers.routes.test.js +++ b/dashcaddy-api/__tests__/routes/containers.routes.test.js @@ -9,7 +9,7 @@ function buildApp(mockDeps) { const app = express(); app.use(express.json()); - const { errorMiddleware } = require('../../error-handler'); + const { errorMiddleware } = require('../../src/utilities/error-handler'); const containersRouteFactory = require('../../routes/containers'); app.use('/api/containers', containersRouteFactory(mockDeps)); app.use(errorMiddleware); diff --git a/dashcaddy-api/__tests__/routes/health.routes.test.js b/dashcaddy-api/__tests__/routes/health.routes.test.js index 558e381..0a04cd1 100644 --- a/dashcaddy-api/__tests__/routes/health.routes.test.js +++ b/dashcaddy-api/__tests__/routes/health.routes.test.js @@ -52,21 +52,21 @@ jest.mock('../../platform-paths', () => ({ })); // Mock fs-helpers.exists -jest.mock('../../fs-helpers', () => ({ +jest.mock('../../src/utilities/fs-helpers', () => ({ exists: jest.fn().mockResolvedValue(true), })); -jest.mock('../../url-resolver', () => ({ +jest.mock('../../src/utilities/url-resolver', () => ({ resolveServiceUrl: jest.fn((id) => `https://${id}.test`), })); -jest.mock('../../pagination', () => ({ +jest.mock('../../src/utilities/pagination', () => ({ paginate: jest.fn((data, params) => ({ data, pagination: null })), parsePaginationParams: jest.fn(() => null), })); -const { exists } = require('../../fs-helpers'); -const { resolveServiceUrl } = require('../../url-resolver'); +const { exists } = require('../../src/utilities/fs-helpers'); +const { resolveServiceUrl } = require('../../src/utilities/url-resolver'); const { execSync } = require('child_process'); describe('Health Routes', () => { @@ -538,7 +538,7 @@ describe('Health Routes', () => { const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); - expect(res.body.status).toBe('healthy'); + expect(res.body.caStatus).toBe('healthy'); expect(res.body.daysUntilExpiration).toBeGreaterThan(90); }); @@ -551,7 +551,7 @@ describe('Health Routes', () => { const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); - expect(res.body.status).toBe('warning'); + expect(res.body.caStatus).toBe('warning'); expect(res.body.daysUntilExpiration).toBeLessThan(90); expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30); }); @@ -565,7 +565,7 @@ describe('Health Routes', () => { const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); - expect(res.body.status).toBe('critical'); + expect(res.body.caStatus).toBe('critical'); expect(res.body.daysUntilExpiration).toBeLessThan(30); expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0); }); @@ -579,7 +579,7 @@ describe('Health Routes', () => { const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); - expect(res.body.status).toBe('critical'); + expect(res.body.caStatus).toBe('critical'); expect(res.body.daysUntilExpiration).toBeLessThan(7); }); @@ -592,7 +592,7 @@ describe('Health Routes', () => { const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); - expect(res.body.status).toBe('critical'); + expect(res.body.caStatus).toBe('critical'); expect(res.body.daysUntilExpiration).toBeLessThan(0); expect(res.body.message).toMatch(/EXPIRED/); }); @@ -601,9 +601,9 @@ describe('Health Routes', () => { exists.mockResolvedValue(false); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); - expect(res.status).toBe(200); - expect(res.body.status).toBe('error'); - expect(res.body.message).toMatch(/not found/); + expect(res.status).toBe(404); + expect(res.body.caStatus).toBe('error'); + expect(res.body.error).toMatch(/not found/); expect(res.body.daysUntilExpiration).toBeNull(); }); @@ -612,9 +612,9 @@ describe('Health Routes', () => { execSync.mockImplementation(() => { throw new Error('openssl not found'); }); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); - expect(res.status).toBe(200); - expect(res.body.status).toBe('error'); - expect(res.body.message).toBe('openssl not found'); + expect(res.status).toBe(500); + expect(res.body.caStatus).toBe('error'); + expect(res.body.error).toBe('openssl not found'); expect(res.body.daysUntilExpiration).toBeNull(); }); }); diff --git a/dashcaddy-api/__tests__/routes/services.routes.test.js b/dashcaddy-api/__tests__/routes/services.routes.test.js index 5506a47..08c4911 100644 --- a/dashcaddy-api/__tests__/routes/services.routes.test.js +++ b/dashcaddy-api/__tests__/routes/services.routes.test.js @@ -9,32 +9,32 @@ function asyncHandler(fn) { } // Mock modules that services.js requires at top-level -jest.mock('../../constants', () => ({ +jest.mock('../../src/utilities/constants', () => ({ APP: { USER_AGENTS: { PROBE: 'DashCaddy/1.0' } }, REGEX: { SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ }, TIMEOUTS: { DEFAULT: 10000 }, HTTP_STATUS: { OK: 200, CREATED: 201, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_ERROR: 500 } })); -jest.mock('../../input-validator', () => ({ +jest.mock('../../src/security/input-validator', () => ({ validateServiceConfig: jest.fn(), isValidPort: jest.fn(p => p >= 1 && p <= 65535), })); -jest.mock('../../fs-helpers', () => ({ +jest.mock('../../src/utilities/fs-helpers', () => ({ exists: jest.fn().mockResolvedValue(true), })); -jest.mock('../../url-resolver', () => ({ +jest.mock('../../src/utilities/url-resolver', () => ({ resolveServiceUrl: jest.fn((id) => `https://${id}.test`), })); -jest.mock('../../pagination', () => ({ +jest.mock('../../src/utilities/pagination', () => ({ paginate: jest.fn((data, params) => ({ data, pagination: null })), parsePaginationParams: jest.fn(() => null), })); -jest.mock('../../response-helpers', () => ({ +jest.mock('../../src/utils/responses', () => ({ success: jest.fn((res, data, statusCode = 200) => { return res.status(statusCode).json({ success: true, ...data }); }), @@ -45,8 +45,8 @@ jest.mock('../../response-helpers', () => ({ // errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError -const { exists } = require('../../fs-helpers'); -const { validateServiceConfig } = require('../../input-validator'); +const { exists } = require('../../src/utilities/fs-helpers'); +const { validateServiceConfig } = require('../../src/security/input-validator'); function createApp(depsOverride = {}) { const defaultDeps = { @@ -103,12 +103,12 @@ describe('Services Routes', () => { }); describe('GET /api/services', () => { - it('returns empty array when no services file', async () => { + it('returns empty services array (enveloped) when no services file', async () => { exists.mockResolvedValue(false); const { app } = createApp(); const res = await request(app).get('/api/services'); expect(res.status).toBe(200); - expect(res.body).toEqual([]); + expect(res.body).toEqual({ success: true, services: [] }); }); it('returns services list', async () => { @@ -450,7 +450,7 @@ describe('Services Routes', () => { }); it('rejects invalid port', async () => { - const { isValidPort } = require('../../input-validator'); + const { isValidPort } = require('../../src/security/input-validator'); isValidPort.mockReturnValue(false); const { app } = createApp(); const res = await request(app) diff --git a/dashcaddy-api/__tests__/ssl-monitor.test.js b/dashcaddy-api/__tests__/ssl-monitor.test.js new file mode 100644 index 0000000..a6861cc --- /dev/null +++ b/dashcaddy-api/__tests__/ssl-monitor.test.js @@ -0,0 +1,203 @@ +/** + * Smoke tests for ssl-monitor.js + * Verifies SSLMonitor loads, exposes the expected interface, can check + * certificates via mocked TLS, manage state, and persist cache. + */ + +jest.mock('tls', () => ({ + connect: jest.fn(), +})); + +jest.mock('../src/utilities/fs-helpers', () => ({ + readJsonFile: jest.fn().mockResolvedValue(null), + writeJsonFile: jest.fn().mockResolvedValue(undefined), +})); + +const tls = require('tls'); +const fsHelpers = require('../src/utilities/fs-helpers'); +const SSLMonitor = require('../src/monitoring/ssl-monitor'); + +function makeSocket({ cert = null, error = null } = {}) { + const { EventEmitter } = require('events'); + const socket = new EventEmitter(); + socket.destroy = jest.fn(); + socket.getPeerCertificate = jest.fn(() => cert); + socket.setTimeout = jest.fn(); + + // Simulate 'connect' on next tick (or 'error') + process.nextTick(() => { + if (error) socket.emit('error', error); + }); + + return socket; +} + +describe('SSLMonitor', () => { + let monitor; + const fakeStateManager = { + read: jest.fn().mockResolvedValue([]), + }; + + beforeEach(() => { + jest.clearAllMocks(); + fsHelpers.readJsonFile.mockResolvedValue(null); + fsHelpers.writeJsonFile.mockResolvedValue(undefined); + fakeStateManager.read.mockResolvedValue([]); + + monitor = new SSLMonitor({ + log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + servicesStateManager: fakeStateManager, + siteConfig: {}, + buildServiceUrl: id => `https://${id}.sami`, + notification: null, + }); + }); + + afterEach(() => { + monitor.stop(); + }); + + test('initializes with empty maps and default config', () => { + expect(monitor.certStatus).toBeInstanceOf(Map); + expect(monitor.notifiedThresholds).toBeInstanceOf(Map); + expect(monitor.hostnameToServiceId).toBeInstanceOf(Map); + expect(monitor.intervalHandle).toBeNull(); + expect(monitor.config.enabled).toBe(true); + expect(typeof monitor.config.intervalMs).toBe('number'); + }); + + test('getConfig returns a copy of the current config', () => { + const cfg = monitor.getConfig(); + expect(cfg).toEqual(monitor.config); + cfg.enabled = false; + // The internal config must not be mutated + expect(monitor.config.enabled).toBe(true); + }); + + test('updateConfig updates enabled and intervalMs', () => { + monitor.updateConfig({ enabled: false, intervalMs: 60000 }); + expect(monitor.config.enabled).toBe(false); + expect(monitor.config.intervalMs).toBe(60000); + }); + + test('updateConfig rejects intervalMs below 60000', () => { + const original = monitor.config.intervalMs; + monitor.updateConfig({ intervalMs: 1000 }); + expect(monitor.config.intervalMs).toBe(original); + }); + + test('getStatus returns an empty object when no checks have run', () => { + expect(monitor.getStatus()).toEqual({}); + }); + + test('getServiceCertStatus returns null for unknown service', () => { + expect(monitor.getServiceCertStatus('unknown-svc')).toBeNull(); + }); + + test('checkCert rejects when peer cert is empty', async () => { + tls.connect.mockImplementation((_opts, onConnect) => { + const sock = makeSocket({ cert: {} }); + // Simulate immediate 'connect' + setImmediate(() => onConnect && onConnect()); + return sock; + }); + + await expect(monitor.checkCert('empty.sami')).rejects.toThrow(/No certificate/); + }); + + test('checkCert resolves with cert details on success', async () => { + const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // +60d + const validTo = futureDate.toUTCString(); + tls.connect.mockImplementation((_opts, onConnect) => { + const sock = makeSocket({ + cert: { + subject: { CN: 'test.sami' }, + issuer: { O: "Sami's CA" }, + valid_from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toUTCString(), + valid_to: validTo, + fingerprint: 'AA:BB:CC', + }, + }); + setImmediate(() => onConnect && onConnect()); + return sock; + }); + + const result = await monitor.checkCert('test.sami', 443); + expect(result.hostname).toBe('test.sami'); + expect(result.port).toBe(443); + expect(result.subject).toBe('test.sami'); + expect(result.daysRemaining).toBeGreaterThan(0); + expect(typeof result.isExpiring).toBe('boolean'); + expect(typeof result.checkedAt).toBe('string'); + }); + + test('checkCert rejects with TLS error event', async () => { + tls.connect.mockImplementation(() => { + const sock = makeSocket({ error: new Error('TLS boom') }); + return sock; + }); + await expect(monitor.checkCert('broken.sami')).rejects.toThrow(/TLS/); + }); + + test('checkAll returns empty status when no services configured', async () => { + const status = await monitor.checkAll(); + expect(status).toEqual({}); + }); + + test('checkAll handles HTTPS services and stores results', async () => { + fakeStateManager.read.mockResolvedValue([ + { id: 'web', name: 'Web', url: 'https://web.sami' }, + ]); + const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); + tls.connect.mockImplementation((_opts, onConnect) => { + const sock = makeSocket({ + cert: { + subject: { CN: 'web.sami' }, + issuer: { O: "Sami's CA" }, + valid_from: new Date().toUTCString(), + valid_to: futureDate.toUTCString(), + fingerprint: 'AA:BB:CC', + }, + }); + setImmediate(() => onConnect && onConnect()); + return sock; + }); + + const status = await monitor.checkAll(); + expect(status['web.sami']).toBeDefined(); + expect(status['web.sami'].hostname).toBe('web.sami'); + expect(monitor.getServiceCertStatus('web')).not.toBeNull(); + }); + + test('start() schedules periodic checks and stop() clears them', () => { + jest.useFakeTimers(); + const originalCheckAll = monitor.checkAll.bind(monitor); + monitor.checkAll = jest.fn().mockResolvedValue(undefined); + monitor.start(120000); + expect(monitor.intervalHandle).not.toBeNull(); + monitor.stop(); + expect(monitor.intervalHandle).toBeNull(); + monitor.checkAll = originalCheckAll; + jest.useRealTimers(); + }); + + test('_saveCache and _loadCache round-trip via fs-helpers', async () => { + await monitor._saveCache(); + expect(fsHelpers.writeJsonFile).toHaveBeenCalled(); + + fsHelpers.readJsonFile.mockResolvedValue({ + lastChecked: new Date().toISOString(), + certs: { 'a.sami': { hostname: 'a.sami', daysRemaining: 30 } }, + hostnameToServiceId: { 'a.sami': 'svc-a' }, + }); + const fresh = new SSLMonitor({ + log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + servicesStateManager: fakeStateManager, + siteConfig: {}, + buildServiceUrl: id => `https://${id}.sami`, + }); + await fresh._loadCache(); + expect(fresh.certStatus.get('a.sami')).toBeDefined(); + expect(fresh.hostnameToServiceId.get('a.sami')).toBe('svc-a'); + }); +}); diff --git a/dashcaddy-api/__tests__/state-manager.test.js b/dashcaddy-api/__tests__/state-manager.test.js index 116615f..1ec428d 100644 --- a/dashcaddy-api/__tests__/state-manager.test.js +++ b/dashcaddy-api/__tests__/state-manager.test.js @@ -11,7 +11,7 @@ jest.mock('fs', () => ({ const lockfile = require('proper-lockfile'); const fs = require('fs'); -const StateManager = require('../state-manager'); +const StateManager = require('../src/managers/state-manager'); describe('StateManager', () => { let sm; diff --git a/dashcaddy-api/__tests__/update-manager.test.js b/dashcaddy-api/__tests__/update-manager.test.js index edcfe66..19a6bea 100644 --- a/dashcaddy-api/__tests__/update-manager.test.js +++ b/dashcaddy-api/__tests__/update-manager.test.js @@ -22,7 +22,7 @@ fs.existsSync.mockReturnValue(false); fs.readFileSync.mockReturnValue('{}'); fs.writeFileSync.mockReturnValue(undefined); -const updateManager = require('../update-manager'); +const updateManager = require('../src/managers/update-manager'); // Helper to create a fake https request that responds with a given statusCode/headers/body function mockHttpsResponse({ statusCode = 200, headers = {}, body = '' } = {}) { diff --git a/dashcaddy-api/__tests__/url-resolver.test.js b/dashcaddy-api/__tests__/url-resolver.test.js index ced64f2..d51d617 100644 --- a/dashcaddy-api/__tests__/url-resolver.test.js +++ b/dashcaddy-api/__tests__/url-resolver.test.js @@ -1,4 +1,4 @@ -const { resolveServiceUrl } = require('../url-resolver'); +const { resolveServiceUrl } = require('../src/utilities/url-resolver'); describe('URL Resolver — DashCaddy service URL resolution', () => { const buildServiceUrl = jest.fn(id => `https://${id}.sami`); diff --git a/dashcaddy-api/error-handler.js b/dashcaddy-api/error-handler.js deleted file mode 100644 index 2e311a2..0000000 --- a/dashcaddy-api/error-handler.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * DashCaddy Error Handler Middleware - * Centralizes error handling logic to eliminate duplicate catch blocks - */ - -const { AppError } = require('./errors'); -const { logError } = require('./error-logger'); - -/** - * 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 - * MUST be registered after all routes in server.js - */ -function errorMiddleware(err, req, res, next) { - // Log all errors with request context - logError(req.path, err, { - method: req.method, - ip: req.ip, - userId: req.user?.id, - body: req.body - }); - - // Determine if this is an operational error (AppError) or programming error - const isOperational = err.isOperational || err instanceof AppError; - - // Status code - const statusCode = err.statusCode || 500; - - // Error code (DC-XXX format) - const code = err.code || `DC-${statusCode}`; - - // Build response - const response = { - success: false, - error: isOperational ? err.message : 'Internal server error', - code - }; - - // Add optional fields if present - if (err.requiresTotp) response.requiresTotp = true; - if (err.retryAfter) response.retryAfter = err.retryAfter; - if (err.field) response.field = err.field; - if (err.resource) response.resource = err.resource; - if (err.details && Object.keys(err.details).length > 0) response.details = err.details; - - // Development mode: include stack trace - if (process.env.NODE_ENV === 'development') { - response.stack = err.stack; - } - - // Send response - res.status(statusCode).json(response); - - // For non-operational errors, log as fatal - if (!isOperational) { - console.error('FATAL: Non-operational error detected', { - error: err.message, - stack: err.stack, - path: req.path - }); - } -} - -/** - * 404 handler for routes not found - * Register this before the global error handler - */ -function notFoundHandler(req, res, next) { - const { NotFoundError } = require('./errors'); - next(new NotFoundError(`Route ${req.method} ${req.path}`)); -} - -module.exports = { - asyncHandler, - errorMiddleware, - notFoundHandler -}; diff --git a/dashcaddy-api/error-logger.js b/dashcaddy-api/error-logger.js deleted file mode 100644 index e35d337..0000000 --- a/dashcaddy-api/error-logger.js +++ /dev/null @@ -1,135 +0,0 @@ -// Error Logger Utility -// Centralized error logging with rotation and request context tracking - -const fsp = require('fs').promises; -const path = require('path'); -const { LIMITS } = require('./constants'); - -const ERROR_LOG_FILE = path.join(__dirname, 'error.log'); -const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE; - -/** - * Check if file exists - */ -async function exists(filepath) { - try { - await fsp.access(filepath); - return true; - } catch { - return false; - } -} - -/** - * Log error with context and rotation - * @param {string} context - Where the error occurred - * @param {Error|string} error - The error to log - * @param {Object} additionalInfo - Additional context (req, etc.) - */ -async function logError(context, error, additionalInfo = {}) { - const timestamp = new Date().toISOString(); - - // Extract request context if a request object is provided - const requestContext = extractRequestContext(additionalInfo.req); - if (additionalInfo.req) { - delete additionalInfo.req; // Remove req to avoid circular refs - } - - const logEntry = { - timestamp, - context, - ...requestContext, - error: { - message: error.message || error, - stack: error.stack, - code: error.code - }, - ...additionalInfo - }; - - // Format log line with request context - const contextInfo = Object.keys(requestContext).length > 0 - ? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}` - : ''; - const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`; - - try { - // Rotate log if it exceeds max size - await rotateLogIfNeeded(); - await fsp.appendFile(ERROR_LOG_FILE, logLine); - } catch (e) { - console.error('Failed to write to error log', e.message); - } -} - -/** - * Extract request context from Express request object - */ -function extractRequestContext(req) { - if (!req) return {}; - - const clientIP = req.ip || req.socket?.remoteAddress || ''; - - return { - requestId: req.id, - ip: clientIP, - userAgent: req.get('user-agent'), - method: req.method, - path: req.path - }; -} - -/** - * Rotate log file if it exceeds max size - */ -async function rotateLogIfNeeded() { - try { - const stats = await fsp.stat(ERROR_LOG_FILE); - if (stats.size > MAX_ERROR_LOG_SIZE) { - const rotated = ERROR_LOG_FILE + '.1'; - if (await exists(rotated)) { - await fsp.unlink(rotated); - } - await fsp.rename(ERROR_LOG_FILE, rotated); - } - } catch (_) { - // File may not exist yet, that's fine - } -} - -/** - * Return a safe error message to the client without leaking internals - */ -function safeErrorMessage(error) { - const msg = error.message || String(error); - - // Detect port conflict errors from Docker - const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/); - if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) { - const port = portMatch ? portMatch[1] : 'requested'; - return `Port ${port} is already in use. Please choose a different port or stop the conflicting service.`; - } - - // Detect container not found errors - if (msg.includes('No such container')) { - return 'Container not found'; - } - - // Detect network errors - if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) { - return 'Service unavailable'; - } - - // Generic safe message for unknown errors - if (process.env.NODE_ENV === 'production') { - return 'An error occurred. Please try again or contact support.'; - } - - // In development, show the actual error - return msg; -} - -module.exports = { - logError, - safeErrorMessage -}; diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index 13a4ddc..59a4a98 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -1,6 +1,6 @@ { "name": "dashcaddy-api", - "version": "1.7.8", + "version": "1.13.4", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "main": "server.js", "scripts": { diff --git a/dashcaddy-api/platform-paths.js b/dashcaddy-api/platform-paths.js index 9ab658c..a2185fc 100644 --- a/dashcaddy-api/platform-paths.js +++ b/dashcaddy-api/platform-paths.js @@ -3,6 +3,7 @@ // All paths can be overridden via environment variables. const path = require('path'); +const fs = require('fs'); const isWindows = process.platform === 'win32'; // Base directories @@ -34,6 +35,8 @@ const paths = { caCertDir: path.join(CADDY_SITES, 'ca'), pkiRootCert: path.join(CADDY_PKI, 'root.crt'), pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'), + generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'), + pkiDir: CADDY_PKI, // Static site base path sitePath: (subdomain) => path.join(CADDY_SITES, subdomain), @@ -41,6 +44,24 @@ const paths = { // Docker data path for app volumes 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 digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'), diff --git a/dashcaddy-api/pylon/dashcaddy-pylon.js b/dashcaddy-api/pylon/dashcaddy-pylon.js index d8539ae..ce23a3d 100644 --- a/dashcaddy-api/pylon/dashcaddy-pylon.js +++ b/dashcaddy-api/pylon/dashcaddy-pylon.js @@ -226,7 +226,23 @@ const server = http.createServer(async (req, res) => { json(res, 404, { error: 'Not found' }); }); -server.listen(PORT, '0.0.0.0', () => { - console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`); +const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842; +const PYLON_HOST = process.env.PYLON_HOST || '0.0.0.0'; + +server.listen(PYLON_PORT, PYLON_HOST, () => { + console.log(`[Pylon] ${PYLON_NAME} listening on ${PYLON_HOST}:${PYLON_PORT}`); if (API_KEY) console.log('[Pylon] API key authentication enabled'); }); + +// 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')); diff --git a/dashcaddy-api/response-helpers.js b/dashcaddy-api/response-helpers.js deleted file mode 100644 index 5f2e276..0000000 --- a/dashcaddy-api/response-helpers.js +++ /dev/null @@ -1,114 +0,0 @@ -// Response Helpers -// Standardize API response format across all routes - -const { HTTP_STATUS } = require('./constants'); - -/** - * Success response with data - */ -function success(res, data, statusCode = HTTP_STATUS.OK) { - return res.status(statusCode).json({ - success: true, - ...data - }); -} - -/** - * Success response with message - */ -function successMessage(res, message, statusCode = HTTP_STATUS.OK) { - return res.status(statusCode).json({ - success: true, - message - }); -} - -/** - * Created response (201) - */ -function created(res, data) { - return res.status(HTTP_STATUS.CREATED).json({ - success: true, - ...data - }); -} - -/** - * No content response (204) - */ -function noContent(res) { - return res.status(HTTP_STATUS.NO_CONTENT).send(); -} - -/** - * Error response - */ -function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) { - return res.status(statusCode).json({ - success: false, - error: message - }); -} - -/** - * Validation error response (400) - */ -function validationError(res, message) { - return res.status(HTTP_STATUS.BAD_REQUEST).json({ - success: false, - error: message - }); -} - -/** - * Unauthorized response (401) - */ -function unauthorized(res, message = 'Unauthorized') { - return res.status(HTTP_STATUS.UNAUTHORIZED).json({ - success: false, - error: message - }); -} - -/** - * Forbidden response (403) - */ -function forbidden(res, message = 'Forbidden') { - return res.status(HTTP_STATUS.FORBIDDEN).json({ - success: false, - error: message - }); -} - -/** - * Not found response (404) - */ -function notFound(res, message = 'Not found') { - return res.status(HTTP_STATUS.NOT_FOUND).json({ - success: false, - error: message - }); -} - -/** - * Conflict response (409) - */ -function conflict(res, message) { - return res.status(HTTP_STATUS.CONFLICT).json({ - success: false, - error: message - }); -} - -module.exports = { - success, - successMessage, - created, - noContent, - error, - validationError, - unauthorized, - forbidden, - notFound, - conflict -}; diff --git a/dashcaddy-api/routes/apps/compose.js b/dashcaddy-api/routes/apps/compose.js index 9473bf7..50a1a2a 100644 --- a/dashcaddy-api/routes/apps/compose.js +++ b/dashcaddy-api/routes/apps/compose.js @@ -1,8 +1,9 @@ const express = require('express'); const yaml = require('js-yaml'); -const { DOCKER, REGEX } = require('../../constants'); -const { ValidationError } = require('../../errors'); +const { DOCKER, REGEX } = require('../../../src/utilities/constants'); +const { ValidationError } = require('../../../src/utilities/errors'); const platformPaths = require('../../platform-paths'); +const { ok } = require('../src/utils/responses'); /** * Docker Compose import routes @@ -162,7 +163,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager } const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack'; const result = parseCompose(yamlStr, name); - res.json({ success: true, ...result }); + ok(res, { ...result }); }, 'compose-import')); // POST /deploy-compose — deploy parsed services @@ -300,7 +301,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason }); } - res.json({ success: true, results, stackName: stackName || prefix }); + ok(res, { results, stackName: stackName || prefix }); }, 'compose-deploy')); // DELETE /compose-stack/:stackName — remove an entire stack @@ -329,7 +330,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager }); await servicesStateManager.update(data => { data.services = updated; }); - res.json({ success: true, removed, count: removed.length }); + ok(res, { removed, count: removed.length }); }, 'compose-stack-delete')); return router; diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index c884c81..f2dc089 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -2,12 +2,13 @@ const express = require('express'); const fsp = require('fs').promises; const path = require('path'); const validatorLib = require('validator'); -const { REGEX, DOCKER } = require('../../constants'); -const { isValidPort } = require('../../input-validator'); -const { exists } = require('../../fs-helpers'); +const { REGEX, DOCKER } = require('../../../src/utilities/constants'); +const { isValidPort } = require('../../../src/security/input-validator'); +const { exists } = require('../../../src/utilities/fs-helpers'); const platformPaths = require('../../platform-paths'); -const { ValidationError } = require('../../errors'); -const { logError } = require('../../src/utils/logging'); +const { ValidationError } = require('../../../src/utilities/errors'); +const { logError } = require('../src/utils/logging'); +const { ok } = require('../src/utils/responses'); /** * Apps deployment routes factory * @param {Object} deps - Explicit dependencies @@ -197,8 +198,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag } } - const container = await docker.client.createContainer(containerConfig); - await container.start(); + let container; + try { + container = await docker.client.createContainer(containerConfig); + await container.start(); + } catch (createErr) { + // If create fails with "no such image", wrap with user-friendly message + const errMsg = createErr?.message || String(createErr); + if (errMsg.includes('No such image') || errMsg.includes('no such image')) { + throw new Error(`[DC-201] Image pull succeeded but container creation failed — image may be corrupted: ${processedTemplate.docker.image}. ${errMsg}`); + } + throw createErr; + } // Prune dangling images to prevent disk bloat try { @@ -233,9 +244,9 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag if (!template) throw new ValidationError('Invalid app template'); const existingContainer = await helpers.findExistingContainerByImage(template); if (existingContainer) { - res.json({ success: true, exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` }); + ok(res, { exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` }); } else { - res.json({ success: true, exists: false, message: `No existing ${template.name} container found` }); + ok(res, { exists: false, message: `No existing ${template.name} container found` }); } }, 'check-existing')); @@ -306,7 +317,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag } else { containerId = await deployContainer(appId, config, template); log.info('deploy', 'Container deployed', { containerId }); - await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort); + await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort, 30); log.info('deploy', 'Container is healthy', { containerId }); } @@ -316,7 +327,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag let dnsWarning = null; if (config.createDns && !isSubdirectoryMode) { try { - await ctx.dns.createRecord(config.subdomain, config.ip); + await ctx.dns.universalCreateRecord(config.subdomain, config.ip); log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip }); } catch (dnsError) { await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip }); @@ -420,10 +431,11 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag res.json(response); } catch (error) { - await logError('app-deploy', error, { appId, config }); - log.error('deploy', 'Deployment failed', { appId, error: error.message }); + try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ } + const msg = error?.message || String(error || 'Unknown error'); + log.error('deploy', 'Deployment failed', { appId, error: msg }); const template = ctx.APP_TEMPLATES[appId]; - ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error'); + try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {} errorResponse(res, 500, ctx.safeErrorMessage(error)); } }, 'apps-deploy')); diff --git a/dashcaddy-api/routes/apps/helpers.js b/dashcaddy-api/routes/apps/helpers.js index d3fb962..f041321 100644 --- a/dashcaddy-api/routes/apps/helpers.js +++ b/dashcaddy-api/routes/apps/helpers.js @@ -2,8 +2,8 @@ const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); -const { REGEX, DOCKER } = require('../../constants'); -const { exists } = require('../../fs-helpers'); +const { REGEX, DOCKER } = require('../../../src/utilities/constants'); +const { exists } = require('../../../src/utilities/fs-helpers'); const platformPaths = require('../../platform-paths'); /** @@ -379,9 +379,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag return content.slice(0, endIdx) + injection + content.slice(endIdx); }); - if (!result.success) { + if (!result.success && result.error !== 'No changes to apply') { throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`); } + 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. */ diff --git a/dashcaddy-api/routes/apps/index.js b/dashcaddy-api/routes/apps/index.js index aeaabef..45a154f 100644 --- a/dashcaddy-api/routes/apps/index.js +++ b/dashcaddy-api/routes/apps/index.js @@ -25,7 +25,6 @@ module.exports = function(ctx) { asyncHandler: ctx.asyncHandler, errorResponse: ctx.errorResponse, log: ctx.log, - // Additional context properties needed by routes APP_TEMPLATES: ctx.APP_TEMPLATES, TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS, @@ -40,26 +39,27 @@ module.exports = function(ctx) { ctx: ctx }; - // Initialize helpers with dependencies (ctx is the Koa context) const helpers = initHelpers({ ...deps, ctx }); - - // Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties const subCtx = Object.assign({}, ctx, { helpers }); - try { router.use('/deploy', initDeploy(subCtx)); } - catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); } + // Mount sub-routers at their prefix paths. + // Sub-modules define routes at '/' (root of their sub-router). + // Final paths: /api/v1/apps/deploy, /api/v1/apps/remove, /api/v1/apps/templates, etc. - try { router.use('/remove', initRemoval(subCtx)); } - catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); } + try { router.use('/apps', initDeploy(subCtx)); } + catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message, e.stack); } + + try { router.use('/apps', initRemoval(subCtx)); } + catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message, e.stack); } try { router.use('/apps', initTemplates(subCtx)); } - catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); } + catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message, e.stack); } - try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); } - catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); } + try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); } + catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); } - try { router.use('/compose', initCompose(subCtx)); } - catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); } + try { router.use('/apps', initCompose(subCtx)); } + catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); } return router; }; diff --git a/dashcaddy-api/routes/apps/removal.js b/dashcaddy-api/routes/apps/removal.js index 1e000a0..7a678a7 100644 --- a/dashcaddy-api/routes/apps/removal.js +++ b/dashcaddy-api/routes/apps/removal.js @@ -1,6 +1,7 @@ const express = require('express'); -const { exists } = require('../../fs-helpers'); -const { logError } = require('../../src/utils/logging'); +const { exists } = require('../../../src/utilities/fs-helpers'); +const { logError } = require('../src/utils/logging'); +const { ok } = require('../src/utils/responses'); module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, log, helpers, @@ -71,18 +72,13 @@ module.exports = function({ if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) { try { const domain = ctx.buildDomain(subdomain); - 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' - }); + const resolveResult = await ctx.dns.universalResolveRecord(domain, 'A'); let recordIp = ip || 'localhost'; - if (getResult.status === 'ok' && getResult.response?.records) { - const aRecord = getResult.response.records.find(r => r.type === 'A'); - if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress; + if (resolveResult) { + recordIp = resolveResult; } - const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { - token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp - }); - results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed'); + await ctx.dns.universalDeleteRecord(domain, recordIp); + results.dns = 'deleted'; log.info('dns', 'DNS record removal', { result: results.dns }); } catch (error) { results.dns = error.message; @@ -140,7 +136,7 @@ module.exports = function({ results.service = error.message; } - res.json({ success: true, message: `App ${appId} removal completed`, results }); + ok(res, { message: `App ${appId} removal completed`, results }); } catch (error) { await logError('app-removal', error); errorResponse(res, 500, ctx.safeErrorMessage(error), { results }); diff --git a/dashcaddy-api/routes/apps/restore.js b/dashcaddy-api/routes/apps/restore.js index 7c9d2f4..9e9b316 100644 --- a/dashcaddy-api/routes/apps/restore.js +++ b/dashcaddy-api/routes/apps/restore.js @@ -1,7 +1,8 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); -const { DOCKER } = require('../../constants'); +const { DOCKER } = require('../../../src/utilities/constants'); +const { ok, validationError, notFound, errorResponse } = require('../../../src/utilities/responses'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); @@ -47,7 +48,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e } const result = await restoreService(service); - res.json({ success: true, result }); + ok(res, { result }); }, 'apps-restore')); /** @@ -59,8 +60,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e const restoreable = services.filter(s => s.deploymentManifest); if (restoreable.length === 0) { - return res.json({ - success: true, + return ok(res, { message: 'No services have deployment manifests to restore', results: [] }); @@ -85,8 +85,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e const skipped = results.filter(r => r.status === 'skipped').length; const failed = results.filter(r => r.status === 'failed').length; - res.json({ - success: true, + ok(res, { message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`, results }); @@ -123,7 +122,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e status.push(entry); } - res.json({ success: true, services: status }); + ok(res, { services: status }); }, 'apps-restore-status')); // ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ==================== @@ -174,8 +173,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e // Sort by timestamp descending (newest first) files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - res.json({ - success: true, + ok(res, { appId, isBackupFile: true, files, @@ -190,12 +188,12 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e // Security: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - return res.status(400).json({ success: false, error: 'Invalid filename' }); + return validationError(res, 'Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { - return res.status(404).json({ success: false, error: `Backup file not found: ${filename}` }); + return notFound(res, `Backup file not found: ${filename}`); } try { @@ -207,7 +205,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e try { fileData = await backupManager.decryptBackup(fileData, encryptionKey); } catch (err) { - return res.status(400).json({ success: false, error: 'Failed to decrypt backup: ' + err.message }); + return validationError(res, 'Failed to decrypt backup: ' + err.message); } } @@ -264,8 +262,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e // Cleanup temp dir fs.rmSync(tempDir, { recursive: true, force: true }); - res.json({ - success: true, + ok(res, { isBackupFile: true, restored: { services: !!restoreData.services, @@ -277,8 +274,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e } else { // Preview mode fs.rmSync(tempDir, { recursive: true, force: true }); - res.json({ - success: true, + ok(res, { isBackupFile: true, preview: true, filename, @@ -296,7 +292,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e throw err; } } catch (err) { - res.status(500).json({ success: false, error: err.message }); + errorResponse(res, 500, err.message); } }, 'apps-revert')); @@ -458,7 +454,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e // DNS record if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') { try { - await ctx.dns.createRecord(manifest.config.subdomain, manifest.config.ip); + await ctx.dns.universalCreateRecord(manifest.config.subdomain, manifest.config.ip); log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain }); } catch (e) { log.warn('restore', `DNS recreation failed: ${e.message}`); diff --git a/dashcaddy-api/routes/apps/templates.js b/dashcaddy-api/routes/apps/templates.js index d073082..7b8f84a 100644 --- a/dashcaddy-api/routes/apps/templates.js +++ b/dashcaddy-api/routes/apps/templates.js @@ -1,5 +1,5 @@ const express = require('express'); -const { exists } = require('../../fs-helpers'); +const { exists } = require('../../../src/utilities/fs-helpers'); /** * Apps templates routes factory * @param {Object} deps - Explicit dependencies @@ -19,7 +19,8 @@ const { exists } = require('../../fs-helpers'); * @param {string} deps.SERVICES_FILE - Services file path * @returns {express.Router} */ -const { REGEX } = require('../../constants'); +const { REGEX } = require('../../../src/utilities/constants'); +const { ok } = require('../src/utils/responses'); module.exports = function({ servicesStateManager, asyncHandler, helpers, @@ -42,8 +43,7 @@ module.exports = function({ // Get available app templates router.get('/templates', asyncHandler(async (req, res) => { - res.json({ - success: true, + ok(res, { templates: ctx.APP_TEMPLATES, categories: ctx.TEMPLATE_CATEGORIES, difficultyLevels: ctx.DIFFICULTY_LEVELS @@ -55,10 +55,10 @@ module.exports = function({ const { appId } = req.params; const template = ctx.APP_TEMPLATES[appId]; if (!template) { - const { NotFoundError } = require('../../errors'); + const { NotFoundError } = require('../../../src/utilities/errors'); throw new NotFoundError('App template'); } - res.json({ success: true, template }); + ok(res, { template }); }, 'apps-template-detail')); // Check port availability @@ -80,7 +80,7 @@ module.exports = function({ const usedPorts = await docker.getUsedPorts(); for (let port = basePort; port < basePort + maxAttempts; port++) { if (!usedPorts.has(port)) { - res.json({ success: true, suggestedPort: port, basePort }); + ok(res, { suggestedPort: port, basePort }); return; } } @@ -90,7 +90,7 @@ module.exports = function({ // Update subdomain for deployed app router.post('/update-subdomain', asyncHandler(async (req, res) => { const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body; - const { ValidationError } = require('../../errors'); + const { ValidationError } = require('../../../src/utilities/errors'); if (!oldSubdomain || typeof oldSubdomain !== 'string') { throw new ValidationError('oldSubdomain is required'); @@ -107,10 +107,8 @@ module.exports = function({ if (oldSubdomain && ctx.dns.getToken()) { try { const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); - const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { - token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost' - }); - results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage; + await ctx.dns.universalDeleteRecord(oldDomain, ip || 'localhost'); + results.oldDns = 'deleted'; log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); } catch (error) { results.oldDns = `failed: ${error.message}`; @@ -120,7 +118,7 @@ module.exports = function({ if (newSubdomain && ctx.dns.getToken()) { try { - await ctx.dns.createRecord(newSubdomain, ip || 'localhost'); + await ctx.dns.universalCreateRecord(newSubdomain, ip || 'localhost'); results.newDns = 'created'; log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) }); } catch (error) { @@ -172,8 +170,7 @@ module.exports = function({ log.warn('deploy', 'Service update warning', { error: error.message || String(error) }); } - res.json({ - success: true, + ok(res, { message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`, newUrl: `https://${ctx.buildDomain(newSubdomain)}`, results diff --git a/dashcaddy-api/routes/arr/config.js b/dashcaddy-api/routes/arr/config.js index b8e9698..534860b 100644 --- a/dashcaddy-api/routes/arr/config.js +++ b/dashcaddy-api/routes/arr/config.js @@ -1,8 +1,9 @@ const express = require('express'); -const { APP_PORTS, ARR_SERVICES } = require('../../constants'); -const { validateURL, validateToken } = require('../../input-validator'); -const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors'); -const { logError } = require('../../src/utils/logging'); +const { APP_PORTS, ARR_SERVICES } = require('../../../src/utilities/constants'); +const { validateURL, validateToken } = require('../../../src/security/input-validator'); +const { ValidationError, AuthenticationError, NotFoundError } = require('../../../src/utilities/errors'); +const { logError } = require('../src/utils/logging'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Arr configuration routes factory @@ -258,11 +259,7 @@ module.exports = function(ctx) { const version = service === 'plex' ? data.MediaContainer?.version : data.version; const appName = service === 'plex' ? 'Plex' : data.appName; log.info('arr', 'Service connection successful', { service, appName, version }); - return res.json({ - success: true, - version, - appName - }); + return ok(res, { version, appName }); } else if (response.status === 401) { throw new AuthenticationError('Invalid API key'); } else if (response.status === 404) { @@ -553,7 +550,7 @@ module.exports = function(ctx) { const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`); const storedProfileId = metadata?.qualityProfileId || null; - res.json({ success: true, profiles: mapped, storedProfileId }); + ok(res, { profiles: mapped, storedProfileId }); } catch (e) { if (e.cause?.code === 'ECONNREFUSED') { return errorResponse(res, 502, 'Connection refused — is the service running?'); @@ -588,7 +585,7 @@ module.exports = function(ctx) { existing.qualityProfileName = qualityProfileName || null; await credentialManager.storeMetadata(credKey, existing); - res.json({ success: true, message: `Quality profile updated for ${service}` }); + successMessage(res, `Quality profile updated for ${service}`); }, 'arr-quality-profile-save')); return router; diff --git a/dashcaddy-api/routes/arr/credentials.js b/dashcaddy-api/routes/arr/credentials.js index 2bc5087..2e2eec2 100644 --- a/dashcaddy-api/routes/arr/credentials.js +++ b/dashcaddy-api/routes/arr/credentials.js @@ -1,6 +1,7 @@ const express = require('express'); -const { validateURL, validateToken } = require('../../input-validator'); -const { ValidationError } = require('../../errors'); +const { validateURL, validateToken } = require('../../../src/security/input-validator'); +const { ValidationError } = require('../../../src/utilities/errors'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Arr credentials routes factory @@ -101,12 +102,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false }); - res.json({ - success: true, - message: `${service} API key stored`, - connectionTest, - url: resolvedUrl - }); + ok(res, { message: `${service} API key stored`, connectionTest, url: resolvedUrl }); }, 'arr-credentials-store')); // List stored arr credentials (keys only, not values) @@ -131,7 +127,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle // Get seedbox base URL const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl'); - res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null }); + ok(res, { credentials, seedboxBaseUrl: seedboxBaseUrl || null }); }, 'arr-credentials-list')); // Delete stored arr credentials @@ -140,7 +136,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`; await credentialManager.delete(credKey); log.info('arr', 'Deleted credentials', { service }); - res.json({ success: true, message: `${service} credentials removed` }); + successMessage(res, `${service} credentials removed`); }, 'arr-credentials-delete')); return router; diff --git a/dashcaddy-api/routes/arr/detect.js b/dashcaddy-api/routes/arr/detect.js index 3bd0ed1..fa529ff 100644 --- a/dashcaddy-api/routes/arr/detect.js +++ b/dashcaddy-api/routes/arr/detect.js @@ -1,5 +1,6 @@ const express = require('express'); -const { APP_PORTS, ARR_SERVICES } = require('../../constants'); +const { APP_PORTS, ARR_SERVICES } = require('../../../src/utilities/constants'); +const { ok } = require('../src/utils/responses'); /** * Arr service detection routes factory @@ -62,8 +63,7 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet detected.plex.token = await helpers.getPlexToken(detected.plex.containerName); } - res.json({ - success: true, + ok(res, { services: detected, summary: { plexReady: !!(detected.plex?.token), @@ -287,7 +287,7 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2 }; - res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary }); + ok(res, { services: result, seedboxBaseUrl: detectedSeedboxUrl, summary }); }, 'smart-detect')); return router; diff --git a/dashcaddy-api/routes/arr/helpers.js b/dashcaddy-api/routes/arr/helpers.js index 312ccdf..f99a161 100644 --- a/dashcaddy-api/routes/arr/helpers.js +++ b/dashcaddy-api/routes/arr/helpers.js @@ -1,4 +1,4 @@ -const { APP_PORTS } = require('../../constants'); +const { APP_PORTS } = require('../../../src/utilities/constants'); /** * Arr helpers factory diff --git a/dashcaddy-api/routes/arr/plex.js b/dashcaddy-api/routes/arr/plex.js index fae8dfd..2f2d903 100644 --- a/dashcaddy-api/routes/arr/plex.js +++ b/dashcaddy-api/routes/arr/plex.js @@ -1,5 +1,6 @@ const express = require('express'); -const { APP_PORTS } = require('../../constants'); +const { APP_PORTS } = require('../../../src/utilities/constants'); +const { ok } = require('../src/utils/responses'); /** * Plex routes factory @@ -86,7 +87,7 @@ module.exports = function({ fetchT, asyncHandler, errorResponse, log: _log, help lastVerified: new Date().toISOString() }); - res.json({ success: true, serverName, version, libraries }); + ok(res, { serverName, version, libraries }); }, 'plex-libraries')); return router; diff --git a/dashcaddy-api/routes/arr/smart-connect.js b/dashcaddy-api/routes/arr/smart-connect.js index b7f557d..3a40906 100644 --- a/dashcaddy-api/routes/arr/smart-connect.js +++ b/dashcaddy-api/routes/arr/smart-connect.js @@ -1,5 +1,5 @@ const express = require('express'); -const { APP_PORTS } = require('../../constants'); +const { APP_PORTS } = require('../../../src/utilities/constants'); /** * Arr smart-connect routes factory diff --git a/dashcaddy-api/routes/auth/keys.js b/dashcaddy-api/routes/auth/keys.js index bf26c15..63243dc 100644 --- a/dashcaddy-api/routes/auth/keys.js +++ b/dashcaddy-api/routes/auth/keys.js @@ -1,5 +1,6 @@ const express = require('express'); -const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors'); +const { ValidationError, ForbiddenError, NotFoundError } = require('../../../src/utilities/errors'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Auth API keys routes factory * @param {Object} deps - Explicit dependencies @@ -39,7 +40,7 @@ module.exports = function({ authManager, asyncHandler, log }) { } const keys = await authManager.listAPIKeys(); - res.json({ success: true, keys }); + ok(res, { keys }); }, 'auth-keys-list')); // Generate new API key @@ -66,8 +67,7 @@ module.exports = function({ authManager, asyncHandler, log }) { scopes || ['read', 'write'] ); - res.json({ - success: true, + ok(res, { key: keyData.key, id: keyData.id, name: keyData.name, @@ -93,7 +93,7 @@ module.exports = function({ authManager, asyncHandler, log }) { const success = await authManager.revokeAPIKey(keyId); if (success) { - res.json({ success: true, message: 'API key revoked successfully' }); + successMessage(res, 'API key revoked successfully'); } else { throw new NotFoundError(`API key ${keyId}`); } @@ -126,8 +126,7 @@ module.exports = function({ authManager, asyncHandler, log }) { const expiresInMs = parseExpiration(expiresIn || '24h'); const expiresAt = new Date(Date.now() + expiresInMs).toISOString(); - res.json({ - success: true, + ok(res, { token, expiresAt, usage: 'Include in Authorization header as: Bearer ' diff --git a/dashcaddy-api/routes/auth/session-handlers.js b/dashcaddy-api/routes/auth/session-handlers.js index d39d1d1..cc2dd72 100644 --- a/dashcaddy-api/routes/auth/session-handlers.js +++ b/dashcaddy-api/routes/auth/session-handlers.js @@ -1,5 +1,5 @@ -const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants'); -const { createCache, CACHE_CONFIGS } = require('../../cache-config'); +const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../../src/utilities/constants'); +const { createCache, CACHE_CONFIGS } = require('../../../src/utilities/cache-config'); /** * Auth session handlers routes factory diff --git a/dashcaddy-api/routes/auth/sso-gate.js b/dashcaddy-api/routes/auth/sso-gate.js index f8fff5c..6b7d5e5 100644 --- a/dashcaddy-api/routes/auth/sso-gate.js +++ b/dashcaddy-api/routes/auth/sso-gate.js @@ -1,6 +1,6 @@ const express = require('express'); -const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants'); -const { AuthenticationError, NotFoundError } = require('../../errors'); +const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../../src/utilities/constants'); +const { AuthenticationError, NotFoundError } = require('../../../src/utilities/errors'); /** * Auth SSO gate routes factory diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index 5adf3ac..52dcc99 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -1,5 +1,6 @@ const express = require('express'); -const { ValidationError, AuthenticationError } = require('../../errors'); +const { ValidationError, AuthenticationError } = require('../../src/utilities/errors'); +const { ok, successMessage } = require('../../src/utils/responses'); /** * Auth TOTP routes factory @@ -27,8 +28,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp // Get current TOTP config (public route) router.get('/totp/config', asyncHandler(async (req, res) => { - res.json({ - success: true, + ok(res, { config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, @@ -122,7 +122,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp color: { dark: '#ffffff', light: '#00000000' } }); - res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret }); + ok(res, { qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret }); }, 'totp-setup')); // Verify first code to confirm setup, then activate TOTP @@ -159,7 +159,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp ctx.session.create(req, ctx.totpConfig.sessionDuration); ctx.session.setCookie(res, ctx.totpConfig.sessionDuration); - res.json({ success: true, message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration }); + ok(res, { message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration }); }, 'totp-verify-setup')); // Login: verify TOTP code and set session cookie @@ -193,7 +193,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https'); log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size }); - res.json({ success: true, message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken }); + ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken }); }, 'totp-verify')); // Check session validity (used by Caddy forward_auth) @@ -245,7 +245,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp ctx.session.clear(req); ctx.session.clearCookie(res); - res.json({ success: true, message: 'TOTP disabled' }); + successMessage(res, 'TOTP disabled'); }, 'totp-disable')); // Update TOTP settings (session duration) @@ -264,8 +264,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp } await ctx.saveTotpConfig(); - res.json({ - success: true, + ok(res, { config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp } }); }, 'totp-config')); diff --git a/dashcaddy-api/routes/auto-restart.js b/dashcaddy-api/routes/auto-restart.js new file mode 100644 index 0000000..26fa542 --- /dev/null +++ b/dashcaddy-api/routes/auto-restart.js @@ -0,0 +1,164 @@ +/** + * 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('../src/utilities/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; +}; diff --git a/dashcaddy-api/routes/backups.js b/dashcaddy-api/routes/backups.js index d89d59c..b870e56 100644 --- a/dashcaddy-api/routes/backups.js +++ b/dashcaddy-api/routes/backups.js @@ -1,8 +1,8 @@ const express = require('express'); const fsp = require('fs').promises; -const path = require('path'); const fs = require('fs'); -const { success } = require('../response-helpers'); +const path = require('path'); +const { success } = require('../src/utils/responses'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES @@ -60,7 +60,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body; if (!appId) { - const { ValidationError } = require('../errors'); + const { ValidationError } = require('../src/utilities/errors'); throw new ValidationError('appId is required'); } @@ -104,7 +104,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { const config = backupManager.getConfig(); if (!config.backups || !config.backups[appId]) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404'); } @@ -164,7 +164,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { const backupConfig = config.backups && config.backups[appId]; if (!backupConfig) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404'); } @@ -240,13 +240,13 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { // Security: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - const { ValidationError } = require('../errors'); + const { ValidationError } = require('../src/utilities/errors'); throw new ValidationError('Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404'); } @@ -376,13 +376,13 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { // Security: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - const { ValidationError } = require('../errors'); + const { ValidationError } = require('../src/utilities/errors'); throw new ValidationError('Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404'); } @@ -546,7 +546,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { router.post('/backups/test-destination', asyncHandler(async (req, res) => { const destination = req.body; if (!destination || !destination.type) { - const { ValidationError } = require('../errors'); + const { ValidationError } = require('../src/utilities/errors'); throw new ValidationError('destination.type is required'); } const result = await backupManager.testDestination(destination); @@ -556,10 +556,10 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { // Get cloud credentials (masked) for a provider // Provider: dropbox | webdav | sftp router.get('/backups/credentials/:provider', asyncHandler(async (req, res) => { - const credentialManager = require('../credential-manager'); + const credentialManager = require('../src/managers/credential-manager'); const provider = req.params.provider; if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { - const { ValidationError } = require('../errors'); + const { ValidationError } = require('../src/utilities/errors'); throw new ValidationError('Invalid provider'); } @@ -588,8 +588,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { // Save cloud credentials for a provider router.post('/backups/credentials/:provider', asyncHandler(async (req, res) => { - const credentialManager = require('../credential-manager'); - const { ValidationError } = require('../errors'); + const credentialManager = require('../src/managers/credential-manager'); + const { ValidationError } = require('../src/utilities/errors'); const provider = req.params.provider; if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { @@ -629,8 +629,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { // Delete cloud credentials for a provider router.delete('/backups/credentials/:provider', asyncHandler(async (req, res) => { - const credentialManager = require('../credential-manager'); - const { ValidationError } = require('../errors'); + const credentialManager = require('../src/managers/credential-manager'); + const { ValidationError } = require('../src/utilities/errors'); const provider = req.params.provider; if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { diff --git a/dashcaddy-api/routes/browse.js b/dashcaddy-api/routes/browse.js index 40d4ed4..f2bdb29 100644 --- a/dashcaddy-api/routes/browse.js +++ b/dashcaddy-api/routes/browse.js @@ -2,9 +2,10 @@ const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); -const { exists, isAccessible } = require('../fs-helpers'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { ValidationError, ForbiddenError } = require('../errors'); +const { exists, isAccessible } = require('../src/utilities/fs-helpers'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); +const { ValidationError, ForbiddenError } = require('../src/utilities/errors'); +const { ok } = require('../src/utils/responses'); /** * Browse route factory @@ -44,7 +45,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d } } - ok(res, { roots }); + return ok(res, { roots }); }, 'browse-roots')); // Browse directory contents @@ -98,7 +99,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d } if (!await exists(resolvedPath)) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Path'); } @@ -124,8 +125,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d const paginationParams = parsePaginationParams(req.query); const result = paginate(folders, paginationParams); - res.json({ - success: true, + ok(res, { path: requestedPath, parent: path.dirname(requestedPath).replace(/\\/g, '/') || null, items: result.data, @@ -190,8 +190,7 @@ module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, d } } - res.json({ - success: true, + ok(res, { mounts: detectedMounts, message: detectedMounts.length > 0 ? `Found ${detectedMounts.length} media mount(s) from existing containers` diff --git a/dashcaddy-api/routes/ca.js b/dashcaddy-api/routes/ca.js index 4a2fac2..a9ac9a8 100644 --- a/dashcaddy-api/routes/ca.js +++ b/dashcaddy-api/routes/ca.js @@ -3,8 +3,9 @@ const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const { execSync } = require('child_process'); -const { exists } = require('../fs-helpers'); -const { ValidationError } = require('../errors'); +const { exists } = require('../src/utilities/fs-helpers'); +const { ValidationError } = require('../src/utilities/errors'); +const { ok } = require('../src/utils/responses'); const platformPaths = require('../platform-paths'); module.exports = function(ctx) { @@ -12,16 +13,13 @@ module.exports = function(ctx) { // Get CA certificate information router.get('/info', ctx.asyncHandler(async (req, res) => { - const certInfoPath = '/app/ca/cert-info.json'; - const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json'); + const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json'); let certInfoFile; if (await exists(certInfoPath)) { certInfoFile = certInfoPath; - } else if (await exists(fallbackCertInfoPath)) { - certInfoFile = fallbackCertInfoPath; } else { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('CA certificate information'); } @@ -29,8 +27,7 @@ module.exports = function(ctx) { const expirationDate = new Date(certInfo.validUntil); const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); - res.json({ - success: true, + ok(res, { certificate: { name: certInfo.name, fingerprint: certInfo.fingerprint, @@ -46,16 +43,14 @@ module.exports = function(ctx) { // Serve root CA certificate directly (works even without DashCA deployed) router.get('/root.crt', ctx.asyncHandler(async (req, res) => { - const pkiCertPath = '/app/pki/root.crt'; const hostCertPath = platformPaths.pkiRootCert; const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt'); let certPath; - if (await exists(pkiCertPath)) certPath = pkiCertPath; - else if (await exists(dashcaCertPath)) certPath = dashcaCertPath; + if (await exists(dashcaCertPath)) certPath = dashcaCertPath; else if (await exists(hostCertPath)) certPath = hostCertPath; else { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Root CA certificate'); } @@ -72,14 +67,13 @@ module.exports = function(ctx) { } // Load cert info to get the fingerprint - const certInfoPath = '/app/ca/cert-info.json'; - const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json'); + const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json'); let certInfoFile; - if (await exists(certInfoPath)) certInfoFile = certInfoPath; - else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2; - else { - const { NotFoundError } = require('../errors'); + if (await exists(certInfoPath)) { + certInfoFile = certInfoPath; + } else { + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.'); } @@ -100,7 +94,7 @@ module.exports = function(ctx) { // Look for template in multiple locations (packaged app vs dev) const templatePaths = [ path.join(__dirname, '..', 'scripts', templateName), - path.join('/app', 'scripts', templateName) + path.join(platformPaths.caddyBase, 'scripts', templateName) ]; let templateContent; @@ -112,7 +106,7 @@ module.exports = function(ctx) { } if (!templateContent) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Install script template (${templateName})`); } @@ -142,8 +136,8 @@ module.exports = function(ctx) { return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`); } - const pkiPath = '/app/pki'; - const certsDir = '/app/generated-certs'; + const pkiPath = platformPaths.pkiDir; + const certsDir = platformPaths.generatedCertsDir; const domainDir = path.join(certsDir, domain); const intermediateCert = path.join(pkiPath, 'intermediate.crt'); @@ -246,10 +240,10 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`; // List generated certificates router.get('/certs', ctx.asyncHandler(async (req, res) => { - const certsDir = '/app/generated-certs'; + const certsDir = platformPaths.generatedCertsDir; if (!await exists(certsDir)) { - return res.json({ success: true, certificates: [] }); + return ok(res, { certificates: [] }); } const dirEntries = await fsp.readdir(certsDir); @@ -284,7 +278,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`; } }))).filter(Boolean); - res.json({ success: true, certificates }); + ok(res, { certificates }); }, 'ca-certs')); return router; diff --git a/dashcaddy-api/routes/config-drift.js b/dashcaddy-api/routes/config-drift.js new file mode 100644 index 0000000..efab9ad --- /dev/null +++ b/dashcaddy-api/routes/config-drift.js @@ -0,0 +1,92 @@ +/** + * 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('../src/utilities/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; +}; diff --git a/dashcaddy-api/routes/config/assets.js b/dashcaddy-api/routes/config/assets.js index 4d24f66..76a3714 100644 --- a/dashcaddy-api/routes/config/assets.js +++ b/dashcaddy-api/routes/config/assets.js @@ -1,9 +1,11 @@ const express = require('express'); const fsp = require('fs').promises; const path = require('path'); -const { LIMITS } = require('../../constants'); -const { exists } = require('../../fs-helpers'); -const { ValidationError } = require('../../errors'); +const { LIMITS } = require('../../../src/utilities/constants'); +const { exists } = require('../../../src/utilities/fs-helpers'); +const { ValidationError } = require('../../../src/utilities/errors'); +const platformPaths = require('../../platform-paths'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Config assets routes factory * @param {Object} deps - Explicit dependencies @@ -51,7 +53,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa const buffer = Buffer.from(base64Data, 'base64'); // Determine assets path (mounted volume) - const assetsPath = process.env.ASSETS_PATH || '/app/assets'; + const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); // Ensure directory exists if (!await exists(assetsPath)) { @@ -62,8 +64,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa const filePath = path.join(assetsPath, safeFilename); await fsp.writeFile(filePath, buffer); - res.json({ - success: true, + ok(res, { path: `/assets/${safeFilename}`, message: `Logo saved to ${filePath}` }); @@ -75,8 +76,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa // Get current logo path, position, and title router.get('/logo', asyncHandler(async (req, res) => { const config = await ctx.readConfig(); - res.json({ - success: true, + ok(res, { // Dark/light variants (new) customLogoDark: config.customLogoDark || 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 buffer = Buffer.from(matches[2], 'base64'); - const assetsPath = process.env.ASSETS_PATH || '/app/assets'; + const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); if (!await exists(assetsPath)) { await fsp.mkdir(assetsPath, { recursive: true }); } @@ -155,8 +155,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa config.updatedAt = new Date().toISOString(); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); - res.json({ - success: true, + ok(res, { pathDark: pathDark, pathLight: pathLight, // Legacy compat @@ -170,7 +169,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa // Reset all branding to defaults router.delete('/logo', asyncHandler(async (req, res) => { const config = await ctx.readConfig(); - const assetsPath = process.env.ASSETS_PATH || '/app/assets'; + const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); // Delete all custom logo files const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean); @@ -194,10 +193,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa config.updatedAt = new Date().toISOString(); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); - res.json({ - success: true, - message: 'Branding reset to defaults' - }); + successMessage(res, 'Branding reset to defaults'); }, 'logo-delete')); // ===== FAVICON ENDPOINTS ===== @@ -206,8 +202,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa // Get current favicon router.get('/favicon', asyncHandler(async (req, res) => { const config = await ctx.readConfig(); - res.json({ - success: true, + ok(res, { customFavicon: config.customFavicon || null, isDefault: !config.customFavicon }); @@ -234,7 +229,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa const base64Data = matches[2]; const buffer = Buffer.from(base64Data, 'base64'); - const assetsPath = process.env.ASSETS_PATH || '/app/assets'; + const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); if (!await exists(assetsPath)) { await fsp.mkdir(assetsPath, { recursive: true }); } @@ -267,8 +262,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa // Update config await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() }); - res.json({ - success: true, + ok(res, { path: '/assets/favicon.ico', message: 'Favicon created successfully' }); @@ -279,7 +273,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa const config = await ctx.readConfig(); // Delete custom favicon files - const assetsPath = process.env.ASSETS_PATH || '/app/assets'; + const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH); const filesToDelete = ['favicon.ico', 'favicon.png']; for (const file of filesToDelete) { const filePath = `${assetsPath}/${file}`; @@ -292,10 +286,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa config.updatedAt = new Date().toISOString(); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); - res.json({ - success: true, - message: 'Favicon reset to default' - }); + successMessage(res, 'Favicon reset to default'); }, 'favicon-delete')); return router; diff --git a/dashcaddy-api/routes/config/backup.js b/dashcaddy-api/routes/config/backup.js index 46dbe2c..d8896b2 100644 --- a/dashcaddy-api/routes/config/backup.js +++ b/dashcaddy-api/routes/config/backup.js @@ -1,9 +1,11 @@ const fsp = require('fs').promises; const fs = require('fs'); const path = require('path'); -const { CADDY } = require('../../constants'); -const { exists } = require('../../fs-helpers'); -const { ValidationError, AuthenticationError } = require('../../errors'); +const { CADDY } = require('../../../src/utilities/constants'); +const { exists } = require('../../../src/utilities/fs-helpers'); +const { ValidationError, AuthenticationError } = require('../../../src/utilities/errors'); +const platformPaths = require('../../platform-paths'); +const { ok } = require('../src/utils/responses'); /** * Config backup routes factory @@ -115,7 +117,7 @@ module.exports = function(deps) { // Include custom assets (logo, favicon) as base64 try { - const assetsDir = process.env.ASSETS_DIR || '/app/assets'; + const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR); const configData = backup.files.config?.data || {}; const assetFiles = [configData.customLogo, configData.customFavicon] .filter(Boolean) @@ -209,7 +211,7 @@ module.exports = function(deps) { preview.browserStateCount = Object.keys(backup.browserState).length; } - res.json({ success: true, preview }); + ok(res, { preview }); }, 'backup-preview')); // Restore configuration from backup @@ -346,7 +348,7 @@ module.exports = function(deps) { // Restore custom assets from base64 if (backup.assets && typeof backup.assets === 'object') { - const assetsDir = process.env.ASSETS_DIR || '/app/assets'; + const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR); for (const [name, b64] of Object.entries(backup.assets)) { try { const safeName = path.basename(name); // prevent path traversal @@ -378,7 +380,7 @@ module.exports = function(deps) { if (results.restored.includes('encryptionKey')) { try { // Clear the cached key so crypto-utils reloads from the new file on next use - const cryptoUtils = require('../../crypto-utils'); + const cryptoUtils = require('../../../src/security/crypto-utils'); if (typeof cryptoUtils.clearCachedKey === 'function') { cryptoUtils.clearCachedKey(); } @@ -390,13 +392,17 @@ module.exports = function(deps) { const success = results.restored.length > 0 && results.errors.length === 0; - res.json({ - success, - message: success - ? `Restored ${results.restored.length} file(s) successfully` - : `Restore completed with ${results.errors.length} error(s)`, - results - }); + if (success) { + ok(res, { + message: `Restored ${results.restored.length} file(s) successfully`, + 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 }); }, 'backup-restore')); diff --git a/dashcaddy-api/routes/config/settings.js b/dashcaddy-api/routes/config/settings.js index 7784c8b..f1ebc6f 100644 --- a/dashcaddy-api/routes/config/settings.js +++ b/dashcaddy-api/routes/config/settings.js @@ -1,7 +1,8 @@ const fsp = require('fs').promises; -const { validateConfig } = require('../../config-schema'); -const { exists } = require('../../fs-helpers'); -const { ValidationError } = require('../../errors'); +const { validateConfig } = require('../../../src/utilities/config-schema'); +const { exists } = require('../../../src/utilities/fs-helpers'); +const { ValidationError } = require('../../../src/utilities/errors'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Config settings routes factory @@ -71,14 +72,14 @@ module.exports = function({ configStateManager: _configStateManager, asyncHandle } log.info('config', 'Config saved', { path: ctx.CONFIG_FILE }); - res.json({ success: true, message: 'Configuration saved', config, warnings }); + ok(res, { message: 'Configuration saved', config, warnings }); }, 'config-save')); router.delete('/config', asyncHandler(async (req, res) => { if (await exists(ctx.CONFIG_FILE)) { await fsp.unlink(ctx.CONFIG_FILE); } - res.json({ success: true, message: 'Configuration reset' }); + successMessage(res, 'Configuration reset'); }, 'config-delete')); return router; diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 1b1a700..cd63eab 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -1,8 +1,8 @@ const express = require('express'); -const { DOCKER } = require('../constants'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { NotFoundError } = require('../errors'); -const { success } = require('../response-helpers'); +const { DOCKER } = require('../src/utilities/constants'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); +const { NotFoundError } = require('../src/utilities/errors'); +const { success } = require('../src/utils/responses'); /** * Containers route factory diff --git a/dashcaddy-api/routes/credentials.js b/dashcaddy-api/routes/credentials.js index f042c11..0baff54 100644 --- a/dashcaddy-api/routes/credentials.js +++ b/dashcaddy-api/routes/credentials.js @@ -1,5 +1,5 @@ const express = require('express'); -const { success, error: errorResponse } = require('../response-helpers'); +const { success, error: errorResponse } = require('../src/utils/responses'); /** * Credentials routes factory diff --git a/dashcaddy-api/routes/dependencies.js b/dashcaddy-api/routes/dependencies.js new file mode 100644 index 0000000..3cdf314 --- /dev/null +++ b/dashcaddy-api/routes/dependencies.js @@ -0,0 +1,235 @@ +/** + * 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('../src/utilities/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; +}; diff --git a/dashcaddy-api/routes/dns.js b/dashcaddy-api/routes/dns.js index 80b1123..2768601 100644 --- a/dashcaddy-api/routes/dns.js +++ b/dashcaddy-api/routes/dns.js @@ -2,10 +2,10 @@ const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const validatorLib = require('validator'); -const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants'); -const { exists } = require('../fs-helpers'); -const { success, error: errorResponse } = require('../response-helpers'); -const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); +const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../src/utilities/constants'); +const { exists } = require('../src/utilities/fs-helpers'); +const { success, error: errorResponse } = require('../src/utils/responses'); +const { ValidationError, AuthenticationError, NotFoundError } = require('../src/utilities/errors'); /** * DNS routes factory @@ -26,7 +26,8 @@ module.exports = function({ log, safeErrorMessage, fetchT, - credentialManager + credentialManager, + dnsPropagationChecker }) { const router = express.Router(); @@ -41,7 +42,137 @@ module.exports = function({ return serverIp; } - // DELETE /record — Delete a DNS record from Technitium + // ===== DNS PROVIDER ENDPOINTS ===== + + // 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) => { const { domain, type, token, server, ipAddress } = req.query; @@ -139,6 +270,14 @@ module.exports = function({ }); 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` }); } else { // Error handled by middleware @@ -194,8 +333,13 @@ module.exports = function({ } }, 'dns-resolve')); - // GET /logs — Fetch DNS query logs from Technitium + // GET /logs — Fetch DNS query logs (Technitium only) 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; if (!server) { @@ -239,9 +383,8 @@ module.exports = function({ const response = await fetchT(technitiumUrl, { method: 'GET', - headers: { 'Accept': 'text/plain' }, - timeout: 10000 - }); + headers: { 'Accept': 'text/plain' } + }, 10000); if (!response.ok) { const errorText = await response.text(); @@ -409,7 +552,7 @@ module.exports = function({ } } - return success(res, { + return ok(res, { message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed', results }); @@ -474,8 +617,13 @@ module.exports = function({ success(res, { message: 'DNS credentials removed' }); }, 'dns-credentials-delete')); - // POST /restart/:dnsId — Restart a DNS server (proxied through backend for auth) + // POST /restart/:dnsId — Restart a DNS server (Technitium only) 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 serverInfo = siteConfig.dnsServers?.[dnsId]; if (!serverInfo?.ip) { @@ -490,7 +638,7 @@ module.exports = function({ const dnsPort = siteConfig.dnsServerPort || '5380'; try { const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`; - const response = await fetchT(url, { method: 'POST', timeout: 5000 }); + const response = await fetchT(url, { method: 'POST' }, 5000); const result = await response.json(); if (result.status === 'ok') { success(res, { message: 'Restart initiated' }); @@ -517,8 +665,13 @@ module.exports = function({ } }, 'dns-refresh-token')); - // GET /check-update — Check for Technitium DNS server updates + // GET /check-update — Check for DNS server updates (Technitium only) 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 { const { server } = req.query; if (!server) { @@ -575,10 +728,13 @@ module.exports = function({ } }, 'dns-check-update')); - // 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. + // POST /update — Update DNS server (Technitium only) 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 { const { server } = req.query; if (!server) { @@ -640,5 +796,68 @@ module.exports = function({ } }, '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; }; diff --git a/dashcaddy-api/routes/docker-resources.js b/dashcaddy-api/routes/docker-resources.js index 8abe317..bdb5220 100644 --- a/dashcaddy-api/routes/docker-resources.js +++ b/dashcaddy-api/routes/docker-resources.js @@ -1,6 +1,6 @@ const express = require('express'); -const { success } = require('../response-helpers'); -const { ValidationError } = require('../errors'); +const { success } = require('../src/utils/responses'); +const { ValidationError } = require('../src/utilities/errors'); /** * Docker resources route factory (volumes, networks, disk usage) diff --git a/dashcaddy-api/routes/errorlogs.js b/dashcaddy-api/routes/errorlogs.js index d9454ab..0478413 100644 --- a/dashcaddy-api/routes/errorlogs.js +++ b/dashcaddy-api/routes/errorlogs.js @@ -1,9 +1,9 @@ const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; -const { exists } = require('../fs-helpers'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { success } = require('../response-helpers'); +const { exists } = require('../src/utilities/fs-helpers'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); +const { success } = require('../src/utils/responses'); /** * Error logs routes factory diff --git a/dashcaddy-api/routes/events.js b/dashcaddy-api/routes/events.js index 8b848d9..52ca0b9 100644 --- a/dashcaddy-api/routes/events.js +++ b/dashcaddy-api/routes/events.js @@ -1,4 +1,5 @@ const express = require('express'); +const { ok } = require('../src/utils/responses'); /** * Server-Sent Events route factory @@ -8,10 +9,14 @@ const express = require('express'); * @param {Object} deps.healthChecker - Health checker * @param {Object} deps.updateManager - Update manager * @param {Function} deps.logError - Error logging function - * @param {Function} deps.ok - Success response helper + * @param {Object} deps.dependencyManager - Dependency manager for restart chain events + * @param {Object} deps.autoRestartManager - Auto-restart manager + * @param {Object} deps.driftDetector - Config drift detector + * @param {Object} deps.sslMonitor - SSL cert expiration monitor + * @param {Object} deps.dnsPropagationChecker - DNS propagation checker * @returns {express.Router} */ -module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, ok }) { +module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, dependencyManager, autoRestartManager, driftDetector, sslMonitor, dnsPropagationChecker }) { const router = express.Router(); const clients = new Set(); @@ -75,6 +80,48 @@ 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 router.get('/stream', (req, res) => { res.writeHead(200, { diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index 7001a89..2035945 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -2,13 +2,13 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { TIMEOUTS } = require('../constants'); -const { exists } = require('../fs-helpers'); -const { paginate, parsePaginationParams } = require('../pagination'); +const { TIMEOUTS } = require('../src/utilities/constants'); +const { exists } = require('../src/utilities/fs-helpers'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); const platformPaths = require('../platform-paths'); -const { resolveServiceUrl } = require('../url-resolver'); -const { success, error: errorResponse } = require('../response-helpers'); -const { ValidationError } = require('../errors'); +const { resolveServiceUrl } = require('../src/utilities/url-resolver'); +const { success, error: errorResponse, errorResponse: sendError, ok, notFound } = require('../src/utils/responses'); +const { ValidationError } = require('../src/utilities/errors'); /** * Health routes factory @@ -190,7 +190,7 @@ module.exports = function({ // Load service config if (!await exists(SERVICES_FILE)) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Services file'); } @@ -199,7 +199,7 @@ module.exports = function({ const service = services.find(s => (s.id || s.name?.toLowerCase()) === serviceId); if (!service) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Service'); } @@ -273,11 +273,7 @@ module.exports = function({ try { // Check if certificate exists if (!await exists(rootCertPath)) { - return res.json({ - status: 'error', - message: 'Root CA certificate not found', - daysUntilExpiration: null - }); + return sendError(res, 404, 'Root CA certificate not found', { caStatus: 'error', daysUntilExpiration: null }); } const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString(); @@ -286,36 +282,32 @@ module.exports = function({ const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); // Alert thresholds - let status = 'healthy'; + let caStatus = 'healthy'; let message = `CA certificate valid for ${daysUntilExpiration} days`; if (daysUntilExpiration < 0) { - status = 'critical'; + caStatus = 'critical'; message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`; } else if (daysUntilExpiration < 7) { - status = 'critical'; + caStatus = 'critical'; message = `CA certificate expires in ${daysUntilExpiration} days!`; } else if (daysUntilExpiration < 30) { - status = 'critical'; + caStatus = 'critical'; message = `CA certificate expires in ${daysUntilExpiration} days!`; } else if (daysUntilExpiration < 90) { - status = 'warning'; + caStatus = 'warning'; message = `CA certificate expires in ${daysUntilExpiration} days`; } - res.json({ - status: status, - message: message, - daysUntilExpiration: daysUntilExpiration, + ok(res, { + caStatus, + message, + daysUntilExpiration, expiresAt: notAfter }); } catch (error) { await logError('GET /api/health/ca', error); - res.json({ - status: 'error', - message: error.message, - daysUntilExpiration: null - }); + sendError(res, 500, error.message, { caStatus: 'error', daysUntilExpiration: null }); } }, 'health-ca')); @@ -349,7 +341,7 @@ module.exports = function({ const hours = parseInt(req.query.hours) || 24; const stats = healthChecker.getServiceStats(req.params.serviceId, hours); if (!stats) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Service'); } success(res, { stats }); diff --git a/dashcaddy-api/routes/license.js b/dashcaddy-api/routes/license.js index 18b716a..9132535 100644 --- a/dashcaddy-api/routes/license.js +++ b/dashcaddy-api/routes/license.js @@ -1,6 +1,6 @@ const express = require('express'); -const { success, error: errorResponse } = require('../response-helpers'); -const { ValidationError } = require('../errors'); +const { success, error: errorResponse } = require('../src/utils/responses'); +const { ValidationError } = require('../src/utilities/errors'); /** * License routes factory diff --git a/dashcaddy-api/routes/logs.js b/dashcaddy-api/routes/logs.js index e753e6b..b51de3f 100644 --- a/dashcaddy-api/routes/logs.js +++ b/dashcaddy-api/routes/logs.js @@ -2,9 +2,10 @@ const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); -const { exists } = require('../fs-helpers'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { NotFoundError, ValidationError, ForbiddenError } = require('../errors'); +const { exists } = require('../src/utilities/fs-helpers'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); +const { NotFoundError, ValidationError, ForbiddenError } = require('../src/utilities/errors'); +const { ok } = require('../src/utils/responses'); /** * Logs route factory @@ -47,7 +48,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan info = await container.inspect(); } catch (err) { if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Container ${containerId}`); } throw err; @@ -96,7 +97,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan await container.inspect(); } catch (err) { if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Container ${containerId}`); } throw err; @@ -231,7 +232,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan try { resolvedPath = await fsp.realpath(normalizedPath); } catch { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Log file'); } @@ -246,7 +247,7 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan } if (!await exists(resolvedPath)) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Log file'); } diff --git a/dashcaddy-api/routes/monitoring.js b/dashcaddy-api/routes/monitoring.js index 28e2255..b3c369b 100644 --- a/dashcaddy-api/routes/monitoring.js +++ b/dashcaddy-api/routes/monitoring.js @@ -1,5 +1,5 @@ const express = require('express'); -const { success } = require('../response-helpers'); +const { success } = require('../src/utils/responses'); /** * Monitoring routes factory @@ -38,7 +38,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica router.get('/monitoring/stats/:containerId', asyncHandler(async (req, res) => { const stats = resourceMonitor.getCurrentStats(req.params.containerId); if (!stats) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Container'); } success(res, { stats }); @@ -54,7 +54,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica const startTime = parseInt(req.query.startTime, 10); const endTime = parseInt(req.query.endTime, 10); if (Number.isNaN(startTime) || Number.isNaN(endTime) || startTime >= endTime) { - const { ValidationError } = require('../errors'); + const { ValidationError } = require('../src/utilities/errors'); throw new ValidationError('Invalid startTime/endTime'); } const result = resourceMonitor.getHistoryByRange(containerId, startTime, endTime); @@ -73,7 +73,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica const hours = parseInt(req.query.hours) || 24; const aggregated = resourceMonitor.getAggregatedStats(req.params.containerId, hours); if (!aggregated) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Monitoring data'); } success(res, { aggregated, hours }); @@ -91,7 +91,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica router.post('/monitoring/alerts/config', asyncHandler(async (req, res) => { const { configs } = req.body; if (!configs || typeof configs !== 'object') { - const { ValidationError } = require('../errors'); + const { ValidationError } = require('../src/utilities/errors'); throw new ValidationError('configs object required'); } for (const [containerId, config] of Object.entries(configs)) { diff --git a/dashcaddy-api/routes/notifications.js b/dashcaddy-api/routes/notifications.js index ebce33d..5d24dca 100644 --- a/dashcaddy-api/routes/notifications.js +++ b/dashcaddy-api/routes/notifications.js @@ -1,8 +1,9 @@ const express = require('express'); -const { validateURL, validateToken } = require('../input-validator'); +const { validateURL, validateToken } = require('../src/security/input-validator'); const validatorLib = require('validator'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { ValidationError } = require('../errors'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); +const { ValidationError } = require('../src/utilities/errors'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Notifications route factory @@ -151,7 +152,7 @@ module.exports = function({ notification, asyncHandler, ok }) { } await notification.saveConfig(); - ok(res, { message: 'Notification config updated' }); + successMessage(res, 'Notification config updated'); }, 'notifications-config-update')); // POST /test — Test notification delivery @@ -206,7 +207,7 @@ module.exports = function({ notification, asyncHandler, ok }) { // DELETE /history — Clear notification history router.delete('/history', asyncHandler(async (req, res) => { notification.clearHistory(); - ok(res, { message: 'Notification history cleared' }); + successMessage(res, 'Notification history cleared'); }, 'notifications-history-clear')); // POST /health-check — Manually trigger health check @@ -223,7 +224,7 @@ module.exports = function({ notification, asyncHandler, ok }) { router.get('/status', asyncHandler(async (req, res) => { const notificationConfig = notification.getConfig(); const providers = notificationConfig.providers || {}; - + ok(res, { enabled: notificationConfig.enabled, providers: { diff --git a/dashcaddy-api/routes/openclaw.js b/dashcaddy-api/routes/openclaw.js index d34c280..f916534 100644 --- a/dashcaddy-api/routes/openclaw.js +++ b/dashcaddy-api/routes/openclaw.js @@ -1,5 +1,6 @@ const express = require('express'); const http = require('http'); +const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses'); /** * OpenClaw management routes @@ -94,8 +95,8 @@ module.exports = function openClawRoutes(ctx) { 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.on('error', function(e) { errorResponse(res, 502, e.message); }); + proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); }); proxyReq.write(body); proxyReq.end(); } else { @@ -105,8 +106,8 @@ module.exports = function openClawRoutes(ctx) { 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.on('error', function(e) { errorResponse(res, 502, e.message); }); + proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); }); } } @@ -149,7 +150,7 @@ module.exports = function openClawRoutes(ctx) { 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' }); + return conflict(res, 'OpenClaw is already deployed'); } const image = 'ghcr.io/nousresearch/openclaw:latest'; @@ -170,7 +171,7 @@ module.exports = function openClawRoutes(ctx) { }); } catch(e) { log.error('OpenClaw pull failed: ' + e.message); - return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message }); + return errorResponse(res, 500, 'Failed to pull image: ' + e.message); } // Create + start container @@ -206,7 +207,7 @@ module.exports = function openClawRoutes(ctx) { }); } catch(e) { log.error('OpenClaw deploy failed: ' + e.message); - res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message }); + errorResponse(res, 500, 'Deploy failed: ' + e.message); } })); @@ -214,7 +215,7 @@ module.exports = function openClawRoutes(ctx) { 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' }); + if (!container) return notFound(res, 'OpenClaw not deployed'); const token = await getGatewayToken(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) { const container = await findOpenClawContainer(); - if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' }); + if (!container) return notFound(res, 'OpenClaw not deployed'); const token = await getGatewayToken(container.Id); const port = await getContainerPort(container.Id); @@ -242,7 +243,7 @@ module.exports = function openClawRoutes(ctx) { router.delete('/', asyncHandler(async function(req, res) { const container = await findOpenClawContainer(); - if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' }); + if (!container) return notFound(res, 'OpenClaw not deployed'); try { const c = docker.client.container(container.Id); @@ -252,7 +253,7 @@ module.exports = function openClawRoutes(ctx) { ok(res, { message: 'OpenClaw removed' }); } catch(e) { log.error('Failed to remove OpenClaw: ' + e.message); - res.status(500).json({ success: false, error: e.message }); + errorResponse(res, 500, e.message); } })); diff --git a/dashcaddy-api/routes/recipes/deploy.js b/dashcaddy-api/routes/recipes/deploy.js index 79c6faa..d3bff7b 100644 --- a/dashcaddy-api/routes/recipes/deploy.js +++ b/dashcaddy-api/routes/recipes/deploy.js @@ -1,7 +1,8 @@ const express = require('express'); -const { ValidationError } = require('../../errors'); +const { ValidationError } = require('../../../src/utilities/errors'); const crypto = require('crypto'); -const { DOCKER } = require('../../constants'); +const { DOCKER } = require('../../../src/utilities/constants'); +const { ok } = require('../src/utils/responses'); /** * Recipes deployment routes factory @@ -27,7 +28,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi // eslint-disable-next-line complexity router.post('/deploy', asyncHandler(async (req, res) => { const { recipeId, config } = req.body; - const { RECIPE_TEMPLATES } = require('../../recipe-templates'); + const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates'); const recipe = RECIPE_TEMPLATES[recipeId]; if (!recipe) throw new ValidationError('Invalid recipe template', 'recipeId'); @@ -146,7 +147,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi 'success' ); - res.json(response); + ok(res, response); } catch (error) { log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message }); diff --git a/dashcaddy-api/routes/recipes/index.js b/dashcaddy-api/routes/recipes/index.js index 1b10cf8..fd87dd5 100644 --- a/dashcaddy-api/routes/recipes/index.js +++ b/dashcaddy-api/routes/recipes/index.js @@ -1,7 +1,8 @@ const express = require('express'); const deployRoutes = require('./deploy'); const manageRoutes = require('./manage'); -const { NotFoundError } = require('../../errors'); +const { NotFoundError } = require('../../../src/utilities/errors'); +const { ok } = require('../src/utils/responses'); /** * Recipes routes aggregator @@ -31,7 +32,7 @@ module.exports = function(ctx) { // GET /api/recipes/templates — list all recipe templates router.get('/templates', deps.asyncHandler(async (req, res) => { - const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../../recipe-templates'); + const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../../../src/recipes/recipe-templates'); const templates = Object.entries(RECIPE_TEMPLATES).map(([id, recipe]) => ({ id, name: recipe.name, @@ -55,16 +56,16 @@ module.exports = function(ctx) { setupInstructions: recipe.setupInstructions })); - res.json({ success: true, templates, categories: RECIPE_CATEGORIES }); + ok(res, { templates, categories: RECIPE_CATEGORIES }); }, 'recipe-templates')); // GET /api/recipes/templates/:recipeId — get single recipe template detail router.get('/templates/:recipeId', deps.asyncHandler(async (req, res) => { - const { RECIPE_TEMPLATES } = require('../../recipe-templates'); + const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates'); const recipe = RECIPE_TEMPLATES[req.params.recipeId]; if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`); - res.json({ success: true, recipe: { id: req.params.recipeId, ...recipe } }); + ok(res, { recipe: { id: req.params.recipeId, ...recipe } }); }, 'recipe-template-detail')); // Mount deploy and manage sub-routes — pass full ctx for sub-routes that reference ctx.* diff --git a/dashcaddy-api/routes/recipes/manage.js b/dashcaddy-api/routes/recipes/manage.js index 9553753..62c9845 100644 --- a/dashcaddy-api/routes/recipes/manage.js +++ b/dashcaddy-api/routes/recipes/manage.js @@ -1,6 +1,7 @@ const express = require('express'); -const { DOCKER } = require('../../constants'); -const { NotFoundError } = require('../../errors'); +const { DOCKER } = require('../../../src/utilities/constants'); +const { NotFoundError } = require('../../../src/utilities/errors'); +const { ok } = require('../src/utils/responses'); module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) { const router = express.Router(); @@ -98,7 +99,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not } } - res.json({ success: true, recipes: Object.values(recipeGroups) }); + ok(res, { recipes: Object.values(recipeGroups) }); }, 'recipe-deployed')); /** @@ -129,7 +130,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not } log.info('recipe', 'Recipe started', { recipeId, results }); - res.json({ success: true, recipeId, results }); + ok(res, { recipeId, results }); }, 'recipe-start')); /** @@ -161,7 +162,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not } log.info('recipe', 'Recipe stopped', { recipeId, results }); - res.json({ success: true, recipeId, results }); + ok(res, { recipeId, results }); }, 'recipe-stop')); /** @@ -187,7 +188,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not } log.info('recipe', 'Recipe restarted', { recipeId, results }); - res.json({ success: true, recipeId, results }); + ok(res, { recipeId, results }); }, 'recipe-restart')); /** @@ -259,7 +260,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not ); log.info('recipe', 'Recipe removed', { recipeId, results }); - res.json({ success: true, recipeId, results }); + ok(res, { recipeId, results }); }, 'recipe-remove')); // === Helper functions === @@ -268,7 +269,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not * Find all Docker containers belonging to a recipe by label */ async function findRecipeContainers(recipeId) { - const { RECIPE_TEMPLATES } = require('../../recipe-templates'); + const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates'); const recipe = RECIPE_TEMPLATES[recipeId]; const recipeLabel = recipe ? recipe.name.toLowerCase().replace(/\s+/g, '-') @@ -292,7 +293,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not * Find recipe ID by its label (name slug) */ function findRecipeIdByLabel(label) { - const { RECIPE_TEMPLATES } = require('../../recipe-templates'); + const { RECIPE_TEMPLATES } = require('../../../src/recipes/recipe-templates'); for (const [id, recipe] of Object.entries(RECIPE_TEMPLATES)) { if (recipe.name.toLowerCase().replace(/\s+/g, '-') === label) { return id; diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index e6d8c77..39bca56 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -4,13 +4,14 @@ const http = require('http'); const https = require('https'); const tls = require('tls'); const validatorLib = require('validator'); -const { APP, REGEX, TIMEOUTS } = require('../constants'); -const { validateServiceConfig, isValidPort } = require('../input-validator'); -const { exists } = require('../fs-helpers'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { ValidationError, NotFoundError, ConflictError } = require('../errors'); -const { resolveServiceUrl } = require('../url-resolver'); -const { success, error: errorResponse } = require('../response-helpers'); +const { APP, REGEX, TIMEOUTS } = require('../src/utilities/constants'); +const { validateServiceConfig, isValidPort } = require('../src/security/input-validator'); +const { exists } = require('../src/utilities/fs-helpers'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); +const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors'); +const { resolveServiceUrl } = require('../src/utilities/url-resolver'); +const { success, error: errorResponse } = require('../src/utils/responses'); +const platformPaths = require('../platform-paths'); /** * Services route factory @@ -46,7 +47,7 @@ module.exports = function({ dns }) { const router = express.Router(); - const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt'; + const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert; const PROBE_CONCURRENCY = 6; let probeHttpsAgent; @@ -355,9 +356,11 @@ module.exports = function({ }, 'services-status')); // 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) => { if (!await exists(SERVICES_FILE)) { - return res.json([]); + return success(res, { services: [] }); } const services = await servicesStateManager.read(); const paginationParams = parsePaginationParams(req.query); @@ -365,14 +368,14 @@ module.exports = function({ if (paginationParams) { success(res, { services: result.data, pagination: result.pagination }); } else { - res.json(result.data); + success(res, { services: result.data }); } }, 'services-list')); // Add a new service router.post('/services', asyncHandler(async (req, res) => { try { - const { id, name, logo } = req.body; + const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body; if (!id || !name) { throw new ValidationError('id and name are required'); @@ -391,7 +394,14 @@ module.exports = function({ throw new ConflictError(`Service "${id}" already exists`, id); } - services.push({ id, name, logo: logo || `/assets/${id}.png` }); + const newService = { 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; }); @@ -513,9 +523,8 @@ module.exports = function({ if (oldSubdomain !== newSubdomain) { try { - const dnsToken = dns.getToken(); - await dns.call(siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' }); - await dns.createRecord(newSubdomain, ip || 'localhost'); + await dns.universalDeleteRecord(oldDomain); + await dns.universalCreateRecord(newSubdomain, ip || 'localhost'); results.dns = 'updated'; } catch (e) { results.dns = `failed: ${e.message}`; @@ -542,6 +551,8 @@ module.exports = function({ }; if (name) services[serviceIndex].name = name; 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'; } else { results.services = 'not found'; diff --git a/dashcaddy-api/routes/sites.js b/dashcaddy-api/routes/sites.js index f4c19a7..bb706e8 100644 --- a/dashcaddy-api/routes/sites.js +++ b/dashcaddy-api/routes/sites.js @@ -1,8 +1,9 @@ const express = require('express'); const fs = require('fs'); -const { CADDY, REGEX, LIMITS } = require('../constants'); -const { ValidationError, ConflictError, NotFoundError } = require('../errors'); -const { validateURL } = require('../input-validator'); +const { CADDY, REGEX, LIMITS } = require('../src/utilities/constants'); +const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors'); +const { validateURL } = require('../src/security/input-validator'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Sites route factory @@ -49,7 +50,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a throw new Error('Caddy reload failed. Check server logs for details.'); } - ok(res, { message: 'Caddy configuration reloaded successfully' }); + successMessage(res, 'Caddy configuration reloaded successfully'); }, 'caddy-reload')); // Get Certificate Authorities from Caddyfile @@ -127,7 +128,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a name: ca.name, displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name })); - res.json({ status: 'success', data: { cas: caList } }); + ok(res, { cas: caList }); }, 'caddy-get-cas')); // Remove a site from Caddyfile @@ -152,7 +153,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a throw new NotFoundError(`Site block for "" in Caddyfile`); } - ok(res, { message: `Site "${domain}" removed from Caddyfile and Caddy reloaded` }); + successMessage(res, `Site "${domain}" removed from Caddyfile and Caddy reloaded`); }, 'site-delete')); // Add a new site to Caddyfile and reload @@ -180,7 +181,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {}); } - ok(res, { message: `Site "${domain}" added to Caddyfile and Caddy reloaded successfully` }); + successMessage(res, `Site "${domain}" added to Caddyfile and Caddy reloaded successfully`); }, 'site-add')); // Add external service reverse proxy to Caddyfile @@ -205,7 +206,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a if (createDns) { try { - await dns.createRecord(subdomain, siteConfig.dnsServerIp); + await dns.universalCreateRecord(subdomain, siteConfig.dnsServerIp); log.info('dns', 'DNS record created for external proxy', { domain, ip: siteConfig.dnsServerIp }); } catch (dnsError) { dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`; diff --git a/dashcaddy-api/routes/ssl-monitor.js b/dashcaddy-api/routes/ssl-monitor.js new file mode 100644 index 0000000..3157ced --- /dev/null +++ b/dashcaddy-api/routes/ssl-monitor.js @@ -0,0 +1,113 @@ +/** + * 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; +}; diff --git a/dashcaddy-api/routes/tailscale.js b/dashcaddy-api/routes/tailscale.js index 432436f..053c8c0 100644 --- a/dashcaddy-api/routes/tailscale.js +++ b/dashcaddy-api/routes/tailscale.js @@ -1,7 +1,9 @@ const express = require('express'); -const { TAILSCALE } = require('../constants'); -const { exists } = require('../fs-helpers'); -const { ValidationError, NotFoundError: _NotFoundError } = require('../errors'); +const fs = require('fs'); +const { TAILSCALE } = require('../src/utilities/constants'); +const { exists } = require('../src/utilities/fs-helpers'); +const { ValidationError, NotFoundError } = require('../src/utilities/errors'); +const { ok, successMessage, unauthorized } = require('../src/utils/responses'); /** * Tailscale route factory @@ -156,7 +158,7 @@ module.exports = function({ const match = content.match(blockRegex); if (!match) { - const { NotFoundError } = require('../errors'); + const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Service ${domain} in Caddyfile`); } @@ -265,7 +267,7 @@ module.exports = function({ tailscale.stopSync(); - ok(res, { message: 'Tailscale OAuth credentials removed' }); + successMessage(res, 'Tailscale OAuth credentials removed'); }, 'tailscale-oauth-delete')); // Get enriched device list from Tailscale API diff --git a/dashcaddy-api/routes/themes.js b/dashcaddy-api/routes/themes.js index 393dc04..073a238 100644 --- a/dashcaddy-api/routes/themes.js +++ b/dashcaddy-api/routes/themes.js @@ -1,8 +1,9 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); -const { success } = require('../response-helpers'); -const { ValidationError, NotFoundError } = require('../errors'); +const { success } = require('../src/utils/responses'); +const { ValidationError, NotFoundError } = require('../src/utilities/errors'); +const platformPaths = require('../platform-paths'); /** * Themes routes factory @@ -13,7 +14,7 @@ const { ValidationError, NotFoundError } = require('../errors'); */ module.exports = function({ asyncHandler, log }) { const router = express.Router(); - const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes'); + const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(platformPaths.servicesFile), 'themes'); // Ensure themes directory exists if (!fs.existsSync(THEMES_DIR)) { diff --git a/dashcaddy-api/routes/updates.js b/dashcaddy-api/routes/updates.js index 0f254a9..4ac35d3 100644 --- a/dashcaddy-api/routes/updates.js +++ b/dashcaddy-api/routes/updates.js @@ -1,6 +1,7 @@ const express = require('express'); -const { paginate, parsePaginationParams } = require('../pagination'); -const { ValidationError } = require('../errors'); +const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); +const { ValidationError } = require('../src/utilities/errors'); +const { ok, successMessage } = require('../src/utils/responses'); /** * Updates route factory @@ -41,7 +42,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError, // Rollback update router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => { await updateManager.rollbackUpdate(req.params.containerId); - ok(res, { message: 'Rollback completed' }); + successMessage(res, 'Rollback completed'); }, 'updates-rollback')); // Get update history @@ -57,7 +58,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError, // Configure auto-update router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => { updateManager.configureAutoUpdate(req.params.containerId, req.body); - ok(res, { message: 'Auto-update configured' }); + successMessage(res, 'Auto-update configured'); }, 'updates-auto-update')); // Get auto-update configuration @@ -87,14 +88,14 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError, // Check for DashCaddy update router.get('/system/update-check', asyncHandler(async (req, res) => { const result = await selfUpdater.checkForUpdate(); - ok(res, { ...result }); + ok(res, result); }, 'system-update-check')); // Apply available update router.post('/system/update-apply', asyncHandler(async (req, res) => { const check = await selfUpdater.checkForUpdate(); if (!check.available) { - return ok(res, { message: 'Already up to date' }); + return successMessage(res, 'Already up to date'); } // Refuse same-version applies. The check.available flag can theoretically be // true with equal versions (commit-mismatch path); applying anyway just @@ -135,7 +136,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError, return res.status(401).json({ success: false, error: 'Invalid notify secret' }); } const result = selfUpdater.notifyAndApply('http-notify'); - ok(res, { ...result }); + ok(res, result); }, 'system-update-notify')); // Get update status diff --git a/dashcaddy-api/routes/workflows.js b/dashcaddy-api/routes/workflows.js index 93d3ea0..a40d04d 100644 --- a/dashcaddy-api/routes/workflows.js +++ b/dashcaddy-api/routes/workflows.js @@ -1,4 +1,5 @@ const express = require('express'); +const { ok } = require('../src/utils/responses'); /** * Workflows routes factory @@ -27,14 +28,14 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok }) router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => { const { workflowId } = req.params; const result = workflowEngine.setWorkflowEnabled(workflowId, true); - ok(res, { ...result }); + ok(res, result); }, 'workflows-enable')); // Disable a workflow router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => { const { workflowId } = req.params; const result = workflowEngine.setWorkflowEnabled(workflowId, false); - ok(res, { ...result }); + ok(res, result); }, 'workflows-disable')); // Manually trigger a workflow diff --git a/dashcaddy-api/scripts/fix-remaining-paths.py b/dashcaddy-api/scripts/fix-remaining-paths.py new file mode 100644 index 0000000..9c44da3 --- /dev/null +++ b/dashcaddy-api/scripts/fix-remaining-paths.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Fix the remaining broken require paths after DC-005 refactor. + +Two patterns to fix: +1. `require('./src/...')` and `require('../../src/...')` and `require('../../../src/...')` + in files inside `src/` directories → should be `require('../...')` (relative to src/) +2. `require('../../../src/...')` in test files in `__tests__/` → should be `require('../src/...')` +""" +import os +import re +from pathlib import Path + +DASHCADDY_API = Path('/root/dashcaddy-krystie/dashcaddy-api') + +# Pattern to match require('../../../src/X/Y') and capture +# We need to detect the file's location and rewrite based on that +# A simple approach: find any require that contains 'src/' in the path, +# and rewrite it to be relative to the file's location. + +def fix_file(filepath: Path) -> bool: + """Returns True if file was changed.""" + content = filepath.read_text() + original = content + + # Find the file's directory relative to dashcaddy-api root + rel_dir = filepath.parent.relative_to(DASHCADDY_API) + depth = len(rel_dir.parts) + + # If file is in src/X/Y/file.js, depth is 3 (src, X, Y) + # If file is in __tests__/file.js, depth is 1 + # If file is in __tests__/routes/file.js, depth is 2 + + # Find all require() calls that contain 'src/' + # Pattern: require('(.....)*src/path') + def replacer(match): + quote = match.group(1) # the quote char + path = match.group(2) # the path inside quotes + # Calculate what the path SHOULD be + if 'src/' not in path: + return match.group(0) + + # Extract the part after 'src/' + idx = path.find('src/') + after_src = path[idx + 4:] # everything after 'src/' + + if filepath.parts[-3] == 'src': + # File is in src/X/file.js - depth 3 + # Should be '../' + new_path = '../' + after_src + elif filepath.parts[-4] == 'src': + # File is in src/X/Y/file.js - depth 4 + # Should be '../../' + new_path = '../../' + after_src + elif filepath.parts[-2] == '__tests__' or filepath.parent.name == '__tests__': + # File is in __tests__/file.js - depth 1 (relative to api root) + # Should be '../src/' + new_path = '../src/' + after_src + elif filepath.parts[-2] == 'routes' and filepath.parts[-3] == '__tests__': + # File is in __tests__/routes/file.js - depth 2 + # Should be '../../src/' + new_path = '../../src/' + after_src + elif 'src' in rel_dir.parts: + # Other src nested location + # Count how many .. we need + src_depth = len(rel_dir.parts) - list(rel_dir.parts).index('src') - 1 + new_path = '../' * src_depth + after_src + else: + # Other location, leave it + return match.group(0) + + return f"require({quote}{new_path}{quote})" + + new_content = re.sub( + r"require\((['\"])([^'\"]*src/[^'\"]*)\1\)", + replacer, + content + ) + + if new_content != original: + filepath.write_text(new_content) + return True + return False + + +def main(): + changed = [] + for js_file in DASHCADDY_API.rglob('*.js'): + # Skip node_modules + if 'node_modules' in js_file.parts: + continue + if fix_file(js_file): + changed.append(str(js_file.relative_to(DASHCADDY_API))) + + print(f"Changed {len(changed)} files:") + for f in changed: + print(f" {f}") + + +if __name__ == '__main__': + main() diff --git a/dashcaddy-api/comprehensive-test.js b/dashcaddy-api/scripts/legacy/comprehensive-test.js similarity index 100% rename from dashcaddy-api/comprehensive-test.js rename to dashcaddy-api/scripts/legacy/comprehensive-test.js diff --git a/dashcaddy-api/test-security-fixes.js b/dashcaddy-api/scripts/legacy/test-security-fixes.js similarity index 100% rename from dashcaddy-api/test-security-fixes.js rename to dashcaddy-api/scripts/legacy/test-security-fixes.js diff --git a/dashcaddy-api/scripts/refactor-requires.js b/dashcaddy-api/scripts/refactor-requires.js new file mode 100644 index 0000000..7e1390a --- /dev/null +++ b/dashcaddy-api/scripts/refactor-requires.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/** + * Refactor helper: rewrites require('./xxx') / require('../xxx') paths in + * dashcaddy-api to point to the new src//xxx.js locations. + * + * Algorithm: + * 1. For each require() call with a relative spec: + * 2. If the resolved file exists, leave it alone. + * 3. If the resolved file does NOT exist, the bare name of the spec + * (or the directory name 'dns-providers') might be one of the + * modules that was moved out of the repo root. In that case, rewrite + * the spec to the correct relative path to the new location. + * 4. Otherwise leave alone. + */ +const fs = require('fs'); +const path = require('path'); + +const REPO = process.cwd(); + +// Map: bare module name (no extension) -> new repo-relative path (no extension) +const NEW_LOCATIONS = { + 'auth-manager': 'src/managers/auth-manager', + 'credential-manager': 'src/managers/credential-manager', + 'license-manager': 'src/managers/license-manager', + 'port-lock-manager': 'src/managers/port-lock-manager', + 'state-manager': 'src/managers/state-manager', + 'notification-manager': 'src/managers/notification-manager', + 'resource-monitor': 'src/managers/resource-monitor', + 'config-drift-detector': 'src/managers/config-drift-detector', + 'auto-restart-manager': 'src/managers/auto-restart-manager', + 'update-manager': 'src/managers/update-manager', + 'dependency-manager': 'src/managers/dependency-manager', + 'csrf-protection': 'src/security/csrf-protection', + 'crypto-utils': 'src/security/crypto-utils', + 'docker-security': 'src/security/docker-security', + 'input-validator': 'src/security/input-validator', + 'keychain-manager': 'src/security/keychain-manager', + 'log-digest': 'src/security/log-digest', + 'audit-logger': 'src/security/audit-logger', + 'docker-maintenance': 'src/docker/docker-maintenance', + 'app-templates': 'src/docker/app-templates', + 'self-updater': 'src/docker/self-updater', + 'dns-propagation': 'src/dns/dns-propagation', + 'recipe-templates': 'src/recipes/recipe-templates', + 'bundled-workflows': 'src/recipes/bundled-workflows', + 'health-checker': 'src/monitoring/health-checker', + 'metrics': 'src/monitoring/metrics', + 'ssl-monitor': 'src/monitoring/ssl-monitor', + 'backup-manager': 'src/utilities/backup-manager', + 'error-handler': 'src/utilities/error-handler', + 'errors': 'src/utilities/errors', + 'fs-helpers': 'src/utilities/fs-helpers', + 'pagination': 'src/utilities/pagination', + 'url-resolver': 'src/utilities/url-resolver', + 'config-schema': 'src/utilities/config-schema', + 'constants': 'src/utilities/constants', + 'middleware': 'src/utilities/middleware', + 'startup-validator': 'src/utilities/startup-validator', + 'cache-config': 'src/utilities/cache-config', +}; + +const SKIP_DIRS = new Set(['node_modules', '.git']); +const SKIP_FILE_PATTERNS = [/\/scripts\/refactor-requires\.js$/]; + +function* walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(full); + } else if (entry.name.endsWith('.js')) { + yield full; + } + } +} + +function toRelativeFromFile(filePath, targetRel) { + const fromDir = path.dirname(filePath); + const targetAbs = path.resolve(REPO, targetRel); + let rel = path.relative(fromDir, targetAbs); + if (!rel.startsWith('.')) rel = './' + rel; + return rel.split(path.sep).join('/'); +} + +function fileExistsWithJsOrIndex(p) { + // exists if p is a file, or p is a dir with index.js + try { + if (fs.existsSync(p) && fs.statSync(p).isFile()) return true; + } catch (_) {} + try { + if (fs.existsSync(p + '.js') && fs.statSync(p + '.js').isFile()) return true; + } catch (_) {} + try { + if ( + fs.existsSync(p) && + fs.statSync(p).isDirectory() && + fs.existsSync(path.join(p, 'index.js')) + ) + return true; + } catch (_) {} + return false; +} + +function refactor(filePath) { + const relFile = path.relative(REPO, filePath); + if (SKIP_FILE_PATTERNS.some((re) => re.test(relFile))) return false; + + const content = fs.readFileSync(filePath, 'utf8'); + let changed = false; + + const requireRe = /require\(\s*(['"])([^'"]+)\1\s*\)/g; + const newContent = content.replace(requireRe, (full, quote, spec) => { + if (!spec.startsWith('.')) return full; // package require, leave alone + const fromDir = path.dirname(filePath); + const resolvedBase = path.resolve(fromDir, spec); + // If the resolved file exists, the require is correct as-is. + if (fileExistsWithJsOrIndex(resolvedBase)) { + // But — check for the special case: require to /dns-providers/x + // which after move becomes /src/dns/dns-providers/x — wait, + // that doesn't exist anymore. The dir was moved. + const dnsProvidersOld = path.resolve(REPO, 'dns-providers'); + if ( + resolvedBase === dnsProvidersOld || + resolvedBase.startsWith(dnsProvidersOld + path.sep) + ) { + const subPath = resolvedBase === dnsProvidersOld ? '' : resolvedBase.slice(dnsProvidersOld.length + 1); + const newResolved = path.resolve(REPO, 'src/dns/dns-providers', subPath); + let rel = path.relative(fromDir, newResolved); + if (!rel.startsWith('.')) rel = './' + rel; + const newSpec = rel.split(path.sep).join('/'); + changed = true; + return `require(${quote}${newSpec}${quote})`; + } + return full; + } + // The file does not exist. Check if the bare name is a moved module. + const bare = path.basename(resolvedBase); + if (bare in NEW_LOCATIONS) { + const target = NEW_LOCATIONS[bare]; + const newSpec = toRelativeFromFile(filePath, target); + changed = true; + return `require(${quote}${newSpec}${quote})`; + } + // Bare not in map. Check for the special case: the spec points into + // the OLD dns-providers dir (now src/dns/dns-providers). E.g. spec + // could be '../dns-providers/registry' or './dns-providers/registry' + // from somewhere else. + if (spec.includes('dns-providers')) { + const dnsProvidersOld = path.resolve(REPO, 'dns-providers'); + if ( + resolvedBase === dnsProvidersOld || + resolvedBase.startsWith(dnsProvidersOld + path.sep) + ) { + const subPath = + resolvedBase === dnsProvidersOld ? '' : resolvedBase.slice(dnsProvidersOld.length + 1); + const newResolved = path.resolve(REPO, 'src/dns/dns-providers', subPath); + let rel = path.relative(fromDir, newResolved); + if (!rel.startsWith('.')) rel = './' + rel; + const newSpec = rel.split(path.sep).join('/'); + changed = true; + return `require(${quote}${newSpec}${quote})`; + } + } + return full; + }); + + if (changed) { + fs.writeFileSync(filePath, newContent); + } + return changed; +} + +let count = 0; +for (const file of walk(REPO)) { + if (refactor(file)) { + count += 1; + console.log('rewrote', path.relative(REPO, file)); + } +} +console.log(`\nDone: rewrote ${count} file(s).`); diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 3c16518..471aedb 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -26,14 +26,15 @@ process.on('uncaughtException', (error) => { // Load license await licenseManager.load(); - const PORT = process.env.PORT || 3001; + const PORT = parseInt(process.env.PORT, 10) || 3001; + const HOST = process.env.HOST || '0.0.0.0'; const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile; const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl; const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile; const CONFIG_FILE = process.env.CONFIG_FILE || platformPaths.servicesFile.replace('services.json', 'config.json'); // Validate startup configuration - const { validateStartupConfig } = require('./startup-validator'); + const { validateStartupConfig } = require('../src/utilities/startup-validator'); await validateStartupConfig({ log, CADDYFILE_PATH, @@ -44,9 +45,10 @@ process.on('uncaughtException', (error) => { }); // Start HTTP server - const server = app.listen(PORT, '0.0.0.0', () => { + const server = app.listen(PORT, HOST, () => { log.info('server', 'DashCaddy API server started', { port: PORT, + host: HOST, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE, @@ -55,17 +57,17 @@ process.on('uncaughtException', (error) => { // Attach WebSocket exec handler (with auth) const attachExecWS = require('./routes/exec'); - const authManager = require('./auth-manager'); + const authManager = require('../src/managers/auth-manager'); attachExecWS(server, log, authManager); log.info('server', 'WebSocket exec handler attached (auth enforced)'); // Start feature modules - const resourceMonitor = require('./resource-monitor'); - const backupManager = require('./backup-manager'); - const healthChecker = require('./health-checker'); - const updateManager = require('./update-manager'); - const selfUpdater = require('./self-updater'); - const portLockManager = require('./port-lock-manager'); + const resourceMonitor = require('../src/managers/resource-monitor'); + const backupManager = require('../src/utilities/backup-manager'); + const healthChecker = require('../src/monitoring/health-checker'); + const updateManager = require('../src/managers/update-manager'); + const selfUpdater = require('../src/docker/self-updater'); + const portLockManager = require('../src/managers/port-lock-manager'); // Create servicesStateManager early — needed by workflow engine init const StateManager = require('./state-manager'); @@ -73,19 +75,22 @@ process.on('uncaughtException', (error) => { // Optional modules let dockerMaintenance, logDigest, bundledWorkflows; - try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ } - try { logDigest = require('./log-digest'); } catch { /* optional */ } - try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ } + try { dockerMaintenance = require('../src/docker/docker-maintenance'); } catch { /* optional */ } + try { logDigest = require('../src/security/log-digest'); } catch { /* optional */ } + try { bundledWorkflows = require('../src/recipes/bundled-workflows'); } catch { /* optional */ } // 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; if (bundledWorkflows) { try { + const { fetchT } = require('./src/utils/http'); const { WorkflowEngine } = bundledWorkflows; // Create a context with needed services const workflowCtx = { docker: { client: require('dockerode')() }, - notification: new (require('./notification-manager'))({ + notification: new (require('./src/managers/notification-manager'))({ NOTIFICATIONS_FILE: process.env.NOTIFICATIONS_FILE || require('./platform-paths').notificationsFile, fetchT, log, @@ -137,7 +142,10 @@ process.on('uncaughtException', (error) => { // Health checker (with service sync) (async () => { try { - const { syncHealthCheckerServices } = require('./startup-validator'); + const { syncHealthCheckerServices } = require('../src/utilities/startup-validator'); + const StateManager = require('../src/managers/state-manager'); + const servicesStateManager = new StateManager(SERVICES_FILE); + await syncHealthCheckerServices({ log, @@ -148,7 +156,7 @@ process.on('uncaughtException', (error) => { ? `https://${config.domain}/${subdomain}` : `https://${subdomain}${config.tld}`, siteConfig: config, - APP: require('./constants').APP + APP: require('../src/utilities/constants').APP }); healthChecker.start(); @@ -230,11 +238,11 @@ process.on('uncaughtException', (error) => { const shutdown = (signal) => { log.info('shutdown', `${signal} received, draining connections...`); - const resourceMonitor = require('./resource-monitor'); - const backupManager = require('./backup-manager'); - const healthChecker = require('./health-checker'); - const updateManager = require('./update-manager'); - const selfUpdater = require('./self-updater'); + const resourceMonitor = require('../src/managers/resource-monitor'); + const backupManager = require('../src/utilities/backup-manager'); + const healthChecker = require('../src/monitoring/health-checker'); + const updateManager = require('../src/managers/update-manager'); + const selfUpdater = require('../src/docker/self-updater'); resourceMonitor.stop(); backupManager.stop(); @@ -243,12 +251,12 @@ process.on('uncaughtException', (error) => { selfUpdater.stop(); try { - const dockerMaintenance = require('./docker-maintenance'); + const dockerMaintenance = require('../src/docker/docker-maintenance'); dockerMaintenance.stop(); } catch { /* optional */ } try { - const logDigest = require('./log-digest'); + const logDigest = require('../src/security/log-digest'); logDigest.stop(); } catch { /* optional */ } diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 84d75e0..a63da9a 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -17,40 +17,41 @@ const { errorResponse, ok } = require('./utils/responses'); const { asyncHandler } = require('./utils/async-handler'); // Managers and utilities -const StateManager = require('../state-manager'); -const { LicenseManager } = require('../license-manager'); -const credentialManager = require('../credential-manager'); -const authManager = require('../auth-manager'); -const dockerSecurity = require('../docker-security'); -const auditLogger = require('../audit-logger'); -const portLockManager = require('../port-lock-manager'); -const resourceMonitor = require('../resource-monitor'); -const backupManager = require('../backup-manager'); -const healthChecker = require('../health-checker'); -const updateManager = require('../update-manager'); -const selfUpdater = require('../self-updater'); -const configureMiddleware = require('../middleware'); -const { syncHealthCheckerServices } = require('../startup-validator'); -const { CSRF_HEADER_NAME } = require('../csrf-protection'); -const { resolveServiceUrl } = require('../url-resolver'); -const metrics = require('../metrics'); -const { validateURL } = require('../input-validator'); +const StateManager = require('managers/state-manager'); +const platformPaths = require('../platform-paths'); +const { LicenseManager } = require('managers/license-manager'); +const credentialManager = require('managers/credential-manager'); +const authManager = require('managers/auth-manager'); +const dockerSecurity = require('security/docker-security'); +const auditLogger = require('security/audit-logger'); +const portLockManager = require('managers/port-lock-manager'); +const resourceMonitor = require('managers/resource-monitor'); +const backupManager = require('utilities/backup-manager'); +const healthChecker = require('monitoring/health-checker'); +const updateManager = require('managers/update-manager'); +const selfUpdater = require('docker/self-updater'); +const configureMiddleware = require('utilities/middleware'); +const { validateStartupConfig: _validateStartupConfig, syncHealthCheckerServices } = require('utilities/startup-validator'); +const { CSRF_HEADER_NAME } = require('security/csrf-protection'); +const { resolveServiceUrl } = require('utilities/url-resolver'); +const metrics = require('monitoring/metrics'); +const { validateURL } = require('security/input-validator'); // Optional modules let dockerMaintenance, logDigest; -try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ } -try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ } +try { dockerMaintenance = require('docker/docker-maintenance'); } catch (_) { /* optional module */ } +try { logDigest = require('security/log-digest'); } catch (_) { /* optional module */ } // Workflow engine (bundled workflows) let bundledWorkflowsModule; let workflowEngine = null; try { - bundledWorkflowsModule = require('../bundled-workflows'); + bundledWorkflowsModule = require('recipes/bundled-workflows'); } catch (_) { /* optional module */ } // Templates -const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); -const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates'); +const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('docker/app-templates'); +const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('recipes/recipe-templates'); // Route modules const healthRoutes = require('../routes/health'); @@ -79,16 +80,39 @@ const themesRoutes = require('../routes/themes'); const dockerResourcesRoutes = require('../routes/docker-resources'); const eventsRoutes = require('../routes/events'); const workflowsRoutes = require('../routes/workflows'); +const dependenciesRoutes = require('../routes/dependencies'); +const DependencyManager = require('managers/dependency-manager'); +const autoRestartRoutes = require('../routes/auto-restart'); +const configDriftRoutes = require('../routes/config-drift'); +const sslMonitorRoutes = require('../routes/ssl-monitor'); +const { AutoRestartManager } = require('managers/auto-restart-manager'); +const { ConfigDriftDetector } = require('managers/config-drift-detector'); +const SSLMonitor = require('monitoring/ssl-monitor'); +const DNSPropagationChecker = require('dns/dns-propagation'); // Constants -const { APP } = require('../constants'); +const { APP } = require('utilities/constants'); /** * Create and configure the Express application */ -function createApp() { +// eslint-disable-next-line require-await -- kept async for API consistency with other factory functions +async function createApp() { 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 const log = createLogger(config.LOG_LEVEL); @@ -104,7 +128,7 @@ function createApp() { licenseManager.loadSecret(config.LICENSE_SECRET_FILE); // HTTPS agent for internal CA - const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt'; + const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert; let httpsAgent; try { const caCert = fs.readFileSync(CA_CERT_PATH); @@ -161,7 +185,26 @@ function createApp() { return first === 100 && second >= 64 && second <= 127; } - function getTailscaleStatus() { + function isPrivateLan(ip) { + if (!ip) return false; + if (ip.startsWith('192.168.') || ip.startsWith('10.')) return true; + return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip); + } + + function collectNetworkInterfaces(osModule) { + const out = []; + const interfaces = osModule.networkInterfaces(); + for (const [name, addrs] of Object.entries(interfaces)) { + for (const addr of addrs) { + if (addr.internal || addr.family !== 'IPv4') continue; + out.push({ name, ip: addr.address }); + } + } + return out; + } + + // eslint-disable-next-line require-await -- stub for now, will gain await when wired into context + async function getTailscaleStatus() { // Stub for now - will be populated by context return null; } @@ -190,15 +233,15 @@ function createApp() { auditLogger, authManager, log, - cryptoUtils: require('../crypto-utils'), + cryptoUtils: require('security/crypto-utils'), isValidContainerId, isTailscaleIP, getTailscaleStatus, - RATE_LIMITS: require('../constants').RATE_LIMITS, - LIMITS: require('../constants').LIMITS, - APP: require('../constants').APP, - CACHE_CONFIGS: require('../cache-config').CACHE_CONFIGS, - createCache: require('../cache-config').createCache, + RATE_LIMITS: require('utilities/constants').RATE_LIMITS, + LIMITS: require('utilities/constants').LIMITS, + APP: require('utilities/constants').APP, + CACHE_CONFIGS: require('utilities/cache-config').CACHE_CONFIGS, + createCache: require('utilities/cache-config').createCache, }); const { strictLimiter } = middlewareResult; @@ -209,9 +252,10 @@ function createApp() { return services.find(s => s.id === serviceId) || null; } + // eslint-disable-next-line require-await -- may grow awaits as config loading evolves async function readConfig() { - const { readJsonFile } = require('../fs-helpers'); - return await readJsonFile(config.CONFIG_FILE, {}); + const { readJsonFile } = require('utilities/fs-helpers'); + return readJsonFile(config.CONFIG_FILE, {}); } async function saveConfig(updates) { @@ -233,7 +277,7 @@ function createApp() { async function saveTotpConfig() { try { - const { writeJsonFile } = require('../fs-helpers'); + const { writeJsonFile } = require('utilities/fs-helpers'); await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig); } catch (e) { log.error('config', 'Could not save TOTP config', { error: e.message }); @@ -356,9 +400,64 @@ 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 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 if (ctx.notification && ctx.resourceMonitor) { ctx.resourceMonitor.on('alert', (alertData) => { @@ -396,7 +495,8 @@ function createApp() { log: ctx.log, safeErrorMessage: ctx.safeErrorMessage, fetchT: ctx.fetchT, - credentialManager: ctx.credentialManager + credentialManager: ctx.credentialManager, + dnsPropagationChecker: ctx.dnsPropagationChecker })); apiRouter.use('/notifications', notificationRoutes({ notification: ctx.notification, @@ -516,26 +616,55 @@ function createApp() { healthChecker: ctx.healthChecker, updateManager: ctx.updateManager, logError: ctx.logError, - ok: ctx.ok + ok: ctx.ok, + dependencyManager: ctx.dependencyManager, + autoRestartManager: ctx.autoRestartManager, + driftDetector: ctx.driftDetector, + sslMonitor: ctx.sslMonitor, + dnsPropagationChecker: ctx.dnsPropagationChecker })); - apiRouter.use(workflowsRoutes({ + apiRouter.use('/workflows', workflowsRoutes({ workflowEngine: ctx.workflowEngine, licenseManager: ctx.licenseManager, asyncHandler: ctx.asyncHandler, ok: ctx.ok })); + 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 apiRouter.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); + ok(res, { status: 'ok', timestamp: new Date().toISOString() }); }); apiRouter.get('/csrf-token', (req, res) => { - res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME }); + ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME }); }); apiRouter.get('/metrics', (req, res) => { - res.json({ success: true, metrics: metrics.getSummary() }); + ok(res, { metrics: metrics.getSummary() }); }); // Mount at /api/v1 (canonical, single version) @@ -543,13 +672,93 @@ function createApp() { // Root-level health check app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); + ok(res, { 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 app.get('/probe/:id', boundAsyncHandler(async (req, res) => { const id = req.params.id; - const { exists } = require('../fs-helpers'); + const { exists } = require('utilities/fs-helpers'); let service = null; if (id !== 'internet' && await exists(config.SERVICES_FILE)) { @@ -676,13 +885,16 @@ function createApp() { }; if (!envLan || !envTailscale) { - const detected = detectInterfaceIps(); - if (!result.lan) result.lan = detected.lan; - if (!result.tailscale) result.tailscale = detected.tailscale; - result.all = detected.all; + result.all = collectNetworkInterfaces(os); + if (!result.tailscale) { + result.tailscale = result.all.find(i => isTailscaleIP(i.ip))?.ip || null; + } + if (!result.lan) { + result.lan = result.all.find(i => isPrivateLan(i.ip))?.ip || null; + } } - res.json(result); + ok(res, result); } catch (error) { errorResponse(res, 500, safeErrorMessage(error)); } @@ -709,7 +921,7 @@ function createApp() { app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => { const path = require('path'); - const { exists } = require('../fs-helpers'); + const { exists } = require('utilities/fs-helpers'); const fsp = require('fs').promises; const specPath = path.join(__dirname, '../openapi.yaml'); @@ -722,7 +934,7 @@ function createApp() { }, 'api-docs-spec')); // Error handlers (MUST be last) - const { notFoundHandler, errorMiddleware } = require('../error-handler'); + const { notFoundHandler, errorMiddleware } = require('utilities/error-handler'); app.use('/api', notFoundHandler); app.use(errorMiddleware); diff --git a/dashcaddy-api/src/config/index.js b/dashcaddy-api/src/config/index.js index ae1a4e8..09119b4 100644 --- a/dashcaddy-api/src/config/index.js +++ b/dashcaddy-api/src/config/index.js @@ -4,7 +4,7 @@ */ const paths = require('./paths'); const site = require('./site'); -const { APP, LIMITS, TIMEOUTS, RETRIES, CADDY } = require('../../constants'); +const { APP, LIMITS, TIMEOUTS, RETRIES, CADDY } = require('../utilities/constants'); // Load logging level const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; diff --git a/dashcaddy-api/src/config/migrations.js b/dashcaddy-api/src/config/migrations.js new file mode 100644 index 0000000..81952a0 --- /dev/null +++ b/dashcaddy-api/src/config/migrations.js @@ -0,0 +1,142 @@ +/** + * Config migration system + * + * When config.json schema changes between versions, register a migration + * function here. On load, the loader detects the stored version, runs all + * migrations from that version forward, and writes the result back. + * + * Migration format: + * migrations[] = (rawConfig) => { ...mutations, _version: toVersion } + * + * Each migration is responsible for transforming the previous version's + * shape into the next version's shape. They run sequentially, so v1→v2→v3 + * all execute in order. + * + * For first-time users with no config file, the loader creates a fresh + * config with CURRENT_VERSION, so they start at the latest schema. + */ +const fs = require('fs'); +const path = require('path'); +const _platformPaths = require('../../platform-paths'); + +const CURRENT_VERSION = 2; + +/** + * Migrations: keys are the version they PRODUCE. + * Each migration takes a raw config object and returns the next version. + */ +const migrations = { + // v0 (unversioned) → v1: add _version field, normalize dns structure + 1: (raw) => { + const migrated = { ...raw }; + if (!migrated._version) migrated._version = 1; + // Normalize: older configs may have dns as a string IP, convert to object + if (typeof migrated.dns === 'string') { + migrated.dns = { ip: migrated.dns, port: 5380 }; + } else if (!migrated.dns) { + migrated.dns = { ip: '', port: 5380 }; + } + return migrated; + }, + + // v1 → v2: add dns.provider field (default: 'technitium' for backwards compat) + 2: (raw) => { + const migrated = { ...raw }; + if (migrated.dns && !migrated.dns.provider) { + migrated.dns.provider = 'technitium'; + } + migrated._version = 2; + return migrated; + } +}; + +/** + * Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION. + * @param {object} raw - The raw config object (may or may not have _version) + * @returns {object} The migrated config + */ +function migrate(raw) { + if (!raw || typeof raw !== 'object') { + // First-time load: return minimal config at current version + return { _version: CURRENT_VERSION }; + } + + const fromVersion = raw._version || 0; + if (fromVersion > CURRENT_VERSION) { + // Config from a future version — bail out, don't corrupt it + // The validation step will catch any actual issues + return raw; + } + + let current = { ...raw }; + for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) { + if (migrations[v]) { + current = migrations[v](current); + } else { + // No migration defined for this version, just bump _version + current._version = v; + } + } + return current; +} + +/** + * Load config from disk, run migrations if needed, and write back the + * migrated version. Safe to call on every startup. + * @param {string} configFile - Absolute path to config.json + * @param {object} log - Logger instance + * @returns {object} The migrated config object + */ +function loadAndMigrate(configFile, log) { + let raw = null; + let fileExisted = false; + + if (fs.existsSync(configFile)) { + fileExisted = true; + try { + raw = JSON.parse(fs.readFileSync(configFile, 'utf8')); + } catch (e) { + if (log && log.error) { + log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message }); + } + raw = null; + } + } + + const fromVersion = raw && raw._version ? raw._version : 0; + const migrated = migrate(raw); + + // Only write back to disk if: + // 1. The file already existed (we don't create configs on fresh installs — + // the loader's defaults handle that case), AND + // 2. The version actually changed (no point rewriting identical content) + if (fileExisted && fromVersion < CURRENT_VERSION) { + if (log && log.info) { + log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, { + from: fromVersion, + to: CURRENT_VERSION, + path: configFile + }); + } + // Write back the migrated config + try { + // Ensure parent dir exists + const dir = path.dirname(configFile); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2)); + } catch (e) { + if (log && log.warn) { + log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message }); + } + } + } + + return migrated; +} + +module.exports = { + CURRENT_VERSION, + migrations, + migrate, + loadAndMigrate +}; diff --git a/dashcaddy-api/src/config/site.js b/dashcaddy-api/src/config/site.js index c13be63..43e11aa 100644 --- a/dashcaddy-api/src/config/site.js +++ b/dashcaddy-api/src/config/site.js @@ -1,10 +1,15 @@ /** * Site configuration loader * Loads and manages site-wide settings from config.json + * + * Includes automatic migration from older config versions (see migrations.js). + * Users never see the migration — it runs silently on startup, writes the + * updated config back, and the rest of the app only ever sees the current + * schema. */ -const fs = require('fs'); -const { validateConfig } = require('../../config-schema'); -const { CADDY } = require('../../constants'); +const { validateConfig } = require('../utilities/config-schema'); +const { CADDY } = require('../utilities/constants'); +const { loadAndMigrate, CURRENT_VERSION } = require('./migrations'); const siteConfig = { tld: '.home', @@ -19,7 +24,7 @@ const siteConfig = { routingMode: 'subdomain' }; -function applyRawConfig(raw) { +function applyConfigFields(raw) { siteConfig.tld = raw.tld || '.home'; if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld; siteConfig.caName = raw.caName || ''; @@ -33,24 +38,27 @@ function applyRawConfig(raw) { siteConfig.routingMode = raw.routingMode || 'subdomain'; siteConfig.pylon = raw.pylon || null; } +function validateAndLogConfig(raw, log) { + const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw); + if (log && log.warn) { + if (!valid) { + log.warn('config', 'Config validation errors', { errors: configErrors }); + } + for (const w of configWarnings) { + log.warn('config', w); + } + } +} function loadSiteConfig(CONFIG_FILE, log) { try { - if (fs.existsSync(CONFIG_FILE)) { - const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + // Run migrations first — this handles config.json files from older + // versions of DashCaddy and writes the migrated version back to disk. + const raw = loadAndMigrate(CONFIG_FILE, log); - // Validate config and log any issues - const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw); - if (log && log.warn) { - if (!valid) { - log.warn('config', 'Config validation errors', { errors: configErrors }); - } - for (const w of configWarnings) { - log.warn('config', w); - } - } - - applyRawConfig(raw); + if (raw && Object.keys(raw).length > 0) { + validateAndLogConfig(raw, log); + applyConfigFields(raw); } } catch (e) { if (log && log.error) { @@ -80,4 +88,5 @@ module.exports = { loadSiteConfig, buildDomain, buildServiceUrl, + CURRENT_VERSION }; diff --git a/dashcaddy-api/src/context/caddy.js b/dashcaddy-api/src/context/caddy.js index 642884d..c92bfc6 100644 --- a/dashcaddy-api/src/context/caddy.js +++ b/dashcaddy-api/src/context/caddy.js @@ -2,7 +2,7 @@ * Caddy context - Caddyfile manipulation and reload */ const fsp = require('fs').promises; -const { RETRIES } = require('../../constants'); +const { RETRIES } = require('../utilities/constants'); /** * Atomically read-modify-write the Caddyfile and reload Caddy. @@ -43,6 +43,7 @@ async function modifyCaddyfile(CADDYFILE_PATH, reloadCaddy, modifyFn) { /** * Read the current Caddyfile content */ +// eslint-disable-next-line require-await -- fsp.readFile already returns a promise async function readCaddyfile(CADDYFILE_PATH) { return await fsp.readFile(CADDYFILE_PATH, 'utf8'); } @@ -93,9 +94,8 @@ async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts try { const response = await fetchT(`https://${domain}/`, { method: 'HEAD', - agent: httpsAgent, - timeout: 5000 - }); + agent: httpsAgent + }, 5000); log.info('caddy', 'Site is accessible', { domain, status: response.status }); return true; diff --git a/dashcaddy-api/src/context/dns.js b/dashcaddy-api/src/context/dns.js index 3cad495..0ee966f 100644 --- a/dashcaddy-api/src/context/dns.js +++ b/dashcaddy-api/src/context/dns.js @@ -1,8 +1,14 @@ /** * 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 { createCache, CACHE_CONFIGS } = require('../../cache-config'); +const { TIMEOUTS, SESSION_TTL, CADDY } = require('../utilities/constants'); +const { createCache, CACHE_CONFIGS } = require('../utilities/cache-config'); +const { createProviderDnsContext } = require('./provider-dns'); // DNS token management let dnsToken = process.env.DNS_ADMIN_TOKEN || ''; @@ -52,9 +58,9 @@ async function refreshDnsToken(username, password, server, fetchT, log) { headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' - }, - timeout: 10000 - } + } + }, + 10000 ); const result = await response.json(); @@ -95,6 +101,20 @@ async function refreshWithPerServerCredentials(dnsId, serverIp, credentialManage /** * Ensure we have a valid DNS token (auto-refresh if needed) */ +async function tryCredentialPair(dnsId, role, primaryIp, siteConfig, credentialManager, fetchT, log) { + 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, fetchT, log); + } + return null; + } catch (err) { + log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message }); + return null; + } +} + async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) { // Check if token is valid and not expired if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) { @@ -105,8 +125,10 @@ async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) { if (primaryIp) { const dnsId = dnsIpToDnsId(primaryIp, siteConfig); if (dnsId) { - const result = await refreshWithPerServerCredentials(dnsId, primaryIp, credentialManager, fetchT, log); - if (result) return result; + for (const role of ['admin', 'readonly']) { + const result = await tryCredentialPair(dnsId, role, primaryIp, siteConfig, credentialManager, fetchT, log); + if (result) return result; + } } } @@ -291,6 +313,10 @@ function invalidateTokenForServer(serverIp) { } 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 require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log); const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role); @@ -299,6 +325,7 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent); return { + // Legacy Technitium-specific interface (unchanged) call, buildUrl: buildDnsUrl, requireToken: require, @@ -312,6 +339,17 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht invalidateTokenForServer, refresh, 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, }; } diff --git a/dashcaddy-api/src/context/docker.js b/dashcaddy-api/src/context/docker.js index 5beb572..fc44ea2 100644 --- a/dashcaddy-api/src/context/docker.js +++ b/dashcaddy-api/src/context/docker.js @@ -2,7 +2,7 @@ * Docker context - Docker client and operations */ const Docker = require('dockerode'); -const { DOCKER } = require('../../constants'); +const { DOCKER } = require('../utilities/constants'); const docker = new Docker(); diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js index 9e07d17..2346b5d 100644 --- a/dashcaddy-api/src/context/index.js +++ b/dashcaddy-api/src/context/index.js @@ -6,7 +6,7 @@ const { createDockerContext } = require('./docker'); const { createCaddyContext } = require('./caddy'); const { createDnsContext } = require('./dns'); const { createSessionContext } = require('./session'); -const NotificationManager = require('../../notification-manager'); +const NotificationManager = require('../managers/notification-manager'); /** * Assemble the full application context diff --git a/dashcaddy-api/src/context/provider-dns.js b/dashcaddy-api/src/context/provider-dns.js new file mode 100644 index 0000000..bf44a67 --- /dev/null +++ b/dashcaddy-api/src/context/provider-dns.js @@ -0,0 +1,310 @@ +/** + * 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('../utilities/cache-config'); +const { TIMEOUTS, SESSION_TTL, CADDY } = require('../utilities/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 || {}; + const builders = { + technitium: () => ({ + serverIp: siteConfig.dnsServerIp || dnsConfig.ip || '', + serverPort: siteConfig.dnsServerPort || dnsConfig.port || '5380', + dnsServers: siteConfig.dnsServers || {}, + dnsId: Object.keys(siteConfig.dnsServers || {})[0] || 'dns1' + }), + cloudflare: () => ({ + apiToken: dnsConfig.apiToken || '', + zoneId: dnsConfig.zoneId || '', + domain: siteConfig.domain || '' + }), + rfc2136: () => ({ + server: dnsConfig.server || siteConfig.dnsServerIp || '', + port: dnsConfig.port || 53, + zone: siteConfig.tld?.replace(/^\./, '') || '', + tsigAlgorithm: dnsConfig.tsigAlgorithm || 'hmac-sha256', + tsigKeyName: dnsConfig.tsigKeyName || '', + tsigSecret: dnsConfig.tsigSecret || '' + }), + manual: () => ({}) + }; + const builder = builders[providerId]; + return builder ? builder() : 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 tryServerRoleCredentials(dnsId, role, primaryIp) { + 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 */ } + return null; + } + + async function tryGlobalCredentials(primaryIp) { + 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 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']) { + const result = await tryServerRoleCredentials(dnsId, role, primaryIp); + if (result) return result; + } + } + } + const globalResult = await tryGlobalCredentials(primaryIp); + if (globalResult) return globalResult; + 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 }; diff --git a/dashcaddy-api/src/dns/dns-propagation.js b/dashcaddy-api/src/dns/dns-propagation.js new file mode 100644 index 0000000..f00416b --- /dev/null +++ b/dashcaddy-api/src/dns/dns-propagation.js @@ -0,0 +1,273 @@ +/** + * 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} 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} 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; diff --git a/dashcaddy-api/src/dns/dns-providers/base.js b/dashcaddy-api/src/dns/dns-providers/base.js new file mode 100644 index 0000000..9db3860 --- /dev/null +++ b/dashcaddy-api/src/dns/dns-providers/base.js @@ -0,0 +1,69 @@ +/** + * 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; diff --git a/dashcaddy-api/src/dns/dns-providers/cloudflare.js b/dashcaddy-api/src/dns/dns-providers/cloudflare.js new file mode 100644 index 0000000..e10eb92 --- /dev/null +++ b/dashcaddy-api/src/dns/dns-providers/cloudflare.js @@ -0,0 +1,269 @@ +/** + * 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; diff --git a/dashcaddy-api/src/dns/dns-providers/manual.js b/dashcaddy-api/src/dns/dns-providers/manual.js new file mode 100644 index 0000000..f6430de --- /dev/null +++ b/dashcaddy-api/src/dns/dns-providers/manual.js @@ -0,0 +1,93 @@ +/** + * 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; diff --git a/dashcaddy-api/src/dns/dns-providers/registry.js b/dashcaddy-api/src/dns/dns-providers/registry.js new file mode 100644 index 0000000..915cf92 --- /dev/null +++ b/dashcaddy-api/src/dns/dns-providers/registry.js @@ -0,0 +1,101 @@ +/** + * 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; diff --git a/dashcaddy-api/src/dns/dns-providers/rfc2136.js b/dashcaddy-api/src/dns/dns-providers/rfc2136.js new file mode 100644 index 0000000..f218c9a --- /dev/null +++ b/dashcaddy-api/src/dns/dns-providers/rfc2136.js @@ -0,0 +1,383 @@ +/** + * 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 `. + */ + 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; diff --git a/dashcaddy-api/src/dns/dns-providers/technitium.js b/dashcaddy-api/src/dns/dns-providers/technitium.js new file mode 100644 index 0000000..7f519cb --- /dev/null +++ b/dashcaddy-api/src/dns/dns-providers/technitium.js @@ -0,0 +1,507 @@ +/** + * 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; diff --git a/dashcaddy-api/app-templates.js b/dashcaddy-api/src/docker/app-templates.js similarity index 99% rename from dashcaddy-api/app-templates.js rename to dashcaddy-api/src/docker/app-templates.js index 288e94f..fdf57ae 100644 --- a/dashcaddy-api/app-templates.js +++ b/dashcaddy-api/src/docker/app-templates.js @@ -342,7 +342,8 @@ const APP_TEMPLATES = { volumes: [ "/var/run/docker.sock:/var/run/docker.sock", "/opt/portainer/data:/data" - ] + ], + environment: {} }, subdomain: "portainer", defaultPort: 9000, @@ -393,7 +394,8 @@ const APP_TEMPLATES = { docker: { image: "louislam/uptime-kuma:latest", ports: ["{{PORT}}:3001"], - volumes: ["/opt/uptime-kuma:/app/data"] + volumes: ["/opt/uptime-kuma:/app/data"], + environment: {} }, subdomain: "uptime", defaultPort: 3002, @@ -549,7 +551,7 @@ const APP_TEMPLATES = { }, subdomain: "dns2", defaultPort: 953, - healthCheck: null, + healthCheck: "tcp://localhost:53", subpathSupport: 'strip', setupInstructions: [ "Configure zone files in /opt/bind9/config/", @@ -640,14 +642,14 @@ const APP_TEMPLATES = { ], docker: { image: "coredns/coredns:latest", - ports: ["53:53", "53:53/udp"], + ports: ["{{PORT}}:53", "53:53", "53:53/udp"], volumes: ["/opt/coredns/config:/etc/coredns"], environment: {}, command: ["-conf", "/etc/coredns/Corefile"] }, subdomain: "dns4", defaultPort: 53, - healthCheck: null, + healthCheck: "tcp://localhost:53", subpathSupport: 'strip', setupInstructions: [ "Create Corefile in /opt/coredns/config/", @@ -1007,7 +1009,9 @@ const APP_TEMPLATES = { docker: { image: "adminer:latest", ports: ["{{PORT}}:8080"], - volumes: [], + volumes: [ + "/opt/adminer:/var/www/html" + ], environment: { "ADMINER_DEFAULT_SERVER": "postgres" } @@ -1099,6 +1103,7 @@ const APP_TEMPLATES = { popularity: 85, difficulty: "Easy", isDashboardWidget: true, + isStaticSite: true, widgetSelector: ".weather-widget-container", subdomain: null, defaultPort: null, @@ -1126,6 +1131,7 @@ const APP_TEMPLATES = { popularity: 80, difficulty: "Easy", isDashboardWidget: true, + isStaticSite: true, widgetSelector: ".clock-widget-container", subdomain: null, defaultPort: null, @@ -1908,7 +1914,9 @@ const APP_TEMPLATES = { docker: { image: "traefik/whoami:latest", ports: ["{{PORT}}:80"], - volumes: [], + volumes: [ + "/opt/whoami/config:/config" + ], environment: {} }, subdomain: "whoami", @@ -2233,7 +2241,9 @@ const APP_TEMPLATES = { docker: { image: "excalidraw/excalidraw:latest", ports: ["{{PORT}}:80"], - volumes: [], + volumes: [ + "/opt/excalidraw/data:/var/lib/excalidraw" + ], environment: {} }, subdomain: "draw", @@ -2258,7 +2268,9 @@ const APP_TEMPLATES = { docker: { image: "corentinth/it-tools:latest", ports: ["{{PORT}}:80"], - volumes: [], + volumes: [ + "/opt/it-tools/config:/config" + ], environment: {} }, subdomain: "tools", @@ -2417,7 +2429,7 @@ const APP_TEMPLATES = { }, subdomain: "mc", defaultPort: 25565, - healthCheck: null, + healthCheck: "tcp://localhost:25565", subpathSupport: 'none', setupInstructions: [ "Server accepts the Minecraft EULA automatically", @@ -2451,7 +2463,7 @@ const APP_TEMPLATES = { }, subdomain: "valheim", defaultPort: 2456, - healthCheck: null, + healthCheck: "tcp://localhost:2456", subpathSupport: 'none', setupInstructions: [ "Connect via Steam: Add Server > IP:2456", diff --git a/dashcaddy-api/docker-maintenance.js b/dashcaddy-api/src/docker/docker-maintenance.js similarity index 99% rename from dashcaddy-api/docker-maintenance.js rename to dashcaddy-api/src/docker/docker-maintenance.js index 25bcab1..d5811f1 100644 --- a/dashcaddy-api/docker-maintenance.js +++ b/dashcaddy-api/src/docker/docker-maintenance.js @@ -9,7 +9,7 @@ const Docker = require('dockerode'); const EventEmitter = require('events'); -const { DOCKER } = require('./constants'); +const { DOCKER } = require('../utilities/constants'); const docker = new Docker(); diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/src/docker/self-updater.js similarity index 98% rename from dashcaddy-api/self-updater.js rename to dashcaddy-api/src/docker/self-updater.js index 2873e38..c308b3f 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/src/docker/self-updater.js @@ -21,17 +21,17 @@ const isWindows = platformPaths.isWindows; const DEFAULTS = { CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes - UPDATE_URL: 'https://get.dashcaddy.net/release', - MIRROR_URL: 'https://get2.dashcaddy.net/release', - UPDATES_DIR: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'updates') : '/app/updates', + UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release', + MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release', + UPDATES_DIR: platformPaths.containerUpdatesDir, // 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'), // FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard - FRONTEND_DIR: platformPaths.isWindows ? path.join(platformPaths.caddySites, 'status') : '/app/dashboard', + FRONTEND_DIR: platformPaths.containerFrontendDir, MAX_BACKUPS: 3, HEALTH_TIMEOUT: 60000, DOWNLOAD_TIMEOUT: 120000, - CHANNEL: 'stable', + CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable', INSTANCE_ID_FILE: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'instance-id') : '/etc/dashcaddy/instance-id', diff --git a/dashcaddy-api/auth-manager.js b/dashcaddy-api/src/managers/auth-manager.js similarity index 99% rename from dashcaddy-api/auth-manager.js rename to dashcaddy-api/src/managers/auth-manager.js index bb0a9bc..e3e14cc 100644 --- a/dashcaddy-api/auth-manager.js +++ b/dashcaddy-api/src/managers/auth-manager.js @@ -7,7 +7,7 @@ const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const credentialManager = require('./credential-manager'); -const cryptoUtils = require('./crypto-utils'); +const cryptoUtils = require('../security/crypto-utils'); // JWT signing secret - derived from encryption key for consistency const JWT_SECRET = cryptoUtils.loadOrCreateKey(); diff --git a/dashcaddy-api/src/managers/auto-restart-manager.js b/dashcaddy-api/src/managers/auto-restart-manager.js new file mode 100644 index 0000000..3c7d1d3 --- /dev/null +++ b/dashcaddy-api/src/managers/auto-restart-manager.js @@ -0,0 +1,503 @@ +/** + * 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('../utilities/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} 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} + */ + 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} 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} 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} 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} + */ + 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} + * @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} + * @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 }; diff --git a/dashcaddy-api/src/managers/config-drift-detector.js b/dashcaddy-api/src/managers/config-drift-detector.js new file mode 100644 index 0000000..dbb677b --- /dev/null +++ b/dashcaddy-api/src/managers/config-drift-detector.js @@ -0,0 +1,376 @@ +/** + * 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} + */ + 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} + * @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 }; diff --git a/dashcaddy-api/credential-manager.js b/dashcaddy-api/src/managers/credential-manager.js similarity index 94% rename from dashcaddy-api/credential-manager.js rename to dashcaddy-api/src/managers/credential-manager.js index 72bd318..2de53a5 100644 --- a/dashcaddy-api/credential-manager.js +++ b/dashcaddy-api/src/managers/credential-manager.js @@ -4,13 +4,32 @@ * Uses OS keychain when available, falls back to encrypted file storage */ -const keychainManager = require('./keychain-manager'); -const cryptoUtils = require('./crypto-utils'); +const keychainManager = require('../security/keychain-manager'); +const cryptoUtils = require('../security/crypto-utils'); const lockfile = require('proper-lockfile'); const fs = require('fs'); const path = require('path'); -const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, 'credentials.json'); +// Resolve credentials file path — supports both standard install (/app/credentials.json) +// and custom deployments with consolidated data directory (/app/data/credentials.json) +function resolveCredentialsFile() { + if (process.env.CREDENTIALS_FILE) { + return process.env.CREDENTIALS_FILE; + } + const candidates = [ + path.join(__dirname, 'credentials.json'), + path.join(__dirname, 'data', 'credentials.json'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + // No existing file — return standard path so first store() creates it there + return candidates[0]; +} + +const CREDENTIALS_FILE = resolveCredentialsFile(); class CredentialManager { constructor() { diff --git a/dashcaddy-api/src/managers/dependency-manager.js b/dashcaddy-api/src/managers/dependency-manager.js new file mode 100644 index 0000000..6cd19bc --- /dev/null +++ b/dashcaddy-api/src/managers/dependency-manager.js @@ -0,0 +1,605 @@ +/** + * 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} + */ + 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} + */ + 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} + */ + 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} 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} 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} + */ + _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} 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} 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} + * @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} `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; diff --git a/dashcaddy-api/license-manager.js b/dashcaddy-api/src/managers/license-manager.js similarity index 97% rename from dashcaddy-api/license-manager.js rename to dashcaddy-api/src/managers/license-manager.js index 341b743..3a6913e 100644 --- a/dashcaddy-api/license-manager.js +++ b/dashcaddy-api/src/managers/license-manager.js @@ -15,6 +15,7 @@ const os = require('os'); const fs = require('fs'); const path = require('path'); const { verifyCode, parseCode, VALID_DURATIONS } = require('./license-keygen'); +const { errorResponse } = require('../utils/responses'); const LICENSE_CRED_KEY = 'license.activation'; const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists @@ -317,6 +318,9 @@ class LicenseManager { */ isExpired() { 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(); } @@ -341,9 +345,7 @@ class LicenseManager { } const featureInfo = PREMIUM_FEATURES[feature] || { name: feature }; - return res.status(403).json({ - success: false, - error: `${featureInfo.name} requires a DashCaddy Premium subscription.`, + return errorResponse(res, 403, `${featureInfo.name} requires a DashCaddy Premium subscription.`, { premiumRequired: true, feature, featureName: featureInfo.name, diff --git a/dashcaddy-api/notification-manager.js b/dashcaddy-api/src/managers/notification-manager.js similarity index 100% rename from dashcaddy-api/notification-manager.js rename to dashcaddy-api/src/managers/notification-manager.js diff --git a/dashcaddy-api/port-lock-manager.js b/dashcaddy-api/src/managers/port-lock-manager.js similarity index 100% rename from dashcaddy-api/port-lock-manager.js rename to dashcaddy-api/src/managers/port-lock-manager.js diff --git a/dashcaddy-api/resource-monitor.js b/dashcaddy-api/src/managers/resource-monitor.js similarity index 100% rename from dashcaddy-api/resource-monitor.js rename to dashcaddy-api/src/managers/resource-monitor.js diff --git a/dashcaddy-api/state-manager.js b/dashcaddy-api/src/managers/state-manager.js similarity index 100% rename from dashcaddy-api/state-manager.js rename to dashcaddy-api/src/managers/state-manager.js diff --git a/dashcaddy-api/update-manager.js b/dashcaddy-api/src/managers/update-manager.js similarity index 100% rename from dashcaddy-api/update-manager.js rename to dashcaddy-api/src/managers/update-manager.js diff --git a/dashcaddy-api/health-checker.js b/dashcaddy-api/src/monitoring/health-checker.js similarity index 100% rename from dashcaddy-api/health-checker.js rename to dashcaddy-api/src/monitoring/health-checker.js diff --git a/dashcaddy-api/metrics.js b/dashcaddy-api/src/monitoring/metrics.js similarity index 100% rename from dashcaddy-api/metrics.js rename to dashcaddy-api/src/monitoring/metrics.js diff --git a/dashcaddy-api/src/monitoring/ssl-monitor.js b/dashcaddy-api/src/monitoring/ssl-monitor.js new file mode 100644 index 0000000..6935482 --- /dev/null +++ b/dashcaddy-api/src/monitoring/ssl-monitor.js @@ -0,0 +1,411 @@ +/** + * 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('../utilities/fs-helpers'); +const { resolveServiceUrl } = require('../utilities/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} hostname → last cert check result */ + this.certStatus = new Map(); + + /** @type {Map} hostname → last notified threshold level */ + this.notifiedThresholds = new Map(); + + /** @type {Map} 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} 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} 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; diff --git a/dashcaddy-api/bundled-workflows.js b/dashcaddy-api/src/recipes/bundled-workflows.js similarity index 100% rename from dashcaddy-api/bundled-workflows.js rename to dashcaddy-api/src/recipes/bundled-workflows.js diff --git a/dashcaddy-api/recipe-templates.js b/dashcaddy-api/src/recipes/recipe-templates.js similarity index 100% rename from dashcaddy-api/recipe-templates.js rename to dashcaddy-api/src/recipes/recipe-templates.js diff --git a/dashcaddy-api/audit-logger.js b/dashcaddy-api/src/security/audit-logger.js similarity index 99% rename from dashcaddy-api/audit-logger.js rename to dashcaddy-api/src/security/audit-logger.js index da05fc4..dcb0047 100644 --- a/dashcaddy-api/audit-logger.js +++ b/dashcaddy-api/src/security/audit-logger.js @@ -1,5 +1,5 @@ const path = require('path'); -const StateManager = require('./state-manager'); +const StateManager = require('../managers/state-manager'); const crypto = require('crypto'); const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(__dirname, 'audit-log.json'); diff --git a/dashcaddy-api/crypto-utils.js b/dashcaddy-api/src/security/crypto-utils.js similarity index 95% rename from dashcaddy-api/crypto-utils.js rename to dashcaddy-api/src/security/crypto-utils.js index f0b8147..28736c5 100644 --- a/dashcaddy-api/crypto-utils.js +++ b/dashcaddy-api/src/security/crypto-utils.js @@ -15,8 +15,26 @@ const IV_LENGTH = 16; // 128 bits for GCM const AUTH_TAG_LENGTH = 16; const SALT_LENGTH = 32; -// Key file location (should be outside of mounted volumes for security) -const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.encryption-key'); +// Resolve encryption key file path — supports both standard install (/app/.encryption-key) +// and custom deployments with consolidated data directory (/app/data/.encryption-key) +function resolveKeyFile() { + if (process.env.ENCRYPTION_KEY_FILE) { + return process.env.ENCRYPTION_KEY_FILE; + } + const candidates = [ + path.join(__dirname, '.encryption-key'), + path.join(__dirname, 'data', '.encryption-key'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + // No existing file — return standard path so first load creates it there + return candidates[0]; +} + +const KEY_FILE = resolveKeyFile(); let encryptionKey = null; diff --git a/dashcaddy-api/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js similarity index 96% rename from dashcaddy-api/csrf-protection.js rename to dashcaddy-api/src/security/csrf-protection.js index ac438c6..cefbf79 100644 --- a/dashcaddy-api/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -8,6 +8,7 @@ const crypto = require('crypto'); const cryptoUtils = require('./crypto-utils'); +const { errorResponse } = require('../utils/responses'); const CSRF_TOKEN_LENGTH = 32; const CSRF_COOKIE_NAME = 'dashcaddy_csrf'; @@ -169,18 +170,14 @@ function csrfValidationMiddleware(req, res, next) { // Validate both values exist if (!cookieNonce) { console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`); - return res.status(403).json({ - success: false, - error: '[DC-100] CSRF token missing', + return errorResponse(res, 403, '[DC-100] CSRF token missing', { message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' }); } if (!headerToken) { console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`); - return res.status(403).json({ - success: false, - error: '[DC-100] CSRF token missing', + return errorResponse(res, 403, '[DC-100] CSRF token missing', { message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.' }); } @@ -204,9 +201,7 @@ function csrfValidationMiddleware(req, res, next) { } catch (err) { console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`); - return res.status(403).json({ - success: false, - error: '[DC-101] CSRF token invalid', + return errorResponse(res, 403, '[DC-101] CSRF token invalid', { message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.' }); } diff --git a/dashcaddy-api/docker-security.js b/dashcaddy-api/src/security/docker-security.js similarity index 100% rename from dashcaddy-api/docker-security.js rename to dashcaddy-api/src/security/docker-security.js diff --git a/dashcaddy-api/input-validator.js b/dashcaddy-api/src/security/input-validator.js similarity index 100% rename from dashcaddy-api/input-validator.js rename to dashcaddy-api/src/security/input-validator.js diff --git a/dashcaddy-api/keychain-manager.js b/dashcaddy-api/src/security/keychain-manager.js similarity index 100% rename from dashcaddy-api/keychain-manager.js rename to dashcaddy-api/src/security/keychain-manager.js diff --git a/dashcaddy-api/log-digest.js b/dashcaddy-api/src/security/log-digest.js similarity index 99% rename from dashcaddy-api/log-digest.js rename to dashcaddy-api/src/security/log-digest.js index 7242ba7..b3cbe3b 100644 --- a/dashcaddy-api/log-digest.js +++ b/dashcaddy-api/src/security/log-digest.js @@ -10,7 +10,7 @@ const EventEmitter = require('events'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); -const { DOCKER } = require('./constants'); +const { DOCKER } = require('../utilities/constants'); const docker = new Docker(); @@ -314,7 +314,7 @@ class LogDigest extends EventEmitter { // Get Docker disk usage let diskUsage = null; try { - const dockerMaintenance = require('./docker-maintenance'); + const dockerMaintenance = require('../docker/docker-maintenance'); diskUsage = await dockerMaintenance.getDiskUsage(); } catch (e) { // Module may not be loaded yet diff --git a/dashcaddy-api/backup-manager.js b/dashcaddy-api/src/utilities/backup-manager.js similarity index 74% rename from dashcaddy-api/backup-manager.js rename to dashcaddy-api/src/utilities/backup-manager.js index a529b01..df24224 100644 --- a/dashcaddy-api/backup-manager.js +++ b/dashcaddy-api/src/utilities/backup-manager.js @@ -286,7 +286,7 @@ class BackupManager extends EventEmitter { */ backupCredentials() { try { - const credentialManager = require('./credential-manager'); + const credentialManager = require('../managers/credential-manager'); return credentialManager.exportBackup(); } catch (error) { console.error('[BackupManager] Error backing up credentials:', error.message); @@ -299,7 +299,7 @@ class BackupManager extends EventEmitter { */ backupStats() { try { - const resourceMonitor = require('./resource-monitor'); + const resourceMonitor = require('../managers/resource-monitor'); return resourceMonitor.exportStats(); } catch (error) { console.error('[BackupManager] Error backing up stats:', error.message); @@ -584,6 +584,257 @@ 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('../managers/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 */ @@ -703,7 +954,7 @@ class BackupManager extends EventEmitter { * Restore credentials */ restoreCredentials(credentials) { - const credentialManager = require('./credential-manager'); + const credentialManager = require('../managers/credential-manager'); credentialManager.importBackup(credentials); console.log('[BackupManager] Credentials restored'); } @@ -712,7 +963,7 @@ class BackupManager extends EventEmitter { * Restore stats */ restoreStats(stats) { - const resourceMonitor = require('./resource-monitor'); + const resourceMonitor = require('../managers/resource-monitor'); resourceMonitor.importStats(stats); console.log('[BackupManager] Stats restored'); } diff --git a/dashcaddy-api/cache-config.js b/dashcaddy-api/src/utilities/cache-config.js similarity index 100% rename from dashcaddy-api/cache-config.js rename to dashcaddy-api/src/utilities/cache-config.js diff --git a/dashcaddy-api/config-schema.js b/dashcaddy-api/src/utilities/config-schema.js similarity index 90% rename from dashcaddy-api/config-schema.js rename to dashcaddy-api/src/utilities/config-schema.js index c8b5438..75fed0c 100644 --- a/dashcaddy-api/config-schema.js +++ b/dashcaddy-api/src/utilities/config-schema.js @@ -59,6 +59,15 @@ function validateConfig(config) { 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.`); + } + } } } diff --git a/dashcaddy-api/constants.js b/dashcaddy-api/src/utilities/constants.js similarity index 98% rename from dashcaddy-api/constants.js rename to dashcaddy-api/src/utilities/constants.js index 02ce109..1e15c31 100644 --- a/dashcaddy-api/constants.js +++ b/dashcaddy-api/src/utilities/constants.js @@ -102,7 +102,7 @@ const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR', // ── Docker ────────────────────────────────────────────────────── const DOCKER = { CONTAINER_PREFIX: 'sami-', - TIMEOUT: 30000, // 30s — timeout for docker pull/create operations + TIMEOUT: 300000, // 300s — timeout for docker pull/create operations LOG_CONFIG: { Type: 'json-file', Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container diff --git a/dashcaddy-api/src/utilities/error-handler.js b/dashcaddy-api/src/utilities/error-handler.js new file mode 100644 index 0000000..216968d --- /dev/null +++ b/dashcaddy-api/src/utilities/error-handler.js @@ -0,0 +1,87 @@ +/** + * DashCaddy Error Handler Middleware + * 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 { LIMITS } = require('./constants'); +const { logError: unifiedLogError, safeErrorMessage } = require('../utils/logging'); +const { errorResponse } = require('../utils/responses'); + +const ERROR_LOG_FILE = path.join(__dirname, 'error.log'); +const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE; + +/** + * Global error handling middleware + * MUST be registered after all routes in server.js + */ +function errorMiddleware(err, req, res, next) { + // Log all errors with request context (unified, same file the rest of the app uses) + unifiedLogError( + ERROR_LOG_FILE, + MAX_ERROR_LOG_SIZE, + req.path, + err, + { + method: req.method, + ip: req.ip, + userId: req.user?.id, + 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 + const isOperational = err.isOperational || err instanceof AppError; + + // Status code + const statusCode = err.statusCode || 500; + + // Error code (DC-XXX format) + const code = err.code || `DC-${statusCode}`; + + // Build extras for response + const extras = { code }; + + // Add optional fields if present + if (err.requiresTotp) extras.requiresTotp = true; + if (err.retryAfter) extras.retryAfter = err.retryAfter; + if (err.field) extras.field = err.field; + if (err.resource) extras.resource = err.resource; + if (err.details && Object.keys(err.details).length > 0) extras.details = err.details; + + // Development mode: include stack trace + if (process.env.NODE_ENV === 'development') { + extras.stack = err.stack; + } + + // Send response + errorResponse(res, statusCode, isOperational ? safeErrorMessage(err) : 'Internal server error', extras); + + // For non-operational errors, log as fatal + if (!isOperational) { + console.error('FATAL: Non-operational error detected', { + error: err.message, + stack: err.stack, + path: req.path + }); + } +} + +/** + * 404 handler for routes not found + * Register this before the global error handler + */ +function notFoundHandler(req, res, next) { + const { NotFoundError } = require('./errors'); + next(new NotFoundError(`Route ${req.method} ${req.path}`)); +} + +module.exports = { + errorMiddleware, + notFoundHandler +}; diff --git a/dashcaddy-api/src/utilities/error.log b/dashcaddy-api/src/utilities/error.log new file mode 100644 index 0000000..c3a7ec5 --- /dev/null +++ b/dashcaddy-api/src/utilities/error.log @@ -0,0 +1,672 @@ +[2026-06-13T19:17:44.371Z] /api/containers/missing123/start: Container missing123 not found +NotFoundError: Container missing123 not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-13T19:17:50.481Z] /api/containers/abc123/update: port already allocated +Error: port already allocated + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-13T19:17:53.507Z] /api/containers/abc123/update: start failed +Error: start failed + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-13T19:18:02.572Z] /api/containers/missing/start: Container missing not found +NotFoundError: Container missing not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-13T19:18:02.581Z] /api/containers/abc123/start: docker daemon not running +Error: docker daemon not running + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-17T12:28:55.678Z] /api/containers/missing123/start: Container missing123 not found +NotFoundError: Container missing123 not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-17T12:29:01.912Z] /api/containers/abc123/update: port already allocated +Error: port already allocated + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-17T12:29:04.955Z] /api/containers/abc123/update: start failed +Error: start failed + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-17T12:29:14.042Z] /api/containers/missing/start: Container missing not found +NotFoundError: Container missing not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-17T12:29:14.049Z] /api/containers/abc123/start: docker daemon not running +Error: docker daemon not running + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-19T12:32:55.465Z] /api/containers/missing123/start: Container missing123 not found +NotFoundError: Container missing123 not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-19T12:33:01.774Z] /api/containers/abc123/update: port already allocated +Error: port already allocated + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-19T12:33:04.933Z] /api/containers/abc123/update: start failed +Error: start failed + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-19T12:33:14.042Z] /api/containers/missing/start: Container missing not found +NotFoundError: Container missing not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-19T12:33:14.047Z] /api/containers/abc123/start: docker daemon not running +Error: docker daemon not running + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:10.270Z] /api/containers/missing123/start: Container missing123 not found +NotFoundError: Container missing123 not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:16.503Z] /api/containers/abc123/update: port already allocated +Error: port already allocated + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:19.514Z] /api/containers/abc123/update: start failed +Error: start failed + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:28.661Z] /api/containers/missing/start: Container missing not found +NotFoundError: Container missing not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:28.668Z] /api/containers/abc123/start: docker daemon not running +Error: docker daemon not running + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:49.276Z] /api/containers/missing123/start: Container missing123 not found +NotFoundError: Container missing123 not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:55.437Z] /api/containers/abc123/update: port already allocated +Error: port already allocated + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:56:58.495Z] /api/containers/abc123/update: start failed +Error: start failed + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:57:07.762Z] /api/containers/missing/start: Container missing not found +NotFoundError: Container missing not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T22:57:07.770Z] /api/containers/abc123/start: docker daemon not running +Error: docker daemon not running + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:02:07.948Z] /api/containers/missing123/start: Container missing123 not found +NotFoundError: Container missing123 not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:02:14.165Z] /api/containers/abc123/update: port already allocated +Error: port already allocated + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:02:17.174Z] /api/containers/abc123/update: start failed +Error: start failed + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:02:26.237Z] /api/containers/missing/start: Container missing not found +NotFoundError: Container missing not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:02:26.243Z] /api/containers/abc123/start: docker daemon not running +Error: docker daemon not running + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:02:52.183Z] /api/containers/missing123/start: Container missing123 not found +NotFoundError: Container missing123 not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:02:58.556Z] /api/containers/abc123/update: port already allocated +Error: port already allocated + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:03:01.603Z] /api/containers/abc123/update: start failed +Error: start failed + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:03:10.686Z] /api/containers/missing/start: Container missing not found +NotFoundError: Container missing not found + at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23 + at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13 +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ +[2026-06-25T23:03:10.698Z] /api/containers/abc123/start: docker daemon not running +Error: docker daemon not running + at Object. (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43) + at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28) + at new Promise () + at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10) + at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40) + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9) + at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3) + at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21) + at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19) + at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16) + at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34) + at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12) +Additional Info: { + "method": "POST", + "ip": "::ffff:127.0.0.1", + "body": {} +} +================================================================================ diff --git a/dashcaddy-api/errors.js b/dashcaddy-api/src/utilities/errors.js similarity index 100% rename from dashcaddy-api/errors.js rename to dashcaddy-api/src/utilities/errors.js diff --git a/dashcaddy-api/fs-helpers.js b/dashcaddy-api/src/utilities/fs-helpers.js similarity index 100% rename from dashcaddy-api/fs-helpers.js rename to dashcaddy-api/src/utilities/fs-helpers.js diff --git a/dashcaddy-api/middleware.js b/dashcaddy-api/src/utilities/middleware.js similarity index 88% rename from dashcaddy-api/middleware.js rename to dashcaddy-api/src/utilities/middleware.js index 719c7ff..997b6cd 100644 --- a/dashcaddy-api/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -13,8 +13,9 @@ const helmet = require('helmet'); const compression = require('compression'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); -const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection'); +const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('../security/csrf-protection'); const { RATE_LIMITS, LIMITS, APP } = require('./constants'); +const { errorResponse, unauthorized, forbidden, validationError } = require('../utils/responses'); const { CACHE_CONFIGS, createCache } = require('./cache-config'); /** @@ -33,7 +34,7 @@ module.exports = function configureMiddleware(app, { // ── Container ID param validation ── app.param('id', (req, res, next, id) => { if (req.path.includes('/containers/') && !isValidContainerId(id)) { - return res.status(400).json({ success: false, error: 'Invalid container ID' }); + return validationError(res, 'Invalid container ID'); } next(); }); @@ -127,9 +128,7 @@ module.exports = function configureMiddleware(app, { const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim())); if (!fromTailscale) { - return res.status(403).json({ - success: false, - error: '[DC-120] Access denied. This dashboard requires Tailscale connection.', + return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', { requiresTailscale: true, clientIP: clientIP }); @@ -150,9 +149,7 @@ module.exports = function configureMiddleware(app, { for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip); } if (!knownIPs.has(clientTailscaleIP)) { - return res.status(403).json({ - success: false, - error: '[DC-121] Access denied. Device not in allowed tailnet.', + return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', { requiresTailscale: true, clientIP }); @@ -277,9 +274,32 @@ module.exports = function configureMiddleware(app, { } // ── Public routes (bypass TOTP and JWT auth) ── + // Routes here are accessible without authentication. By default the + // monitoring/health-check endpoints are public so the dashboard can + // render widgets before the user logs in. Set MONITORING_PUBLIC=false + // (env var) or `monitoring: { public: false }` (config.json) to require + // auth for these — useful for internet-exposed deployments where + // CPU/memory/disk data is sensitive. + const MONITORING_PUBLIC = (() => { + if (process.env.MONITORING_PUBLIC === 'false') return false; + if (process.env.MONITORING_PUBLIC === 'true') return true; + // Default: check config.json if loaded + try { + const cfg = require('../config/site').siteConfig; + if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') { + return cfg.monitoring.public; + } + } catch { /* config not loaded yet, use default */ } + return true; // default: public (current behavior, dashboard needs it) + })(); + const PUBLIC_ROUTES = [ { path: '/health', exact: true }, + { path: '/health/live', exact: true }, + { path: '/health/ready', exact: true }, { path: '/api/v1/health', exact: true }, + { path: '/api/v1/health/live', exact: true }, + { path: '/api/v1/health/ready', exact: true }, { path: '/probe/', prefix: true }, { path: '/api/v1/tailscale/', prefix: true }, { path: '/api/v1/totp/config', exact: true, method: 'GET' }, @@ -318,6 +338,12 @@ module.exports = function configureMiddleware(app, { { 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' }, + // Monitoring endpoints — only public if MONITORING_PUBLIC is true + ...(MONITORING_PUBLIC ? [ + { path: '/api/v1/monitoring/stats', exact: true, method: 'GET' }, + { path: '/api/v1/health-checks/status', exact: true, method: 'GET' }, + ] : []), + { path: '/api/v1/version', exact: true, method: 'GET' }, ]; function isPublicRoute(req) { @@ -342,7 +368,7 @@ module.exports = function configureMiddleware(app, { if (isPublicRoute(req)) return next(); if (isSessionValid(req)) return next(); - return res.status(401).json({ success: false, error: '[DC-110] Authentication required', requiresTotp: true }); + return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true }); }; app.use(totpAuthMiddleware); @@ -390,9 +416,7 @@ module.exports = function configureMiddleware(app, { } // No valid auth — reject - return res.status(401).json({ - success: false, - error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', + return errorResponse(res, 401, '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', { requiresTotp: totpConfig.enabled }); }; diff --git a/dashcaddy-api/pagination.js b/dashcaddy-api/src/utilities/pagination.js similarity index 100% rename from dashcaddy-api/pagination.js rename to dashcaddy-api/src/utilities/pagination.js diff --git a/dashcaddy-api/startup-validator.js b/dashcaddy-api/src/utilities/startup-validator.js similarity index 100% rename from dashcaddy-api/startup-validator.js rename to dashcaddy-api/src/utilities/startup-validator.js diff --git a/dashcaddy-api/url-resolver.js b/dashcaddy-api/src/utilities/url-resolver.js similarity index 100% rename from dashcaddy-api/url-resolver.js rename to dashcaddy-api/src/utilities/url-resolver.js diff --git a/dashcaddy-api/src/utils/async-handler.js b/dashcaddy-api/src/utils/async-handler.js index b4960c7..e2703ee 100644 --- a/dashcaddy-api/src/utils/async-handler.js +++ b/dashcaddy-api/src/utils/async-handler.js @@ -1,7 +1,7 @@ /** * Async handler wrapper - Eliminates try/catch boilerplate */ -const { AppError } = require('../../errors'); +const { AppError } = require('../utilities/errors'); /** * Wrap async route handlers - catches errors and logs them diff --git a/dashcaddy-api/src/utils/http.js b/dashcaddy-api/src/utils/http.js index 97154ea..506e90d 100644 --- a/dashcaddy-api/src/utils/http.js +++ b/dashcaddy-api/src/utils/http.js @@ -3,7 +3,7 @@ */ const http = require('http'); const https = require('https'); -const { TIMEOUTS } = require('../../constants'); +const { TIMEOUTS } = require('../utilities/constants'); // HTTPS agent that trusts internal CA certs (self-signed .sami TLD etc.) // Lazy-initialized singleton to avoid creating a new agent per request. @@ -38,7 +38,15 @@ function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { if (!opts.signal) { opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) }; } - delete opts.timeout; + // The `timeout` key in fetch() opts is silently ignored by undici. Callers + // should use the third arg of fetchT() (timeoutMs) instead. If a caller + // passes `timeout: N` here, it's almost certainly a bug — we used to silently + // strip it, which masked the issue. Now we surface it in logs and strip it. + if ('timeout' in opts) { + console.warn(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}`); + const { timeout: _timeout, ...rest } = opts; + opts = rest; + } return fetch(url, opts); } @@ -160,7 +168,7 @@ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { }); }); }); - + req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`)); diff --git a/dashcaddy-api/src/utils/responses.js b/dashcaddy-api/src/utils/responses.js index f454549..d980a46 100644 --- a/dashcaddy-api/src/utils/responses.js +++ b/dashcaddy-api/src/utils/responses.js @@ -1,22 +1,124 @@ /** * 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('../utilities/constants'); + +// ── Success helpers ──────────────────────────────────────────── /** - * Standard error response + * Standard success response. Use this in route handlers. + * Wraps the data object with a `success: true` envelope. + * @param {object} res Express response + * @param {object} [data={}] fields to include in the response body + * @param {number} [statusCode=200] HTTP status code + */ +function ok(res, data = {}, statusCode = HTTP_STATUS.OK) { + return res.status(statusCode).json({ success: true, ...data }); +} + +/** + * Alias for `ok` — prefer `ok` in new code, but kept for code that imports as `success`. + */ +function success(res, data, statusCode) { + return ok(res, data, statusCode); +} + +/** + * Success response with a human-readable message field. + * Use when there's no data to return, just confirmation. + */ +function successMessage(res, message, statusCode = HTTP_STATUS.OK) { + return res.status(statusCode).json({ success: true, message }); +} + +/** + * 201 Created response. + */ +function created(res, data = {}) { + return res.status(HTTP_STATUS.CREATED).json({ success: true, ...data }); +} + +/** + * 204 No Content response. + */ +function noContent(res) { + return res.status(HTTP_STATUS.NO_CONTENT).send(); +} + +// ── Error helpers ────────────────────────────────────────────── + +/** + * Standard error response. Use this in route handlers. + * @param {object} res Express response + * @param {number} statusCode HTTP status code + * @param {string} message Human-readable error message + * @param {object} [extras={}] additional fields to merge into the response */ function errorResponse(res, statusCode, message, extras = {}) { return res.status(statusCode).json({ success: false, error: message, ...extras }); } /** - * Standard success response + * Alias for `errorResponse` — kept for code that imports as `error`. */ -function ok(res, data = {}) { - return res.json({ success: true, ...data }); +function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) { + return res.status(statusCode).json({ success: false, error: message }); +} + +/** + * 400 Bad Request — invalid input from the user. + */ +function validationError(res, message) { + return res.status(HTTP_STATUS.BAD_REQUEST).json({ success: false, error: message }); +} + +/** + * 401 Unauthorized — no valid credentials. + */ +function unauthorized(res, message = 'Unauthorized') { + return res.status(HTTP_STATUS.UNAUTHORIZED).json({ success: false, error: message }); +} + +/** + * 403 Forbidden — credentials valid but permission denied. + */ +function forbidden(res, message = 'Forbidden') { + return res.status(HTTP_STATUS.FORBIDDEN).json({ success: false, error: message }); +} + +/** + * 404 Not Found — resource doesn't exist. + */ +function notFound(res, message = 'Not found') { + return res.status(HTTP_STATUS.NOT_FOUND).json({ success: false, error: message }); +} + +/** + * 409 Conflict — request conflicts with current state (e.g. duplicate). + */ +function conflict(res, message) { + return res.status(HTTP_STATUS.CONFLICT).json({ success: false, error: message }); } module.exports = { - errorResponse, + // Success helpers ok, + success, + successMessage, + created, + noContent, + // Error helpers + errorResponse, + error, + validationError, + unauthorized, + forbidden, + notFound, + conflict, }; diff --git a/dashcaddy-installer/src/main/config-manager.js b/dashcaddy-installer/src/main/config-manager.js index 5e927a4..0416b9f 100644 --- a/dashcaddy-installer/src/main/config-manager.js +++ b/dashcaddy-installer/src/main/config-manager.js @@ -6,7 +6,7 @@ const { REQUIRED_DIRS } = require('../shared/constants'); let cryptoUtils; try { // Try to load from dashcaddy-api if available - cryptoUtils = require('../../dashcaddy-api/crypto-utils'); + cryptoUtils = require('../../../src/security/crypto-utils'); } catch { // Fallback: create minimal crypto implementation const crypto = require('crypto'); diff --git a/scripts/backup-gitea-to-dropbox.sh b/scripts/backup-gitea-to-dropbox.sh new file mode 100644 index 0000000..788fc5d --- /dev/null +++ b/scripts/backup-gitea-to-dropbox.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# ============================================================================= +# DashCaddy Gitea — Off-host backup to Dropbox +# ============================================================================= +# - Stops gitea container briefly to ensure SQLite DB consistency +# - Syncs /var/lib/docker/volumes/gitea-data to dropbox:/Apps/dashcaddy-gitea-backups// +# - Date-stamped snapshots (one per day), kept for 7 days locally +# - Restarts gitea even if sync fails +# - Logs to /var/log/gitea-backup.log +# ============================================================================= +set -u # don't use -e: we want to always restart gitea + +LOG=/var/log/gitea-backup.log +DATA_SRC=/var/lib/docker/volumes/gitea-data/_data +DEST="dropbox:/Apps/dashcaddy-gitea-backups" +TODAY=$(date -u +%Y-%m-%d) +BACKUP_PATH="${DEST}/${TODAY}" +RETENTION_DAYS=7 + +log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG"; } + +log "=== Backup start ===" + +# 0. Sanity checks +if [ ! -d "$DATA_SRC" ]; then + log "ERROR: data dir $DATA_SRC missing" + exit 1 +fi + +# 1. Stop gitea to flush SQLite +log "Stopping gitea container..." +docker stop gitea >> "$LOG" 2>&1 +STOP_RC=$? +if [ $STOP_RC -ne 0 ]; then + log "WARNING: docker stop returned $STOP_RC — container may not be running" +fi + +# 2. Sync (use copy so source files are preserved as-is, no --delete) +log "Syncing $DATA_SRC -> $BACKUP_PATH" +rclone copy "$DATA_SRC" "$BACKUP_PATH" \ + --transfers 4 \ + --checkers 8 \ + --retries 3 \ + --low-level-retries 10 \ + --stats 30s \ + --log-file "$LOG" \ + --log-level INFO +SYNC_RC=$? + +# 3. Always restart gitea +log "Starting gitea container..." +docker start gitea >> "$LOG" 2>&1 +START_RC=$? + +# Wait for gitea to be ready +for i in {1..30}; do + if curl -sf http://localhost:3000/api/v1/version > /dev/null 2>&1; then + log "Gitea is up after ${i}s" + break + fi + sleep 1 +done + +# 4. Cleanup old backups (older than RETENTION_DAYS) +log "Pruning local + remote snapshots older than ${RETENTION_DAYS} days..." +CUTOFF=$(date -u -d "${RETENTION_DAYS} days ago" +%Y-%m-%d) +rclone lsf "$DEST/" --dirs-only 2>/dev/null | while read -r d; do + # rclone returns names with trailing / + name="${d%/}" + if [[ "$name" < "$CUTOFF" ]]; then + log " removing old: $name" + rclone purge "${DEST}/${name}" >> "$LOG" 2>&1 + fi +done + +# 5. Report +if [ $SYNC_RC -eq 0 ] && [ $START_RC -eq 0 ]; then + log "=== Backup OK ===" + exit 0 +else + log "=== Backup completed with errors (sync=$SYNC_RC, start=$START_RC) ===" + exit 1 +fi diff --git a/scripts/dashcaddy-update.sh b/scripts/dashcaddy-update.sh new file mode 100755 index 0000000..b6de496 --- /dev/null +++ b/scripts/dashcaddy-update.sh @@ -0,0 +1,379 @@ +#!/usr/bin/env bash +# DashCaddy Host-Side Updater +# Triggered by systemd path unit when the container writes trigger.json. +# Reads the trigger, backs up current API + data/, copies new files, rebuilds container. +# Writes result.json so the new container knows the outcome. +# +# This runs on the HOST, outside the container. + +set -euo pipefail + +readonly UPDATES_DIR="/opt/dashcaddy/updates" +readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json" +readonly RESULT_FILE="${UPDATES_DIR}/result.json" +readonly BACKUPS_DIR="${UPDATES_DIR}/backups" +readonly CONTAINER_NAME="dashcaddy-api" +readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest" +readonly MAX_BACKUPS=3 +readonly HEALTH_TIMEOUT=60 + +# Data directory backup — stored alongside code backups so everything rolls back together +readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data" +readonly DATA_BACKUP_PREFIX="data-backup" + +log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; } + +write_result() { + local success="$1" version="$2" duration="$3" + shift 3 + local error="${1:-}" + + if [[ "$success" == "true" ]]; then + cat > "$RESULT_FILE" < "$RESULT_FILE" </dev/null | wc -l) + if (( count > MAX_BACKUPS )); then + log "Cleaning old backups (${count} > ${MAX_BACKUPS})" + find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \ + | sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \ + | xargs rm -rf + fi +} + +# ── Data backup (rsync for efficiency + permissions) ────────────────────────── +backup_data_dir() { + local backup_dir="$1" + if [[ -d "$DATA_SOURCE_DIR" ]]; then + log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/" + mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}" + rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \ + || cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}" + log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))" + else + log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup" + fi +} + +# ── Data restore ────────────────────────────────────────────────────────────── +restore_data_dir() { + local backup_dir="$1" + local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}" + if [[ -d "$data_backup" ]]; then + log "Restoring data/ from backup..." + rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \ + || cp -a "$data_backup" "$DATA_SOURCE_DIR" + log "Data restored successfully" + else + log "WARNING: No data backup found at ${data_backup} — data/ not restored" + fi +} + +wait_for_health() { + local port="${1:-3001}" + local timeout="$HEALTH_TIMEOUT" + local elapsed=0 + + log "Waiting for health check (timeout: ${timeout}s)..." + while (( elapsed < timeout )); do + if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then + log "Health check passed after ${elapsed}s" + return 0 + fi + sleep 2 + elapsed=$(( elapsed + 2 )) + done + + log "Health check FAILED after ${timeout}s" + return 1 +} + +# ── Shared rollback: restore code + data ──────────────────────────────────── +rollback_restore() { + local backup_dir="$1" + log "Rolling back: restoring code files..." + for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do + [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true + done + if [[ -d "$backup_dir/routes" ]]; then + rm -rf "$api_source_dir/routes" + cp -rf "$backup_dir/routes" "$api_source_dir/routes" + fi + if [[ -d "$backup_dir/src" ]]; then + rm -rf "$api_source_dir/src" + cp -rf "$backup_dir/src" "$api_source_dir/src" + fi + if [[ -d "$backup_dir/dns-providers" ]]; then + rm -rf "$api_source_dir/dns-providers" + cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers" + fi + restore_data_dir "$backup_dir" +} + +# ── Deployment mode ─────────────────────────────────────────────────────────── +# Reproduce the SAME container the install created so an auto-update keeps every +# volume + env var (docker socket, Caddyfile, config/credentials, updates mount), +# not a minimal subset. Standard installs use docker-compose (compose file in the +# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a +# bare docker run is the last resort. build_image() and restart_container() both +# honor the detected mode so build and run stay consistent. +deploy_mode() { + if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then + echo compose + elif [[ -x /opt/dashcaddy/start.sh ]]; then + echo startsh + else + echo run + fi +} + +# Build the API image using whatever the install is wired for. Returns the build +# command's exit status so callers can detect failure. +build_image() { + cd "$api_source_dir" || return 1 + case "$(deploy_mode)" in + compose) docker compose build 2>&1 || docker-compose build 2>&1 ;; + *) docker build -t "$IMAGE_TAG" . 2>&1 ;; + esac +} + +# ── Shared container restart — recreate with the full, install-defined spec ─── +# Recreates (rm + run / compose up) so new code AND new env vars take effect. +restart_container() { + cd "$api_source_dir" 2>/dev/null || true + case "$(deploy_mode)" in + compose) + log "Recreating container via docker compose (full compose spec)..." + docker compose up -d 2>&1 || docker-compose up -d 2>&1 + ;; + startsh) + log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..." + bash /opt/dashcaddy/start.sh + ;; + *) + log "Recreating container via minimal docker run (fallback)..." + docker rm -f "$CONTAINER_NAME" 2>/dev/null || true + docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \ + -p 127.0.0.1:3001:3001 \ + -v /opt/dashcaddy/dashcaddy-api/data:/app/data \ + -e SERVICES_FILE=/app/data/services.json \ + "$IMAGE_TAG" + ;; + esac + log "Container recreated" +} + +# ── Code-only restore (used after failed build when data hasn't changed yet) ── +code_restore() { + local backup_dir="$1" + log "Restoring code files..." + for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do + [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true + done + if [[ -d "$backup_dir/routes" ]]; then + rm -rf "$api_source_dir/routes" + cp -rf "$backup_dir/routes" "$api_source_dir/routes" + fi + if [[ -d "$backup_dir/src" ]]; then + rm -rf "$api_source_dir/src" + cp -rf "$backup_dir/src" "$api_source_dir/src" + fi + if [[ -d "$backup_dir/dns-providers" ]]; then + rm -rf "$api_source_dir/dns-providers" + cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers" + fi +} + +main() { + local start_time + start_time=$(date +%s) + + # 1. Read trigger + if [[ ! -f "$TRIGGER_FILE" ]]; then + log "No trigger file found — nothing to do" + exit 0 + fi + + # Parse trigger.json (uses python3 which is available on all supported distros) + local action version from_version staging_dir api_source_dir commit + local frontend_staging_dir frontend_target_dir + action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])") + version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])") + from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])") + staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])") + api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])") + commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')") + frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')") + frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')") + # Handle action=rollback (no new version to deploy) + local to_version="${version}" + + log "=== ${action^^}: v${from_version} -> v${to_version} ===" + log "Staging: ${staging_dir}" + log "API source: ${api_source_dir}" + + # Consume the trigger immediately so we don't re-process on failure + mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing" + + # ── Handle rollback ──────────────────────────────────────────────────────── + if [[ "$action" == "rollback" ]]; then + local backup_dir="${BACKUPS_DIR}/${version}" + if [[ ! -d "$backup_dir" ]]; then + log "ERROR: No backup found for version ${version}" + write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}" + rm -f "${TRIGGER_FILE}.processing" + exit 1 + fi + + log "Performing rollback to v${version}..." + rollback_restore "$backup_dir" + + # Rebuild old code + log "Rebuilding container..." + build_image 2>&1 | tail -3 || true + + restart_container + wait_for_health || log "WARNING: Health check failed after rollback" + + write_result "true" "$version" "$(( $(date +%s) - start_time ))" + rm -f "${TRIGGER_FILE}.processing" + log "=== Rollback complete ===" + exit 0 + fi + + # ── Handle update ─────────────────────────────────────────────────────────── + if [[ ! -d "$staging_dir" ]]; then + log "ERROR: Staging directory not found: ${staging_dir}" + write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found" + rm -f "${TRIGGER_FILE}.processing" + exit 1 + fi + + # 2. Backup current API code + data/ + local backup_dir="${BACKUPS_DIR}/${from_version}" + mkdir -p "$backup_dir" + log "Backing up current API files to ${backup_dir}" + for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do + [[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true + done + [[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/" + [[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/" + [[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/" + + # Backup data/ directory (services.json, config.json, credentials, etc.) + backup_data_dir "$backup_dir" + + cleanup_old_backups + + # 3. Copy new files from staging to API source + log "Deploying new API files..." + for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do + [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true + done + if [[ -d "$staging_dir/routes" ]]; then + rm -rf "$api_source_dir/routes" + cp -rf "$staging_dir/routes" "$api_source_dir/routes" + fi + if [[ -d "$staging_dir/src" ]]; then + rm -rf "$api_source_dir/src" + cp -rf "$staging_dir/src" "$api_source_dir/src" + fi + if [[ -d "$staging_dir/dns-providers" ]]; then + rm -rf "$api_source_dir/dns-providers" + cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers" + fi + if [[ -n "$commit" ]]; then + echo "$commit" > "$api_source_dir/VERSION" + fi + + # 3b. Sync frontend + if [[ -z "$frontend_staging_dir" ]]; then + parent_staging=$(dirname "$staging_dir") + [[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status" + fi + if [[ -z "$frontend_target_dir" ]]; then + for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do + [[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break + done + fi + if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then + log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir" + mkdir -p "$frontend_target_dir" + [[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html" + [[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js" + for sub in dist css vendor js; do + if [[ -d "$frontend_staging_dir/$sub" ]]; then + mkdir -p "$frontend_target_dir/$sub" + cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true + fi + done + if [[ -d "$frontend_staging_dir/assets" ]]; then + mkdir -p "$frontend_target_dir/assets" + cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true + fi + fi + + # 4. Rebuild container + log "Rebuilding container..." + local build_ok=false + if build_image; then + build_ok=true + fi + + if [[ "$build_ok" != "true" ]]; then + log "ERROR: Docker build failed — rolling back code + data" + code_restore "$backup_dir" + build_image 2>&1 | tail -3 || true + restart_container + wait_for_health || true + write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed" + rm -f "${TRIGGER_FILE}.processing" + exit 1 + fi + + # 5. Restart container (recreate so new code + env vars take effect) + restart_container + + # 6. Health check + if wait_for_health; then + local duration=$(( $(date +%s) - start_time )) + log "=== Update successful: v${to_version} in ${duration}s ===" + write_result "true" "$to_version" "$duration" + else + local duration=$(( $(date +%s) - start_time )) + log "ERROR: Health check failed after update — rolling back code + data" + rollback_restore "$backup_dir" + build_image 2>&1 | tail -3 || true + restart_container + wait_for_health || log "WARNING: Rollback health check also failed" + write_result "false" "$to_version" "$duration" "Health check failed after update" + fi + + # 7. Cleanup + rm -f "${TRIGGER_FILE}.processing" + rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true + + log "=== Update process complete ===" +} + +main "$@" diff --git a/scripts/release.sh b/scripts/release.sh index c0b42f9..2ea5d8e 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -43,14 +43,17 @@ echo " release: $RELEASE_HOST" echo " mirror: $MIRROR_HOST" echo -# ── 1. Bump dashcaddy-api/package.json ──────────────────────────────────── -echo "[1/6] Bumping dashcaddy-api/package.json" +# ── 1. Bump dashcaddy-api/package.json + root VERSION file ────────────── +echo "[1/6] Bumping dashcaddy-api/package.json + VERSION" node -e " const fs = require('fs'); const pkg = require('./dashcaddy-api/package.json'); pkg.version = '$VERSION'; fs.writeFileSync('./dashcaddy-api/package.json', JSON.stringify(pkg, null, 2) + '\n'); " +# Keep root VERSION in sync with package.json — otherwise downstream tooling +# (installer, status page, rollback checks) reads a stale version. +echo "$VERSION" > VERSION # ── 2. Rebuild status frontend so dist/*.js matches source ──────────────── if [[ -f status/build.js ]]; then @@ -60,7 +63,7 @@ fi # ── 3. Commit + push ────────────────────────────────────────────────────── echo "[3/6] Committing + pushing" -git add dashcaddy-api/package.json +git add dashcaddy-api/package.json VERSION # Everything the build rewrites must be staged or the tarball ships stale # copies. status/dist/ is .gitignored (-f bypasses); index.html and sw.js are # tracked but get rewritten by build.js (CSP hash + SW cache tag derived from diff --git a/scripts/samihost-fail2ban-watchdog.sh b/scripts/samihost-fail2ban-watchdog.sh new file mode 100755 index 0000000..b8b10c6 --- /dev/null +++ b/scripts/samihost-fail2ban-watchdog.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Samihost fail2ban watchdog — auto-unban whitelisted IPs and keep ignoreip list in sync. +# Deployed to /usr/local/bin/samihost-fail2ban-watchdog.sh on 194.163.161.162 +# Cron: every 30 min (0,30 * * * *) + +set -euo pipefail + +JAIL_LOCAL=/etc/fail2ban/jail.local +BACKUP=/etc/fail2ban/jail.local.watchdog.bak +EXPECTED_IGNOREIP="127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 fe80::/10 100.64.0.0/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162" +LOG=/var/log/samihost-fail2ban-watchdog.log +TELEGRAM_LOG=/tmp/fail2ban-watchdog-last-action + +ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } +log() { echo "$(ts) $*" | tee -a "$LOG"; } + +mkdir -p "$(dirname "$LOG")" +touch "$LOG" + +# --- 1. Verify ignoreip line is intact and matches expected --- +CURRENT=$(grep '^ignoreip' "$JAIL_LOCAL" | sed 's/^ignoreip[[:space:]]*=[[:space:]]*//' || true) +EXPECTED_NORMALIZED=$(echo "$EXPECTED_IGNOREIP" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') +CURRENT_NORMALIZED=$(echo "$CURRENT" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') + +if [ "$CURRENT_NORMALIZED" != "$EXPECTED_NORMALIZED" ]; then + log "ALERT: ignoreip line drifted. Restoring." + cp "$JAIL_LOCAL" "$BACKUP" + sed -i "s|^ignoreip = .*|ignoreip = $EXPECTED_IGNOREIP|" "$JAIL_LOCAL" + fail2ban-client reload + echo "ignoreip restored at $(ts)" > "$TELEGRAM_LOG" + log "ignoreip restored, fail2ban reloaded" +fi + +# --- 2. Unban any currently-banned IPs that match our trusted set --- +BANNED=$(fail2ban-client status sshd 2>/dev/null | awk -F: '/Banned IP list/{print $2}' | tr ' ' '\n' | grep -v '^$' || true) +UNBANNED=0 +for ip in $BANNED; do + # Match against any trusted network + is_trusted=0 + for net in 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 ::1 fc00::/7 fe80::/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162; do + if [[ "$net" == *"/"* ]]; then + # CIDR match (simple IPv4 only — IPv6 needs python or ipcalc, skip for now) + base="${net%/*}" + mask="${net#*/}" + if [[ "$ip" == "$base"* ]] || python3 -c "import ipaddress,sys; sys.exit(0 if ipaddress.ip_address('$ip') in ipaddress.ip_network('$net', strict=False) else 1)" 2>/dev/null; then + is_trusted=1 + break + fi + else + if [ "$ip" = "$net" ]; then + is_trusted=1 + break + fi + fi + done + if [ "$is_trusted" = "1" ]; then + if fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1; then + log "auto-unbanned trusted IP: $ip" + UNBANNED=$((UNBANNED+1)) + fi + fi +done + +[ "$UNBANNED" -gt 0 ] && echo "auto-unbanned $UNBANNED trusted IPs at $(ts)" > "$TELEGRAM_LOG" + +# --- 3. Cap the ban count — if more than 200 are banned, mass-unban stale ones --- +TOTAL_BANNED=$(fail2ban-client status sshd 2>/dev/null | awk '/Currently banned/{print $NF}' || echo 0) +if [ "$TOTAL_BANNED" -gt 200 ]; then + log "ALERT: $TOTAL_BANNED IPs banned. Mass-unbanning all." + for ip in $BANNED; do + fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1 || true + done + echo "mass-unbanned $TOTAL_BANNED stale bans at $(ts)" > "$TELEGRAM_LOG" +fi + +log "watchdog run complete (unbanned=$UNBANNED, total_banned=$TOTAL_BANNED)" diff --git a/status/dist/core.js b/status/dist/core.js new file mode 100644 index 0000000..4a66f74 --- /dev/null +++ b/status/dist/core.js @@ -0,0 +1,800 @@ +(function(o){"use strict";class f{constructor(){this.errors=[],this.maxErrors=50}logError(y,l,r={}){const h={timestamp:new Date().toISOString(),context:y,message:l instanceof Error?l.message:l,stack:l instanceof Error?l.stack:null,metadata:r};this.errors.push(h),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${y}:`,l,r)}recoverFromError(y,l){switch(this.classifyError(y)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",y,{currentStep:l}),{action:"SKIP_STEP",nextStep:l+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",y),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",y),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",y,{currentStep:l}),{action:"SKIP_STEP",nextStep:l+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",y),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",y,{currentStep:l}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(y){const l=y.message||y.toString();return l.includes("element")&&l.includes("not found")?"ELEMENT_NOT_FOUND":l.includes("storage")||l.includes("quota")?"STORAGE_UNAVAILABLE":l.includes("driver")||l.includes("undefined")?"DRIVER_NOT_LOADED":l.includes("invalid")||l.includes("validation")?"INVALID_TOOLTIP":l.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const y={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(l=>{y.byContext[l.context]=(y.byContext[l.context]||0)+1;const r=this.classifyError({message:l.message});y.byType[r]=(y.byType[r]||0)+1}),y}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const y=document.createElement("div");return y.id="onboarding-fallback",y.style.cssText=` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--card-base, #2a2a2a); + color: var(--fg, #ffffff); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 9999; + max-width: 300px; + font-size: 14px; + `,y.innerHTML=` + Welcome to DashCaddy!
+

+ The interactive tour is unavailable, but you can explore the dashboard freely. + Check the documentation for help getting started. +

+ `,document.body.appendChild(y),setTimeout(()=>{y.parentNode&&y.parentNode.removeChild(y)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const y={data:{},getItem(l){return this.data[l]||null},setItem(l,r){this.data[l]=r},removeItem(l){delete this.data[l]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),y}sendToErrorTracking(y){}}o.ErrorHandler=f,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const y=await fetch("/api/v1/config");if(y.ok){const l=await y.json();if(l.tld&&(SITE.tld=l.tld.startsWith(".")?l.tld:"."+l.tld),l.dns&&(SITE.dnsIp=l.dns.ip||"",SITE.dnsPort=l.dns.port||DC.DEFAULTS.DNS_PORT),l.dnsServers&&typeof l.dnsServers=="object")for(const[h,a]of Object.entries(l.dnsServers))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(SITE.dnsServers[h]=a);l.configurationType&&(SITE.configurationType=l.configurationType),l.domain&&(SITE.domain=l.domain),l.defaults&&(SITE.defaults=l.defaults),l.routingMode&&(SITE.routingMode=l.routingMode),SITE.onboardingCompleted=l.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const r=document.getElementById("manage-tokens");r&&(r.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(y=>y.textContent=SITE.tld);const f=document.getElementById("edit-tld-suffix");f&&(f.textContent=SITE.tld);const u=document.getElementById("external-proxy-ip");u&&SITE.dnsIp&&(u.value=SITE.dnsIp,u.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const f=SITE.dnsServers[o];return f?`${f.ip}:${f.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,f]of Object.entries(SITE.dnsServers))if(f.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const f=Object.keys(SITE.dnsServers);if(!f.length)return;const u='',y=o.firstElementChild;f.forEach(l=>{const r=escapeHtml(l),h=escapeHtml((SITE.dnsServers[l].name||l).toUpperCase()),a=document.createElement("div");a.className="card",a.setAttribute("data-app",l),a.setAttribute("data-status","off"),a.innerHTML=`
${u}
${h}OFF
--
--
`,o.insertBefore(a,y)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.json()).token,csrfToken}catch(o){throw errorHandler.logError("[CSRF] Get Token",o,{function:"getCSRFToken"}),o}}async function secureFetch(o,f={}){const u=(f.method||"GET").toUpperCase(),y=!["GET","HEAD","OPTIONS"].includes(u);if(y)try{const r=await getCSRFToken();f.headers={...f.headers,"X-CSRF-Token":r}}catch(r){errorHandler.logError("[CSRF] Add to Request",r,{function:"secureFetch"})}f.signal||(f={...f,signal:AbortSignal.timeout(15e3)});const l=await fetch(o,f);if(y&&l.status===403)try{const r=await l.clone().json();if(r.error&&(r.error.includes("DC-100")||r.error.includes("DC-101"))){csrfToken=null;const h=await getCSRFToken();return f.headers={...f.headers,"X-CSRF-Token":h},f.signal=AbortSignal.timeout(15e3),fetch(o,f)}}catch{}return l}async function postJSON(o,f){const u=await secureFetch(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)}),y=await u.json();if(!u.ok||y.success===!1)throw new Error(y.error||`Request failed (${u.status})`);return y}async function getJSON(o){const f=await secureFetch(o);if(!f.ok){let u=`Request failed (${f.status})`;try{u=(await f.json()).error||u}catch{}throw new Error(u)}return f.json()}async function deleteAPI(o){const f=await secureFetch(o,{method:"DELETE"}),u=await f.json();if(!f.ok||u.success===!1)throw new Error(u.error||`Delete failed (${f.status})`);return u}async function withButton(o,f,u,y={}){const l=o.innerHTML,{successText:r="\u2705",resetDelay:h=DC.DELAYS.BTN_RESET}=y;o.disabled=!0,o.innerHTML=f;try{const a=await u();return o.innerHTML=r,setTimeout(()=>{o.innerHTML=l,o.disabled=!1},h),a}catch(a){throw o.innerHTML=l,o.disabled=!1,a}}function openModal(o){document.getElementById(o)?.classList.add("show")}function closeModal(o){document.getElementById(o)?.classList.remove("show")}function wireModal(o,...f){o&&(o.addEventListener("click",u=>{u.target===o&&o.classList.remove("show")}),f.forEach(u=>{u&&typeof u.addEventListener=="function"&&u.addEventListener("click",()=>o.classList.remove("show"))}))}function showNotification(o,f="info",u=3e3){const y=document.querySelector(".deploy-notification");y&&y.remove();const l={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},r=l[f]||l.info,h=document.createElement("div");h.className="deploy-notification",h.textContent=o,h.style.cssText=` + position: fixed; top: 20px; right: 20px; + background: ${r.bg}; color: ${r.fg}; + padding: 16px 24px; border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,.3); + z-index: 10000; animation: slideIn 0.3s ease-out; + max-width: 400px; white-space: pre-line; font-size: 14px; + `,document.body.appendChild(h),u>0&&setTimeout(()=>h.remove(),u)}function timeAgo(o){const f=Date.now()-new Date(o).getTime();return f<6e4?"just now":f<36e5?Math.floor(f/6e4)+"m ago":f<864e5?Math.floor(f/36e5)+"h ago":Math.floor(f/864e5)+"d ago"}function safeGet(o,f=null){try{const u=localStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSet(o,f){try{localStorage.setItem(o,f)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,f=null){try{const u=sessionStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSessionSet(o,f){try{sessionStorage.setItem(o,f)}catch{}}function safeGetJSON(o,f=null){try{const u=localStorage.getItem(o);return u?JSON.parse(u):f}catch{return f}}function escapeHtml(o){return String(o??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(o,f){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",f)}const DC_BUS={_handlers:{},on(o,f){var u;((u=this._handlers)[o]||(u[o]=[])).push(f)},off(o,f){this._handlers[o]=this._handlers[o]?.filter(u=>u!==f)},emit(o,f){this._handlers[o]?.forEach(u=>u(f))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(f=>f.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const f=this._apps.findIndex(u=>u.id===o);return f>-1&&(this._apps.splice(f,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),f>-1},updateApp(o,f){const u=this._apps.find(y=>y.id===o);if(u){for(const[y,l]of Object.entries(f))y!=="__proto__"&&y!=="constructor"&&y!=="prototype"&&(u[y]=l);DC_BUS.emit("apps:changed",this._apps)}return u}};(function(){function o(){const y=document.createElement("div");return y.className="skeleton-card",y.innerHTML='
',y}function f(y){const l=document.getElementById("cards");if(!(!l||l.querySelector(".card"))){y=y||6;for(let r=0;r.4,A={};return A.hover=I?d(w,B,.35):d(w,$,.08),A["card-hover"]=d(w,A.hover,.5),A.base=d(B,w,.6),A["fg-muted"]=d(x,B,.35),A.success=C,A.error=k,A.warning=I?"#d68a00":"#f39c12",A}function s(S,B){var $=B.lightBg||B.bg&&g(B.bg)>.4,x=B.accent||B["accent-strong"]||"#888888",w=m(x);return $?":root."+S+` body { + background: + radial-gradient(1200px 800px at 10% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .08), transparent 60%), + radial-gradient(1000px 700px at 110% 10%, rgba(`+w.r+","+w.g+","+w.b+`, .05), transparent 55%), + var(--bg); +} +`:":root."+S+` body { + background: + radial-gradient(1200px 900px at 8% -12%, rgba(`+w.r+","+w.g+","+w.b+`, .10), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .07), transparent 55%), + var(--bg); +} +`}function p(S,B){var $=B.lightBg||B.bg&&g(B.bg)>.4;return $?":root."+S+` button:hover { + background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); + border-color: rgba(0, 0, 0, .15); + box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8); +} +`:":root."+S+` button:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function c(){r.forEach(function(S){document.documentElement.style.removeProperty("--"+S)})}function v(S,B){var $=S.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),y.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(f,{}),w=$,C=2;x[$]&&$!==B;)$=w+"-"+C++;return $}function i(S){var B=document.getElementById("user-theme-styles");B&&B.remove(),l.length=y.length,Object.keys(b).forEach(function(k){y.indexOf(k)===-1&&delete b[k]});var $=S||safeGetJSON(f,{}),x=Object.keys($);if(x=x.filter(function(k){return y.indexOf(k)===-1}),!!x.length){var w="";x.forEach(function(k){var I=$[k];l.indexOf(k)===-1&&l.push(k);var A={};r.forEach(function(D){I[D]&&(A[D]=I[D])}),A["card-bg"]=I["card-base"]||I.bg,I.lightBg&&(A.lightBg=!0);var O=e(A);a.forEach(function(D){!A[D]&&O[D]&&(A[D]=O[D])}),b[k]=A,w+=":root."+k+` { +`,r.forEach(function(D){A[D]&&(w+=" --"+D+": "+A[D]+`; +`)}),w+=`} +`,w+=s(k,A),w+=p(k,A)});var C=document.createElement("style");C.id="user-theme-styles",C.textContent=w,document.head.appendChild(C)}}function E(){secureFetch("/api/v1/themes").then(function(S){return S.json()}).then(function(S){if(!(!S.success||!S.themes)){var B=S.themes,$=safeGetJSON(f,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(f,JSON.stringify(B)),i(B);var x=safeGet(o);x&&l.indexOf(x)!==-1&&L(x)}}}).catch(function(){})}function T(){var S=safeGetJSON(u);if(S){var B=S.name||"Custom",$=v(B),x={name:B};r.forEach(function(k){S[k]&&(x[k]=S[k])});var w=safeGetJSON(f,{});w[$]=x,safeSet(f,JSON.stringify(w)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(u);var C={};r.forEach(function(k){x[k]&&(C[k]=x[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:C})}).catch(function(){})}}function L(S){document.documentElement.classList.add("theme-transitioning"),l.forEach(function(w){w!=="dark"&&document.documentElement.classList.remove(w)}),c(),S!=="dark"&&document.documentElement.classList.add(S),safeSet(o,S);var B=b[S],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var x=B&&B.lightBg;!x&&B&&B.bg&&(x=g(B.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),i();var P=safeGet(o);P==="red"&&(P="black",safeSet(o,"black")),P&&P!=="dark"&&l.indexOf(P)===-1&&(P=null),L(P||t()),E(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(S){safeGet(o)||L(S.matches?"dark":"light")}),window.THEMES=l,window.BUILTIN_THEMES=y,window.THEME_COLORS=b,window.THEME_PROPS=r,window.BASE_PROPS=h,window.DERIVED_PROPS=a,window.USER_THEMES_KEY=f,window.applyTheme=L,window.clearCustomProperties=c,window.injectUserThemeStyles=i,window.syncThemesFromServer=E,window.slugifyThemeName=v,window.getActiveTheme=function(){return safeGet(o)||t()},window.deriveExtendedColors=e,window.hexToRgb=m,window.rgbToHex=n,window.blendColors=d})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const b=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!b)return;const m=(.299*+b[0]+.587*+b[1]+.114*+b[2])/255,n=h.querySelector(".totp-logo-dark"),d=h.querySelector(".totp-logo-light");n&&(n.style.display=m>.5?"none":""),d&&(d.style.display=m>.5?"":"none")}function f(){const h=document.getElementById("totp-overlay");if(h){h.classList.add("show"),setTimeout(o,50);const a=h.querySelector(".totp-digits input");a&&setTimeout(()=>a.focus(),100)}}function u(){const h=document.getElementById("totp-overlay");h&&h.classList.remove("show")}const y=document.getElementById("totp-digits");if(y){const h=y.querySelectorAll("input");h.forEach((a,b)=>{a.addEventListener("input",m=>{const n=m.target.value.replace(/\D/g,"");m.target.value=n.slice(0,1),n&&bg.value).join("");d.length===6&&l(d)}),a.addEventListener("keydown",m=>{m.key==="Backspace"&&!m.target.value&&b>0&&(h[b-1].focus(),h[b-1].value="")}),a.addEventListener("paste",m=>{m.preventDefault();const n=(m.clipboardData.getData("text")||"").replace(/\D/g,"");n.length>=6&&(h.forEach((d,g)=>{d.value=n[g]||""}),h[5].focus(),l(n.slice(0,6)))})})}async function l(h){const a=document.getElementById("totp-error");a.textContent="Verifying...",a.className="totp-error verifying";try{const m=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();if(m.success){a.textContent="",m.csrfToken&&(csrfToken=m.csrfToken),u();const n=safeSessionGet("totp_redirect");if(n){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=n;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{a.textContent=m.error||"Invalid code",a.className="totp-error";const n=document.querySelectorAll("#totp-digits input");n.forEach(d=>{d.value=""}),n[0]?.focus()}}catch{a.textContent="Connection error",a.className="totp-error"}}const r=new URLSearchParams(window.location.search);if(r.get("auth")==="required"){const h=r.get("return");if(h)try{const a=new URL(h,window.location.origin),b=a.hostname,m=a.origin===window.location.origin,n=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,d=b.endsWith(n)||b===n.substring(1);(m||d)&&safeSessionSet("totp_redirect",h)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=f})(),(function(){const o=new ErrorHandler;injectModal("folder-browser-modal",`
+
+

\u{1F4C2} Browse for Media Folders

+ +
+ / +
+ +
+
Loading...
+
+ + + +
+ +
+ + +
+
+
+
`),injectModal("service-creds-modal",`
+
+

Service Credentials

+

Credentials are injected automatically when accessing this service.

+ + +
+ + No credentials stored +
+ + + + + + + + + + + + + + + + + +
+ + + +
+
+
`);const f=document.getElementById("service-creds-modal");let u=null;const y=["sonarr","radarr","prowlarr","overseerr"],l=["sonarr","radarr"];function r(n){return n.externalUrl||n.url||""}function h(n){const d=document.getElementById("svc-creds-error");d.textContent=n,d.style.display=""}function a(){const n=document.getElementById("svc-creds-error");n.textContent="",n.style.display="none"}window.openServiceCredsModal=async function(n){u=n,a();const d=document.getElementById("svc-creds-title"),g=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),s=document.getElementById("svc-creds-apikey"),p=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");d.textContent=n.name+" Credentials";const c=!!n.isExternal,v=y.includes(n.id)||y.includes(n.appTemplate),i=l.includes(n.id)||l.includes(n.appTemplate);e.style.display=c?"":"none",s.style.display=v?"":"none",t.style.display=i?"":"none",p.style.display=c?"none":"";const E=document.getElementById("svc-quality-select");E.innerHTML='',document.getElementById("svc-quality-status").textContent="",c?(g.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${n.name}`):v?g.textContent="API key bypasses the app login screen automatically.":g.textContent="Credentials are injected automatically when accessing this service.",await b(n),f.classList.add("show")};async function b(n){const d=document.getElementById("svc-creds-dot"),g=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let s=!1;if(n.isExternal){try{const c=await(await fetch(`/api/v1/seedhost-creds?serviceId=${n.id}`)).json();c.success?(document.getElementById("svc-seedhost-user").value=c.username||"",c.hasCredentials&&(s=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const c=await(await fetch(`/api/v1/services/${n.id}/credentials`)).json();c.success&&(c.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",s=!0):document.getElementById("svc-apikey-input").value="",c.hasBasicAuth&&!n.isExternal?(document.getElementById("svc-basic-user").value=c.username||"",s=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const p=n.id||n.appTemplate;if(l.includes(p)&&await m(n),s){d.style.background="var(--ok-fg, #74dfc4)",g.style.color="var(--ok-fg, #74dfc4)",g.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${n.id}`);t&&t.classList.add("has-creds")}else d.style.background="var(--muted)",g.style.color="var(--muted)",g.textContent="No credentials stored",e.style.display="none"}async function m(n){const d=document.getElementById("svc-quality-select"),g=document.getElementById("svc-quality-status"),e=n.id||n.appTemplate,s=r(n);if(!s){d.innerHTML='';return}d.innerHTML='',g.textContent="";try{const p=new URLSearchParams({service:e,url:s}),c=await(await fetch(`/api/v1/arr/quality-profiles?${p}`)).json();if(!c.success||!c.profiles?.length){d.innerHTML='';return}d.innerHTML="";for(const v of c.profiles){const i=document.createElement("option");i.value=v.id,i.textContent=v.name,d.appendChild(i)}if(c.storedProfileId&&(d.value=String(c.storedProfileId)),!d.value){const v=c.profiles.find(i=>/720/i.test(i.name));v&&(d.value=String(v.id))}!d.value&&c.profiles.length&&(d.value=String(c.profiles[0].id)),g.innerHTML=`${c.profiles.length} profiles loaded`}catch(p){d.innerHTML='',g.innerHTML=`Error: ${p.message}`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!u)return;const n=u.id||u.appTemplate,d=r(u),e=document.getElementById("svc-apikey-input")?.value.trim(),s=document.getElementById("svc-quality-select"),p=document.getElementById("svc-quality-status");if(!d){p.innerHTML='No service URL available';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){p.innerHTML='Enter an API key first';return}s.innerHTML='',p.textContent="";try{const t=new URLSearchParams({service:n,url:d,apiKey:e}),v=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!v.success){s.innerHTML='',p.innerHTML=`${v.error||"Failed to fetch profiles"}`;return}if(!v.profiles?.length){s.innerHTML='';return}s.innerHTML="";for(const E of v.profiles){const T=document.createElement("option");T.value=E.id,T.textContent=E.name,s.appendChild(T)}const i=v.profiles.find(E=>/720/i.test(E.name));i?s.value=String(i.id):v.profiles.length&&(s.value=String(v.profiles[0].id)),p.innerHTML=`${v.profiles.length} profiles loaded`}catch(t){s.innerHTML='',p.innerHTML=`${t.message}`}}),document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!u)return;const n=document.getElementById("svc-creds-save");n.textContent="Saving...",n.disabled=!0,a();try{const d=y.includes(u.id)||y.includes(u.appTemplate),g=u.id||u.appTemplate;if(u.isExternal){const p=document.getElementById("svc-seedhost-user").value.trim(),t=document.getElementById("svc-seedhost-pass").value;p&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:p,password:t||void 0,serviceId:u.id})})}const s=document.getElementById("svc-apikey-input")?.value.trim();if(s&&s!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")if(d){const p=r(u),t=document.getElementById("svc-quality-select"),c=t?.value?parseInt(t.value):void 0,v=t?.selectedOptions?.[0]?.textContent||void 0,E=await(await secureFetch("/api/v1/arr/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,apiKey:s,url:p||void 0,qualityProfileId:c||void 0,qualityProfileName:v||void 0})})).json();if(!E.success){h(E.error||"Failed to save API key"),n.textContent="Save",n.disabled=!1;return}E.connectionTest&&!E.connectionTest.success&&h(`API key saved but connection test failed: ${E.connectionTest.error}`)}else await secureFetch(`/api/v1/services/${u.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:s})});else if(d&&l.includes(g)){const p=document.getElementById("svc-quality-select"),t=p?.value?parseInt(p.value):void 0,c=p?.selectedOptions?.[0]?.textContent||void 0;t&&await secureFetch("/api/v1/arr/quality-profiles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,qualityProfileId:t,qualityProfileName:c})})}if(!u.isExternal){const p=document.getElementById("svc-basic-user").value.trim(),t=document.getElementById("svc-basic-pass").value;p&&t&&await secureFetch(`/api/v1/services/${u.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:p,password:t})})}await b(u)}catch(d){o.logError("[ServiceCredentials] Save",d,{function:"saveCredentials"}),h("Failed to save: "+(d.message||"Unknown error"))}n.textContent="Save",n.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(u&&confirm(`Remove stored credentials for ${u.name}?`)){a();try{const n=u.id||u.appTemplate,d=y.includes(n);u.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${u.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${u.id}/credentials`,{method:"DELETE"}),d&&await secureFetch(`/api/v1/arr/credentials/${n}`,{method:"DELETE"});const g=document.getElementById(`creds-btn-${u.id}`);g&&g.classList.remove("has-creds"),await b(u)}catch(n){o.logError("[ServiceCredentials] Clear",n,{function:"clearCredentials"}),h("Failed to clear: "+(n.message||"Unknown error"))}}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{f.classList.remove("show"),u=null}),f?.addEventListener("click",n=>{n.target===f&&(f.classList.remove("show"),u=null)}),window.refreshCredsButtons=async function(){try{for(const n of window.APPS||[]){if(!n.isExternal&&!n.appTemplate&&!n.url)continue;let d=!1;if(n.isExternal)try{const s=await(await fetch(`/api/v1/seedhost-creds?serviceId=${n.id}`)).json();s.success&&s.hasCredentials&&(d=!0)}catch{}try{const s=await(await fetch(`/api/v1/services/${n.id}/credentials`)).json();s.success&&(s.hasApiKey||s.hasBasicAuth)&&(d=!0)}catch{}const g=document.getElementById(`creds-btn-${n.id}`);g&&g.classList.toggle("has-creds",d)}}catch{}}})(),(function(){const o=new ErrorHandler;injectModal("totp-settings-modal",`
+
+

Authentication Settings

+ + +
+ + TOTP is not configured +
+ + +
+ +
+
+ or +
+
+
+ +
+ + +
+
+
+
+ + + + + + + + + + + +
+ +
+
+
`);async function f(){try{const r=await(await fetch("/api/v1/totp/config")).json();if(!r.success)return;const{enabled:h,sessionDuration:a,isSetUp:b}=r.config,m=document.getElementById("totp-status-dot"),n=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),g=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),s=document.getElementById("totp-duration-section"),p=document.getElementById("totp-disable-section");h&&b?(m.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",n.textContent="TOTP is active",n.style.color="var(--ok-fg, #7ef2ff)",g.style.display="none",e.style.display="none",s.style.display="block",p.style.display="block",document.getElementById("totp-duration-select").value=a):(m.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",n.textContent="TOTP is not configured",n.style.color="var(--muted)",g.style.display="block",e.style.display="none",s.style.display="none",p.style.display="none"),y(h&&b,a)}catch(l){console.warn("Failed to load TOTP settings:",l)}}const u={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function y(l,r){const h=document.getElementById("auth-card"),a=document.getElementById("auth-pill"),b=document.getElementById("auth-dot"),m=document.getElementById("auth-status-text");h&&(l?(h.setAttribute("data-status","on"),a.className="badge on",a.textContent="YES",b.className="dot ok at-bl",m.textContent="Session: "+(u[r]||r)):(h.setAttribute("data-status","off"),a.className="badge off",a.textContent="NO",b.className="dot bad at-bl",m.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();r.success&&(document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(l){o.logError("[TOTP] Setup Failed",l,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const l=document.getElementById("totp-import-key").value.trim(),r=document.getElementById("totp-import-error");if(r.textContent="",!l){r.textContent="Paste a Base32 secret key first";return}try{const a=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:l})})).json();a.success?(r.textContent="",document.getElementById("totp-qr-image").src=a.qrCode,document.getElementById("totp-manual-key").textContent=a.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):r.textContent=a.error||a.message||"Import failed"}catch{r.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const l=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(l).then(()=>{const r=document.getElementById("totp-copy-key");r.textContent="\u2705",setTimeout(()=>{r.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const l=document.getElementById("totp-setup-code").value,r=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(l)){r.textContent="Enter a 6-digit code";return}try{const a=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:l})})).json();a.success?(r.textContent="",f()):r.textContent=a.error||"Invalid code"}catch{r.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",l=>{l.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async l=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:l.target.value})}),f()}catch(r){o.logError("[TOTP] Update Session Duration",r,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"})).json()).success&&f()}catch(l){o.logError("[TOTP] Disable Failed",l,{function:"disableTOTP"})}}),document.getElementById("auth-settings-btn")?.addEventListener("click",()=>{f(),openModal("totp-settings-modal")}),document.getElementById("totp-modal-close")?.addEventListener("click",()=>{closeModal("totp-settings-modal")}),document.getElementById("totp-settings-modal")?.addEventListener("click",l=>{l.target.id==="totp-settings-modal"&&closeModal("totp-settings-modal")}),window._updateAuthCard=y,(async()=>{try{const r=await(await fetch("/api/v1/totp/config")).json();if(r.success){const h=r.config.enabled&&r.config.isSetUp;y(h,r.config.sessionDuration)}}catch(l){o.logError("[TOTP] AuthCard Update",l,{function:"authCardUpdate"})}})()})(),(function(){injectModal("token-management-modal",` +
+
+

\u{1F511} DNS Credentials

+ +

+ Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates. +

+ +
+ + +
+
+ `);function o(){return Object.keys(SITE.dnsServers||{})}function f(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function u(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const c=o();if(c.length===0){t.innerHTML='

No DNS servers configured.

';return}for(const v of c)t.insertAdjacentHTML("beforeend",` +
+

${f(v)}

+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ `)}function y(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const c=safeGet("dashcaddy-encryption-key");if(c)return safeSessionSet("dashcaddy-encryption-key",c),safeRemove("dashcaddy-encryption-key"),c;const v=new Uint8Array(32);return crypto.getRandomValues(v),t=Array.from(v,i=>i.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const l=y();function r(t,c){if(!t)return"";const v=crypto.getRandomValues(new Uint8Array(8)),i=Array.from(v,L=>L.toString(16).padStart(2,"0")).join(""),E=new TextEncoder().encode(c+i);let T="";for(let L=0;LparseInt($,16))),P=atob(t.substring(17)),S=new TextEncoder().encode(c+T);let B="";for(let $=0;${["readonly","admin"].forEach(c=>{["token","username"].forEach(v=>{safeRemove(`${t}-${c}-${v}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function p(t){const c=m(t,"readonly"),v=n(t,"readonly"),i=m(t,"admin"),E=n(t,"admin"),T=h(safeGet(`${t}-token-enc`),l),L=h(safeGet(`${t}-username-enc`),l);return{username:E||v||L,token:i||c||T,readonlyToken:c||T,readonlyUsername:v||L,adminToken:i||T,adminUsername:E||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{u();const t=document.getElementById("token-management-modal"),c=e();o().forEach(v=>{const i=c[v];document.getElementById(`${v}-readonly-username`).value=i.readonly.username,document.getElementById(`${v}-readonly-token`).value=i.readonly.token,document.getElementById(`${v}-admin-username`).value=i.admin.username,document.getElementById(`${v}-admin-token`).value=i.admin.token,document.getElementById(`${v}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const c=t.target.closest(".token-toggle");if(c){const v=c.dataset.target,i=document.getElementById(v);i.type==="password"?(i.type="text",c.textContent="\u{1F648}"):(i.type="password",c.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=o();t.forEach(i=>{g(i,"readonly",document.getElementById(`${i}-readonly-username`).value.trim()),d(i,"readonly",document.getElementById(`${i}-readonly-token`).value.trim()),g(i,"admin",document.getElementById(`${i}-admin-username`).value.trim()),d(i,"admin",document.getElementById(`${i}-admin-token`).value.trim())});const c={};let v=!1;if(t.forEach(i=>{const E={},T=document.getElementById(`${i}-readonly-username`).value.trim(),L=document.getElementById(`${i}-readonly-token`).value.trim(),P=document.getElementById(`${i}-admin-username`).value.trim(),S=document.getElementById(`${i}-admin-token`).value.trim();T&&L&&(E.readonly={username:T,password:L},v=!0),P&&S&&(E.admin={username:P,password:S},v=!0),Object.keys(E).length>0&&(c[i]=E)}),v){t.forEach(i=>{c[i]&&(document.getElementById(`${i}-token-status`).textContent="Verifying...",document.getElementById(`${i}-token-status`).className="token-status")});try{const E=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:c})})).json();E.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!c[T]){L.textContent="";return}const P=E.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):E.success?t.forEach(T=>{c[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{c[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(E.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(i){console.error("Failed to sync DNS credentials to backend:",i),t.forEach(E=>{c[E]&&(document.getElementById(`${E}-token-status`).textContent="\u2713 Saved locally (sync failed)",document.getElementById(`${E}-token-status`).className="token-status")})}}else t.forEach(i=>{document.getElementById(`${i}-token-status`).textContent=""});setTimeout(()=>{t.every(E=>{const T=document.getElementById(`${E}-token-status`)?.textContent;return!T||T.includes("\u2713")})&&closeModal("token-management-modal")},1500)}),document.getElementById("token-cancel")?.addEventListener("click",()=>{closeModal("token-management-modal")}),document.getElementById("token-clear-all")?.addEventListener("click",async()=>{if(confirm("Clear all stored DNS credentials? This cannot be undone.")){s(),o().forEach(t=>{document.getElementById(`${t}-readonly-username`).value="",document.getElementById(`${t}-readonly-token`).value="",document.getElementById(`${t}-admin-username`).value="",document.getElementById(`${t}-admin-token`).value="",document.getElementById(`${t}-token-status`).textContent="\u2713 Cleared",document.getElementById(`${t}-token-status`).className="token-status success"});try{await secureFetch("/api/v1/dns/credentials",{method:"DELETE"})}catch{}}}),window.getToken=m,window.getUsername=n,window.setToken=d,window.setUsername=g,window.getAllCredentials=e,window.getCredential=a,window.setCredential=b,window.getEncryptionKey=y,window.getDnsIds=o,window.getDnsDisplayName=f})(),(function(){function o(d,g,e=null){const s=document.getElementById(d+"-dot"),p=document.getElementById(d+"-pill"),t=document.getElementById(d+"-time"),c=document.querySelector(`[data-app="${d}"]`);s&&(s.classList.toggle("ok",g),s.classList.toggle("bad",!g)),p&&(p.textContent=g?"ON":"OFF",p.classList.toggle("on",g),p.classList.toggle("off",!g)),t&&e!==null&&(t.textContent=g?`${e}ms`:"timeout",t.className=`response-time ${f(e,g)}`),c&&c.setAttribute("data-status",g?"on":"off")}function f(d,g){return g?d<200?"excellent":d<500?"good":d<1e3?"fair":"slow":"timeout"}async function u(d){const g=performance.now();try{const e=await fetch("/probe/"+d,{cache:"no-store"}),s=performance.now(),p=Math.round(s-g);return{isUp:e.status>=200&&e.status<400||e.status===401||e.status===403,responseTime:p}}catch{const e=performance.now();return{isUp:!1,responseTime:Math.round(e-g)}}}window.APPS=[];let y=null,l=!1;async function r(){try{window.SkeletonLoader&&window.SkeletonLoader.show(6);const d=await fetch("/api/v1/services",{cache:"no-store"});d.ok?(window.APPS=await d.json(),window.SkeletonLoader&&window.SkeletonLoader.hide()):(console.error("Failed to load services:",d.status),window.SkeletonLoader&&window.SkeletonLoader.hide())}catch(d){console.error("Failed to load services:",d),window.SkeletonLoader&&window.SkeletonLoader.hide()}}function h(d){const g=window.APPS?.find(s=>s.id===d);if(g?.url)return g.url.startsWith("http")?g.url:"https://"+g.url;if(g?.isExternal&&g.externalUrl)return g.externalUrl;const e=SITE.dnsServers?.[d];return e?"http://"+e.ip+":"+(e.port||5380):buildServiceUrl(d)}function a(d,g,e){const s=document.createElement(d);return g&&(s.className=g),e&&(s.textContent=e),s}function b(){const d=document.getElementById("cards");d.innerHTML="";for(let g=0;g{O.stopPropagation(),window.openContainerLogsModal(e.containerId,e.name)},w.appendChild(k);const I=a("button","update-btn","\u2B06\uFE0F");I.title="Update container to latest version",I.id=`update-btn-${e.id}`,I.onclick=O=>{O.stopPropagation(),window.updateContainer(e.containerId,e.name,e.id)},w.appendChild(I);const A=a("button","exec-btn",">_");A.title="Open terminal",A.onclick=O=>{O.stopPropagation(),window.openExecModal&&window.openExecModal(e.containerId,e.name)},w.appendChild(A)}if(e.logPath&&!e.containerId){const k=a("button","logs-btn","\u{1F4CB}");k.title="View application logs",k.onclick=I=>{I.stopPropagation(),window.openFileLogsModal(e.logPath,e.name)},w.appendChild(k)}if(e.isExternal||e.appTemplate||e.url){const k=a("button","creds-btn","\u{1F511}");k.title="Auto-login credentials",k.id=`creds-btn-${e.id}`,k.onclick=I=>{I.stopPropagation(),window.openServiceCredsModal(e)},w.appendChild(k)}if(e.id!=="internet"){const k=a("button","options-btn","\u2699\uFE0F");k.title="Edit service settings",k.onclick=I=>{I.stopPropagation(),window.openServiceEditModal(e)},w.appendChild(k)}if(e.id!=="internet"){const k=a("button","delete-btn","\u{1F5D1}\uFE0F");k.title="Delete this service",k.onclick=I=>{I.stopPropagation(),window.deleteService(e.id,e.name)},w.appendChild(k)}const C=a("button",null,"Open");C.onclick=()=>window.open(h(e.id),"_blank","noopener"),w.appendChild(C),s.appendChild(w),s.style.transitionDelay=`${Math.min(g*45,270)}ms`,d.appendChild(s)}requestAnimationFrame(()=>{d.querySelectorAll(".card").forEach(g=>g.classList.add("loaded"))}),window.groupRecipeCards&&requestAnimationFrame(()=>window.groupRecipeCards()),window.refreshServiceFilter&&window.refreshServiceFilter()}function m(d,g,e=null){const s=document.getElementById("dot-"+d+"-grid"),p=document.getElementById("badge-"+d),t=document.getElementById("time-"+d),c=document.querySelector(`[data-app="${d}"]`);s&&(s.classList.toggle("ok",g),s.classList.toggle("bad",!g)),p&&(p.textContent=g?"ON":"OFF",p.classList.toggle("on",g),p.classList.toggle("off",!g)),t&&e!==null&&(t.textContent=g?`${e}ms`:"timeout",t.className=`response-time ${f(e,g)}`),c&&c.setAttribute("data-status",g?"on":"off")}async function n(){if(y)return l=!0,y;function d(s,p=new Date){const t=document.getElementById("stamp");t&&(t.textContent=`${s}: ${new Date(p).toLocaleTimeString()}`)}function g(s){Object.keys(SITE.dnsServers).forEach(t=>{const c=s[t];c&&o(t,c.isUp,c.responseTime)}),s.internet&&o("internet",s.internet.isUp,s.internet.responseTime),window.APPS.forEach(t=>{const c=s[t.id];c&&m(t.id,c.isUp,c.responseTime)})}async function e(){const s=Object.keys(SITE.dnsServers),p=s.map(i=>u(i));p.push(u("internet"));const t=await Promise.all(p);s.forEach((i,E)=>o(i,t[E].isUp,t[E].responseTime));const c=t[t.length-1];o("internet",c.isUp,c.responseTime),(await Promise.all(window.APPS.map(async i=>{const E=await u(i.id);return{id:i.id,...E}}))).forEach(i=>{m(i.id,i.isUp,i.responseTime)})}return y=(async()=>{try{const s=await fetch("/api/v1/services/status",{cache:"no-store"});if(!s.ok)throw new Error(`Status refresh failed (${s.status})`);const p=await s.json();g(p.statuses||{}),d("last check",p.checkedAt||new Date)}catch(s){console.warn("Batched status refresh failed, falling back to direct probes:",s);try{await e(),d("last check")}catch(p){console.error("Dashboard refresh failed:",p),d("last failed")}}finally{y=null,l&&(l=!1,setTimeout(()=>{window.refreshAll()},0))}})(),y}document.querySelector(".top")?.addEventListener("click",d=>{const g=d.target.closest('[id$="-open"]');if(!g)return;const e=g.id.replace("-open","");SITE.dnsServers[e]&&window.open(h(e),"_blank","noopener")}),document.getElementById("ca-open")?.addEventListener("click",()=>window.open(h("ca"),"_blank","noopener")),document.getElementById("creds-btn-ca")?.addEventListener("click",d=>{d.stopPropagation();const g=window.APPS.find(e=>e.id==="ca");g&&window.openServiceCredsModal&&window.openServiceCredsModal(g)}),document.getElementById("options-btn-ca")?.addEventListener("click",d=>{d.stopPropagation();const g=window.APPS.find(e=>e.id==="ca");g&&window.openServiceEditModal&&window.openServiceEditModal(g)}),document.getElementById("delete-btn-ca")?.addEventListener("click",d=>{d.stopPropagation(),window.deleteService&&window.deleteService("ca","DashCA")}),window.loadServices=r,window.buildGrid=b,window.refreshAll=n,window.setQuick=o,window.setBadge=m,window.getResponseTimeClass=f,window.checkServiceWithTiming=u,window.serviceUrl=h,window.el=a})(),(function(){async function o(a){const m=await(await secureFetch(`/api/v1/dns/restart/${a}`,{method:"POST"})).json();if(!m.success)throw new Error(m.error||"Restart failed");return m}document.querySelector(".top")?.addEventListener("click",async a=>{const b=a.target.closest('[id$="-restart"]');if(!b)return;const m=b.id.replace("-restart","");if(SITE.dnsServers[m]&&confirm(`Restart ${m.toUpperCase()} service?`))try{await withButton(b,"...",()=>o(m)),setTimeout(window.refreshAll,DC.DELAYS.RELOAD)}catch(n){showNotification("Restart failed: "+n.message,"error")}});async function f(a,b){const m=document.getElementById(`${a}-update`),n=m?.textContent||"\u2B06\uFE0F";try{m.textContent="\u{1F50D}",m.disabled=!0,m.title="Checking for updates...";const g=await(await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(b)}`)).json();if(!g.success)throw new Error(g.error||"Failed to check for updates");if(!g.updateAvailable){m.textContent="\u2705",m.title=`Already on latest version (${g.currentVersion})`,showNotification(`${a.toUpperCase()} is already up to date! Current version: ${g.currentVersion}`,"info"),setTimeout(()=>{m.textContent=n,m.disabled=!1,m.title="Update DNS server"},3e3);return}if(!confirm(`Update available for ${a.toUpperCase()}! + +Current: ${g.currentVersion} +New: ${g.updateVersion} + +`+(g.updateTitle?`${g.updateTitle} + +`:"")+`The DNS server will restart during the update. +Proceed?`)){m.textContent=n,m.disabled=!1,m.title="Update DNS server";return}m.textContent="\u{1F504}",m.title="Updating...";const p=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(b)}`,{method:"POST"})).json();if(!p.success)throw new Error(p.error||"Update failed");if(p.manualUpdateRequired){m.textContent="\u2B06\uFE0F",m.title=`Update available: ${p.newVersion}`;const t=p.downloadLink?` +Download: ${p.downloadLink}`:"",c=p.instructionsLink?` +Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} update requires manual installation. Current: ${p.previousVersion} \u2192 ${p.newVersion}. Please update manually on the host machine.`,"warning",8e3),m.disabled=!1;return}m.textContent="\u2705",m.title="Updated successfully!",showNotification(`${a.toUpperCase()} updated successfully! ${p.previousVersion} \u2192 ${p.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{m.textContent=n,m.disabled=!1,m.title="Update DNS server",window.refreshAll()},1e4)}catch(d){console.error("DNS update error:",d),m.textContent="\u274C",m.title="Update failed",showNotification(`Failed to update ${a.toUpperCase()}: ${d.message}`,"error"),setTimeout(()=>{m.textContent=n,m.disabled=!1,m.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",a=>{const b=a.target.closest('[id$="-update"]');if(!b)return;const m=b.id.replace("-update","");SITE.dnsServers[m]&&f(m,SITE.dnsServers[m]?.ip)}),injectModal("dns-settings-modal",` +
+
+

DNS Settings

+ +
+
+ + +
+
+ + +
+
+ + +
+
Manage credentials via Tokens in the toolbar
+
+ +
+ + + +
+
+
`);let u=null;function y(a){u=a;const b=SITE.dnsServers[a]||{},m=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(b.name||a).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=b.ip||"",document.getElementById("dns-edit-port").value=b.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=b.name||"",m.classList.add("show")}async function l(){if(!u)return;const a=document.getElementById("dns-edit-ip").value.trim(),b=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,m=document.getElementById("dns-edit-name").value.trim();if(!a){showNotification("Server IP is required","warning");return}const n={dnsServers:{}};n.dnsServers[u]={ip:a,port:String(b)},m&&(n.dnsServers[u].name=m);try{const g=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();g.success?(SITE.dnsServers[u]=n.dnsServers[u],showNotification(`${u.toUpperCase()} settings saved`,"success"),h(),window.refreshAll()):showNotification(g.error||"Failed to save settings","error")}catch(d){showNotification("Failed to save: "+d.message,"error")}}async function r(){if(u&&confirm(`Remove ${u.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const b=await(await secureFetch("/api/v1/config")).json();b.dnsServers&&delete b.dnsServers[u];const n=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:b.dnsServers||{}})})).json();if(n.success){delete SITE.dnsServers[u];const d=document.querySelector(`.top [data-app="${u}"]`);d&&d.remove(),showNotification(`${u.toUpperCase()} removed from dashboard`,"success"),h()}else showNotification(n.error||"Failed to remove","error")}catch(a){showNotification("Failed to remove: "+a.message,"error")}}function h(){closeModal("dns-settings-modal"),u=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",h),document.getElementById("dns-settings-save")?.addEventListener("click",l),document.getElementById("dns-settings-delete")?.addEventListener("click",r),document.getElementById("dns-settings-modal")?.addEventListener("click",a=>{a.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",a=>{const b=a.target.closest('[id$="-settings"]');if(!b)return;const m=b.id.replace("-settings","");SITE.dnsServers[m]&&(a.stopPropagation(),y(m))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",` +
+
+
+

DNS Logs

+
+ + + + + +
+
+
+
+
Loading logs...
+
+
+
+
`);let o=null,f=null,u=!1,y=null,l=null,r=!1,h=null,a=null,b=!1,m=null,n=!1;async function d(x,w=25){try{const C=getDnsServerAddr(x),k=await fetch(`/api/v1/dns/logs?server=${C}&limit=${w}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,server:I.server}:{error:I.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(C){return console.error("DNS logs fetch failed:",C),{error:C.message}}}function g(x){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[x]||"var(--fg)"}function e(x){const w=document.createElement("div");if(w.className="log-entry",w.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",x.parsed===!1)return w.style.gridTemplateColumns="1fr",w.innerHTML=`${escapeHtml(x.raw)}`,w;const C=g(x.rcode),k=x.rcode==="Refused"||x.rcode==="REFUSED";return w.innerHTML=` + ${escapeHtml(x.timestamp)} + ${escapeHtml(x.client)} + ${escapeHtml(x.domain)} + ${escapeHtml(x.type)} + ${escapeHtml(x.rcode)} + `,w}async function s(){if(b){await B();return}if(r){await T();return}if(u||!o)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await d(o,x);if(C.error){w.innerHTML=` +
+
\u26A0\uFE0F Error
+
${escapeHtml(C.error)}
+
`;return}w.innerHTML=` +
+ Time + Client + Domain + Type + Status +
`,C.logs&&C.logs.length>0?C.logs.forEach(k=>{const I=e(k);w.appendChild(I)}):w.innerHTML+=` +
+ No DNS queries logged yet +
`}catch(C){w.innerHTML=` +
+ Failed to fetch logs: ${escapeHtml(C.message)} +
`}}function p(x){o=x,u=!1,r=!1;const w=document.getElementById("logs-modal"),C=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),I=document.getElementById("logs-stream");C.textContent=`${x.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),I&&(I.style.display="none"),w.classList.add("show"),s(),f=setInterval(s,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),f&&(clearInterval(f),f=null),v(),o=null,r=!1,y=null,l=null,b=!1,h=null,a=null,u=!1}function c(x){m&&v();const w=document.getElementById("logs-stream"),C=document.getElementById("logs-pause"),k=document.getElementById("logs-content");f&&(clearInterval(f),f=null);try{m=new EventSource(`/api/v1/logs/stream/${x}`),n=!0,w.classList.add("active"),w.textContent="\u{1F534} Live",w.title="Streaming - click to stop",C.style.display="none";const I=document.getElementById("logs-title");I.textContent.includes("\u{1F534}")||(I.innerHTML=I.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),m.onmessage=A=>{try{const O=JSON.parse(A.data);if(O.error){console.error("Stream error:",O.error),v();return}const D=document.createElement("div");D.className="log-entry",D.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(O.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",F=`${R?"STDERR":"STDOUT"}`;for(D.innerHTML=` +
${F}
+
${escapeHtml(O.text)}
+ `,k.appendChild(D),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},m.onerror=A=>{console.error("EventSource error:",A),v()}}catch(I){console.error("Failed to start streaming:",I),v()}}function v(){m&&(m.close(),m=null),n=!1;const x=document.getElementById("logs-stream"),w=document.getElementById("logs-pause"),C=document.getElementById("logs-title");x&&(x.classList.remove("active"),x.textContent="\u{1F4E1} Live",x.title="Enable real-time streaming"),w&&(w.style.display=""),C&&(C.textContent=C.textContent.replace(" \u{1F534}","")),r&&y&&!f&&(f=setInterval(T,DC.POLL.LOGS))}async function i(x,w=100){try{const C=`/api/v1/logs/container/${x}?tail=${w}×tamps=true`,k=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,containerName:I.containerName,containerId:I.containerId}:{error:I.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(C){return console.error("Container logs fetch failed:",C),{error:C.message}}}function E(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=x.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=x.stream==="stderr"?'STDERR':'STDOUT';return w.innerHTML=` +
${k}
+
${escapeHtml(x.text)}
+ `,w}async function T(){if(u||!y||!r)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await i(y,x);if(C.error){w.innerHTML=` +
+
\u26A0\uFE0F Error
+
${escapeHtml(C.error)}
+
`;return}w.innerHTML=` +
+ Stream + Log Output +
`,C.logs&&C.logs.length>0?(C.logs.forEach(k=>{const I=E(k);w.appendChild(I)}),w.scrollTop=w.scrollHeight):w.innerHTML+=` +
+ No logs available for this container +
`}catch(C){w.innerHTML=` +
+ Failed to fetch logs: ${escapeHtml(C.message)} +
`}}function L(x,w){y=x,l=w,r=!0,b=!1,u=!1,v();const C=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Container Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display=""),C.classList.add("show"),T(),f=setInterval(T,DC.POLL.LOGS)}async function P(x,w=100){try{const C=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${w}`,k=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,logPath:I.logPath,totalLines:I.totalLines}:{error:I.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(C){return console.error("File logs fetch failed:",C),{error:C.message}}}function S(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=x.text;let k="INFO",I="var(--fg)";C.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",I="var(--bad-fg)"):C.match(/WARN|WARNING/i)?(k="WARN",I="#f39c12"):C.match(/DEBUG/i)&&(k="DEBUG",I="var(--muted)");const O=`${k}`;return w.innerHTML=` +
${O}
+
${escapeHtml(C)}
+ `,w}async function B(){if(u||!h||!b)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await P(h,x);if(C.error){w.innerHTML=` +
+
\u26A0\uFE0F Error
+
${escapeHtml(C.error)}
+
`;return}w.innerHTML=` +
+ Log Output (${C.count} of ${C.totalLines} lines) +
`,C.logs&&C.logs.length>0?(C.logs.forEach(k=>{const I=S(k);w.appendChild(I)}),w.scrollTop=w.scrollHeight):w.innerHTML+=` +
+ No logs available in this file +
`}catch(C){w.innerHTML=` +
+ Failed to fetch logs: ${escapeHtml(C.message)} +
`}}function $(x,w){h=x,a=w,b=!0,r=!1,u=!1;const C=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Application Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display="none"),C.classList.add("show"),B(),f=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const w=x.target.closest('[id$="-logs"]');if(!w)return;const C=w.id.replace("-logs","");SITE.dnsServers[C]&&p(C)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{u=!u;const x=document.getElementById("logs-pause");u?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),s())}),document.getElementById("log-lines")?.addEventListener("change",()=>{u||s()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!r||!y||(n?v():c(y))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=p})(),(function(){injectModal("service-edit-modal",` +
+
+

Edit Service

+ +
+ +
+ +
+
+
+
+ + +
+ + +
+ + +
+ +
+ + .home +
+
+ + +
+ + +
+ The port Caddy will proxy to (container's exposed port) +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ Enter a URL or upload an image file (PNG, JPG, SVG) +
+
+ + +
+ + +
+
+ +
+ + +
+
+
`),injectModal("delete-service-modal",` +
+
+

Delete Service

+ +
+ +
+ + + +
+ + + +
+ + +
+
`),injectModal("add-service-modal",` +
+
+

Add Service

+ + +
+ + +
+ + + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ + + +
+
+
+ + +
+ Options +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + + +
+ Checking Tailscale... +
+ + + + + +
+ + +
Group services on the dashboard by purpose (Media, Productivity, etc.)
+
+ +
+ +
+
+ + + + +
+
+ + + + + + +
+
+ +
+
+
+ + + + +
+ + +
+
+
`)})(),(function(){async function o(r){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(r)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const a=await h.json();if(a.status==="success"){const b=document.getElementById("existing-ca-select");return b.innerHTML="",a.data.cas.length===0?b.innerHTML='':(b.innerHTML='',a.data.cas.forEach(m=>{const n=document.createElement("option");typeof m=="object"?(n.value=m.id,n.textContent=m.displayName||m.name):(n.value=m,n.textContent=m),b.appendChild(n)})),a.data.cas}else throw new Error(a.message)}catch(h){console.error("Error loading CAs:",h);const a=document.getElementById("existing-ca-select");return a.innerHTML='',[]}}function f(r){const{subdomain:h,port:a,ip:b,sslType:m,caName:n,existingCa:d,enableAuth:g,enableCors:e,customHeaders:s,upstreamPath:p,healthCheck:t,timeout:c,tailscaleOnly:v}=r;let i=`${buildDomain(h)} { +`;switch(v&&(i+=` @blocked not remote_ip 100.64.0.0/10 +`,i+=` respond @blocked "Access denied. Tailscale connection required." 403 +`),m){case"letsencrypt":break;case"caddy-managed":i+=` tls internal +`;break;case"existing-ca":d&&(i+=` tls { + ca ${d} + } +`);break;case"custom-ca":n&&(i+=` tls { + ca ${n} + } +`);break}if(g&&(i+=` basicauth { + admin $2a$14$hashed_password_here + } +`),e&&(i+=` header { +`,i+=` Access-Control-Allow-Origin "*" +`,i+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" +`,i+=` Access-Control-Allow-Headers "Content-Type, Authorization" +`,i+=` } +`),s)try{const E=JSON.parse(s);i+=` header { +`,Object.entries(E).forEach(([T,L])=>{i+=` ${T} "${L}" +`}),i+=` } +`}catch{console.warn("Invalid JSON in custom headers")}return t&&(i+=` health_uri ${t} +`),i+=` reverse_proxy ${b}:${a} { +`,p&&p!=="/"&&(i+=` rewrite ${p} +`),c&&c!==30&&(i+=` transport http { +`,i+=` dial_timeout ${c}s +`,i+=` response_header_timeout ${c}s +`,i+=` } +`),i+=` } +`,i+=`} +`,i}async function u(r,h,a=DC.DEFAULTS.TTL){const b=window.getToken(getPrimaryDnsId(),"admin");if(!b)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const m=buildDomain(r),n=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:m,ip:h,ttl:a,token:b,server:SITE.dnsIp})});if(!n.ok){const g=await n.text();throw new Error(`DNS API Error: ${n.status} - ${g}`)}const d=await n.json();if(!d.success)throw new Error(`DNS Error: ${d.error||"Unknown error"}`);return d}async function y(r){const h={id:r.subdomain,name:r.name,logo:r.logo||`/assets/${r.subdomain}.png`};r.category&&(h.category=r.category),r.containerId&&(h.containerId=r.containerId);try{const a=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!a.ok){const b=await a.json();throw new Error(b.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),h}catch(a){throw console.error("Failed to add service to config:",a),a}}async function l(r){const h=document.getElementById("service-subdomain-input").value.trim(),a=document.getElementById("service-ip-input").value.trim()||"localhost",b=document.getElementById("service-port-input").value.trim()||"80",m=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(h),upstream:`${a}:${b}`,config:r})}),n=await m.json();if(!m.ok||!n.success)throw new Error(n.error||`Caddy API Error: ${m.status}`);return n}window.loadExistingCAs=o,window.generateCaddyConfig=f,window.createDnsRecord=u,window.addServiceToConfig=y,window.addToCaddyfile=l})(),(function(){let o=null;function f(a){o=a;const b=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${a.name}`,document.getElementById("edit-service-name").value=a.name,document.getElementById("edit-service-url-display").textContent=a.url||buildServiceUrl(a.id),document.getElementById("edit-service-logo-preview").src=a.logo||`/assets/${a.id}.png`,document.getElementById("edit-subdomain").value=a.id,document.getElementById("edit-port").value=a.port||"",document.getElementById("edit-ip").value=a.ip||"localhost",document.getElementById("edit-tailscale-only").checked=a.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=a.logo||"";const m=document.getElementById("edit-service-category");m&&(m.dataset.current=a.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),b.classList.add("show")}function u(){closeModal("service-edit-modal"),o=null}async function y(){if(!o)return;const a=document.getElementById("edit-subdomain").value.trim().toLowerCase(),b=document.getElementById("edit-service-name").value.trim(),m=document.getElementById("edit-port").value.trim(),n=document.getElementById("edit-ip").value.trim()||"localhost",d=document.getElementById("edit-tailscale-only").checked,g=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!a){showNotification("Subdomain is required","warning");return}const s=o.id,p=[];if(a!==s&&p.push("subdomain"),b&&b!==o.name&&p.push("name"),m&&m!==String(o.port)&&p.push("port"),n!==o.ip&&p.push("ip"),d!==(o.tailscaleOnly||!1)&&p.push("tailscale"),g&&g!==o.logo&&p.push("logo"),e!==(o.category||"")&&p.push("category"),p.length===0){u();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const v=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:s,newSubdomain:a,name:b||o.name,port:m||o.port,ip:n,tailscaleOnly:d,logo:g||void 0,category:e})})).json();if(!v.success)throw new Error(v.error||"Failed to update service");const i=window.APPS.findIndex(E=>E.id===s);i!==-1&&(window.APPS[i]={...window.APPS[i],id:a,name:b||window.APPS[i].name,port:m||window.APPS[i].port,ip:n,tailscaleOnly:d,logo:g||window.APPS[i].logo,category:e||void 0}),u(),window.buildGrid(),window.refreshAll()}catch(c){console.error("Error saving service changes:",c),showNotification(`Error saving changes: ${c.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async a=>{const b=a.target.files[0];if(!b)return;if(!b.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const m=new FileReader;m.onload=async n=>{const d=n.target.result;if(document.getElementById("edit-service-logo-preview").src=d,document.getElementById("edit-logo-url").value=d,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:d})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},m.readAsDataURL(b)}),document.getElementById("service-edit-cancel")?.addEventListener("click",u),document.getElementById("service-edit-save")?.addEventListener("click",y),document.getElementById("service-edit-modal")?.addEventListener("click",a=>{a.target.id==="service-edit-modal"&&u()});function l(a,b,m){return new Promise(n=>{const d=document.getElementById("delete-service-modal"),g=document.getElementById("delete-modal-title"),e=document.getElementById("delete-modal-message"),s=document.getElementById("delete-modal-container-info"),p=document.getElementById("delete-modal-container-name"),t=document.getElementById("delete-modal-help"),c=document.getElementById("delete-modal-cancel"),v=document.getElementById("delete-modal-remove"),i=document.getElementById("delete-modal-delete");g.textContent=`Delete "${a}"`,b?(e.innerHTML="This service has an associated Docker container.
Choose how to proceed:",s.style.display="block",p.textContent=`Container ID: ${m?.slice(0,12)||"Unknown"}`,t.style.display="block",i.style.display="block"):(e.textContent="Remove this service from the dashboard?",s.style.display="none",t.style.display="none",i.style.display="none");const E=()=>{d.classList.remove("show"),c.removeEventListener("click",T),v.removeEventListener("click",L),i.removeEventListener("click",P),d.removeEventListener("click",S)},T=()=>{E(),n(null)},L=()=>{E(),n(!1)},P=()=>{E(),n(!0)},S=B=>{B.target===d&&(E(),n(null))};c.addEventListener("click",T),v.addEventListener("click",L),i.addEventListener("click",P),d.addEventListener("click",S),d.classList.add("show")})}async function r(a,b,m){const n=document.getElementById(`update-btn-${m}`),d=n?.textContent;if(confirm(`Update ${b} to the latest version? + +This will: +1. Pull the latest image +2. Stop the container +3. Recreate with same settings + +The service will be briefly unavailable.`))try{n&&(n.textContent="\u{1F504}",n.disabled=!0,n.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${a}/update`,{method:"POST"})).json();if(e.success){const s=window.APPS.find(p=>p.id===m);s&&e.newContainerId&&(s.containerId=e.newContainerId),n&&(n.textContent="\u2705",n.title="Updated successfully!",setTimeout(()=>{n.textContent=d,n.disabled=!1,n.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${b} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(g){console.error("Update error:",g),n&&(n.textContent="\u274C",n.title="Update failed",setTimeout(()=>{n.textContent=d,n.disabled=!1,n.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${b}: ${g.message}`,"error")}}async function h(a,b){const m=window.APPS.find(i=>i.id===a),n=m?buildDomain(m.id):null,d=m?.containerId,g=await l(b||a,d,m?.containerId);if(g===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(g&&d)try{const i=new URLSearchParams({containerId:m.containerId,subdomain:m.id,ip:m.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(m.id)}?${i.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(i){console.error("App removal error:",i)}else if(g&&n){try{const i=m?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(n)}&type=A&ipAddress=${encodeURIComponent(i)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(i){e.dns=i.message}try{const E=await(await secureFetch(`/api/v1/site/${encodeURIComponent(n)}`,{method:"DELETE"})).json();e.caddy=E.success||E.error&&E.error.includes("not found")?"removed":E.error||"failed"}catch(i){e.caddy=i.message}}const s=window.APPS.findIndex(i=>i.id===a);s>-1&&(window.APPS.splice(s,1),e.dashboard=!0);try{const i=safeGetJSON("custom-apps",[]),E=i.findIndex(T=>T.id===a);E>-1&&(i.splice(E,1),safeSet("custom-apps",JSON.stringify(i)))}catch{}try{const E=await(await secureFetch(`/api/v1/services/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.service=E.success?"removed":E.error||"failed"}catch(i){e.service=i.message}window.buildGrid(),window.refreshAll();let p=!1,t=[];e.dashboard||(p=!0,t.push("\u2717 Failed to remove from dashboard"));const c=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],v=i=>!i||c.some(E=>i.toLowerCase().includes(E.toLowerCase()));e.container&&!v(e.container)&&(p=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!v(e.dns)&&(p=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!v(e.caddy)&&(p=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!v(e.service)&&(p=!0,t.push(`\u26A0 Service File: ${e.service}`)),p&&showNotification(`Error deleting "${b||a}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=f,window.showDeleteModal=l,window.updateContainer=r,window.deleteService=h})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function f(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function u(){const e=document.getElementById("service-subdomain-input").value||"subdomain",s=document.getElementById("service-ip-input").value||y.lan||"localhost",p=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,c=document.getElementById("ca-name-input").value||"sami-ca",v=document.getElementById("existing-ca-select").value,i=document.getElementById("enable-auth").checked,E=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,S=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${s}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:p,ip:s,sslType:t,caName:c,existingCa:v,enableAuth:i,enableCors:E,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:S},w=window.generateCaddyConfig(x),C=document.getElementById("caddy-config-preview");C&&(C.value=w)}const y={localhost:"127.0.0.1",lan:"",tailscale:""};async function l(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const c=await t.json();c.lan&&(y.lan=c.lan),c.tailscale&&(y.tailscale=c.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),s=document.getElementById("quick-ip-tailscale");e&&(y.lan?(e.dataset.ip=y.lan,e.textContent=`LAN (${y.lan})`,e.title=`LAN IP: ${y.lan}`):e.style.display="none"),s&&(y.tailscale?(s.dataset.ip=y.tailscale,s.textContent=`Tailscale (${y.tailscale})`,s.title=`Tailscale IP: ${y.tailscale}`):s.style.display="none");const p=document.getElementById("service-ip-input");p&&!p.value&&y.lan&&(p.value=y.lan)}function r(){document.querySelectorAll(".quick-ip-btn").forEach(e=>{e.addEventListener("click",()=>{const s=e.dataset.ip;s&&(document.getElementById("service-ip-input").value=s,document.querySelectorAll(".quick-ip-btn").forEach(p=>p.classList.remove("active")),e.classList.add("active"),u())})}),document.getElementById("service-ip-input")?.addEventListener("input",e=>{const s=e.target.value;document.querySelectorAll(".quick-ip-btn").forEach(p=>{p.classList.toggle("active",p.dataset.ip===s)})})}async function h(){const e=document.getElementById("add-service-modal");e.classList.add("show");const s=e.querySelector(".weather-modal-content");s&&(s.scrollTop=0),document.body.style.overflow="hidden";const p=document.getElementById("ssl-type-select");p&&(p.value=f()),await l();const t=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(t);const c=document.getElementById("manual-tailscale-status"),v=document.getElementById("manual-tailscale-only");try{const E=await(await fetch("/api/v1/tailscale/status")).json();E.success&&E.installed&&E.connected?(c.innerHTML=` + \u2713 Connected + ${E.self?.hostname} (${E.self?.ip}) + `,v.disabled=!1):E.installed?(c.innerHTML='\u26A0 Not connected',v.disabled=!0):(c.innerHTML='Not available',v.disabled=!0)}catch{c.innerHTML='Could not check',v.disabled=!0}v.checked=!1,u()}function a(){const e=document.getElementById("service-type-local"),s=document.getElementById("service-type-external"),p=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),c=document.getElementById("tab-local"),v=document.getElementById("tab-external");function i(){e.checked?(p.style.display="grid",t.style.display="none",c&&(c.style.background="var(--accent)",c.style.color="var(--bg)"),v&&(v.style.background="transparent",v.style.color="var(--muted)")):(p.style.display="none",t.style.display="block",v&&(v.style.background="var(--accent)",v.style.color="var(--bg)"),c&&(c.style.background="transparent",c.style.color="var(--muted)"))}e?.addEventListener("change",i),s?.addEventListener("change",i)}function b(){const e=document.getElementById("service-name-input"),s=document.getElementById("service-subdomain-input"),p=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=o(e.value);!t&&s&&(s.value=L),p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),u()}),s?.addEventListener("input",()=>{t=s.value!==o(e?.value||"");const L=s.value.trim()||o(e?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),u()});const c=document.getElementById("external-service-name"),v=document.getElementById("external-service-subdomain"),i=document.getElementById("external-subdomain-preview"),E=document.getElementById("external-domain-preview");let T=!1;c?.addEventListener("input",()=>{const L=o(c.value);!T&&v&&(v.value=L);const P=v?.value||L;i&&(i.textContent=P?`\u2192 ${buildDomain(P)}`:""),E&&(E.textContent=P?buildDomain(P):"")}),v?.addEventListener("input",()=>{T=v.value!==o(c?.value||"");const L=v.value.trim()||o(c?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),E&&(E.textContent=L?buildDomain(L):"")})}async function m(){const e=document.getElementById("external-service-name").value.trim(),s=document.getElementById("external-service-url").value.trim(),p=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),c=document.getElementById("external-service-icon").value.trim(),v=document.getElementById("external-create-dns").checked,i=document.getElementById("external-create-caddy").checked,E=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!s){showNotification("Please fill in Name and External URL","warning");return}if(!p){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!s.startsWith("http://")&&!s.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const S=buildDomain(p);try{const B={dns:null,caddy:null,dashboard:!1};if(v)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:S,ip:E,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(I){B.dns=I.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(i)try{const k={subdomain:p,externalUrl:s,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:p,name:e,url:`https://${S}`,externalUrl:s,logo:t||c||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const x=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(k=>!x.includes(k.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),n();const C=[`External service "${e}" added!`];v&&C.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),i&&C.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),C.push(`Access at: https://${S}`),showNotification(C.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`Failed to create external service: ${B.message}`,"error")}}function n(){closeModal("add-service-modal"),document.body.style.overflow="",document.getElementById("service-name-input").value="",document.getElementById("service-subdomain-input").value="",document.getElementById("service-port-input").value="",document.getElementById("service-ip-input").value=y.lan||"",document.getElementById("service-logo-input").value="",document.getElementById("dns-ttl-input").value=DC.DEFAULTS.TTL,document.getElementById("ssl-type-select").value=f(),document.getElementById("ca-name-input").value="",document.getElementById("enable-auth").checked=!1,document.getElementById("enable-cors").checked=!1,document.getElementById("custom-headers-input").value="",document.getElementById("upstream-path-input").value="/",document.getElementById("health-check-input").value="",document.getElementById("timeout-input").value="30";const e=document.getElementById("subdomain-preview");e&&(e.textContent="");const s=document.getElementById("external-subdomain-preview");s&&(s.textContent="");const p=document.getElementById("external-service-name");p&&(p.value="");const t=document.getElementById("external-service-subdomain");t&&(t.value="");const c=document.getElementById("external-service-url");c&&(c.value="");const v=document.getElementById("external-service-logo");v&&(v.value="");const i=document.getElementById("external-service-icon");i&&(i.value="");const E=document.getElementById("local-advanced-options");E&&E.removeAttribute("open");const T=document.getElementById("external-advanced-options");T&&T.removeAttribute("open");const L=document.getElementById("service-type-local");L&&(L.checked=!0);const P=document.getElementById("local-service-config"),S=document.getElementById("external-service-config");P&&(P.style.display="grid"),S&&(S.style.display="none");const B=document.getElementById("tab-local"),$=document.getElementById("tab-external");B&&(B.style.background="var(--accent)",B.style.color="var(--bg)"),$&&($.style.background="transparent",$.style.color="var(--muted)")}async function d(){const e=document.getElementById("service-name-input").value.trim(),s=(document.getElementById("service-subdomain-input").value.trim()||o(e)).toLowerCase(),p=document.getElementById("service-port-input").value.trim(),t=document.getElementById("service-ip-input").value.trim(),c=document.getElementById("service-logo-input").value.trim(),v=document.getElementById("create-dns-record").checked,i=parseInt(document.getElementById("dns-ttl-input").value)||DC.DEFAULTS.TTL,E=document.getElementById("manual-tailscale-only")?.checked||!1,T=document.getElementById("ssl-type-select")?.value||"caddy-managed",L=document.getElementById("ca-name-input")?.value||"",P=document.getElementById("existing-ca-select")?.value||"",S=document.getElementById("enable-auth")?.checked||!1,B=document.getElementById("enable-cors")?.checked||!1,$=document.getElementById("custom-headers-input")?.value||"",x=document.getElementById("upstream-path-input")?.value||"/",w=document.getElementById("health-check-input")?.value||"",C=document.getElementById("timeout-input")?.value||30,I=(document.getElementById("service-category-input")||document.getElementById("external-service-category"))?.value||"",A=window.getToken(getPrimaryDnsId(),"admin");if(!e||!p||!t){showNotification("Please fill in Name, Port, and IP Address","warning");return}if(!s){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(v&&!A){showNotification("DNS Admin token required. Configure it in the Tokens menu first.","warning");return}const O={dns:null,caddy:null,dashboard:!1};try{if(v)try{await window.createDnsRecord(s,t,i),O.dns="created"}catch(N){throw console.error("DNS creation failed:",N),O.dns=N.message,new Error(`DNS creation failed: ${N.message}`)}else O.dns="skipped";const D=window.generateCaddyConfig({subdomain:s,port:p,ip:t,sslType:T,caName:L,existingCa:P,enableAuth:S,enableCors:B,customHeaders:$,upstreamPath:x,healthCheck:w,timeout:C,tailscaleOnly:E});try{const U=await(await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(s),upstream:`${t}:${p}`,config:D})})).json();if(U.success)O.caddy="added & reloaded";else throw console.error("Caddy configuration failed:",U.error),O.caddy=U.error||"failed",new Error(`Caddy configuration failed: ${U.error}`)}catch(N){throw console.error("Caddy API error:",N),O.caddy=N.message,new Error(`Caddy API error: ${N.message}`)}const M={name:e,subdomain:s,port:p,ip:t,logo:c||`/assets/${s}.png`,tailscaleOnly:E||!1};I&&(M.category=I),await window.addServiceToConfig(M),O.dashboard=!0;const R=[`DNS: ${O.dns==="created"?"\u2713":O.dns==="skipped"?"\u25CB":"\u2717"}`,`Caddy: ${O.caddy==="added & reloaded"?"\u2713":"\u2717"}`,`Dashboard: ${O.dashboard?"\u2713":"\u2717"}`];showNotification(`Service "${e}" created! ${R.join(" | ")} \u2014 ${buildServiceUrl(s)}${E?" (Tailscale)":""}`,"success",6e3),n(),window.buildGrid(),window.refreshAll()}catch(D){console.error("Error creating service:",D),showNotification(`Error creating "${e}": ${D.message}`,"error",6e3)}}document.getElementById("add-service")?.addEventListener("click",h),document.getElementById("add-service-cancel")?.addEventListener("click",n),document.getElementById("add-service-create")?.addEventListener("click",()=>{document.querySelector('input[name="service-type"]:checked')?.value==="external"?m():d()}),a(),b(),r(),document.getElementById("ssl-type-select")?.addEventListener("change",e=>{const s=document.getElementById("existing-ca-config"),p=document.getElementById("custom-ca-config");s.style.display="none",p.style.display="none",e.target.value==="existing-ca"?s.style.display="block":e.target.value==="custom-ca"&&(p.style.display="block"),u()}),document.getElementById("refresh-cas")?.addEventListener("click",async()=>{const e=document.getElementById("refresh-cas"),s=e.textContent;e.textContent="\u231B Loading...",e.disabled=!0;try{const p=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(p),e.textContent="\u2705 Refreshed"}catch(p){e.textContent="\u274C Failed",console.error("Failed to refresh CAs:",p)}setTimeout(()=>{e.textContent=s,e.disabled=!1},2e3)}),document.getElementById("create-dns-record")?.addEventListener("change",e=>{const s=document.getElementById("dns-config");s.style.display=e.target.checked?"block":"none"}),["service-subdomain-input","service-ip-input","service-port-input","ca-name-input","existing-ca-select","enable-auth","enable-cors","custom-headers-input","upstream-path-input","health-check-input","timeout-input"].forEach(e=>{const s=document.getElementById(e);s&&(s.addEventListener("input",u),s.addEventListener("change",u))});function g(){const e=safeGet("custom-services");if(e)try{JSON.parse(e).forEach(p=>{window.APPS.find(t=>t.id===p.id)||window.APPS.push(p)})}catch(s){console.warn("Failed to load custom services:",s)}}g(),window.openAddServiceModal=h,window.closeAddServiceModal=n})(),(function(){let o=null,f=1e3;const u=3e4;function y(){if(o)try{o.close()}catch{}o=new EventSource("/api/v1/events/stream"),o.addEventListener("connected",()=>{f=1e3,debug("[SSE] Connected to event stream")}),o.addEventListener("status-change",l=>{try{const r=JSON.parse(l.data);if(r.serviceId&&typeof window.setBadge=="function"){const h=r.status==="up"||r.status==="healthy";window.setBadge(r.serviceId,h,r.responseTime||null)}}catch{}}),o.addEventListener("resource-alert",l=>{try{const r=JSON.parse(l.data),h=`${r.containerName||r.containerId}: ${r.metric} at ${r.value}% (threshold: ${r.threshold}%)`;typeof showNotification=="function"&&showNotification(h,"warning")}catch{}}),o.addEventListener("auto-restart",l=>{try{const r=JSON.parse(l.data);typeof showNotification=="function"&&showNotification(`Container "${r.containerName}" was auto-restarted`,"info")}catch{}}),o.addEventListener("update-available",l=>{try{const r=JSON.parse(l.data),h=document.getElementById("updates-btn");if(h&&!h.querySelector(".sse-dot")){const a=document.createElement("span");a.className="sse-dot",a.style.cssText="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;",h.appendChild(a)}typeof showNotification=="function"&&showNotification(`Update available for ${r.containerName||r.containerId}`,"info")}catch{}}),o.addEventListener("update-complete",l=>{try{const r=JSON.parse(l.data);typeof showNotification=="function"&&showNotification(`Update completed: ${r.containerName||r.containerId}`,"success"),typeof window.refreshAll=="function"&&window.refreshAll()}catch{}}),o.addEventListener("update-failed",l=>{try{const r=JSON.parse(l.data);typeof showNotification=="function"&&showNotification(`Update failed: ${r.containerName||r.containerId} \u2014 ${r.error||"unknown error"}`,"error")}catch{}}),o.addEventListener("incident",l=>{try{const r=JSON.parse(l.data);typeof showNotification=="function"&&(r.type==="created"?showNotification(`Incident: ${r.message||r.serviceId}`,"error"):r.type==="resolved"&&showNotification(`Resolved: ${r.serviceId||"incident"}`,"success"))}catch{}}),o.onerror=()=>{o.close(),console.warn(`[SSE] Disconnected, reconnecting in ${f/1e3}s...`),setTimeout(y,f),f=Math.min(f*2,u)}}y(),window._sseReconnect=y})(),(function(){const o=document.getElementById("service-filter-search"),f=document.getElementById("service-filter-status"),u=document.getElementById("service-filter-category"),y=document.getElementById("service-filter-count");function l(){const b=new Set,m=new Set;document.querySelectorAll("#cards .card[data-category]").forEach(g=>{const e=g.dataset.category.trim();e&&m.add(e)});const n=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES||{};return Object.keys(n).concat([...m].filter(g=>!n[g])).forEach(g=>b.add(g)),{list:[...b],apiCats:n}}function r(){if(!u)return;const{list:b,apiCats:m}=l(),n=u.value;u.innerHTML='',b.sort().forEach(d=>{const g=m[d],e=document.createElement("option");e.value=d,e.textContent=g?`${g.icon||""} ${d}`.trim():d,u.appendChild(e)}),n&&[...u.options].some(d=>d.value===n)?u.value=n:u.value="all"}function h(){r();const b=o.value.toLowerCase().trim(),m=f.value,n=u?u.value:"all",d=document.querySelectorAll("#cards .card");let g=0;if(d.forEach(e=>{const s=e.querySelector(".name")?.textContent?.toLowerCase()||"",p=e.dataset.app?.toLowerCase()||"",t=e.dataset.status||"off",c=e.dataset.category||"";(!b||s.includes(b)||p.includes(b))&&(m==="all"||t===m)&&(n==="all"||c===n)?(e.style.display="",g++):e.style.display="none"}),y){const e=d.length;y.textContent=`${g} of ${e} services`}}function a(b,m){let n;return function(...d){clearTimeout(n),n=setTimeout(()=>b.apply(this,d),m)}}o?.addEventListener("input",a(h,200)),f?.addEventListener("change",h),u?.addEventListener("change",h),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>setTimeout(h,500)):setTimeout(h,500),window.refreshServiceFilter=h,window.refreshCategoryDropdown=r})(),(function(){const o=document.getElementById("batch-operations-btn"),f=document.getElementById("batch-action-bar"),u=document.getElementById("batch-selected-count"),y=document.getElementById("batch-start-btn"),l=document.getElementById("batch-stop-btn"),r=document.getElementById("batch-restart-btn"),h=document.getElementById("batch-cancel-btn");let a=!1,b=new Set;function m(){a=!0,b.clear(),f.style.display="",o.textContent="\u2713 Exit Batch Mode",d(),document.querySelectorAll("#cards .card[data-app]").forEach(s=>{const p=s.dataset.containerId;if(!p)return;const t=s.querySelector(".batch-checkbox");t&&t.remove();const c=document.createElement("input");c.type="checkbox",c.className="batch-checkbox",c.dataset.containerId=p,c.dataset.serviceName=s.querySelector(".name")?.textContent||p,c.style.cssText="position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;",c.addEventListener("change",v=>{v.stopPropagation(),c.checked?b.add(p):b.delete(p),d()}),s.style.position="relative",s.insertBefore(c,s.firstChild)})}function n(){a=!1,b.clear(),f.style.display="none",o.textContent="\u2630 Batch Operations",document.querySelectorAll(".batch-checkbox").forEach(e=>e.remove())}function d(){const e=b.size;u.textContent=`${e} selected`,y.disabled=e===0,l.disabled=e===0,r.disabled=e===0}async function g(e){if(b.size===0)return;const s=Array.from(b),p={start:"Starting",stop:"Stopping",restart:"Restarting"}[e];if(!confirm(`${p} ${s.length} container(s)? This cannot be undone.`))return;const t=[y,l,r];t.forEach(E=>{E.disabled=!0,E.textContent="..."});let c=0,v=0;const i=[];for(const E of s)try{const T=await fetch(`/api/v1/containers/${encodeURIComponent(E)}/${e}`,{method:"POST"});if(T.ok)c++;else{v++;const L=await T.json().catch(()=>({}));i.push(`${E}: ${L.error||T.statusText}`)}}catch(T){v++,i.push(`${E}: ${T.message}`)}t[0].textContent="\u25B6 Start All",t[1].textContent="\u2B1B Stop All",t[2].textContent="\u{1F504} Restart All",d(),v===0?typeof showNotification=="function"&&showNotification(`${p} completed: ${c} container(s)`,"success"):(typeof showNotification=="function"&&showNotification(`${p}: ${c} succeeded, ${v} failed`,"warning"),console.error("Batch operation errors:",i)),setTimeout(()=>{typeof refreshAll=="function"&&refreshAll()},1500)}o?.addEventListener("click",()=>{a?n():m()}),y?.addEventListener("click",()=>g("start")),l?.addEventListener("click",()=>g("stop")),r?.addEventListener("click",()=>g("restart")),h?.addEventListener("click",n)})(); diff --git a/status/dist/features.js b/status/dist/features.js index 86dc01e..5c1e7d8 100644 --- a/status/dist/features.js +++ b/status/dist/features.js @@ -127,7 +127,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
Timezone: ${g.replace(/_/g," ")}
- `,D+="",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const f=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:f,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-public")});const R=document.getElementById("setup-finish");R&&(R.onclick=function(x){x.preventDefault(),A()}),window.getGlobalConfig=async function(){try{const D=await fetch("/api/v1/config");if(D.ok){const g=await D.json();if(g&&g.setupComplete)return g}}catch{console.warn("Could not fetch config from server")}const x=safeGet("dashcaddy-config");return x?JSON.parse(x):{setupComplete:!1,configurationType:"homelab",tld:".home",caName:"",defaults:{dnsType:"private",sslType:"internal",targetIP:"localhost"}}},window.resetSetupWizard=async function(){if(confirm("Reset DashCaddy configuration? This will show the setup wizard again.")){try{await secureFetch("/api/v1/config",{method:"DELETE"})}catch{console.warn("Could not delete server config")}safeRemove("dashcaddy-setup"),safeRemove("dashcaddy-config"),location.reload()}}})(),(function(){const h=new ErrorHandler;injectModal("app-selector-modal",`
+ `,D+="
",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};h==="homelab"?(x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",x.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-public")});const R=document.getElementById("setup-finish");R&&(R.onclick=function(x){x.preventDefault(),A()}),window.getGlobalConfig=async function(){try{const D=await fetch("/api/v1/config");if(D.ok){const g=await D.json();if(g&&g.setupComplete)return g}}catch{console.warn("Could not fetch config from server")}const x=safeGet("dashcaddy-config");return x?JSON.parse(x):{setupComplete:!1,configurationType:"homelab",tld:".home",caName:"",defaults:{dnsType:"private",sslType:"internal",targetIP:"localhost"}}},window.resetSetupWizard=async function(){if(confirm("Reset DashCaddy configuration? This will show the setup wizard again.")){try{await secureFetch("/api/v1/config",{method:"DELETE"})}catch{console.warn("Could not delete server config")}safeRemove("dashcaddy-setup"),safeRemove("dashcaddy-config"),location.reload()}}})(),(function(){const h=new ErrorHandler;injectModal("app-selector-modal",`

Choose an App

diff --git a/status/dist/init.js b/status/dist/init.js index 66c039e..574acae 100644 --- a/status/dist/init.js +++ b/status/dist/init.js @@ -1,4 +1,4 @@ -(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(d=>{window.APPS.find(o=>o.id===d.id)||window.APPS.push(d)})}catch(a){console.warn("Failed to load custom services:",a)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((a,d)=>{a.style.transitionDelay=`${Math.min(d*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(a=>a.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(a=>{console.warn("[init] Service worker registration failed:",a)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await g(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const a=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();a.success&&window._updateAuthCard(a.config.enabled&&a.config.isSetUp,a.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const a=document.createElement("link");a.rel="stylesheet",a.href="/css/driver.min.css",document.head.appendChild(a)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const a=document.createElement("link");a.rel="stylesheet",a.href="/css/onboarding.css",document.head.appendChild(a)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let a={};try{a=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(d=>{const o=d.dataset.section,n=d.querySelector(".tools-section-header");n&&(a[o]&&(d.classList.add("open"),n.setAttribute("aria-expanded","true")),n.addEventListener("click",c=>{c.preventDefault();const r=d.classList.toggle("open");n.setAttribute("aria-expanded",r?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(e=>{m[e.dataset.section]=e.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const o=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(o&&o.tourCompleted)}catch{}const a=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!a)return;const d=document.createElement("button");d.id="restart-tour-btn",d.textContent=i?"Help Tour":"\u{1F393} Help Tour",d.title="Restart the onboarding tour",d.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const o=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(o),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(o),5e3)}},a.appendChild(d)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function g(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const a=await i.json();a&&a.categories&&(window.DC_CATEGORIES=a.categories,typeof DC<"u"&&(DC.CATEGORIES=a.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(a=>{const d=a.dataset.current||"",o=a.querySelector('option[value=""]');if(a.innerHTML="",o)a.appendChild(o);else{const n=document.createElement("option");n.value="",n.textContent="\u2014 Select category \u2014",a.appendChild(n)}Object.entries(i).forEach(([n,c])=>{const r=document.createElement("option");r.value=n,r.textContent=`${c.icon||""} ${n}`.trim(),n===d&&(r.selected=!0),a.appendChild(r)})})}window.populateCategorySelects=q,window.loadTemplateCategories=g,(async()=>{try{const a=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(a.success&&a.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(i){console.warn("TOTP check failed, proceeding normally:",i)}f()})()})(),(function(){const v=document.createElement("style");v.textContent=` +(function(){function v(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(e=>{window.APPS.find(n=>n.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const a=document.querySelectorAll(".top .card");a.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function E(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(e=>{const n=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[n]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(n&&n.tourCompleted)}catch{}const i=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=a?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const n=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(n),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(n),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const a=await fetch("/api/v1/templates",{cache:"no-store"});if(!a.ok)return;const i=await a.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(a){console.warn("[init] Failed to load template categories:",a)}}function q(){const a=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;a&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",n=i.querySelector('option[value=""]');if(i.innerHTML="",n)i.appendChild(n);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(a).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(i.success&&i.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}f()})()})(),(function(){const v=document.createElement("style");v.textContent=` .dc-monitor { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); @@ -109,7 +109,7 @@
\u2014
\u2014
- `,k.parentNode.insertBefore(u,k);function h(o,n){const c=document.getElementById(o);if(!c)return;const r=Math.max(0,Math.min(100,Number(n)||0));c.style.width=r+"%",c.classList.remove("warn","bad"),r>=85?c.classList.add("bad"):r>=65&&c.classList.add("warn")}function f(o){return o==null||isNaN(o)?"\u2014":Math.round(o*10)/10+"%"}function b(o){if(o==null||isNaN(o))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;o>=1024&&c0){const n=document.querySelectorAll('#cards .card[data-status="on"]').length;return{total:window.APPS.length,up:n,source:"APPS"}}const o=document.querySelectorAll("#cards .card");if(o.length>0){const n=Array.from(o).filter(c=>c.dataset.status==="on").length;return{total:o.length,up:n,source:"DOM"}}try{const n=await fetch("/api/v1/services",{cache:"no-store"});if(!n.ok)return{total:0,up:0,source:"fetch-fail"};const c=await n.json(),r=c&&Array.isArray(c.services)?c.services:Array.isArray(c)?c:[];(Array.isArray(window.APPS)||typeof window.APPS>"u")&&(window.APPS=r);const m=document.querySelectorAll('#cards .card[data-status="on"]').length;return{total:r.length,up:m,source:"fetch"}}catch{return{total:0,up:0,source:"fetch-error"}}}async function E(){const{total:o,up:n}=await C(),c=document.getElementById("dc-monitor-services"),r=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${o}`),r&&(r.textContent=o===0?"no services yet":`${n} online \xB7 ${o-n} offline`)}function S(o){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!o||o.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const r=o.summary,m=r.healthy??r.up??0,e=r.unhealthy??r.down??0,t=r.total??m+e;n.textContent=`${m}/${t}`,c&&(e===0?c.innerHTML='\u25CF all healthy':e<=2?c.innerHTML=`\u25CF ${e} degraded`:c.innerHTML=`\u25CF ${e} down`)}async function g(){try{const o=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!o.ok)return null;const n=await o.json();return n&&n.stats?n.stats:null}catch{return null}}async function q(){try{const o=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return o.ok?await o.json():null}catch{return null}}function i(o){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),r=document.getElementById("dc-monitor-cpu"),m=document.getElementById("dc-monitor-mem");if(!o){n&&(n.textContent="\u2014"),r&&(r.textContent="\u2014"),m&&(m.textContent="\u2014");return}const e=Object.values(o);if(e.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),r&&(r.textContent="0%"),m&&(m.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,s=0,p=0,l=0,y=0;e.forEach(w=>{if(w.cpu!=null){const x=Number(w.cpu);isNaN(x)||(t+=x>1?x:x*100,l++)}if(w.memory!=null){const x=Number(w.memory);isNaN(x)||(s+=x,p+=Number(w.memoryUsage||0),y++)}});const A=l?t/l:0,L=y?s/y:0;if(n&&(n.textContent=String(e.length)),c){const w=p?` \xB7 ${b(p)} RAM`:"";c.textContent=`running${w}`}r&&(r.textContent=f(A)),m&&(m.textContent=f(L)),h("dc-monitor-cpu-bar",A),h("dc-monitor-mem-bar",L)}let a=!1;async function d(){if(!a){a=!0;try{E();const[o,n]=await Promise.all([g(),q()]);i(o),S(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const r=new Date;c.textContent=`updated ${r.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=d,setInterval(d,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(d,200)})(),(function(){"use strict";const v=(...e)=>{window.DASHCADDY_DEBUG&&console.log(...e)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(e){console.warn("[Keyboard Shortcuts] Failed to initialize:",e.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=` + `,k.parentNode.insertBefore(u,k);function h(e,n){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(n)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c{l.dataset.status==="on"&&n++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${n} online \xB7 ${e-n} offline`)}function E(e){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!e||e.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;n.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='\u25CF all healthy':m<=2?c.innerHTML=`\u25CF ${m} degraded`:c.innerHTML=`\u25CF ${m} down`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const n=await e.json();return n&&n.stats?n.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){n&&(n.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(n&&(n.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let a=!1;async function i(){if(!a){a=!0;try{C();const[e,n]=await Promise.all([S(),y()]);q(e),E(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
\u{1F50D} @@ -123,7 +123,7 @@ Esc Close
- `;const e=document.createElement("style");e.textContent=` + `;const t=document.createElement("style");t.textContent=` .quick-search-modal { display: none; position: fixed; @@ -271,7 +271,7 @@ font-family: monospace; margin-right: 4px; } - `,document.head.appendChild(e),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",d),h.addEventListener("keydown",r),u.addEventListener("click",t=>{t.target===u&&g()})}function E(e){try{if((e.ctrlKey||e.metaKey)&&e.key==="k"){e.preventDefault(),S();return}if(e.key==="Escape"){if(u&&u.classList.contains("show")){g();return}q()}}catch(t){console.warn("[Keyboard Shortcuts] Error handling keydown:",t.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),i()}catch(e){console.warn("[Keyboard Shortcuts] Error opening quick search:",e.message)}}function g(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(e){console.warn("[Keyboard Shortcuts] Error closing quick search:",e.message)}}function q(){for(const e of k){const t=document.querySelector(e);if(t&&(t.classList.contains("show")||t.style.display==="flex"))return t.classList.remove("show"),t.style.display="none",!0}return!1}function i(){const e=` + `,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),a()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function a(){const t=`
Quick Actions
\u{1F504} @@ -303,29 +303,29 @@
Services
- ${a()} - `;f.innerHTML=e,c()}function a(){const e=document.querySelectorAll(".card[data-app], #cards .card");let t="";return e.forEach(s=>{const p=s.querySelector(".name")?.textContent||"Unknown",l=s.dataset.status||"unknown",y=s.dataset.app||"";p&&p!=="--"&&(t+=` -
- ${l==="on"?"\u{1F7E2}":"\u{1F534}"} + ${i()} + `;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=` +
+ ${d==="on"?"\u{1F7E2}":"\u{1F534}"}
${p}
Click to open service
- ${l.toUpperCase()} + ${d.toUpperCase()}
- `)}),t||'
No services found
'}function d(e){try{const t=e.target.value.toLowerCase().trim();if(!t){i();return}const s=o(t);n(s)}catch(t){console.warn("[Keyboard Shortcuts] Error handling search input:",t.message)}}function o(e){const t={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(l=>{(l.title.toLowerCase().includes(e)||l.keywords.includes(e))&&t.actions.push(l)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(l=>{const y=l.querySelector(".name")?.textContent||"",A=l.dataset.app||"",L=l.dataset.status||"unknown";(y.toLowerCase().includes(e)||A.toLowerCase().includes(e))&&t.services.push({id:A,title:y,status:L,icon:L==="on"?"\u{1F7E2}":"\u{1F534}"})}),t}function n(e){let t="";e.actions.length>0&&(t+='
Actions
',e.actions.forEach(s=>{t+=` -
- ${s.icon} + `)}),o||'
No services found
'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){a();return}const r=n(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function n(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='
Actions
',t.actions.forEach(r=>{o+=` +
+ ${r.icon}
-
${s.title}
+
${r.title}
- `})),e.services.length>0&&(t+='
Services
',e.services.forEach(s=>{t+=` -
- ${s.icon} + `})),t.services.length>0&&(o+='
Services
',t.services.forEach(r=>{o+=` +
+ ${r.icon}
-
${s.title}
+
${r.title}
- ${s.status.toUpperCase()} + ${r.status.toUpperCase()}
- `})),t||(t='
No results found
'),f.innerHTML=t,c()}function c(){f.querySelectorAll(".quick-search-item").forEach((t,s)=>{t.addEventListener("click",()=>m(t)),s===0&&t.classList.add("selected")})}function r(e){try{const t=f.querySelectorAll(".quick-search-item"),s=f.querySelector(".quick-search-item.selected"),p=Array.from(t).indexOf(s);if(e.key==="ArrowDown"){e.preventDefault(),s&&s.classList.remove("selected");const l=(p+1)%t.length;t[l]?.classList.add("selected"),t[l]?.scrollIntoView({block:"nearest"})}else if(e.key==="ArrowUp"){e.preventDefault(),s&&s.classList.remove("selected");const l=p<=0?t.length-1:p-1;t[l]?.classList.add("selected"),t[l]?.scrollIntoView({block:"nearest"})}else e.key==="Enter"&&(e.preventDefault(),s&&m(s))}catch(t){console.warn("[Keyboard Shortcuts] Error handling search navigation:",t.message)}}function m(e){try{const t=e.dataset.action,s=e.dataset.service;switch(g(),t){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(s){const p=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const l=document.querySelector(`[data-app="${s}"]`);l&&l.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",t)}}catch(t){console.warn("[Keyboard Shortcuts] Error executing action:",t.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:g}})(); + `})),o||(o='
No results found
'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})(); diff --git a/status/js/core/grid.js b/status/js/core/grid.js index 68d6c88..9ae1df5 100644 --- a/status/js/core/grid.js +++ b/status/js/core/grid.js @@ -65,7 +65,9 @@ if (window.SkeletonLoader) window.SkeletonLoader.show(6); const response = await fetch('/api/v1/services', { cache: 'no-store' }); if (response.ok) { - window.APPS = await response.json(); + const result = await response.json(); + // Standard envelope: { success: true, services: [...], pagination?: {...} } + window.APPS = result.services || []; if (window.SkeletonLoader) window.SkeletonLoader.hide(); } else { console.error('Failed to load services:', response.status); @@ -95,6 +97,8 @@ const card = el('div', 'card'); card.setAttribute('data-app', s.id); card.setAttribute('data-status', 'off'); // Initial status + if (s.containerId) card.setAttribute('data-container-id', s.containerId); + if (s.category) card.setAttribute('data-category', s.category); if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId); const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot); @@ -156,6 +160,16 @@ nameSpan.appendChild(tsBadge); } + // Add Category badge if service has one (colored pill with icon) + if (s.category) { + const cats = (typeof DC !== 'undefined' && DC.CATEGORIES) || window.DC_CATEGORIES || {}; + const catInfo = cats[s.category] || {}; + const catBadge = el('span', 'cat-badge', `${catInfo.icon || ''} ${s.category}`.trim()); + catBadge.title = `Category: ${s.category}`; + catBadge.style.cssText = `margin-left: 6px; font-size: 0.65rem; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 25%, transparent); color: ${catInfo.color || '#7f8c8d'}; border: 1px solid color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 50%, transparent); white-space: nowrap; font-weight: 500;`; + nameSpan.appendChild(catBadge); + } + row.appendChild(el('span', 'spacer')); const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill); @@ -282,6 +296,9 @@ // Group recipe cards visually after grid is built if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards()); + + // Refresh the service filter so the category dropdown reflects new services + if (window.refreshServiceFilter) window.refreshServiceFilter(); } function setBadge(id, up, responseTime = null) { diff --git a/status/js/core/init.js b/status/js/core/init.js index fa25312..34c86ab 100644 --- a/status/js/core/init.js +++ b/status/js/core/init.js @@ -59,11 +59,13 @@ } _dashboardInitialized = true; await window.loadServices(); + await loadTemplateCategories(); window.buildGrid(); animateTopCards(); window.refreshAll(); setInterval(window.refreshAll, DC.POLL.DASHBOARD); if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons(); + if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets(); // Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct) if (typeof window._updateAuthCard === 'function') { try { @@ -200,6 +202,55 @@ window.loadCustomServices = loadCustomServices; registerServiceWorker(); + // ===== TEMPLATE CATEGORIES ===== + // Cached template categories from /api/v1/templates for use across the UI + // (service create/edit, filter dropdown, category badges, etc.) + async function loadTemplateCategories() { + try { + const r = await fetch('/api/v1/templates', { cache: 'no-store' }); + if (!r.ok) return; + const data = await r.json(); + if (data && data.categories) { + window.DC_CATEGORIES = data.categories; + // Also expose via globals.js constant for convenience + if (typeof DC !== 'undefined') DC.CATEGORIES = data.categories; + // Populate any category + + +
@@ -239,6 +249,15 @@ Reload Caddy after adding + +
+ + +
Group services on the dashboard by purpose (Media, Productivity, etc.)
+
+
@@ -326,6 +345,14 @@ Follow Redirects + +
+ + +
+
diff --git a/status/js/dns-template-selector.js b/status/js/dns-template-selector.js index d0f03a8..613509a 100644 --- a/status/js/dns-template-selector.js +++ b/status/js/dns-template-selector.js @@ -95,6 +95,36 @@ 'Prometheus metrics' ], recommended: false + }, + { + id: 'cloudflare', + name: 'Cloudflare DNS', + description: 'Managed DNS with API access — no self-hosting needed', + icon: '🔶', + difficulty: 'Easy', + features: [ + 'Fully managed, no server needed', + 'API for automated record management', + 'Global anycast network', + 'Free tier available' + ], + recommended: false, + providerId: 'cloudflare' + }, + { + id: 'external', + name: 'External / Manual DNS', + description: 'Use your own DNS provider (cPanel, Route53, etc.)', + icon: '🔗', + difficulty: 'Easy', + features: [ + 'Works with any DNS provider', + 'DashCaddy shows you what records to create', + 'Propagation checking still works', + 'No API credentials needed' + ], + recommended: false, + providerId: 'manual' } ]; } diff --git a/status/js/monitoring-widgets.js b/status/js/monitoring-widgets.js new file mode 100644 index 0000000..63455bd --- /dev/null +++ b/status/js/monitoring-widgets.js @@ -0,0 +1,304 @@ +// ========== MONITORING WIDGETS ========== +// Embeds a compact system-resource + health summary panel directly on the +// main dashboard. Replaces the need for a separate monitoring-dashboard.html +// page — quick at-a-glance stats where you already are. +(function () { + + // ----- Style injection (scoped to .dc-monitor so it doesn't leak) ----- + const styleEl = document.createElement('style'); + styleEl.textContent = ` + .dc-monitor { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-bottom: 16px; + padding: 12px 16px; + background: var(--card-base); + border: 1px solid var(--border); + border-radius: var(--radius); + } + .dc-monitor-card { + padding: 10px 12px; + background: var(--card-bg, rgba(255,255,255,0.04)); + border-radius: 8px; + border: 1px solid var(--border); + } + .dc-monitor-label { + font-size: 0.7rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } + .dc-monitor-value { + font-size: 1.4rem; + font-weight: 600; + color: var(--fg); + } + .dc-monitor-sub { + font-size: 0.7rem; + color: var(--muted); + margin-top: 4px; + } + .dc-monitor-bar { + margin-top: 6px; + width: 100%; + height: 4px; + background: color-mix(in srgb, var(--muted) 20%, transparent); + border-radius: 2px; + overflow: hidden; + } + .dc-monitor-bar-fill { + height: 100%; + width: 0%; + background: var(--ok-fg, #27ae60); + transition: width 0.3s ease, background 0.3s ease; + } + .dc-monitor-bar-fill.warn { background: #f39c12; } + .dc-monitor-bar-fill.bad { background: #e74c3c; } + .dc-monitor-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + .dc-monitor-title { + font-size: 0.85rem; + font-weight: 500; + color: var(--muted); + display: flex; + align-items: center; + gap: 6px; + } + .dc-monitor-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 500; + } + .dc-monitor-pill.ok { background: color-mix(in srgb, #27ae60 20%, transparent); color: #27ae60; } + .dc-monitor-pill.warn { background: color-mix(in srgb, #f39c12 20%, transparent); color: #f39c12; } + .dc-monitor-pill.bad { background: color-mix(in srgb, #e74c3c 20%, transparent); color: #e74c3c; } + .dc-monitor-refresh { + font-size: 0.7rem; + color: var(--muted); + opacity: 0.7; + } + `; + document.head.appendChild(styleEl); + + // ----- Container element (inserted above service-filter-bar) ----- + const filterBar = document.getElementById('service-filter-bar'); + if (!filterBar) return; + + const panel = document.createElement('div'); + panel.className = 'dc-monitor'; + panel.id = 'dc-monitor-panel'; + panel.innerHTML = ` +
+
📊 System Overview
+ +
+
+
Services
+
+
loading…
+
+
+
Containers Up
+
+
loading…
+
+
+
Avg CPU
+
+
+
+
+
Avg Memory
+
+
+
+
+
Health
+
+
+
+ `; + // Insert ABOVE the filter bar + filterBar.parentNode.insertBefore(panel, filterBar); + + // ----- Helpers ----- + function setBar(id, pct) { + const el = document.getElementById(id); + if (!el) return; + const p = Math.max(0, Math.min(100, Number(pct) || 0)); + el.style.width = p + '%'; + el.classList.remove('warn', 'bad'); + if (p >= 85) el.classList.add('bad'); + else if (p >= 65) el.classList.add('warn'); + } + + function fmtPct(v) { + if (v == null || isNaN(v)) return '—'; + return (Math.round(v * 10) / 10) + '%'; + } + + function fmtBytes(b) { + if (b == null || isNaN(b)) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; } + return b.toFixed(1) + ' ' + units[i]; + } + + function setServicesCard() { + const total = (window.APPS || []).length; + let up = 0; + document.querySelectorAll('#cards .card').forEach(c => { + if (c.dataset.status === 'on') up++; + }); + const el = document.getElementById('dc-monitor-services'); + const sub = document.getElementById('dc-monitor-services-sub'); + if (el) el.textContent = `${up} / ${total}`; + if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`; + } + + function applyHealthSummary(data) { + const el = document.getElementById('dc-monitor-health'); + const sub = document.getElementById('dc-monitor-health-sub'); + if (!el) return; + if (!data || data.summary == null) { + el.textContent = '—'; + if (sub) sub.textContent = 'no data'; + return; + } + const s = data.summary; + const healthy = s.healthy ?? s.up ?? 0; + const unhealthy = s.unhealthy ?? s.down ?? 0; + const total = s.total ?? (healthy + unhealthy); + el.textContent = `${healthy}/${total}`; + if (sub) { + if (unhealthy === 0) { + sub.innerHTML = '● all healthy'; + } else if (unhealthy <= 2) { + sub.innerHTML = `● ${unhealthy} degraded`; + } else { + sub.innerHTML = `● ${unhealthy} down`; + } + } + } + + // ----- Data fetches ----- + async function fetchStats() { + try { + const r = await fetch('/api/v1/monitoring/stats', { cache: 'no-store' }); + if (!r.ok) return null; + const data = await r.json(); + return (data && data.stats) ? data.stats : null; + } catch (_) { + return null; + } + } + + async function fetchHealth() { + try { + const r = await fetch('/api/v1/health-checks/status', { cache: 'no-store' }); + if (!r.ok) return null; + return await r.json(); + } catch (_) { + return null; + } + } + + function applyStats(stats) { + const containers = document.getElementById('dc-monitor-containers'); + const containersSub = document.getElementById('dc-monitor-containers-sub'); + const cpuEl = document.getElementById('dc-monitor-cpu'); + const memEl = document.getElementById('dc-monitor-mem'); + + if (!stats) { + if (containers) containers.textContent = '—'; + if (cpuEl) cpuEl.textContent = '—'; + if (memEl) memEl.textContent = '—'; + return; + } + + const entries = Object.values(stats); + if (entries.length === 0) { + if (containers) containers.textContent = '0'; + if (containersSub) containersSub.textContent = 'no containers reporting'; + if (cpuEl) cpuEl.textContent = '0%'; + if (memEl) memEl.textContent = '0%'; + setBar('dc-monitor-cpu-bar', 0); + setBar('dc-monitor-mem-bar', 0); + return; + } + + let cpuSum = 0, memSum = 0, memBytes = 0, cpuCount = 0, memCount = 0; + entries.forEach(s => { + // CPU may be percentage (0-100) or fraction (0-1) — handle both + if (s.cpu != null) { + const cpu = Number(s.cpu); + if (!isNaN(cpu)) { + cpuSum += cpu > 1 ? cpu : cpu * 100; + cpuCount++; + } + } + if (s.memory != null) { + const mem = Number(s.memory); + if (!isNaN(mem)) { + memSum += mem; + memBytes += Number(s.memoryUsage || 0); + memCount++; + } + } + }); + + const avgCpu = cpuCount ? cpuSum / cpuCount : 0; + const avgMem = memCount ? memSum / memCount : 0; + + if (containers) containers.textContent = String(entries.length); + if (containersSub) { + const memTxt = memBytes ? ` · ${fmtBytes(memBytes)} RAM` : ''; + containersSub.textContent = `running${memTxt}`; + } + if (cpuEl) cpuEl.textContent = fmtPct(avgCpu); + if (memEl) memEl.textContent = fmtPct(avgMem); + setBar('dc-monitor-cpu-bar', avgCpu); + setBar('dc-monitor-mem-bar', avgMem); + } + + // ----- Public refresh function ----- + let inFlight = false; + async function refresh() { + if (inFlight) return; + inFlight = true; + try { + setServicesCard(); + const [stats, health] = await Promise.all([fetchStats(), fetchHealth()]); + applyStats(stats); + applyHealthSummary(health); + const stamp = document.getElementById('dc-monitor-refresh-stamp'); + if (stamp) { + const now = new Date(); + stamp.textContent = `updated ${now.toLocaleTimeString()}`; + } + } finally { + inFlight = false; + } + } + + // Expose for init.js to call once and re-call after each refreshAll cycle + window.refreshMonitoringWidgets = refresh; + + // Auto-refresh on the STATS interval (separate from full DASHBOARD refresh) + setInterval(refresh, (typeof DC !== 'undefined' && DC.POLL && DC.POLL.STATS) || 5000); + + // Refresh once on first script load (init.js also calls this; double-call is harmless) + setTimeout(refresh, 200); + +})(); diff --git a/status/js/service-filter.js b/status/js/service-filter.js index 3ca7125..847f8b0 100644 --- a/status/js/service-filter.js +++ b/status/js/service-filter.js @@ -2,11 +2,50 @@ (function() { const searchInput = document.getElementById('service-filter-search'); const statusSelect = document.getElementById('service-filter-status'); + const categorySelect = document.getElementById('service-filter-category'); const countSpan = document.getElementById('service-filter-count'); + // Build a single category list from both the API categories and any + // categories present on the actual rendered cards (covers custom services + // whose category isn't in TEMPLATE_CATEGORIES). + function getCategoryList() { + const seen = new Set(); + const fromCards = new Set(); + document.querySelectorAll('#cards .card[data-category]').forEach(c => { + const cat = c.dataset.category.trim(); + if (cat) fromCards.add(cat); + }); + const apiCats = (window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES)) || {}; + const all = Object.keys(apiCats).concat([...fromCards].filter(c => !apiCats[c])); + all.forEach(c => seen.add(c)); + return { list: [...seen], apiCats }; + } + + function refreshCategoryDropdown() { + if (!categorySelect) return; + const { list, apiCats } = getCategoryList(); + const current = categorySelect.value; + categorySelect.innerHTML = ''; + list.sort().forEach(name => { + const info = apiCats[name]; + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = info ? `${info.icon || ''} ${name}`.trim() : name; + categorySelect.appendChild(opt); + }); + // Restore selection if it still exists + if (current && [...categorySelect.options].some(o => o.value === current)) { + categorySelect.value = current; + } else { + categorySelect.value = 'all'; + } + } + function updateFilter() { + refreshCategoryDropdown(); const query = searchInput.value.toLowerCase().trim(); const statusFilter = statusSelect.value; // 'all', 'on', or 'off' + const categoryFilter = categorySelect ? categorySelect.value : 'all'; const cards = document.querySelectorAll('#cards .card'); let visibleCount = 0; @@ -15,11 +54,13 @@ const name = card.querySelector('.name')?.textContent?.toLowerCase() || ''; const app = card.dataset.app?.toLowerCase() || ''; const status = card.dataset.status || 'off'; // 'on' or 'off' + const category = card.dataset.category || ''; const matchesSearch = !query || name.includes(query) || app.includes(query); const matchesStatus = statusFilter === 'all' || status === statusFilter; + const matchesCategory = categoryFilter === 'all' || category === categoryFilter; - if (matchesSearch && matchesStatus) { + if (matchesSearch && matchesStatus && matchesCategory) { card.style.display = ''; visibleCount++; } else { @@ -44,6 +85,7 @@ searchInput?.addEventListener('input', debounce(updateFilter, 200)); statusSelect?.addEventListener('change', updateFilter); + categorySelect?.addEventListener('change', updateFilter); // Initial count on page load if (document.readyState === 'loading') { @@ -52,6 +94,7 @@ setTimeout(updateFilter, 500); } - // Expose for external triggers + // Expose for external triggers (called after buildGrid to repopulate categories) window.refreshServiceFilter = updateFilter; + window.refreshCategoryDropdown = refreshCategoryDropdown; })(); diff --git a/status/js/setup-wizard.js b/status/js/setup-wizard.js index 3a94f27..e125891 100644 --- a/status/js/setup-wizard.js +++ b/status/js/setup-wizard.js @@ -174,8 +174,9 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) { if (currentConfigType === 'homelab') { config.tld = document.getElementById('setup-tld')?.value?.trim() || '.home'; config.caName = document.getElementById('setup-ca-name')?.value?.trim() || ''; + const selectedProvider = document.getElementById('setup-dns-provider')?.value || 'technitium'; config.dns = { - provider: 'technitium', + provider: selectedProvider, ip: document.getElementById('setup-dns-ip')?.value?.trim() || '', port: document.getElementById('setup-dns-port')?.value?.trim() || DC.DEFAULTS.DNS_PORT, token: document.getElementById('setup-dns-token')?.value?.trim() || '' diff --git a/status/sw.js b/status/sw.js index ac6d82e..6caf6d1 100644 --- a/status/sw.js +++ b/status/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'dashcaddy-shell-f6673e7190'; +const CACHE = 'dashcaddy-shell-43a872cc40'; const PRECACHE = [ '/', '/index.html',