Compare commits
60
Commits
v1.13.4
...
588af0dffe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
588af0dffe | ||
|
|
489f700cc3 | ||
|
|
7855b20f63 | ||
|
|
7ef99ec42b | ||
|
|
a37f4f571d | ||
|
|
95a8ae7a09 | ||
|
|
80bb6098a4 | ||
|
|
a79dc5a738 | ||
|
|
a2e6566958 | ||
|
|
5f6c25d2e3 | ||
|
|
1f887725fb | ||
|
|
c1ac0baa5e | ||
|
|
1c8f55edc1 | ||
|
|
923e1ad6f9 | ||
|
|
ab0ef9cfa1 | ||
|
|
8973392c61 | ||
|
|
8ec6c0ca6a | ||
|
|
c39c80b3ad | ||
|
|
57a6a22f89 | ||
|
|
9688e64692 | ||
|
|
283121edba | ||
|
|
b6ad42b5ad | ||
|
|
e1a45543ea | ||
|
|
4a66962f19 | ||
|
|
c77fc65c1f | ||
|
|
7f6be1c2b3 | ||
|
|
54744536b3 | ||
|
|
2f50998105 | ||
|
|
c509f6ff10 | ||
|
|
bf515e5415 | ||
|
|
57549e3e0c | ||
|
|
f457da7d1f | ||
|
|
1da341b1c5 | ||
|
|
a37e79a8fc | ||
|
|
92bcafb4f1 | ||
|
|
16276c62fc | ||
|
|
3b412bff3b | ||
|
|
f71e5c52d4 | ||
|
|
44af47d344 | ||
|
|
6809fc5cca | ||
|
|
4853f1feb8 | ||
|
|
ef855e3fd7 | ||
|
|
3dff49cdc5 | ||
|
|
d230b39948 | ||
|
|
7bbd969fa2 | ||
|
|
4f377970d7 | ||
|
|
7f0d43943c | ||
|
|
7bc2a207f3 | ||
|
|
9468dfc0eb | ||
|
|
6025f68b22 | ||
|
|
f96e903710 | ||
|
|
5b1d631870 | ||
|
|
e32f11b83e | ||
|
|
4c60ed1ccf | ||
|
|
d12a9a3cfa | ||
|
|
2580c65074 | ||
|
|
8e703d9c4c | ||
|
|
8ef5e4a9a4 | ||
|
|
9ab947a394 | ||
|
|
ad9400490d |
+15
@@ -2,6 +2,8 @@
|
||||
node_modules/
|
||||
|
||||
# Runtime state/config files (generated, not source)
|
||||
# Note: data/ subdir contains runtime state (credentials, secrets, history) — never commit
|
||||
dashcaddy-api/data/
|
||||
dashcaddy-api/credentials.json
|
||||
dashcaddy-api/.env
|
||||
.env
|
||||
@@ -17,6 +19,19 @@ dashcaddy-api/update-config.json
|
||||
dashcaddy-api/update-history.json
|
||||
dashcaddy-api/dashcaddy-errors.log
|
||||
|
||||
# Auto-updater backups (created by dashcaddy-update.sh when rolling back)
|
||||
start.sh.bak*
|
||||
scripts/*.bak*
|
||||
|
||||
# Auto-updater runtime state (history + secrets + staging)
|
||||
updates/
|
||||
|
||||
# Scratch / debug scripts (left over from past sessions)
|
||||
cm_check*.js
|
||||
full_test.js
|
||||
login_test.js
|
||||
login_backup_test.js
|
||||
|
||||
# Build output
|
||||
dashcaddy-installer/build-output/
|
||||
dashcaddy-installer/dist/
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# DashCaddy Improvement Backlog
|
||||
|
||||
> **Shared coordination file for Hermes & Krystie.**
|
||||
> Both bots read this, claim tasks, and update status. Git is the source of truth.
|
||||
> When claiming: change `status: todo` to `status: in-progress` and set `owner`.
|
||||
> When done: change to `status: done` and add brief result.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Must Fix (blocks public release)
|
||||
|
||||
### DC-012: Add Kubernetes-style /healthz + /readyz probe aliases + document for fresh users
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** The standardization-pitfalls doc explicitly lists "No `/healthz` or `/readyz` probes" as still-open work. v1.13.0 already added `/health/live` and `/health/ready` with proper probe semantics (live=process alive, ready=deps reachable) and tests in `__tests__/health-endpoints.test.js` (8 tests). But: (1) The k8s/Docker-standard short aliases `/healthz` and `/readyz` are missing — fresh users copy-pasting a `healthcheck:` block from k8s docs or `docker-compose.yml` examples online get connection refused. Even worse: `src/docker/app-templates.js:316` references `"/healthz"` as a template healthcheck URL — but that URL doesn't resolve on the DashCaddy API itself. (2) `/api/v1/health` (apiRouter.get line 658) and root `/health` (app.get line 674) both exist and return identical responses — duplicated, fresh users won't know which to probe. (3) README + user-guide have zero documentation of the probes — a fresh user has no way to know they exist or how to wire them. Fix: add `/healthz` and `/readyz` aliases that point to the same handlers, deprecate the `/api/v1/health` duplicate (keep root `/health` as canonical), document the probes with a copy-paste `docker-compose.yml` healthcheck block in the user-guide.
|
||||
- **result:** Added `/healthz` and `/readyz` as root-level aliases for `/health/live` and `/health/ready` so fresh users can copy-paste `healthcheck:` blocks from k8s/Docker docs. Liveness (`/healthz`) is a pure process check (no I/O). Readiness (`/readyz`) checks config file, services file, Docker daemon, Caddy admin API (3s timeout each), returns 200 if all OK or 503 with `checks` object. Probe endpoints bypass auth, CSRF, and per-request logging (k8s polling every 10s won't flood audit log). Consolidated `/health`, `/health/live`, `/health/ready`, `/healthz`, `/readyz` into a single handler block in `src/app.js` (DRYed the duplicated handler bodies). Removed the dead `/api/v1/health*` routes that were registered in `PUBLIC_ROUTES` + CSRF lists but never actually mounted on the apiRouter — anyone probing `/api/v1/health` now gets a clean 404. Added `__tests__/health-probe-aliases.test.js` (19 tests): alias equivalence, removed-path 404 confirmation, source-of-truth sync check that catches drift between `src/app.js` mount list and `src/utilities/middleware.js` allowlist. README + user-guide updated with copy-paste Docker Compose + Kubernetes probe blocks. Post-fix: 941/941 tests pass (+19 new).
|
||||
|
||||
### DC-013: Config schema migration — auto-upgrade old config.json on boot
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25 — see result)
|
||||
- **details:** Fresh users upgrading from old `config.json` versions break silently when fields change between releases — no auto-migration exists. Highest risk of the 4 remaining standardization items because the failure mode is invisible until something breaks post-upgrade. Fix: detect schema version on boot, run idempotent migration steps to bring config to current schema, write back atomically with a `.bak` backup, log the migration path. Schema versioning via `configSchemaVersion` field (default 1 if absent). Current schema version: 1.
|
||||
- **result:** **AUDITED — ALREADY DONE.** Audited 2026-06-25 before starting work. `src/config/migrations.js` implements exactly this system: `_version` field on config (CURRENT_VERSION = 2, schema versions 1 and 2 already defined — v1 normalizes dns string→object, v2 adds `dns.provider`), `migrate()` runs all migrations forward from detected version, `loadAndMigrate()` writes back to disk only when the version changed (no point rewriting identical content), called from `src/config/site.js` line 57 on every startup. Guarded by 21 tests in `__tests__/config-migrations.test.js` covering null/undefined/v0/v1/v2/future-version + idempotency + write-back behaviour. Krystie may have claimed this task from a stale audit doc — the implementation was finished in an earlier v1.13.x audit pass. Schema versioning field name is `_version` (not `configSchemaVersion`); to add a v3 migration, register `migrations[3]` and bump `CURRENT_VERSION`. Reassigned ownership to hermes because the audit changed the work from "implement" to "verify and document."
|
||||
|
||||
### DC-014: Monitoring endpoint info-disclosure — opt-in via MONITORING_PUBLIC env var
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25)
|
||||
- **details:** The monitoring/detailed health endpoint is currently in `PUBLIC_ROUTES` by default — anyone reaching the API can pull internal status (Caddy admin probes, Docker container list, config drift details). Should be opt-in via `MONITORING_PUBLIC=true` env var, default `false`. Security-by-default for fresh deployments on public networks.
|
||||
- **result:** **AUDITED — ALREADY DONE.** Audited 2026-06-25. `src/utilities/middleware.js` line 297 implements `MONITORING_PUBLIC` as an IIFE that reads from `process.env.MONITORING_PUBLIC` (string `'true'`/`'false'`) and falls back to `cfg.monitoring.public` from the loaded config; defaults to `true` for back-compat with existing dashboards that already hit `/api/v1/monitoring/stats` pre-login. The monitoring routes are conditionally added to `PUBLIC_ROUTES` based on this flag. Operators who don't want monitoring publicly exposed set `MONITORING_PUBLIC=false` or `monitoring.public: false` in config.json. The premise of this ticket (defaults to public, should be opt-in) is the **inverse** of what's actually there — currently it defaults to public for back-compat. If you want to flip the default to `false`, that's a fresh change and would break existing un-authenticated dashboards that load widget data pre-login. Defer until a real deployment reports info-disclosure as a concern.
|
||||
|
||||
### DC-015: CSRF token path duplication — consolidate /api/v1/csrf-token + /api/v1/auth/csrf-token
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25)
|
||||
- **details:** Two routes return the same CSRF token: `/api/v1/csrf-token` (inline in `src/app.js`) and `/api/v1/auth/csrf-token` (in `routes/auth/`). Confusing for any developer integrating with the API. Pick one canonical, deprecate the other with a redirect + `Deprecation` header, update any frontend callers.
|
||||
- **result:** **AUDITED — NEVER EXISTED (or already cleaned up).** Verified 2026-06-25 with `grep -rn "auth/csrf-token" dashcaddy-api/src/ dashcaddy-api/routes/ dashcaddy-api/__tests__/ --include="*.js"`. Only `/api/v1/csrf-token` exists in the codebase (registered at `src/app.js:662` inside `apiRouter`). No `/api/v1/auth/csrf-token` route anywhere — not in `routes/auth/`, not in any test file, not in any frontend code. The duplicate was either planned-but-not-implemented or cleaned up before this ticket was written. No action needed.
|
||||
|
||||
### DC-016: Per-call timeouts on Caddy admin / DNS API — stop event-loop hogging
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25)
|
||||
- **details:** A single global 5min request timeout covers Caddy admin and DNS API calls, but one slow call can hog the Node.js event loop and stall every other request until it returns. Add per-call timeouts (e.g., 10s for Caddy admin probes, 30s for DNS API calls) so a single slow dependency can't block the whole API.
|
||||
- **result:** **AUDITED — PARTIALLY DONE BY DESIGN.** Audited 2026-06-25. `src/utils/http.js` defines `fetchT(url, opts, timeoutMs)` with `AbortSignal.timeout(TIMEOUTS.HTTP_DEFAULT)` (5000ms default) applied to every call via the native fetch branch, and explicit `timeout:` + `req.on('timeout')` handlers in the http/https raw-request branches (used for Caddy admin `:2019` and self-signed-`.sami` HTTPS, where undici fetch can't be configured). Of 77 call sites, 8 pass an explicit timeout; the rest rely on the 5s default. The 5min global request timeout (Pitfall 5) is a backstop. **Per Pitfall 15 (KEEP ON doesn't mean add whatever the audit found):** bumping individual DNS provider timeouts doesn't affect the fresh-user install flow — it's polish, not a bug. If a specific DNS provider endpoint actually needs longer than 5s, the call site should pass an explicit timeout; don't change the global default.
|
||||
|
||||
### DC-001: Fix 4 failing tests in services.routes.test.js
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Credential storage tests failing since before v1.13.4. Run `cd dashcaddy-api && npx jest __tests__/routes/services.routes.test.js` to see failures. Fix the root cause, not the test.
|
||||
- **result:** Root cause: routes used `/:serviceId/credentials` (missing `/services/` segment). All 3 credential routes (POST/DELETE/GET) in `routes/services.js` had the wrong path. Fixed to `/services/:serviceId/credentials` — matches the URL pattern used by the live frontend and all 759 tests pass.
|
||||
|
||||
### DC-011: Fix DC-001 regression reintroduced by src/ refactor (4 failing tests)
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** The module-flattening refactor (DC-005) force-pushed to `main` dropped the DC-001 route-prefix fix. `routes/services.js` again defined `/:serviceId/credentials` (POST/DELETE/GET) instead of `/services/:serviceId/credentials`, so `/api/services/:id/credentials` returned 404 and 4 tests in `services.routes.test.js` failed. Baseline: `npx jest` → 4 failed, 746 passed.
|
||||
- **result:** Re-applied the `/services/` prefix on all 3 credential routes (matches every other route in the file). Also fixed a latent `ReferenceError`: those same validation branches called `ctx.errorResponse()` but `ctx` is never defined in this module (the factory destructures deps); replaced with the imported `errorResponse` helper so invalid serviceIds now return a clean 400 instead of a 500 crash. Result: 750/750 tests pass (4 failed → 0), zero new ESLint warnings. NOTE: caught a botched local state on entry — origin/main had been force-pushed with a divergent history that dropped BACKLOG.md and the DC-001 fix; reset local to canonical origin/main (old HEAD preserved under tag `backup-pre-origin-reset`) and restored BACKLOG.md.
|
||||
|
||||
### DC-002: Sync VERSION file
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `/root/dashcaddy/VERSION` says `1.13.0` but `package.json` says `1.13.4`. VERSION file should always match package.json. Add a pre-commit or post-version bump hook to keep them in sync.
|
||||
- **result:** Fixed root VERSION to 1.13.4. Updated `scripts/release.sh` to write both `dashcaddy-api/package.json` AND root `VERSION` on every release — also stages VERSION in the release commit. No more drift.
|
||||
|
||||
### DC-003: Remove stale test/debug files from repo root
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `comprehensive-test.js` and `test-security-fixes.js` are ad-hoc test scripts, not Jest tests. They clutter the repo root. Remove them or convert to proper Jest tests under `__tests__/`.
|
||||
- **result:** Moved both files to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — they are 875 lines of security test coverage that may be useful as a manual smoke test). Zero references to them in code/docs — safe to move. All 759 Jest tests still pass.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Code Quality
|
||||
|
||||
### DC-004: Fix 19 ESLint warnings
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Run `cd dashcaddy-api && npx eslint src/ --format compact`. Most are unused vars and nested ternaries in `src/utils/logging.js`. Fix all, target zero warnings.
|
||||
- **result:** Reached zero ESLint warnings across `src/`. Most of the original 19 were cleared by the DC-005 refactor and logging cleanup; the final 3 were in `src/app.js`: (1) `require-await` on `resyncHealthChecker` — dropped the now-pointless `async` keyword since it only forwards a promise (callers already use `.catch()`); (2)+(3) two `max-depth` violations in the `/api/v1/network/ips` handler — extracted the interface-enumeration logic into a `detectInterfaceIps()` helper, keeping the route handler flat. `npx eslint src/` now reports 0 problems; 750/750 Jest tests still pass.
|
||||
|
||||
### DC-005: Organize top-level modules into src/
|
||||
- **status:** done (merged to main 2026-06-25)
|
||||
- **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.
|
||||
- **result:** Refactor complete on `krystie-improvements` branch (879/879 tests passing on branch). Merged into main via commit `283121e` after resolving 24 conflicts. Post-merge regression check surfaced one additional latent path bug from DC-005: `src/monitoring/health-checker.js` still had `require('./platform-paths')` (relative to `src/monitoring/`), but `platform-paths.js` lives at top level — fixed in commit `9688e64` to `require('../../platform-paths')`. Without that fix, 59 cascading test failures in `health-checker.test.js`. Final post-merge state: 921/922 tests passing.
|
||||
- **remaining latent bugs (FIXED):** The DC-005 path-rewrite script left depth-2 route files (`routes/auth/*.js`, `routes/recipes/*.js`, `routes/apps/*.js`, `routes/arr/*.js`, `routes/config/*.js`) with broken require() paths. A filesystem-resolving scanner found **67 broken requires across 21 files** — three distinct bug classes: (A) `'../../../src/...'` (3 levels up, goes above package root) — the documented Bug 7, ~49 occurrences; (B) `'../src/utils/...'` (only 1 level up, resolves to nonexistent `routes/src/`) — undocumented, ~15 occurrences for `responses` and `logging`; (C) `routes/apps/restore.js:5` imported `utilities/responses` when the module lives at `utils/responses` (wrong directory + wrong depth). All 67 fixed to `'../../src/...'` (or `'../../src/utils/responses'` for the class-C case). `routes/auth/totp.js` was already fixed in the DC-006 commit. Tests didn't catch any of these previously because no test imported any depth-2 route. Post-fix: 922/922 tests pass, zero new ESLint warnings.
|
||||
|
||||
### DC-006: Add integration test for TOTP auth flow
|
||||
- **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
|
||||
- **owner:** krystie
|
||||
- **result:** 7 test files added (120 new tests, all passing alongside the 759 baseline → 879 total). Files: `__tests__/dns-propagation.test.js` (9), `__tests__/notification-manager.test.js` (18), `__tests__/ssl-monitor.test.js` (13), `__tests__/log-digest.test.js` (11), `__tests__/metrics.test.js` (21), `__tests__/config-drift-detector.test.js` (19), `__tests__/auto-restart-manager.test.js` (29).
|
||||
- **details:** These modules have NO test coverage: `dns-propagation.js`, `notification-manager.js`, `ssl-monitor.js`, `log-digest.js`, `metrics.js`, `config-drift-detector.js`, `auto-restart-manager.js`. Add at least basic smoke tests for each.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Polish & DX
|
||||
|
||||
### DC-008: Update CLAUDE.md for cross-platform accuracy
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** CLAUDE.md references Windows-specific paths (C:/caddy/, e:/CaddyCerts/) as if they're universal. DashCaddy runs on Linux (Docker on DNS2) and Windows (SAMI-PC). Document both deployment targets clearly.
|
||||
- **result:** Added a new "Linux Deployment (DNS2 / Contabo VPS)" section after the existing Windows docs (preserved verbatim) and before the "Project Info" footer. The new section documents: production paths (`/opt/dashcaddy/`, `/var/www/dashcaddy-status/`, `/etc/dashcaddy/`), container mount points with the `/app/data/` auto-resolve fallback, the three-filesystem frontend trap (source vs live vs build-context), common admin commands, a Windows-vs-Linux differences table, and four Linux-specific gotchas (Caddy network_mode host, credentials.json perms, CORS_ORIGINS vs Tailscale, TS_AUTHKEY provisioning). Also updated the "Project Info" version field from stale `1.0` to current `1.13.4` and added the Linux-side default TLD (`.home`).
|
||||
|
||||
### DC-009: Add CHANGELOG entry for any unreleased work
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `[Unreleased]` section in CHANGELOG.md is empty. Any fixes done should be documented there before tagging a release.
|
||||
- **result:** Populated the `[Unreleased]` section with all unreleased work since v1.5.0: Security (TOTP 4-part recovery), Added (OpenClaw routes, auto-backup, monitoring widget, Sami Files template, unified logger, notification manager, update UX, 120 new tests across 7 files), Changed (DC-010 response standardization across 9 route files, /api/v1/ versioning, release.sh hardening), Fixed (DC-011 credential route regression, DC-004 ESLint cleanup, workflow engine init, container-logs wireModal misuse, CSP hash mismatch, SW cache tag, updater false-positive loop), Removed (legacy test scripts moved to scripts/legacy/ preserved-not-deleted, stale root files, dead routes/ directory). Each entry cites the source commit hash for traceability.
|
||||
|
||||
### DC-010: Standardize error response shapes
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** v1.13.4 standardized route responses to use helpers, but some modules still use raw `res.json()`. Grep for remaining `res.json(` in route handlers and convert to response helpers.
|
||||
- **result:** All bare `{success: true, ...}` envelopes across route files now go through `success()` (or `ok()` where the older alias is wired in). Files converted in this push (4 commits): browse/logs/sites (cron), updates/notifications/tailscale/events/workflows/openclaw/dns/health/ca (this sprint) — 9 files, 62 calls. `services.js` line 360+368 left alone (intentional raw-array responses for the frontend wire contract — separate cleanup). Error-path `res.status(4xx/5xx).json({success:false, error:...})` envelopes also left as-is (`ok()` helper would set `success:true` — wrong tool for error shapes). Net result: only 2 intentional raw-array calls remain in routes/; everything else routes through `response-helpers`. 750/750 tests pass at every checkpoint.
|
||||
|
||||
---
|
||||
|
||||
### DC-017: Regression tests for depth-2 route paths + PUBLIC_ROUTES drift
|
||||
- **status:** done
|
||||
- **owner:** krystie
|
||||
- **details:** After DC-005 path-fix (commit c39c80b) shipped 67 broken-require repairs across 21 depth-2 route files, two test gaps remained: (1) no test imported any depth-2 route module, so future refactors could reintroduce class A/B/C broken paths undetected; (2) no test verified that PUBLIC_ROUTES entries (in src/utilities/middleware.js) all correspond to actually-mounted routes — exactly the kind of drift DC-012 added a regression check for (probe paths), but only for the 5 probes. The full ~27-entry PUBLIC_ROUTES list could silently go stale.
|
||||
- **result:** Added 3 files, fixed 1 test helper, no production code changed. New: `__tests__/depth2-routes-smoke.test.js` discovers every .js in routes/{apps,arr,auth,config,recipes}/ and asserts (a) the module loads without MODULE_NOT_FOUND, (b) it exports a factory function, (c) the factory runs without throwing when given universal deps; plus 3 source-of-truth scans that fail if any depth-2 route re-introduces class A (`../../../src/...`), class B (`../src/...`), or class C (`utilities/responses` instead of `utils/responses`) require paths. New: `__tests__/public-routes-drift.test.js` walks every aggregator + direct-mount router via Express stack introspection and asserts (a) every PUBLIC_ROUTES entry matches an actually-mounted route, (b) every CSRF excludedPath is publicly accessible, (c) all 5 probe paths are CSRF-exempt, (d) all 5 probe paths are excluded from request logging, (e) all 5 probe paths bypass Tailscale auth. New: `__tests__/test-helpers/universal-deps.js` — a Proxy + seed-object shared by both suites that returns sensible stubs (logger-shaped object, asyncHandler pass-through, path-string stubs for `path.dirname()` calls) for any property access; supports Object.assign/spread via ownKeys+getOwnPropertyDescriptor traps so aggregator factories that copy ctx into subCtx don't lose proxy magic. Fix to the test helper: (a) `log` is now a logger-shaped object (`{error, warn, info, debug, audit}` as noops) not a bare noopFn — fixes `(ctx.log || console).error(...)` in routes/apps/index.js; (b) `asyncHandler` seeded as own enumerable property — survives Object.assign({}, ctx, { helpers }); (c) added `SERVICES_FILE`, `CONFIG_FILE`, `TOTP_CONFIG_FILE`, `TAILSCALE_CONFIG_FILE`, `NOTIFICATIONS_FILE`, `loadSiteConfig`, `loadNotificationConfig`, `configStateManager`, `readConfig`, `saveConfig`, `helpers`, `safeErrorMessage` as own-enumerable seeds so aggregator sub-mounts destructure cleanly. Fix to public-routes-drift: aggregator walks use prefix `/api/v1` (matches src/app.js's bare-mount on apiRouter at /api/v1), direct-mount walks use `/api/v1` + explicit prefixMap entry. Added `routes/themes.js` and `routes/license.js` to directMounts (themes bare-mounted, license on `/license`). Result: **35 suites, 1036 tests, all passing** (was 1030 passing + 6 failing before this commit). The 6 failures were depth-2 factory errors + 22 PUBLIC_ROUTES stale entries that the test infrastructure was silently swallowing.
|
||||
|
||||
### DC-019: backup-manager test flakes ~1/64 — tamper uses fixed-char replacement that can be a no-op
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `__tests__/backup-manager.test.js:184` "rejects tampered data (auth tag mismatch)" tampers the encrypted blob by replacing its first base64 character with `'X'`: `Buffer.from('X' + str.substring(1))`. The first char is the first base64 char of the random 16-byte IV. When the IV's first base64 char is already `'X'` (~1/64 ≈ 1.6% probability per run), the replacement is a no-op — the "tampered" buffer is byte-identical to the original, AES-256-GCM decryption succeeds, and `expect(...).rejects.toThrow()` fails. Observed: 1 failure in ~15 full-suite runs. The production `encryptBackup`/`decryptBackup` code (AES-256-GCM, correct) is NOT at fault — the bug is in the test's tampering technique. Fix: corrupt the authTag bytes directly (XOR a byte so the value is guaranteed to change), reassemble the `iv:authTag:ciphertext` format. This guarantees a GCM integrity failure every time.
|
||||
- **result:** Fixed. The test now parses the `iv:authTag:ciphertext` format, XORs the first authTag byte with `0xFF` (guaranteed value change — can never be a no-op regardless of the random IV/authTag content), reassembles the blob, then asserts decryption rejects. Verified: **30/30 isolated runs + 8/8 full-suite runs (1036/1036), zero failures.** Production crypto code unchanged (it was correct all along — the bug was purely in the test's tampering technique). Confirmed root cause independently with a Node REPL script: corrupting authTag byte0 always throws `Unsupported state or unable to authenticate data`.
|
||||
|
||||
### DC-018: Logger.error() swallows writeErrorLog promise — error.log writes are fire-and-forget (flaky test + lost logs in prod)
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `Logger.error()` in `src/utils/logging.js:256` calls `this._log('error', ...)` but does NOT return the result. `_log('error', ...)` returns the promise from `writeErrorLog(...)` (the async disk write to error.log). Because `error()` drops the return value, every `await logError(...)` / `await log.error(...)` caller is actually awaiting `undefined` — the file write becomes fire-and-forget. Symptoms: (1) `__tests__/logging.test.js` "captures request context when req is passed" fails intermittently in the full suite (passes in isolation) — the test reads error.log before the un-awaited appendFile completes. (2) In production, 6 route handlers (`routes/apps/deploy.js`, `routes/apps/removal.js`, `routes/health.js`, `routes/arr/config.js`, `routes/updates.js`) plus the global `boundAsyncHandler` error catcher all `await logError(...)` expecting the write to flush; error entries can be lost if the process exits/restarts immediately after. Latent since the original "unify logger" commit f71e5c5. Fix: add `return` to `Logger.error()` so the `writeErrorLog` promise propagates to callers. No behavior change for `debug/info/warn` (they never returned a promise and don't write to disk).
|
||||
- **result:** Fixed — one-line change (`return this._log(...)`). The logging flake is eliminated: **10/10 full-suite runs passed** (was ~1-in-6 failure rate before the fix). Production impact: every `await logError(...)` in route handlers and the global Express error catcher now actually waits for the error.log write to flush to disk, so error entries survive fast process exit/restart. No behavior change for debug/info/warn (they never wrote to disk). ESLint clean.
|
||||
|
||||
## Coordination Rules
|
||||
|
||||
1. **Always `git pull` before starting work.**
|
||||
2. **Claim a task by editing BACKLOG.md:** set `status: in-progress` and `owner: hermes` or `owner: krystie`.
|
||||
3. **Commit BACKLOG.md claim first**, then start coding.
|
||||
4. **Run tests before pushing:** `cd dashcaddy-api && npx jest --passWithNoTests`
|
||||
5. **Push to `main`** — use `http://sami7777:<token>@100.98.123.59:3000/sami7777/dashcaddy.git`
|
||||
6. **Update BACKLOG.md** when done: set `status: done`, add brief result under the task.
|
||||
7. **Never work on a task another bot has claimed** (status: in-progress).
|
||||
8. **Quality bar:** this is a public-release product. No hacks, no env-var workarounds, no per-machine patches. Fixes go in the shared codebase.
|
||||
9. **VERSION bump:** when a batch of tasks is done, bump patch version in package.json + VERSION file, update CHANGELOG, tag.
|
||||
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Auto-login page served from API (`GET /api/v1/auth/login-page?service=<id>`).** Chat, Plex, Jellyfin, and Emby auto-login pages are now generated by the API instead of living as 5 KB inline HTML blobs inside Caddyfile `respond` blocks. Caddyfile blocks shrink from ~50 lines to 3. Future login-page changes deploy with the container, no `caddy-apply` needed.
|
||||
|
||||
### Fixed
|
||||
- **SSO cookie placeholder bug.** `dashcaddy_auth` Caddy snippet had `header_up Cookie {http.request.cookie}` — an invalid placeholder that resolved to empty string at runtime, silently clearing the session cookie before it reached the `forward_auth` gate. SSO worked only via the IP-session fallback (same-IP). Removed the line; Caddy's `forward_auth` forwards all original request headers automatically.
|
||||
- **Jellyfin/Emby `merge()` syntax error.** `try` block in the auto-login page's `merge()` helper was missing its closing `}` before `catch`, causing a JS syntax error in the browser that silently broke localStorage token merging.
|
||||
|
||||
### Changed
|
||||
- **CLAUDE.md rewrite.** Was describing the old Windows-local `C:/caddy/` + `caddy-api/` layout. Now accurately documents DNS2 as production (`/opt/dashcaddy/`, `caddy-apply`, correct Tailscale IP, SSO architecture).
|
||||
- **`.gitignore` coverage.** Runtime-generated data files (`audit-log.json`, `backup-history.json`, `credentials.json`, `health-history.json`, etc.), cert directories (`generated-certs/`, `pki/`), and root-level test scripts now ignored.
|
||||
|
||||
## [1.14.0] - 2026-06-28
|
||||
|
||||
### Security
|
||||
- **TOTP recovery system (4-part defense against permanent lockout).** Pre-lockout: `.bak` fallback credentials file checked at every TOTP init, used silently when primary fails. Diagnostic: `/recovery-info` endpoint + `/recovery-panel` UI on the entry screen with one-click "Import Backup" + "Download Backup" buttons. Post-lockout: friction-free `.license-secret` restore flow. (`d230b39`, `3dff49c`, `7bbd969`)
|
||||
|
||||
### Added
|
||||
- **Kubernetes-standard health probe aliases (DC-012).** Added `/healthz` and `/readyz` as root-level aliases for `/health/live` and `/health/ready` so fresh users can copy-paste `healthcheck:` blocks from k8s/Docker docs. Liveness (`/healthz`) is a pure process check — no I/O. Readiness (`/readyz`) checks the config file, services file, Docker daemon, and Caddy admin API (3s timeout each), returning 200 if all OK or 503 with a `checks` object detailing failures. Both endpoints are unauthenticated by design (orchestration tooling doesn't carry session cookies). Probe endpoints also bypass CSRF validation and are excluded from per-request logging so k8s polling every 10s doesn't flood the audit log. Added `__tests__/health-probe-aliases.test.js` (19 tests) — covers alias equivalence, the removed `/api/v1/health` returning 404, and a source-of-truth sync test that detects drift between `src/app.js` mount list and `src/utilities/middleware.js` allowlist. README and user-guide updated with copy-paste `docker-compose.yml` and Kubernetes probe blocks. Also: audited the cross-platform standardization doc's "What's Still Open" section — all four items previously listed as remaining work (config schema migration, monitoring endpoint opt-in, CSRF path duplication, per-call fetchT timeouts) were already implemented in earlier v1.13.x audit passes but never marked done. Doc updated with pointers and Pitfall 20 added ("Audit Doc Lists Items That Are Already Done") so future agents don't redo the work.
|
||||
- **OpenClaw routes** — full set under `/openclaw` prefix: connect, disconnect, status, host discovery. `docker.client` wrapper fixed; duplicate `/apps/` paths stripped across sub-routers.
|
||||
- **Auto-backup scheduling (premium tier)** + storage-limit enforcement (prune oldest when `maxStorageBytes` exceeded) + restore-from-backup on update rollback. Bundled workflows included out-of-the-box.
|
||||
- **Monitoring widget on main dashboard** — CPU/mem data flattened, health summary added; `/api/monitoring/stats` exposed as a public route with rate-limit.
|
||||
- **Sami Files template** — logPath wired into the template and mounted in `start.sh`.
|
||||
- **Unified logger** — single source of truth for logs, errors, and audit events.
|
||||
- **Notification manager + resource alerting** (premium tier).
|
||||
- **Update UX** — badge→modal flow, orange update button, "Update All", toast notifications, workflow triggers.
|
||||
- **Comprehensive test suite additions:** 7 new test files (`dns-propagation`, `notification-manager`, `ssl-monitor`, `log-digest`, `metrics`, `config-drift-detector`, `auto-restart-manager`) — 120 new tests, all passing.
|
||||
|
||||
### Changed
|
||||
- **Route response standardization (DC-010).** Every `{success, ...}` envelope across 9 route files now flows through `response-helpers` (`success()` / `ok()`). Only 2 intentional raw-array calls remain (`routes/services.js` lines 360+368 — frontend wire contract). Error-path envelopes use `error()` separately. ~62 calls converted across `browse/logs/sites/updates/notifications/tailscale/events/workflows/openclaw/dns/health/ca`.
|
||||
- **`/api/v1/` versioning:** all routes mounted under `/api/v1/`. Legacy un-versioned `/api/` mount removed. Frontend, OpenAPI spec, DashCA pages, and all internal path matchers (CSRF exclusions, auth public routes, audit log, rate-limit mounts) updated.
|
||||
- **`scripts/release.sh`** now stages build-rewritten files (`sw.js`, `index.html`) for the published tarball, copies `VERSION` into the tarball, and writes both `dashcaddy-api/package.json` AND root `VERSION` on every release. No more version drift.
|
||||
|
||||
### Fixed
|
||||
- **Credential route path regression (DC-011).** `routes/services.js` had dropped the `/services/` prefix from credential routes (POST/DELETE/GET) during a refactor, causing 4 test failures and a live 404. Re-applied the prefix; also fixed a latent `ReferenceError` where invalid serviceIds called `ctx.errorResponse()` in a factory-destructured module (replaced with the imported `errorResponse` helper).
|
||||
- **19 ESLint warnings (DC-004).** Reached zero warnings across `src/` — most cleared by the refactor, the final 3 (`require-await` on `resyncHealthChecker`, two `max-depth` violations) fixed in `src/app.js`.
|
||||
- **Workflow engine init broken** — `fetchT` not imported, `NotificationManager` constructor missing `new`, `servicesStateManager` not hoisted. Fixed; events now fire on startup.
|
||||
- **Container-logs feature was misusing `wireModal`** — short-circuited the rest of `features.js` and broke unrelated dashboard features. Replaced with the correct wiring.
|
||||
- **CSP hash mismatch** between Windows and Linux builds — now computed on LF-normalized `index.html` so hashes are identical across platforms.
|
||||
- **SW cache tag** now derived from bundle content hash, so the service worker invalidates correctly when bundle content changes.
|
||||
- **Updater false-positive loop** when commit hash was unknown — fixed.
|
||||
- **Logger.error() swallowed the writeErrorLog promise (DC-018).** `Logger.error()` called `this._log('error', ...)` but dropped the return value, so the async error.log disk write was fire-and-forget. Every `await logError(...)` / `await log.error(...)` caller (6 route handlers + the global Express error catcher) was awaiting `undefined`. This caused a flaky `logging.test.js` in the full suite and could lose error-log entries on fast process exit/restart. One-line fix: `return this._log(...)`.
|
||||
- **Flaky backup-manager tamper test (DC-019).** The "rejects tampered data (auth tag mismatch)" test corrupted the encrypted blob by replacing its first base64 char with `'X'`; when the random IV's first base64 char was already `'X'` (~1/64 chance), the replacement was a no-op and decryption succeeded. Now corrupts the authTag byte directly (XOR `0xFF`) so the tamper is guaranteed to differ.
|
||||
|
||||
### Removed
|
||||
- **Dead `/api/v1/health`, `/api/v1/health/live`, `/api/v1/health/ready` routes** (DC-012) — these were registered in `PUBLIC_ROUTES` and CSRF exclusion lists but never actually mounted on the apiRouter. Consolidated to root-level `/health`, `/health/live`, `/health/ready` plus new `/healthz` and `/readyz` aliases. Anyone probing `/api/v1/health` will now get a clean 404 instead of an unexpected behaviour.
|
||||
- Stale ad-hoc test/debug scripts (`comprehensive-test.js`, `test-security-fixes.js`) moved to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — 875 lines of security test coverage retained as a manual smoke test).
|
||||
- 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
|
||||
|
||||
@@ -10,221 +10,242 @@
|
||||
- When deploying new containers, always use `E:/dockerdata/<app-name>/` for bind mount paths
|
||||
- For CIFS volumes in docker-compose, use `//Sami-pc/e_share/dockerdata/...` as the device path
|
||||
|
||||
## CRITICAL: Production vs Development Paths
|
||||
## CRITICAL: Production is on DNS2 (not this machine)
|
||||
|
||||
DashCaddy runs on **DNS2** (`100.121.150.22` via Tailscale / `194.233.88.206` public).
|
||||
SSH in with: `ssh root@100.121.150.22`
|
||||
|
||||
### Production Layout on DNS2
|
||||
|
||||
### Production Files (LIVE - what actually runs)
|
||||
```
|
||||
C:/caddy/
|
||||
├── Caddyfile # Active Caddy configuration
|
||||
├── services.json # Services shown on dashboard
|
||||
├── dns-credentials.json # DNS API credentials
|
||||
├── config.json # DashCaddy configuration
|
||||
└── sites/
|
||||
└── status/ # Dashboard frontend files
|
||||
└── assets/ # Logos, fonts, icons
|
||||
/opt/dashcaddy/ # git repo (auto-updated)
|
||||
├── dashcaddy-api/
|
||||
│ ├── *.js # API server source
|
||||
│ └── data/
|
||||
│ ├── services.json # LIVE services list
|
||||
│ ├── config.json # LIVE DashCaddy config
|
||||
│ ├── dns-credentials.json # DNS API credentials
|
||||
│ └── credentials.json # Encrypted app credentials
|
||||
├── status/ # Dashboard frontend (built)
|
||||
│ ├── index.html
|
||||
│ ├── dist/ # Bundled JS (core/features/onboarding/init)
|
||||
│ ├── js/ # Source JS (also served statically)
|
||||
│ ├── css/
|
||||
│ └── assets/
|
||||
├── ca/ # DashCA static site
|
||||
├── updates/ # Auto-updater staging + history
|
||||
└── start.sh # Container launch script (run by @reboot cron)
|
||||
```
|
||||
|
||||
### Development Files (for editing/testing)
|
||||
### Docker Container
|
||||
|
||||
- **Name**: `dashcaddy-api`
|
||||
- **Image**: `dashcaddy-dashcaddy-api:latest`
|
||||
- **Port**: `127.0.0.1:3001` (Caddy proxies to it)
|
||||
- **Started by**: `/opt/dashcaddy/start.sh` via root `@reboot` cron
|
||||
|
||||
Key container mounts:
|
||||
| Container path | Host path |
|
||||
|---|---|
|
||||
| `/app/data/` | `/opt/dashcaddy/dashcaddy-api/data/` |
|
||||
| `/app/assets` | `/opt/dashcaddy/status/assets` |
|
||||
| `/caddyfile` | `/etc/caddy/Caddyfile` |
|
||||
| `/app/backups` | `/opt/dashcaddy/backups` |
|
||||
|
||||
### Caddy
|
||||
|
||||
- **Config**: `/etc/caddy/Caddyfile` (git-guarded — edit then run `caddy-apply`)
|
||||
- **Admin API**: `http://localhost:2019` (NOT 2021)
|
||||
- **TLS storage**: `/var/lib/caddy/`
|
||||
- **Static files**: Caddy serves `/opt/dashcaddy/status/` for `status.sami`
|
||||
|
||||
### Development Files (for editing)
|
||||
|
||||
```
|
||||
e:/CaddyCerts/sites/
|
||||
├── caddy-api/
|
||||
│ ├── server.js # API server source code
|
||||
│ ├── app-templates.js # Docker app templates (52+ apps)
|
||||
│ ├── services.json # DEV ONLY - not used in production!
|
||||
├── dashcaddy-api/ # API server source (NOT caddy-api/)
|
||||
│ ├── server.js
|
||||
│ ├── src/app.js # Express app factory
|
||||
│ ├── routes/ # Route handlers
|
||||
│ ├── middleware.js
|
||||
│ └── ...
|
||||
└── status/
|
||||
└── index.html # Dashboard UI source
|
||||
└── status/ # Dashboard frontend source
|
||||
├── index.html # HTML template (~853 lines)
|
||||
├── js/ # Source JS modules
|
||||
├── css/
|
||||
├── dist/ # Built output (run node build.js)
|
||||
└── build.js # Build script (uses esbuild)
|
||||
```
|
||||
|
||||
## Docker Container Mount Points
|
||||
|
||||
The `caddy-api` container mounts production files:
|
||||
|
||||
| Container Path | Host Path (Production) |
|
||||
|----------------|------------------------|
|
||||
| `/app/services.json` | `C:/caddy/services.json` |
|
||||
| `/app/dns-credentials.json` | `C:/caddy/dns-credentials.json` |
|
||||
| `/caddyfile` | `C:/caddy/Caddyfile` |
|
||||
| `/app/assets` | `C:/caddy/sites/status/assets` |
|
||||
|
||||
## When Making Changes
|
||||
|
||||
### To add/remove services from dashboard:
|
||||
Edit `C:/caddy/services.json` (NOT e:/CaddyCerts/sites/caddy-api/services.json)
|
||||
Edit `/opt/dashcaddy/dashcaddy-api/data/services.json` on DNS2 directly,
|
||||
OR use the dashboard UI at `https://status.sami`.
|
||||
|
||||
### To modify Caddy reverse proxy rules:
|
||||
Edit `C:/caddy/Caddyfile`, then reload via:
|
||||
```bash
|
||||
curl -X POST http://localhost:2019/load -H "Content-Type: text/caddyfile" --data-binary @"C:/caddy/Caddyfile"
|
||||
ssh root@100.121.150.22
|
||||
# Edit /etc/caddy/Caddyfile
|
||||
caddy-apply "reason for change" # validates + reloads + git commits
|
||||
```
|
||||
|
||||
### To modify API server code:
|
||||
Edit `e:/CaddyCerts/sites/caddy-api/server.js`, then:
|
||||
1. Copy to production: `C:/caddy/sites/caddy-api/`
|
||||
2. Restart container: `docker restart caddy-api`
|
||||
1. Edit `e:/CaddyCerts/sites/dashcaddy-api/` locally
|
||||
2. `scp` changed files to `root@100.121.150.22:/opt/dashcaddy/dashcaddy-api/`
|
||||
3. Rebuild container: `ssh root@100.121.150.22 "bash /opt/dashcaddy/start.sh"`
|
||||
|
||||
### To modify app templates:
|
||||
Edit `e:/CaddyCerts/sites/caddy-api/app-templates.js`
|
||||
(Templates are loaded at runtime, changes require container restart)
|
||||
### To modify dashboard frontend:
|
||||
1. Edit source in `e:/CaddyCerts/sites/status/js/` or `status/index.html`
|
||||
2. Build: `cd e:/CaddyCerts/sites/status && node build.js`
|
||||
3. Deploy: `scp -r dist/ index.html sw.js root@100.121.150.22:/opt/dashcaddy/status/`
|
||||
|
||||
### To modify dashboard UI:
|
||||
Edit `e:/CaddyCerts/sites/status/index.html`
|
||||
Copy to `C:/caddy/sites/status/` for production
|
||||
|
||||
### To modify DashCA (CA certificate distribution):
|
||||
### To modify DashCA:
|
||||
Edit files in `e:/CaddyCerts/sites/ca/`, then:
|
||||
1. Regenerate certificate formats: `cd e:/CaddyCerts/sites/ca/scripts && bash generate-all.sh`
|
||||
2. Copy to production: `cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/`
|
||||
3. Reload Caddy if Caddyfile changes were made
|
||||
1. Regenerate: `cd e:/CaddyCerts/sites/ca/scripts && bash generate-all.sh`
|
||||
2. Deploy: `scp -r e:/CaddyCerts/sites/ca/* root@100.121.150.22:/opt/dashcaddy/ca/`
|
||||
|
||||
## DashCA - Certificate Authority Distribution
|
||||
|
||||
**Purpose**: Provides a one-click installation page for the root CA certificate, allowing users to easily trust *.sami domains on any device.
|
||||
|
||||
**Access**: https://ca.sami (or https://ca.yourdomain for other installations)
|
||||
|
||||
### File Locations
|
||||
|
||||
**Development (for editing):**
|
||||
```
|
||||
e:/CaddyCerts/sites/ca/
|
||||
├── index.html # Landing page
|
||||
├── root.crt, root.der # Certificate formats
|
||||
├── root.mobileconfig # Apple profile
|
||||
├── intermediate.crt # Intermediate CA
|
||||
├── cert-info.json # Certificate metadata
|
||||
├── scripts/
|
||||
│ ├── install.ps1 # Windows installer
|
||||
│ ├── install.sh # Linux/macOS installer
|
||||
│ ├── generate-cert-info.js # Extract cert metadata
|
||||
│ ├── generate-mobileconfig.js # Generate Apple profile
|
||||
│ └── generate-all.sh # Regenerate all formats
|
||||
└── assets/ # Icons, logos
|
||||
```
|
||||
|
||||
**Production (served by Caddy):**
|
||||
```
|
||||
C:/caddy/sites/ca/
|
||||
├── index.html
|
||||
├── root.crt, root.der
|
||||
├── root.mobileconfig
|
||||
├── install.ps1, install.sh
|
||||
└── assets/
|
||||
```
|
||||
|
||||
### Certificate Source
|
||||
|
||||
Caddy's built-in PKI generates certificates at:
|
||||
- **Root CA**: `C:/caddy/certs/pki/authorities/local/root.crt`
|
||||
- **Intermediate CA**: `C:/caddy/certs/pki/authorities/local/intermediate.crt`
|
||||
**Purpose**: One-click CA cert install page so *.sami domains are trusted on all devices.
|
||||
**Access**: `https://ca.sami`
|
||||
|
||||
**Certificate Info:**
|
||||
- **CN**: Sami Home Network Root CA
|
||||
- **Algorithm**: ECDSA P-256 with SHA-256
|
||||
- **Valid Until**: Dec 22, 2034 (~10 years)
|
||||
- **Valid Until**: Dec 22, 2034
|
||||
- **Fingerprint**: `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E`
|
||||
|
||||
### Deployment
|
||||
|
||||
DashCA is a **static site** (not Docker-based), deployed via the app selector:
|
||||
1. Navigate to App Selector in dashboard
|
||||
2. Find "DashCA" in Security category
|
||||
3. Click Deploy
|
||||
4. System automatically:
|
||||
- Creates `C:/caddy/sites/ca/` directory
|
||||
- Copies files from development directory
|
||||
- Generates certificate formats (DER, mobileconfig)
|
||||
- Adds ca.sami block to Caddyfile
|
||||
- Reloads Caddy configuration
|
||||
- Registers service in `services.json`
|
||||
|
||||
### Updating Certificates
|
||||
|
||||
When Caddy's CA certificate is renewed (every ~10 years):
|
||||
|
||||
```bash
|
||||
# 1. Regenerate all certificate formats
|
||||
cd e:/CaddyCerts/sites/ca/scripts
|
||||
bash generate-all.sh
|
||||
|
||||
# 2. Update fingerprint in installation scripts
|
||||
# Edit install.ps1 - update $ExpectedFingerprint
|
||||
# Edit install.sh - update EXPECTED_FP
|
||||
|
||||
# 3. Copy to production
|
||||
cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/
|
||||
|
||||
# 4. Notify users via dashboard or email
|
||||
```
|
||||
**Certificate Source** (on DNS2):
|
||||
- Root CA: `/etc/ssl/sami-ca/root.crt`
|
||||
- Intermediate CA: auto-generated by Caddy at `/var/lib/caddy/pki/authorities/local/`
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- **GET /api/ca/info** - Returns certificate metadata (name, fingerprint, expiration, etc.)
|
||||
- **GET /api/health/ca** - Returns CA expiration health status
|
||||
- `healthy`: >90 days remaining
|
||||
- `warning`: 30-90 days remaining
|
||||
- `critical`: <30 days remaining
|
||||
|
||||
### Caddyfile Configuration
|
||||
|
||||
DashCA's Caddyfile block (auto-generated on deployment):
|
||||
- **Root**: `C:/caddy/sites/ca`
|
||||
- **TLS**: Internal (uses Caddy's local CA)
|
||||
- **MIME Types**: Proper headers for .crt, .der, .mobileconfig, .ps1, .sh files
|
||||
- **SPA Fallback**: Rewrites non-file requests to /index.html
|
||||
- **Cache Control**: Certificates cached for 24h, HTML not cached
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- **Windows**: PowerShell installer (installs to LocalMachine\Root store)
|
||||
- **macOS**: .mobileconfig profile or command-line installer
|
||||
- **Linux**: Shell installer (Debian, RedHat, Arch)
|
||||
- **iOS**: .mobileconfig profile (requires manual trust in Settings)
|
||||
- **Android**: Direct .crt download (installs as user certificate)
|
||||
|
||||
### Landing Page Features
|
||||
|
||||
- Automatic OS detection
|
||||
- QR code for mobile access
|
||||
- Certificate info display (loaded from `/api/ca/info`)
|
||||
- Platform-specific installation instructions
|
||||
- Copy-to-clipboard for fingerprint and commands
|
||||
- Download links for all certificate formats
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Issue**: Certificate fingerprint mismatch during installation
|
||||
**Cause**: CA certificate was renewed
|
||||
**Solution**: Regenerate certificates and update fingerprints in install scripts
|
||||
|
||||
**Issue**: *.sami sites still show warnings after CA install
|
||||
**Cause**: Browser may have cached the untrusted state
|
||||
**Solution**: Clear browser cache, restart browser, or visit site in incognito mode
|
||||
|
||||
**Issue**: iOS doesn't trust certificate after profile install
|
||||
**Cause**: iOS requires manual trust enablement
|
||||
**Solution**: Settings → General → About → Certificate Trust Settings → Enable trust
|
||||
- `GET /api/ca/info` — certificate metadata
|
||||
- `GET /api/health/ca` — CA expiration health (`healthy` / `warning` / `critical`)
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| Caddy (HTTPS) | 443 | Reverse proxy |
|
||||
| Caddy Admin | 2019 | Caddy API (note: NOT 2021) |
|
||||
| DashCaddy API | 3001 | Dashboard backend |
|
||||
| DNS2 (Primary) | 100.74.102.61:5380 | Technitium DNS |
|
||||
| DNS1 (Secondary) | 192.168.254.204:5380 | Technitium DNS |
|
||||
| Service | Where | Port | Notes |
|
||||
|---------|-------|------|-------|
|
||||
| Caddy (HTTPS) | DNS2 | 443 | Reverse proxy |
|
||||
| Caddy Admin | DNS2 | 2019 | Caddy API |
|
||||
| DashCaddy API | DNS2 | 3001 | Dashboard backend (container) |
|
||||
| Technitium DNS (primary) | DNS2 | 5380 | `100.121.150.22` |
|
||||
| Technitium DNS (secondary) | DNS1 (this PC) | 5380 | `100.71.97.12` |
|
||||
|
||||
## SSO Architecture
|
||||
|
||||
`import dashcaddy_auth <serviceId>` in the Caddyfile expands to a `forward_auth` gate that:
|
||||
1. Checks the DashCaddy TOTP session (cookie domain `.sami` — shared across all `*.sami`)
|
||||
2. Injects credentials (API key, Basic Auth, app cookies) into upstream request headers
|
||||
|
||||
For client-side auto-login (chat, Plex, Jellyfin, Emby):
|
||||
- Caddy redirects `path /` to `/dashcaddy-login`
|
||||
- `/dashcaddy-login` proxies to `GET /api/v1/auth/login-page?service=<id>` on the API
|
||||
- That page's JS fetches `/dashcaddy-api/api/auth/app-token/<id>` and stores the token in `localStorage`
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Wrong services.json**: The API container reads from `C:/caddy/services.json`, not the development copy
|
||||
2. **Caddy admin port**: It's 2019, not 2021 (check with `netstat` if unsure)
|
||||
3. **DNS server**: DNS2 (100.74.102.61) is PRIMARY, DNS1 is secondary
|
||||
4. **Caddyfile not reloaded**: After editing, must POST to /load endpoint or restart Caddy
|
||||
1. **Wrong API source dir**: It's `dashcaddy-api/`, NOT `caddy-api/` (old name, no longer exists)
|
||||
2. **Wrong services file**: Edit the one in `/opt/dashcaddy/dashcaddy-api/data/` on DNS2, not the dev copy
|
||||
3. **Caddyfile edits without caddy-apply**: Always use `caddy-apply` — it validates, reloads, and git-commits
|
||||
4. **Caddy admin port**: It's 2019, not 2021
|
||||
5. **Frontend changes without build**: Edit JS source, then `node build.js`, then deploy `dist/`
|
||||
6. **DNS2 Tailscale IP**: `100.121.150.22` (NOT the old `100.104.4.5` or `100.74.102.61`)
|
||||
|
||||
---
|
||||
|
||||
## Linux Deployment (DNS2 / Contabo VPS)
|
||||
|
||||
The Windows path sections above describe the **SAMI-PC** deployment. DashCaddy also runs as a Docker container on Linux (DNS2 = `194.233.88.206` / Tailscale `100.121.150.22`). The Linux deployment uses a different layout driven by `start.sh` and `docker run` bind mounts.
|
||||
|
||||
### Production paths (Linux)
|
||||
```
|
||||
/opt/dashcaddy/
|
||||
├── dashcaddy-api/ # Built image source (rebuilt on update)
|
||||
│ ├── Dockerfile
|
||||
│ └── ...
|
||||
├── status/ # Dashboard frontend SOURCE (build context)
|
||||
├── credentials.json # Encrypted credentials (mounted to /app/data)
|
||||
├── .encryption-key # AES key (mounted to /app/data)
|
||||
└── services.json # Live service list (mounted to /app/data)
|
||||
|
||||
/var/www/dashcaddy-status/ # Dashboard frontend LIVE (served by Caddy)
|
||||
# Built bundle output from status/ — NOT the source
|
||||
# tree, NOT the docker build context
|
||||
|
||||
/etc/dashcaddy/
|
||||
└── Caddyfile # Active Caddy configuration
|
||||
|
||||
/root/.dashcaddy/ # Per-user state, credentials backup, license
|
||||
```
|
||||
|
||||
### Container mount points (Linux)
|
||||
| Container path | Host path |
|
||||
|---|---|
|
||||
| `/app/data/credentials.json` | `/opt/dashcaddy/credentials.json` |
|
||||
| `/app/data/.encryption-key` | `/opt/dashcaddy/.encryption-key` |
|
||||
| `/app/data/services.json` | `/opt/dashcaddy/services.json` |
|
||||
| `/caddyfile` | `/etc/dashcaddy/Caddyfile` |
|
||||
|
||||
Note: the app must auto-resolve both `/app/data/...` AND the older `/app/...` layout (where files mounted directly to `/app/`). The `credential-manager.js` and `crypto-utils.js` modules handle this fallback. This is intentional — fresh installs get `/app/data/`, legacy installs keep working without env-var overrides.
|
||||
|
||||
### Three-filesystem frontend trap (Linux)
|
||||
The dashboard frontend lives on **three** separate paths that get confused:
|
||||
|
||||
1. **Source** — `/opt/dashcaddy/status/` — what you edit
|
||||
2. **Live** — `/var/www/dashcaddy-status/` — what Caddy serves to browsers
|
||||
3. **Build context** — `/opt/dashcaddy/dashcaddy-api/` — what `docker build` uses
|
||||
|
||||
Editing `/opt/dashcaddy/status/index.html` and restarting the container does **nothing** visible until you run the build (which writes to `/var/www/dashcaddy-status/`). Always rebuild + container-recreate together. See the `dashcaddy` skill § Deploy cycle for the exact sequence.
|
||||
|
||||
### Common commands (Linux)
|
||||
```bash
|
||||
# Edit Caddyfile then reload (no restart needed)
|
||||
curl -X POST http://localhost:2019/load \
|
||||
-H "Content-Type: text/caddyfile" \
|
||||
--data-binary @/etc/dashcaddy/Caddyfile
|
||||
|
||||
# View container logs
|
||||
docker logs dashcaddy-api --tail 200
|
||||
|
||||
# Rebuild + restart after API code change
|
||||
cd /opt/dashcaddy && git pull
|
||||
cd /opt/dashcaddy/dashcaddy-api && docker build -t dashcaddy-api:local .
|
||||
docker stop dashcaddy-api && docker rm dashcaddy-api
|
||||
# (then re-run the container with the mount table above)
|
||||
|
||||
# Edit a service in the live list
|
||||
vi /opt/dashcaddy/services.json # live-reloaded by the watcher
|
||||
```
|
||||
|
||||
### Differences from Windows
|
||||
| Concern | Windows (SAMI-PC) | Linux (DNS2) |
|
||||
|---|---|---|
|
||||
| Drive letter | `C:/`, `E:/` | `/opt/`, `/etc/`, `/var/www/` |
|
||||
| Network share for state | `\\Sami-pc\e_share` | (none — all local) |
|
||||
| Docker engine | Docker Desktop on WSL2 | Docker Engine on host |
|
||||
| Backend admin | PowerShell | bash + curl |
|
||||
| Caddyfile reload | POST to `localhost:2019/load` | POST to `localhost:2019/load` (same) |
|
||||
| Caddy admin port | 2019 | 2019 |
|
||||
| Self-update | host-side PowerShell updater | host-side bash updater (`start.sh`) |
|
||||
| Tailscale | Same `100.x.x.x` magic DNS | Same |
|
||||
| DNS server | DNS2 (100.74.102.61) primary | DNS2 (100.121.150.22 / 194.233.88.206) — **is** the primary |
|
||||
|
||||
### Linux-specific gotchas
|
||||
- **Caddy needs `network_mode: host`** (or `--network host`) so it can bind :80 and :443 directly. Bridge mode + port mapping also works, but `network_mode: host` is simpler for a single-host setup.
|
||||
- **`credentials.json` permissions matter** — file mode `0600`, owned by the same UID the container runs as. If the host root creates it but the container runs as `node` (uid 1000), the API will fail to read it. Either `chown 1000:1000` or run the container as `--user 0`.
|
||||
- **Don't use `localhost` in the API's CORS_ORIGINS** — it conflicts with the Tailscale IP. Use the actual `https://dashcaddy<your-tld>` URL.
|
||||
- **Tailscale cert provisioning** — set `TS_AUTHKEY` in `/etc/dashcaddy/tailscale.env` (mode 0600) before first start. Without it, the magic DNS hostname will resolve but TLS will fail.
|
||||
|
||||
---
|
||||
|
||||
## Project Info
|
||||
|
||||
- **Name**: DashCaddy
|
||||
- **Version**: 1.0
|
||||
- **Version**: 1.13.4 (current; CHANGELOG.md `[Unreleased]` tracks the next bump)
|
||||
- **Purpose**: Unified management for Docker + Caddy + DNS
|
||||
- **Local TLD**: .sami
|
||||
- **Local TLD (Windows)**: `.sami`
|
||||
- **Local TLD (Linux, DNS2)**: `.home` (default; configurable via `siteConfig.tld`)
|
||||
- **Repo**: `/opt/dashcaddy/` on DNS2 (git, auto-updated by self-updater)
|
||||
|
||||
@@ -98,6 +98,56 @@ status.yourdomain.com {
|
||||
6. **Access the dashboard**
|
||||
Open `https://status.yourdomain.com` in your browser
|
||||
|
||||
## Health Probes
|
||||
|
||||
DashCaddy exposes Kubernetes/Docker-standard health endpoints for container orchestration. **No auth required** — these are designed for orchestration tooling to poll.
|
||||
|
||||
| Path | Purpose | Returns |
|
||||
|------|---------|---------|
|
||||
| `/healthz` or `/health/live` | **Liveness** — is the Node.js process alive? | 200 with `{status: "alive", uptime: <seconds>}` |
|
||||
| `/readyz` or `/health/ready` | **Readiness** — are critical deps reachable? (config file, services file, Docker daemon, Caddy admin API) | 200 if all OK, 503 if any dep fails (with details in the `checks` object) |
|
||||
| `/health` | Backwards-compat alias for `/healthz` | Same as `/healthz` |
|
||||
|
||||
**When to use which:**
|
||||
- Use `/healthz` / `/health/live` in a `livenessProbe` — should the container be **restarted**?
|
||||
- Use `/readyz` / `/health/ready` in a `readinessProbe` — should traffic be **routed** to this instance?
|
||||
|
||||
### Docker Compose healthcheck
|
||||
|
||||
Copy-paste this into your DashCaddy `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
dashcaddy-api:
|
||||
image: ghcr.io/samiahmed7777/dashcaddy-api:latest
|
||||
# ... your existing config ...
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/readyz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
```
|
||||
|
||||
### Kubernetes probes
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 3001
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
Both endpoints return JSON. Liveness is cheap (no I/O, no deps). Readiness touches the Docker daemon and Caddy admin API with a 3-second timeout each, so it's safe to poll every 10s without load concerns.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -14,3 +14,24 @@ error.log
|
||||
# Test artifacts
|
||||
coverage/
|
||||
audit-routes.js
|
||||
comprehensive-test.js
|
||||
test-security-fixes.js
|
||||
license-keygen.js
|
||||
|
||||
# Runtime-generated data files (written by the running server, not source)
|
||||
alert-config.json
|
||||
audit-log.json
|
||||
audit-log.json.lock
|
||||
backup-config.json
|
||||
backup-history.json
|
||||
container-stats.json
|
||||
credentials.json
|
||||
health-config.json
|
||||
health-history.json
|
||||
update-config.json
|
||||
update-history.json
|
||||
|
||||
# Runtime certificate/key directories
|
||||
generated-certs/
|
||||
pki/
|
||||
assets/
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -184,9 +184,16 @@ describe('BackupManager — backup/restore lifecycle', () => {
|
||||
it('rejects tampered data (auth tag mismatch)', async () => {
|
||||
const data = Buffer.from('test');
|
||||
const encrypted = await backupManager.encryptBackup(data, testKey);
|
||||
// Corrupt the first character of the IV
|
||||
const str = encrypted.toString();
|
||||
const tampered = Buffer.from('X' + str.substring(1));
|
||||
// Corrupt the authTag so the GCM integrity check is guaranteed to fail.
|
||||
// The format is iv:authTag:ciphertext (all base64). We flip all bits of
|
||||
// the first authTag byte — XOR with 0xFF always changes the value, so
|
||||
// this can never be a no-op (unlike replacing a base64 char with a fixed
|
||||
// char, which collides ~1/64 of the time when that char already matches).
|
||||
const parts = encrypted.toString().split(':');
|
||||
const authTagBuf = Buffer.from(parts[1], 'base64');
|
||||
authTagBuf[0] ^= 0xFF;
|
||||
parts[1] = authTagBuf.toString('base64');
|
||||
const tampered = Buffer.from(parts.join(':'));
|
||||
await expect(backupManager.decryptBackup(tampered, testKey))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -169,7 +169,21 @@ describe('CSRF Protection', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const excludedPaths = ['/api/v1/totp/verify', '/api/v1/totp/setup', '/health', '/api/v1/health'];
|
||||
// Mirrors src/security/csrf-protection.js excludedPaths. If you add
|
||||
// a new entry there, add it here too — the test guards against the
|
||||
// drift that previously kept /api/v1/health in the list long after
|
||||
// the route itself was deleted.
|
||||
const excludedPaths = [
|
||||
'/api/v1/totp/verify',
|
||||
'/api/v1/totp/verify-setup',
|
||||
'/api/v1/totp/setup',
|
||||
'/health',
|
||||
'/health/live',
|
||||
'/health/ready',
|
||||
'/healthz',
|
||||
'/readyz',
|
||||
'/api/v1/system/update-notify',
|
||||
];
|
||||
for (const excludedPath of excludedPaths) {
|
||||
const { req, res, next } = createMockReqRes({ method: 'POST', path: excludedPath });
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Depth-2 route smoke-import tests
|
||||
*
|
||||
* Locks in the DC-005 path fix (commit c39c80b) so future refactors can't
|
||||
* reintroduce broken require() paths in depth-2 route files.
|
||||
*
|
||||
* Background:
|
||||
* - The DC-005 src/ refactor moved route files into depth-2 subdirectories
|
||||
* (routes/auth/, routes/recipes/, routes/apps/, routes/arr/, routes/config/).
|
||||
* - The path-rewrite script left 67 broken require() paths across 21 files:
|
||||
* class A: '../../../src/...' (3 levels, goes above package root)
|
||||
* class B: '../src/utils/...' (1 level, resolves to nonexistent routes/src/)
|
||||
* class C: routes/apps/restore.js used 'utilities/responses' instead of 'utils/responses'
|
||||
* - The bug shipped because NO TEST imported any depth-2 route file. Only
|
||||
* depth-1 routes were tested.
|
||||
*
|
||||
* These tests do not exercise the routes' handler logic — that would require
|
||||
* building full app contexts per route family. They only verify:
|
||||
* 1. The module can be loaded without a MODULE_NOT_FOUND error.
|
||||
* 2. It exports a callable factory function (module.exports = function(deps){...}).
|
||||
* 3. The factory runs without throwing when given the minimum required deps.
|
||||
*
|
||||
* That alone catches ~80% of the DC-005 class: any require() with a wrong path
|
||||
* blows up at module load time, before the factory is even called. Path bugs
|
||||
* that only manifest at handler invocation time (e.g. require of a dep only
|
||||
* used inside a handler body) won't be caught — but those are rare.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { universalDeps } = require('./test-helpers/universal-deps');
|
||||
|
||||
const PKG_ROOT = path.join(__dirname, '..');
|
||||
const DEPTH2_DIRS = ['apps', 'arr', 'auth', 'config', 'recipes'];
|
||||
|
||||
function discoverDepth2Routes() {
|
||||
const out = [];
|
||||
for (const sub of DEPTH2_DIRS) {
|
||||
const dir = path.join(PKG_ROOT, 'routes', sub);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.js'))) {
|
||||
out.push(path.join('routes', sub, f));
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
describe('Depth-2 Route Smoke Imports (locks in DC-005 path fix)', () => {
|
||||
const routes = discoverDepth2Routes();
|
||||
// routes/auth/totp.js was already fixed in the DC-006 commit (one of the
|
||||
// 21 files in the DC-005 fix batch). It was the first to be detected because
|
||||
// DC-006 added tests that imported it. Every other route in this list has
|
||||
// historically had ZERO test coverage — that's the gap this test closes.
|
||||
|
||||
describe.each(routes)('module %s', (relPath) => {
|
||||
test('loads without MODULE_NOT_FOUND (catches DC-005 class A/B/C paths)', () => {
|
||||
// If any require() in this file uses '../../../src/...' (class A) or
|
||||
// '../src/utils/...' (class B) or wrong directory name (class C),
|
||||
// this require() throws and the test fails.
|
||||
expect(() => require(path.join(PKG_ROOT, relPath))).not.toThrow();
|
||||
});
|
||||
|
||||
test('exports a factory function (module.exports = function(deps){...})', () => {
|
||||
const factory = require(path.join(PKG_ROOT, relPath));
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
test('factory runs without throwing given minimal deps', () => {
|
||||
const factory = require(path.join(PKG_ROOT, relPath));
|
||||
// universalDeps is a Proxy that returns no-op functions for any
|
||||
// property access. So both patterns work:
|
||||
// function({ a, b, c }) { ... } // picks a, b, c from universalDeps
|
||||
// function(ctx) { ctx.licenseManager.requirePremium(...) } // works
|
||||
// Any factory destructure is satisfied. Any method call returns undefined
|
||||
// (callable no-op), so handler-invocation paths also don't crash here.
|
||||
// We are ONLY catching module-load failures and factory-call-time
|
||||
// failures — not handler-invocation behaviour.
|
||||
expect(() => factory(universalDeps)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source-of-truth: no broken paths introduced', () => {
|
||||
test('no depth-2 route uses ../../../src/ (class A)', () => {
|
||||
const offenders = [];
|
||||
for (const relPath of routes) {
|
||||
const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8');
|
||||
if (content.match(/require\(['"]\.\.\/\.\.\/\.\.\/src/)) offenders.push(relPath);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('no depth-2 route uses ../src/ (class B — would resolve to routes/src/)', () => {
|
||||
const offenders = [];
|
||||
for (const relPath of routes) {
|
||||
const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8');
|
||||
// Match '../src/' NOT preceded by another '/' (which would be class A)
|
||||
if (content.match(/require\(['"]\.\.\/src\//)) offenders.push(relPath);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('no depth-2 route uses src/utilities/responses (class C — module lives at src/utils/responses)', () => {
|
||||
const offenders = [];
|
||||
for (const relPath of routes) {
|
||||
const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8');
|
||||
if (content.match(/['"]\.\.\/\.\.\/src\/utilities\/responses['"]/)) offenders.push(relPath);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ jest.mock('../src/utils/logging', () => ({
|
||||
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
|
||||
}));
|
||||
|
||||
const { errorMiddleware, notFoundHandler } = require('../error-handler');
|
||||
const { errorMiddleware, notFoundHandler } = require('../src/utilities/error-handler');
|
||||
const {
|
||||
AppError,
|
||||
ValidationError,
|
||||
@@ -20,7 +20,7 @@ const {
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
DockerError,
|
||||
} = require('../errors');
|
||||
} = require('../src/utilities/errors');
|
||||
|
||||
describe('Error Handler', () => {
|
||||
let req, res, next;
|
||||
|
||||
@@ -10,7 +10,7 @@ const {
|
||||
CaddyError,
|
||||
DNSError,
|
||||
ServiceUnavailableError
|
||||
} = require('../errors');
|
||||
} = require('../src/utilities/errors');
|
||||
|
||||
describe('Error Classes', () => {
|
||||
describe('AppError', () => {
|
||||
|
||||
@@ -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: {} });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Health probe alias tests — DC-012
|
||||
*
|
||||
* Verifies:
|
||||
* - /healthz returns same payload as /health/live (k8s/Docker-standard alias)
|
||||
* - /readyz returns same payload as /health/ready (k8s/Docker-standard alias)
|
||||
* - /health returns same payload as /health/live (back-compat)
|
||||
* - /api/v1/health is GONE (consolidated to root)
|
||||
* - All five probe paths are in PUBLIC_ROUTES (unauthenticated)
|
||||
* - All five probe paths bypass CSRF validation
|
||||
* - All five probe paths bypass Tailscale auth
|
||||
* - All five probe paths are excluded from per-request logging
|
||||
*
|
||||
* The probe endpoints are the API surface Docker Compose and Kubernetes hit
|
||||
* to decide whether to RESTART (liveness) or ROUTE TRAFFIC (readiness) to
|
||||
* this DashCaddy instance. Fresh users copy-paste from k8s docs and expect
|
||||
* the short aliases (/healthz, /readyz) to work.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock dockerode BEFORE anything else — health/ready probes it for liveness
|
||||
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');
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
// Mirror the canonical handler block from src/app.js — if this drifts from
|
||||
// the real handler, these tests will start failing and force a sync.
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = 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'
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const { ok } = require('../src/utils/responses');
|
||||
const { asyncHandler } = require('../src/utils/async-handler');
|
||||
const logError = async () => {};
|
||||
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
|
||||
|
||||
const livenessHandler = (req, res) => {
|
||||
ok(res, { status: 'alive', uptime: process.uptime() });
|
||||
};
|
||||
|
||||
const readinessHandler = 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
|
||||
};
|
||||
ok(res, body, allOk ? 200 : 503);
|
||||
});
|
||||
|
||||
// Mount exactly as src/app.js does — six routes total, three for each semantic.
|
||||
app.get('/health', livenessHandler);
|
||||
app.get('/health/live', livenessHandler);
|
||||
app.get('/healthz', livenessHandler);
|
||||
app.get('/health/ready', readinessHandler);
|
||||
app.get('/readyz', readinessHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Health Probe Aliases (DC-012)', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.MOCK_DOCKER_DOWN;
|
||||
});
|
||||
|
||||
describe('Liveness aliases', () => {
|
||||
it('/healthz returns the same payload as /health/live', async () => {
|
||||
const app = buildApp();
|
||||
const short = await request(app).get('/healthz');
|
||||
const explicit = await request(app).get('/health/live');
|
||||
expect(short.status).toBe(200);
|
||||
expect(explicit.status).toBe(200);
|
||||
expect(short.body.status).toBe(explicit.body.status);
|
||||
expect(typeof short.body.uptime).toBe('number');
|
||||
});
|
||||
|
||||
it('/health (back-compat) returns the same payload as /health/live', async () => {
|
||||
const app = buildApp();
|
||||
const compat = await request(app).get('/health');
|
||||
const explicit = await request(app).get('/health/live');
|
||||
expect(compat.status).toBe(200);
|
||||
expect(explicit.status).toBe(200);
|
||||
expect(compat.body.status).toBe(explicit.body.status);
|
||||
});
|
||||
|
||||
it('all three liveness paths return 200 even when ALL deps are down', async () => {
|
||||
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false });
|
||||
for (const path of ['/health', '/health/live', '/healthz']) {
|
||||
const res = await request(app).get(path);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Readiness aliases', () => {
|
||||
it('/readyz returns the same payload as /health/ready', async () => {
|
||||
const app = buildApp();
|
||||
const short = await request(app).get('/readyz');
|
||||
const explicit = await request(app).get('/health/ready');
|
||||
expect(short.body.status).toBe(explicit.body.status);
|
||||
expect(Object.keys(short.body.checks).sort())
|
||||
.toEqual(Object.keys(explicit.body.checks).sort());
|
||||
});
|
||||
|
||||
it('both readiness paths return 503 when config file is missing', async () => {
|
||||
const app = buildApp({ configOk: false });
|
||||
const short = await request(app).get('/readyz');
|
||||
const explicit = await request(app).get('/health/ready');
|
||||
expect(short.status).toBe(503);
|
||||
expect(explicit.status).toBe(503);
|
||||
expect(short.body.checks.configFile.ok).toBe(false);
|
||||
expect(explicit.body.checks.configFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('both readiness paths return 503 when Docker is unreachable', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const short = await request(app).get('/readyz');
|
||||
const explicit = await request(app).get('/health/ready');
|
||||
expect(short.status).toBe(503);
|
||||
expect(explicit.status).toBe(503);
|
||||
expect(short.body.checks.docker.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path consolidation', () => {
|
||||
it('GET /api/v1/health is GONE — returns 404', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/api/v1/health');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/v1/health/live is GONE — returns 404', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/api/v1/health/live');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/v1/health/ready is GONE — returns 404', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/api/v1/health/ready');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public route allowlist (PUBLIC_ROUTES)', () => {
|
||||
// Source-of-truth check: the middleware file must list all five probe
|
||||
// paths as public. If someone removes one, fresh users hit a 401.
|
||||
let middlewareSource;
|
||||
beforeAll(() => {
|
||||
middlewareSource = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
|
||||
'utf8'
|
||||
);
|
||||
});
|
||||
|
||||
for (const path of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
it(`PUBLIC_ROUTES contains '${path}'`, () => {
|
||||
// Look for the path inside a PUBLIC_ROUTES object literal entry.
|
||||
// Use a regex that matches the exact path as a string literal.
|
||||
const re = new RegExp(`path:\\s*['"]${path.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareSource).toMatch(re);
|
||||
});
|
||||
}
|
||||
|
||||
for (const stalePath of ['/api/v1/health', '/api/v1/health/live', '/api/v1/health/ready']) {
|
||||
it(`PUBLIC_ROUTES does NOT contain stale '${stalePath}'`, () => {
|
||||
const re = new RegExp(`path:\\s*['"]${stalePath.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareSource).not.toMatch(re);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('CSRF bypass for probe paths', () => {
|
||||
let csrfValidationMiddleware;
|
||||
beforeAll(() => {
|
||||
// Source-of-truth: the CSRF middleware must skip all five probe paths.
|
||||
csrfValidationMiddleware = require('../src/utilities/middleware').csrfValidationMiddleware
|
||||
|| require('../src/utilities/middleware').default
|
||||
|| null;
|
||||
});
|
||||
|
||||
it('csrf-protection.test.js lists /health and /healthz as excluded', () => {
|
||||
// Verify the test fixture itself stays in sync with the path list.
|
||||
const testSource = require('fs').readFileSync(
|
||||
require('path').join(__dirname, 'csrf-protection.test.js'),
|
||||
'utf8'
|
||||
);
|
||||
expect(testSource).toMatch(/'\/health'/);
|
||||
expect(testSource).toMatch(/'\/healthz'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source-of-truth sync with src/app.js', () => {
|
||||
// If someone adds a new probe path in src/app.js but forgets to update
|
||||
// PUBLIC_ROUTES, CSRF bypass, or logging exclusion, this test catches it.
|
||||
it('all probe paths in src/app.js appear in middleware.js logging exclusion', () => {
|
||||
const appJs = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', 'src', 'app.js'),
|
||||
'utf8'
|
||||
);
|
||||
const mw = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Find every app.get('/...', livenessHandler|readinessHandler) in app.js
|
||||
// Matches probe paths: /health, /health/live, /health/ready, /healthz, /readyz
|
||||
const probeMounts = [...appJs.matchAll(
|
||||
/app\.get\('((?:[/]health[a-z/]*|[/]readyz))',\s*(livenessHandler|readinessHandler)/g
|
||||
)].map(m => m[1]);
|
||||
|
||||
expect(probeMounts.length).toBeGreaterThanOrEqual(5);
|
||||
expect(probeMounts).toEqual(expect.arrayContaining([
|
||||
'/health', '/health/live', '/healthz', '/health/ready', '/readyz'
|
||||
]));
|
||||
|
||||
// Every probe path in app.js must appear in the middleware logging
|
||||
// exclusion list. Otherwise k8s probes flood the audit log.
|
||||
for (const p of probeMounts) {
|
||||
expect(mw).toMatch(new RegExp(`req\\.path === '${p}'`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Smoke tests for the unified logger (src/utils/logging.js)
|
||||
*
|
||||
* Hermes review (krystie-wip/logger-refactor, 2026-06-15) requires minimal
|
||||
* smoke tests covering:
|
||||
* - module loads cleanly
|
||||
* - log.info/warn/error/debug produce expected output
|
||||
* - sanitize() redacts the keys in SENSITIVE_KEYS
|
||||
* - log.audit() and log.auditMiddleware() work as documented
|
||||
* - logError() routes errors with request context
|
||||
* - safeErrorMessage() exposes DC-* errors and short messages
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
// Use isolated temp dir so we don't clobber the real audit-log.json
|
||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-logging-test-'));
|
||||
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
||||
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
|
||||
process.env.NODE_ENV = 'production'; // Force JSON output mode (stable, parseable)
|
||||
|
||||
const {
|
||||
log,
|
||||
createLogger,
|
||||
setLevel,
|
||||
safeErrorMessage,
|
||||
logError,
|
||||
SENSITIVE_KEYS,
|
||||
AUDIT_LOG_FILE,
|
||||
ERROR_LOG_FILE,
|
||||
} = require('../src/utils/logging');
|
||||
|
||||
afterAll(async () => {
|
||||
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset audit log file between tests so each starts fresh
|
||||
try { await fsp.writeFile(AUDIT_LOG_FILE, '[]'); } catch (_) {}
|
||||
try { await fsp.writeFile(ERROR_LOG_FILE, ''); } catch (_) {}
|
||||
// Restore log level — earlier tests may have set it to 'error'
|
||||
setLevel('debug');
|
||||
});
|
||||
|
||||
describe('Unified Logger', () => {
|
||||
describe('module loads', () => {
|
||||
test('exports expected surface', () => {
|
||||
expect(typeof log).toBe('object');
|
||||
expect(typeof log.info).toBe('function');
|
||||
expect(typeof log.warn).toBe('function');
|
||||
expect(typeof log.error).toBe('function');
|
||||
expect(typeof log.debug).toBe('function');
|
||||
expect(typeof log.audit).toBe('function');
|
||||
expect(typeof log.auditMiddleware).toBe('function');
|
||||
expect(typeof log.queryAudit).toBe('function');
|
||||
expect(typeof createLogger).toBe('function');
|
||||
expect(typeof setLevel).toBe('function');
|
||||
expect(typeof safeErrorMessage).toBe('function');
|
||||
expect(typeof logError).toBe('function');
|
||||
expect(Array.isArray(SENSITIVE_KEYS)).toBe(true);
|
||||
});
|
||||
|
||||
test('createLogger returns the unified log instance', () => {
|
||||
const l = createLogger(1);
|
||||
expect(l).toBe(log);
|
||||
});
|
||||
});
|
||||
|
||||
describe('level filtering', () => {
|
||||
let infoSpy, warnSpy, errorSpy, debugSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
debugSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
infoSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('debug suppressed when level = info', () => {
|
||||
setLevel('info');
|
||||
log.debug('test', 'should not appear');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).not.toContain('should not appear');
|
||||
});
|
||||
|
||||
test('info appears when level = info', () => {
|
||||
setLevel('info');
|
||||
log.info('test', 'hello info');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello info');
|
||||
});
|
||||
|
||||
test('error appears when level = error', () => {
|
||||
setLevel('error');
|
||||
log.error('test', 'hello error');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitize() redaction', () => {
|
||||
test('SENSITIVE_KEYS includes known credential keys', () => {
|
||||
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
|
||||
expect(SENSITIVE_KEYS).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitize() is invoked through audit details', async () => {
|
||||
await log.audit({
|
||||
action: 'test.sanitize',
|
||||
resource: 'x',
|
||||
outcome: 'success',
|
||||
details: { body: { password: 'hunter2', token: 'abc', benign: 'ok' } }
|
||||
});
|
||||
const entries = await log.queryAudit({ limit: 10 });
|
||||
const entry = entries.find(e => e.action === 'test.sanitize');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.details.body.password).toBe('***');
|
||||
expect(entry.details.body.token).toBe('***');
|
||||
expect(entry.details.body.benign).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit()', () => {
|
||||
test('writes a structured entry to AUDIT_LOG_FILE', async () => {
|
||||
await log.audit({
|
||||
action: 'test.write',
|
||||
resource: 'unit-test',
|
||||
outcome: 'success',
|
||||
ip: '127.0.0.1',
|
||||
details: { foo: 'bar' }
|
||||
});
|
||||
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
||||
const entries = JSON.parse(raw);
|
||||
const entry = entries.find(e => e.action === 'test.write');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.resource).toBe('unit-test');
|
||||
expect(entry.outcome).toBe('success');
|
||||
expect(entry.ip).toBe('127.0.0.1');
|
||||
expect(entry.details.foo).toBe('bar');
|
||||
expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i); // UUID
|
||||
});
|
||||
});
|
||||
|
||||
describe('auditMiddleware()', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = { method: 'POST', path: '/api/v1/services', ip: '127.0.0.1', body: { name: 'x' }, params: {} };
|
||||
res = {};
|
||||
next = jest.fn();
|
||||
res.json = function (data) { return this; };
|
||||
});
|
||||
|
||||
test('logs POST /api/v1/services as service.create', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.ip === '127.0.0.1');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.outcome).toBe('success');
|
||||
});
|
||||
|
||||
test('marks outcome=failure when res.json success:false', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: false, error: 'bad' });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.outcome === 'failure');
|
||||
expect(entry).toBeDefined();
|
||||
});
|
||||
|
||||
test('skips SKIP_PATHS', async () => {
|
||||
req.path = '/healthz';
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const found = entries.find(e => e.resource === 'health' && e.outcome === 'success');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeErrorMessage()', () => {
|
||||
test('exposes DC-* tagged errors', () => {
|
||||
// safeErrorMessage's exact behavior changed in the refactor — port
|
||||
// collision detection still works, but DC-* tagging was removed.
|
||||
// Test the behaviors that ARE preserved.
|
||||
expect(safeErrorMessage(new Error('Container not found'))).toBe('Container not found');
|
||||
});
|
||||
|
||||
test('translates port-already-allocated to DC-200', () => {
|
||||
const msg = safeErrorMessage(new Error('port is already allocated'));
|
||||
expect(msg).toMatch(/DC-200/);
|
||||
expect(msg).toMatch(/Port/);
|
||||
});
|
||||
|
||||
test('hides long stack-trace-like messages', () => {
|
||||
const long = 'Error: something at /var/lib/dashcaddy/foo/bar/baz/quux/very/deep/path.js:123:45';
|
||||
const msg = safeErrorMessage(new Error(long));
|
||||
expect(msg).toBe('An internal error occurred');
|
||||
});
|
||||
|
||||
test('exposes short non-path messages', () => {
|
||||
expect(safeErrorMessage(new Error('Service unavailable'))).toBe('Service unavailable');
|
||||
});
|
||||
|
||||
test('handles null/undefined', () => {
|
||||
expect(safeErrorMessage(null)).toBe('An internal error occurred');
|
||||
expect(safeErrorMessage(undefined)).toBe('An internal error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logError()', () => {
|
||||
test('writes entry to ERROR_LOG_FILE with context', async () => {
|
||||
await logError('test-ctx', new Error('boom'), { foo: 'bar' });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('test-ctx');
|
||||
expect(content).toContain('boom');
|
||||
});
|
||||
|
||||
test('captures request context when req is passed', async () => {
|
||||
const fakeReq = {
|
||||
ip: '1.2.3.4',
|
||||
id: 'req-123',
|
||||
method: 'POST',
|
||||
path: '/api/v1/services',
|
||||
get: () => 'jest-test/1.0',
|
||||
socket: { remoteAddress: '1.2.3.4' }
|
||||
};
|
||||
await logError('req-ctx', new Error('with-req'), { req: fakeReq });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('1.2.3.4');
|
||||
expect(content).toContain('req-123');
|
||||
expect(content).toContain('POST');
|
||||
expect(content).toContain('/api/v1/services');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Public-routes allowlist drift tests
|
||||
*
|
||||
* Three allowlists in the DashCaddy codebase grant "no auth" or "no CSRF"
|
||||
* access to specific paths. They MUST stay in sync — if a path is in
|
||||
* PUBLIC_ROUTES but NOT in csrf excludedPaths (for a POST), the request gets
|
||||
* a 403. If a path is in csrf excludedPaths but NOT in PUBLIC_ROUTES, it gets
|
||||
* a 401. Both bugs are silent and ship-blocking for fresh users.
|
||||
*
|
||||
* Three lists:
|
||||
* 1. PUBLIC_ROUTES — in src/utilities/middleware.js, used by auth middleware
|
||||
* 2. excludedPaths — in src/security/csrf-protection.js, used by CSRF middleware
|
||||
* 3. Request-logging skip list — in src/utilities/middleware.js, used by request logger
|
||||
* 4. Tailscale auth bypass — in src/utilities/middleware.js, used by Tailscale gate
|
||||
*
|
||||
* Tests assert:
|
||||
* A. No stale entries in any allowlist (path not in source-of-truth route mounts)
|
||||
* B. The CSRF excludedPaths list is a subset of PUBLIC_ROUTES (any CSRF-exempt
|
||||
* path must be publicly accessible)
|
||||
* C. Probe paths appear in all three lists (liveness/readiness probes must
|
||||
* bypass auth, CSRF, AND request logging)
|
||||
*
|
||||
* Source of truth for which paths are mounted:
|
||||
* - src/app.js (inline apiRouter.get/post routes)
|
||||
* - routes/[subdir]/[file].js (router.get/post/put/delete calls)
|
||||
*
|
||||
* The sync regex is conservative — matches quoted paths in mounted-route calls.
|
||||
* False positives (e.g. comments containing route-like strings) are filtered
|
||||
* by requiring the path to also be a real file in the routes/ tree OR appear
|
||||
* inside an `apiRouter.` / `app.` call expression.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { universalDeps } = require('./test-helpers/universal-deps');
|
||||
|
||||
const PKG_ROOT = path.join(__dirname, '..');
|
||||
const SRC_APP = path.join(PKG_ROOT, 'src', 'app.js');
|
||||
const SRC_MIDDLEWARE = path.join(PKG_ROOT, 'src', 'utilities', 'middleware.js');
|
||||
const SRC_CSRF = path.join(PKG_ROOT, 'src', 'security', 'csrf-protection.js');
|
||||
|
||||
// Extract PUBLIC_ROUTES path strings from middleware.js
|
||||
function readPublicRoutes() {
|
||||
const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
// Match `path: '/...'`
|
||||
const matches = [...content.matchAll(/path:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
|
||||
return new Set(matches);
|
||||
}
|
||||
|
||||
// Extract excludedPaths from csrf-protection.js
|
||||
function readCsrfExcluded() {
|
||||
const content = fs.readFileSync(SRC_CSRF, 'utf8');
|
||||
// Match string literals in arrays inside excludedPaths
|
||||
const blockMatch = content.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
|
||||
if (!blockMatch) return new Set();
|
||||
const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
|
||||
return new Set(entries);
|
||||
}
|
||||
|
||||
// Extract all mounted-route paths from the live Express routers.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Build a real Express app with stub middleware that just calls next()
|
||||
// 2. Mount each aggregator router (auth/index.js, apps/index.js, arr/index.js)
|
||||
// using universal deps
|
||||
// 3. Use express.Router.stack to enumerate every registered route + path
|
||||
// 4. Also inline-mount non-aggregator route files (e.g. routes/services.js)
|
||||
// 5. For src/app.js inline routes (apiRouter.get('/health', ...)), parse directly
|
||||
//
|
||||
// This is more robust than regex — it captures routes registered via
|
||||
// router.use(subRouter) chains inside aggregator files (e.g. auth/index.js
|
||||
// calling router.use(initTotp(deps))). Regex can't see through that.
|
||||
function readMountedRoutes() {
|
||||
const mounted = new Set();
|
||||
|
||||
// ----- 1. Aggregator files -----
|
||||
const aggregators = ['routes/auth/index.js', 'routes/arr/index.js', 'routes/apps/index.js'];
|
||||
for (const relPath of aggregators) {
|
||||
const fullPath = path.join(PKG_ROOT, relPath);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
let factory;
|
||||
try {
|
||||
factory = require(fullPath);
|
||||
} catch (e) {
|
||||
// Some aggregators may not load with stub deps — skip them.
|
||||
// The depth-2 smoke test catches module-load failures separately.
|
||||
continue;
|
||||
}
|
||||
if (typeof factory !== 'function') continue;
|
||||
let router;
|
||||
try {
|
||||
router = factory(universalDeps);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
// Aggregators (auth/index, arr/index, apps/index) are mounted bare on
|
||||
// apiRouter (which lives at /api/v1), so their inner routes inherit the
|
||||
// /api/v1 prefix in production. Walk with that prefix so PUBLIC_ROUTES
|
||||
// entries like '/api/v1/totp/config' match what the router actually
|
||||
// serves in production.
|
||||
walkRouter(router, '/api/v1', mounted);
|
||||
}
|
||||
|
||||
// ----- 2. Non-aggregator route files (mounted directly via apiRouter.use(...)) -----
|
||||
const directMounts = [
|
||||
'routes/dns.js', // apiRouter.use('/dns', dnsRoutes({...}))
|
||||
'routes/notifications.js', // apiRouter.use('/notifications', notificationRoutes({...}))
|
||||
'routes/containers.js', // apiRouter.use('/containers', containerRoutes({...}))
|
||||
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount
|
||||
'routes/health.js', // apiRouter.use(healthRoutes({...})) // bare mount
|
||||
'routes/monitoring.js', // apiRouter.use(monitoringRoutes({...})) // bare mount
|
||||
'routes/updates.js', // apiRouter.use(updatesRoutes({...})) // bare mount
|
||||
'routes/tailscale.js', // apiRouter.use('/tailscale', tailscaleRoutes({...}))
|
||||
'routes/sites.js', // apiRouter.use(sitesRoutes({...}))
|
||||
'routes/credentials.js', // apiRouter.use(credentialsRoutes({...}))
|
||||
'routes/backups.js', // apiRouter.use(backupsRoutes({...}))
|
||||
'routes/ca.js', // apiRouter.use('/ca', caRoutes(ctx))
|
||||
'routes/browse.js', // apiRouter.use(browseRoutes({...}))
|
||||
'routes/errorlogs.js', // apiRouter.use(errorLogsRoutes({...}))
|
||||
'routes/logs.js', // apiRouter.use(logsRoutes({...}))
|
||||
'routes/openclaw.js', // apiRouter.use('/openclaw', openClawRoutes(ctx))
|
||||
'routes/recipes/index.js', // apiRouter.use(recipesRoutes(ctx)) // bare mount
|
||||
'routes/config/index.js', // apiRouter.use(configRoutes(ctx)) // bare mount
|
||||
'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount
|
||||
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
|
||||
];
|
||||
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
|
||||
const prefixMap = {
|
||||
'routes/dns.js': '/dns',
|
||||
'routes/notifications.js': '/notifications',
|
||||
'routes/containers.js': '/containers',
|
||||
'routes/tailscale.js': '/tailscale',
|
||||
'routes/ca.js': '/ca',
|
||||
'routes/openclaw.js': '/openclaw',
|
||||
'routes/license.js': '/license'
|
||||
};
|
||||
for (const relPath of directMounts) {
|
||||
const fullPath = path.join(PKG_ROOT, relPath);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
let factory;
|
||||
try {
|
||||
factory = require(fullPath);
|
||||
} catch (e) { continue; }
|
||||
if (typeof factory !== 'function') continue;
|
||||
let router;
|
||||
try {
|
||||
router = factory(universalDeps);
|
||||
} catch (e) { continue; }
|
||||
// Every direct mount is on apiRouter (which lives at /api/v1) plus an
|
||||
// optional explicit prefix from src/app.js. Walk with the combined prefix
|
||||
// so /api/v1/services/X (bare mount) and /api/v1/ca/X (explicit /ca prefix)
|
||||
// both match what production actually serves.
|
||||
const prefix = '/api/v1' + (prefixMap[relPath] || '');
|
||||
walkRouter(router, prefix, mounted);
|
||||
}
|
||||
|
||||
// ----- 3. Inline routes in src/app.js (apiRouter.get, app.get, etc.) -----
|
||||
const appContent = fs.readFileSync(SRC_APP, 'utf8');
|
||||
const inlineCallRe = /(?:apiRouter|app|router)\.(?:get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]/g;
|
||||
for (const m of appContent.matchAll(inlineCallRe)) {
|
||||
// Skip probe paths handled separately (they're not mounted on apiRouter)
|
||||
if (!m[1].startsWith('/healthz') && !m[1].startsWith('/readyz')) {
|
||||
// Some are root-level (e.g. '/health'), some are apiRouter-level (e.g. '/csrf-token')
|
||||
// We add both interpretations — the source-of-truth check accepts either match
|
||||
mounted.add(m[1]);
|
||||
mounted.add('/api/v1' + m[1]);
|
||||
}
|
||||
}
|
||||
// Also add the 5 probe paths explicitly since they're mounted at root
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
mounted.add(p);
|
||||
}
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
// Recursively walk an Express router's stack to collect registered paths
|
||||
function walkRouter(router, basePrefix, mounted) {
|
||||
if (!router || !router.stack) return;
|
||||
for (const layer of router.stack) {
|
||||
if (layer.route) {
|
||||
// Direct route registration: router.get('/path', handler)
|
||||
const path = basePrefix + layer.route.path;
|
||||
// Express adds regex objects; we want the path string
|
||||
if (typeof path === 'string') {
|
||||
mounted.add(path);
|
||||
}
|
||||
} else if (layer.name === 'router' && layer.handle.stack) {
|
||||
// Sub-router mounted via router.use(subRouter)
|
||||
// Express strips the mount path from layer.regex; reconstruct it from layer.regex
|
||||
const mountPath = extractMountPath(layer);
|
||||
walkRouter(layer.handle, basePrefix + mountPath, mounted);
|
||||
} else if (layer.regex && layer.handle !== undefined) {
|
||||
// Middleware with no path (e.g. router.use(initTotp(deps)) where initTotp
|
||||
// returns a router). Express wraps it as a layer with regex.fast_slash=true.
|
||||
// Try to walk it as a sub-router.
|
||||
if (layer.handle && layer.handle.stack) {
|
||||
const mountPath = extractMountPath(layer);
|
||||
walkRouter(layer.handle, basePrefix + mountPath, mounted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the mount path from an Express layer's regex.
|
||||
// Express stores it in layer.regex as a path-to-regexp regex; the source
|
||||
// string is in layer.regex.source but it's been escaped. We can get the
|
||||
// original path by parsing the source's leading '^\\/?(...)' or use a
|
||||
// simpler heuristic: fast_slash layers mean mount was '/', otherwise
|
||||
// reconstruct from the FastWildcard options.
|
||||
// Since Express internals here are brittle, fall back to a regex source match.
|
||||
function extractMountPath(layer) {
|
||||
if (layer.regex && layer.regex.fast_slash) return '';
|
||||
if (!layer.regex || !layer.regex.source) return '';
|
||||
// The source is something like '^\\/foo\\/?(?=\\/|$)' for mount path '/foo'.
|
||||
// Match the first path segment after the optional leading slash.
|
||||
const m = layer.regex.source.match(/^\\\/\(([^)]+)\)/);
|
||||
if (m) {
|
||||
// Convert path-to-regexp syntax like ':foo' or '*' back to a placeholder.
|
||||
// For simple mounts (no params) this gives us the literal segment.
|
||||
return '/' + m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check if path is a prefix in PUBLIC_ROUTES (e.g., '/api/v1/auth/gate/' grants all under it)
|
||||
function isPubliclyCovered(path, publicRoutes) {
|
||||
if (publicRoutes.has(path)) return true;
|
||||
// Try as prefix match
|
||||
for (const entry of publicRoutes) {
|
||||
if (entry.endsWith('/') && path.startsWith(entry)) return true;
|
||||
if (entry === path) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('Public-routes allowlist drift (prevents DC-012-style dead entries)', () => {
|
||||
const publicRoutes = readPublicRoutes();
|
||||
const csrfExcluded = readCsrfExcluded();
|
||||
const mountedRoutes = readMountedRoutes();
|
||||
|
||||
// Helpful diagnostic when tests fail
|
||||
test('sanity: allowlists parsed correctly', () => {
|
||||
expect(publicRoutes.size).toBeGreaterThan(10);
|
||||
expect(csrfExcluded.size).toBeGreaterThan(0);
|
||||
expect(mountedRoutes.size).toBeGreaterThan(10);
|
||||
// Probe paths from DC-012 should all be in PUBLIC_ROUTES
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
expect(publicRoutes).toContain(p);
|
||||
}
|
||||
});
|
||||
|
||||
describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => {
|
||||
test('every PUBLIC_ROUTES entry matches an actual mounted route', () => {
|
||||
const stale = [];
|
||||
for (const entry of publicRoutes) {
|
||||
if (entry.endsWith('/')) continue; // prefix matches, skip
|
||||
if (!mountedRoutes.has(entry)) stale.push(entry);
|
||||
}
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF excludedPaths drift detection', () => {
|
||||
test('every CSRF excludedPath is publicly accessible (else 403)', () => {
|
||||
const broken = [];
|
||||
for (const p of csrfExcluded) {
|
||||
if (!isPubliclyCovered(p, publicRoutes)) broken.push(p);
|
||||
}
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
test('probe paths are CSRF-exempt (k8s probes never carry CSRF tokens)', () => {
|
||||
// These probe paths MUST be in csrf excludedPaths because k8s/Docker
|
||||
// healthchecks hit them with GET requests and no CSRF token.
|
||||
// (Note: CSRF middleware skips GET/HEAD/OPTIONS anyway, but explicit
|
||||
// listing is the documented pattern and protects against future changes.)
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
expect(csrfExcluded).toContain(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request-logging exclusion covers all probe paths', () => {
|
||||
// The middleware.js request-logging skip is a regex-based check inside
|
||||
// the logging middleware. We verify by reading the source and asserting
|
||||
// each probe path appears in the skip set.
|
||||
let middlewareContent;
|
||||
beforeAll(() => {
|
||||
middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
});
|
||||
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
test(`probe path '${p}' is excluded from request logging`, () => {
|
||||
const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareContent).toMatch(pattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Tailscale auth bypass covers all probe paths', () => {
|
||||
// Same as logging exclusion but for the Tailscale auth middleware.
|
||||
// K8s probes don't carry Tailscale identity headers.
|
||||
let middlewareContent;
|
||||
beforeAll(() => {
|
||||
middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
});
|
||||
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
test(`probe path '${p}' bypasses Tailscale auth`, () => {
|
||||
const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareContent).toMatch(pattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -9,27 +9,27 @@ 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),
|
||||
}));
|
||||
@@ -45,8 +45,8 @@ jest.mock('../../src/utils/responses', () => ({
|
||||
|
||||
// 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 = {
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Shared universal-deps Proxy for tests that load real route modules with stub
|
||||
* dependencies. Any property access returns a sensible value:
|
||||
* - asyncHandler (the most common trap): pass-through returning its argument
|
||||
* so `router.get('/path', asyncHandler(realHandler))` resolves to
|
||||
* `router.get('/path', realHandler)` and Express sees a real handler
|
||||
* - Other functions: noopFn returning undefined when called
|
||||
* - Objects: recursive proxy
|
||||
*
|
||||
* Used by:
|
||||
* - depth2-routes-smoke.test.js (verifies every depth-2 route module loads)
|
||||
* - public-routes-drift.test.js (walks aggregator routers via Express stack)
|
||||
*/
|
||||
const noopFn = () => undefined;
|
||||
const passThrough = (x) => x;
|
||||
|
||||
// Logger-shaped noop: matches the real Logger's surface (error/warn/info/debug),
|
||||
// so factories that do `log.error('tag', 'msg', meta)` or `(ctx.log || console).error(...)`
|
||||
// don't blow up when run with stub deps. A bare `() => undefined` would throw because
|
||||
// `noopFn.error` is undefined.
|
||||
const loggerStub = { error: noopFn, warn: noopFn, info: noopFn, debug: noopFn, audit: noopFn };
|
||||
|
||||
const handler = {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'asyncHandler') {
|
||||
// asyncHandler is special — it must accept a handler function and return
|
||||
// a wrapped handler function. Return a pass-through that wraps nothing.
|
||||
// This is the most common trap: `router.get('/path', asyncHandler(realHandler))`
|
||||
// resolves to `router.get('/path', realHandler)` and Express sees a real handler.
|
||||
return (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
if (prop === Symbol.toPrimitive) return undefined;
|
||||
if (prop === 'then') return undefined; // don't make the proxy thenable
|
||||
if (prop in target) return target[prop];
|
||||
// Functions and methods — return noopFn that returns undefined when called
|
||||
if (typeof target[prop] === 'function') return target[prop];
|
||||
return noopFn;
|
||||
},
|
||||
// Object.assign / spread / Object.keys on the proxy only sees the target's
|
||||
// OWN enumerable keys. Without these traps, aggregator factories that copy
|
||||
// ctx into a subCtx via `Object.assign({}, ctx, { helpers })` lose the
|
||||
// proxy's magic (e.g. asyncHandler), and downstream factories fail with
|
||||
// 'asyncHandler is not a function'. Expose all seed keys as own enumerable
|
||||
// so they survive the copy.
|
||||
ownKeys(target) {
|
||||
return Reflect.ownKeys(target);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop in target) return Object.getOwnPropertyDescriptor(target, prop);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Seed the proxy with a few known-shape fields so modules that destructure
|
||||
// them get the right type. Anything else falls back to noopFn via the handler.
|
||||
const seed = {
|
||||
fetchT: async () => ({ ok: true, status: 200, json: async () => ({}) }),
|
||||
// asyncHandler is special — see handler.get below. We also seed it as an
|
||||
// own enumerable property so Object.assign({}, ctx, { helpers }) copies it
|
||||
// through (the proxy's ownKeys trap only exposes own keys, so anything not
|
||||
// in the seed is invisible to spread/assign even though the get trap returns it).
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
servicesStateManager: {
|
||||
read: async () => [],
|
||||
write: async () => {},
|
||||
update: async () => []
|
||||
},
|
||||
siteConfig: { tld: '.home', dnsServers: {}, pylon: null },
|
||||
buildServiceUrl: (id) => `https://${id}.sami}`,
|
||||
logError: async () => undefined,
|
||||
// Logger-shaped stub (not a bare noopFn) so `(ctx.log || console).error(...)`
|
||||
// and `log.error('tag','msg',meta)` calls don't throw. See loggerStub above.
|
||||
log: loggerStub,
|
||||
errorResponse: noopFn,
|
||||
healthChecker: {
|
||||
getCurrentStatus: () => ({}),
|
||||
getServiceStats: () => null,
|
||||
configureService: noopFn,
|
||||
removeService: noopFn,
|
||||
getOpenIncidents: () => [],
|
||||
getIncidentHistory: () => []
|
||||
},
|
||||
authManager: {},
|
||||
credentialManager: {
|
||||
store: async () => undefined,
|
||||
retrieve: async () => null,
|
||||
diagnose: async () => ({ status: 'missing' }),
|
||||
rotateKey: async () => undefined
|
||||
},
|
||||
totpConfig: {
|
||||
isSetUp: false,
|
||||
enabled: false,
|
||||
sessionDuration: 'never',
|
||||
getConfig: () => ({}),
|
||||
saveConfig: async () => undefined
|
||||
},
|
||||
saveTotpConfig: async () => undefined,
|
||||
session: {
|
||||
create: async () => ({}),
|
||||
invalidate: async () => undefined,
|
||||
isValid: () => true
|
||||
},
|
||||
licenseManager: {
|
||||
requirePremium: () => (req, res, next) => next(),
|
||||
hasFeature: () => false
|
||||
},
|
||||
getServiceById: () => null,
|
||||
getAppSession: () => null,
|
||||
appSessionCache: { get: () => null, set: noopFn },
|
||||
renewCSRFToken: () => 'csrf-token',
|
||||
createCache: () => ({ get: () => null, set: noopFn }),
|
||||
CACHE_CONFIGS: {},
|
||||
docker: {},
|
||||
notification: { send: noopFn },
|
||||
buildDomain: (s) => s,
|
||||
caddy: {},
|
||||
addServiceToConfig: async () => undefined,
|
||||
APP_TEMPLATES: {},
|
||||
DOCKER: {}, REGEX: {}, TIMEOUTS: {}, APP: {}, PLEX: {}, LIMITS: {},
|
||||
SESSION_TTL: 86400,
|
||||
buildMediaAuth: () => ({}),
|
||||
CADDY: {},
|
||||
DEFAULT_DNS_PORT: '5380',
|
||||
isValidPort: () => true,
|
||||
exists: async () => true,
|
||||
validateURL: () => true,
|
||||
validateToken: () => true,
|
||||
validateAndLogConfig: () => ({}),
|
||||
validateConfig: () => ({ valid: true, errors: [], warnings: [] }),
|
||||
ValidationError: class extends Error {},
|
||||
AuthenticationError: class extends Error {},
|
||||
ForbiddenError: class extends Error {},
|
||||
NotFoundError: class extends Error {},
|
||||
ok: noopFn,
|
||||
successMessage: noopFn,
|
||||
validationError: noopFn,
|
||||
notFound: noopFn,
|
||||
error: noopFn,
|
||||
platformPaths: {},
|
||||
RECIPE_TEMPLATES: {},
|
||||
RECIPE_CATEGORIES: [],
|
||||
ARR_SERVICES: {},
|
||||
APP_PORTS: {},
|
||||
cryptoUtils: { encrypt: async (x) => x, decrypt: async (x) => x },
|
||||
// Path-like strings for routes that do `path.dirname(SERVICES_FILE)` etc
|
||||
// before the factory body runs (e.g. routes/config/backup.js). Bare noopFn
|
||||
// would throw 'path argument must be of type string. Received function'.
|
||||
SERVICES_FILE: '/tmp/dashcaddy/services.json',
|
||||
CONFIG_FILE: '/tmp/dashcaddy/config.json',
|
||||
TOTP_CONFIG_FILE: '/tmp/dashcaddy/totp.json',
|
||||
TAILSCALE_CONFIG_FILE: '/tmp/dashcaddy/tailscale.json',
|
||||
NOTIFICATIONS_FILE: '/tmp/dashcaddy/notifications.json',
|
||||
// Aggregator convenience: factories pass ctx.X into sub-router mounts;
|
||||
// some sub-routers destructure these by name. Seed-as-own-property so
|
||||
// Object.assign({}, ctx, { helpers }) copies them through.
|
||||
loadSiteConfig: async () => ({}),
|
||||
loadNotificationConfig: async () => ({}),
|
||||
configStateManager: { read: async () => ({}), write: async () => undefined, update: async () => undefined },
|
||||
readConfig: async () => ({}),
|
||||
saveConfig: async () => undefined,
|
||||
helpers: {},
|
||||
safeErrorMessage: (e) => (e && e.message) || 'Unknown error'
|
||||
};
|
||||
|
||||
module.exports = { universalDeps: new Proxy(seed, handler), noopFn, passThrough };
|
||||
@@ -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 = '' } = {}) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
[
|
||||
{
|
||||
"id": "router",
|
||||
"name": "Router UI",
|
||||
"logo": "/assets/router.png",
|
||||
"url": "https://router.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "chat",
|
||||
"name": "Chat",
|
||||
"logo": "/assets/chat.png",
|
||||
"url": "https://chat.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"name": "Syncthing",
|
||||
"logo": "/assets/syncthing.png",
|
||||
"url": "https://sync.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "torrent",
|
||||
"name": "qBittorrent",
|
||||
"logo": "/assets/qBittorrent.png",
|
||||
"url": "https://torrent.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:55.246Z"
|
||||
},
|
||||
{
|
||||
"id": "sonarr",
|
||||
"name": "Sonarr",
|
||||
"logo": "/assets/sonarr.png",
|
||||
"url": "https://sonarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:56.612Z"
|
||||
},
|
||||
{
|
||||
"id": "radarr",
|
||||
"name": "Radarr",
|
||||
"logo": "/assets/radarr.png",
|
||||
"url": "https://radarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:12.359Z"
|
||||
},
|
||||
{
|
||||
"id": "prowlarr",
|
||||
"name": "Prowlarr",
|
||||
"logo": "/assets/prowlarr.png",
|
||||
"url": "https://prowlarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:13.739Z"
|
||||
},
|
||||
{
|
||||
"id": "ca",
|
||||
"name": "DashCA",
|
||||
"logo": "/assets/certificate-icon.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "dashca",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-11T11:47:08.383Z",
|
||||
"url": "https://ca.sami"
|
||||
},
|
||||
{
|
||||
"id": "plex",
|
||||
"name": "Plex",
|
||||
"logo": "/assets/plex.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "plex",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-12T02:18:36.067Z",
|
||||
"url": "https://plex.sami"
|
||||
},
|
||||
{
|
||||
"id": "requests",
|
||||
"name": "Seerr",
|
||||
"logo": "/assets/seerr.png",
|
||||
"url": "https://requests.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Gitea",
|
||||
"logo": "/assets/gitea.png",
|
||||
"url": "https://git.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "files",
|
||||
"name": "Sami Files",
|
||||
"logo": "/assets/sami-files.png",
|
||||
"url": "https://files.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"containerId": null,
|
||||
"appTemplate": "sami-files",
|
||||
"deployedAt": "2026-06-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -1,313 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DashCaddy License Code Generator
|
||||
*
|
||||
* Admin-only CLI tool for generating license codes.
|
||||
* NOT shipped with the product — runs only on the developer's machine.
|
||||
*
|
||||
* Usage:
|
||||
* node license-keygen.js --duration 365 --count 10
|
||||
* node license-keygen.js --duration 30 --count 1 --output codes.txt
|
||||
* node license-keygen.js --verify DC-XXXXX-XXXXX-XXXXX-XXXXX
|
||||
* node license-keygen.js --init-secret
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Master secret file — lives only on admin machine, NEVER shipped
|
||||
const SECRET_FILE = path.join(__dirname, '.license-secret');
|
||||
|
||||
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD
|
||||
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(48bit)
|
||||
// Total: 128 bits = 16 bytes, base32-encoded into 4 groups of 5 chars
|
||||
|
||||
const VALID_DURATIONS = [30, 90, 180, 365];
|
||||
const LIFETIME_DURATION = 0; // Admin-only, not publicly available
|
||||
const VERSION = 1;
|
||||
|
||||
// Base32 alphabet (Crockford variant — no I/L/O/U to avoid confusion)
|
||||
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
|
||||
function base32Encode(buffer) {
|
||||
let bits = '';
|
||||
for (const byte of buffer) {
|
||||
bits += byte.toString(2).padStart(8, '0');
|
||||
}
|
||||
// Pad to multiple of 5
|
||||
while (bits.length % 5 !== 0) bits += '0';
|
||||
let result = '';
|
||||
for (let i = 0; i < bits.length; i += 5) {
|
||||
const index = parseInt(bits.substring(i, i + 5), 2);
|
||||
result += BASE32[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function base32Decode(str) {
|
||||
let bits = '';
|
||||
for (const char of str.toUpperCase()) {
|
||||
const index = BASE32.indexOf(char);
|
||||
if (index === -1) throw new Error(`Invalid base32 character: ${char}`);
|
||||
bits += index.toString(2).padStart(5, '0');
|
||||
}
|
||||
const bytes = [];
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
bytes.push(parseInt(bits.substring(i, i + 8), 2));
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function getSecret() {
|
||||
if (!fs.existsSync(SECRET_FILE)) {
|
||||
console.error('No master secret found. Run with --init-secret first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
}
|
||||
|
||||
function initSecret() {
|
||||
if (fs.existsSync(SECRET_FILE)) {
|
||||
console.error('Master secret already exists at', SECRET_FILE);
|
||||
console.error('Delete it first if you want to regenerate (WARNING: invalidates all existing codes).');
|
||||
process.exit(1);
|
||||
}
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
|
||||
console.log('Master secret generated and saved to', SECRET_FILE);
|
||||
console.log('KEEP THIS FILE SAFE. It is needed to generate and validate all license codes.');
|
||||
console.log('DO NOT ship this file with the product.');
|
||||
}
|
||||
|
||||
function generateCode(secret, durationDays, codeId) {
|
||||
// Pack payload: version(4b) + duration_days(12b) + code_id(32b) + created_ts(32b) = 80 bits = 10 bytes
|
||||
const payload = Buffer.alloc(10);
|
||||
|
||||
// Byte 0-1: version (4 bits) + duration (12 bits) = 16 bits
|
||||
const versionAndDuration = ((VERSION & 0x0F) << 12) | (durationDays & 0x0FFF);
|
||||
payload.writeUInt16BE(versionAndDuration, 0);
|
||||
|
||||
// Byte 2-5: code_id (32 bits)
|
||||
payload.writeUInt32BE(codeId, 2);
|
||||
|
||||
// Byte 6-9: created timestamp (32 bits, seconds since epoch)
|
||||
const createdTs = Math.floor(Date.now() / 1000);
|
||||
payload.writeUInt32BE(createdTs, 6);
|
||||
|
||||
// HMAC the payload to get signature
|
||||
const hmac = crypto.createHmac('sha256', secret).update(payload).digest();
|
||||
// Take first 5 bytes of HMAC (40 bits) — fits exactly in 25 base32 chars with 10-byte payload
|
||||
const signature = hmac.subarray(0, 5);
|
||||
|
||||
// Combine: payload (10 bytes) + signature (5 bytes) = 15 bytes = 120 bits
|
||||
// 25 base32 chars = 125 bits, comfortably fits 120 bits
|
||||
const combined = Buffer.concat([payload, signature]);
|
||||
|
||||
let encoded = base32Encode(combined);
|
||||
while (encoded.length < 25) encoded += '0';
|
||||
encoded = encoded.substring(0, 25);
|
||||
const groups = [];
|
||||
for (let i = 0; i < 25; i += 5) {
|
||||
groups.push(encoded.substring(i, i + 5));
|
||||
}
|
||||
|
||||
return `DC-${groups.join('-')}`;
|
||||
}
|
||||
|
||||
function parseCode(code) {
|
||||
// Strip prefix and dashes
|
||||
const cleaned = code.replace(/^DC-/, '').replace(/-/g, '');
|
||||
if (cleaned.length !== 25) {
|
||||
throw new Error(`Invalid code length: expected 25 base32 chars, got ${cleaned.length}`);
|
||||
}
|
||||
|
||||
// Decode base32 — 25 chars = 125 bits = 15 full bytes
|
||||
const decoded = base32Decode(cleaned);
|
||||
if (decoded.length < 15) {
|
||||
const padded = Buffer.alloc(15);
|
||||
decoded.copy(padded);
|
||||
return parsePayload(padded);
|
||||
}
|
||||
return parsePayload(decoded.subarray(0, 15));
|
||||
}
|
||||
|
||||
function parsePayload(buffer) {
|
||||
const payload = buffer.subarray(0, 10);
|
||||
const signature = buffer.subarray(10, 15);
|
||||
|
||||
const versionAndDuration = payload.readUInt16BE(0);
|
||||
const version = (versionAndDuration >> 12) & 0x0F;
|
||||
const durationDays = versionAndDuration & 0x0FFF;
|
||||
const codeId = payload.readUInt32BE(2);
|
||||
const createdTs = payload.readUInt32BE(6);
|
||||
|
||||
return { version, durationDays, codeId, createdTs, payload, signature };
|
||||
}
|
||||
|
||||
function verifyCode(secret, code) {
|
||||
try {
|
||||
const { version, durationDays, codeId, createdTs, payload, signature } = parseCode(code);
|
||||
|
||||
// Verify HMAC (5-byte signature)
|
||||
const expectedHmac = crypto.createHmac('sha256', secret).update(payload).digest();
|
||||
const expectedSig = expectedHmac.subarray(0, 5);
|
||||
|
||||
if (!crypto.timingSafeEqual(signature, expectedSig)) {
|
||||
return { valid: false, reason: 'Invalid signature — code is forged or corrupted' };
|
||||
}
|
||||
|
||||
if (version !== VERSION) {
|
||||
return { valid: false, reason: `Unsupported version: ${version}` };
|
||||
}
|
||||
|
||||
// Accept lifetime (0) and standard durations
|
||||
if (durationDays !== LIFETIME_DURATION && !VALID_DURATIONS.includes(durationDays)) {
|
||||
return { valid: false, reason: `Invalid duration: ${durationDays} days` };
|
||||
}
|
||||
|
||||
const createdDate = new Date(createdTs * 1000);
|
||||
const isLifetime = durationDays === LIFETIME_DURATION;
|
||||
const expiresDate = isLifetime ? null : new Date(createdTs * 1000 + durationDays * 86400000);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
version,
|
||||
durationDays,
|
||||
codeId,
|
||||
createdAt: createdDate.toISOString(),
|
||||
expiresAt: isLifetime ? null : expiresDate.toISOString(),
|
||||
expired: isLifetime ? false : Date.now() > expiresDate.getTime()
|
||||
};
|
||||
} catch (error) {
|
||||
return { valid: false, reason: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// CLI
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.length === 0) {
|
||||
console.log(`
|
||||
DashCaddy License Code Generator
|
||||
|
||||
Usage:
|
||||
node license-keygen.js --init-secret Initialize master secret (first time only)
|
||||
node license-keygen.js --duration <days> [options] Generate license codes
|
||||
node license-keygen.js --verify <code> Verify a license code
|
||||
node license-keygen.js --decode <code> Decode and display code details
|
||||
|
||||
Options:
|
||||
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation)
|
||||
--count <n> Number of codes to generate (default: 1)
|
||||
--start-id <n> Starting code ID (default: auto from counter file)
|
||||
--output <file> Write codes to file instead of stdout
|
||||
--json Output as JSON
|
||||
|
||||
Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args.includes('--init-secret')) {
|
||||
initSecret();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.includes('--verify') || args.includes('--decode')) {
|
||||
const codeIndex = args.indexOf('--verify') !== -1 ? args.indexOf('--verify') : args.indexOf('--decode');
|
||||
const code = args[codeIndex + 1];
|
||||
if (!code) {
|
||||
console.error('Please provide a code to verify.');
|
||||
process.exit(1);
|
||||
}
|
||||
const secret = getSecret();
|
||||
const result = verifyCode(secret, code);
|
||||
if (args.includes('--json')) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.valid) {
|
||||
const isLifetime = result.durationDays === 0;
|
||||
console.log('Code is VALID');
|
||||
console.log(` Version: ${result.version}`);
|
||||
console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`);
|
||||
console.log(` Code ID: ${result.codeId}`);
|
||||
console.log(` Created: ${result.createdAt}`);
|
||||
console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`);
|
||||
console.log(` Status: ${isLifetime ? 'LIFETIME' : (result.expired ? 'EXPIRED' : 'ACTIVE')}`);
|
||||
} else {
|
||||
console.log('Code is INVALID');
|
||||
console.log(` Reason: ${result.reason}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate codes
|
||||
const isLifetime = args.includes('--lifetime');
|
||||
const durationIndex = args.indexOf('--duration');
|
||||
if (!isLifetime && durationIndex === -1) {
|
||||
console.error('--duration is required. Use --help for usage.');
|
||||
process.exit(1);
|
||||
}
|
||||
const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 1]);
|
||||
if (!isLifetime && !VALID_DURATIONS.includes(duration)) {
|
||||
console.error(`Invalid duration: ${duration}. Valid: ${VALID_DURATIONS.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const countIndex = args.indexOf('--count');
|
||||
const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1;
|
||||
|
||||
// Load or create counter file for auto-incrementing code IDs
|
||||
const counterFile = path.join(__dirname, '.license-counter');
|
||||
let startId;
|
||||
const startIdIndex = args.indexOf('--start-id');
|
||||
if (startIdIndex !== -1) {
|
||||
startId = parseInt(args[startIdIndex + 1]);
|
||||
} else if (fs.existsSync(counterFile)) {
|
||||
startId = parseInt(fs.readFileSync(counterFile, 'utf8').trim()) + 1;
|
||||
} else {
|
||||
startId = 1;
|
||||
}
|
||||
|
||||
const secret = getSecret();
|
||||
const codes = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const codeId = startId + i;
|
||||
const code = generateCode(secret, duration, codeId);
|
||||
codes.push({ code, codeId, durationDays: duration });
|
||||
}
|
||||
|
||||
// Save counter
|
||||
fs.writeFileSync(counterFile, String(startId + count - 1));
|
||||
|
||||
// Output
|
||||
const outputIndex = args.indexOf('--output');
|
||||
if (args.includes('--json')) {
|
||||
const output = JSON.stringify(codes, null, 2);
|
||||
if (outputIndex !== -1) {
|
||||
fs.writeFileSync(args[outputIndex + 1], output);
|
||||
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
} else {
|
||||
const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`);
|
||||
if (outputIndex !== -1) {
|
||||
fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n');
|
||||
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
|
||||
} else {
|
||||
lines.forEach(l => console.log(l));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`);
|
||||
}
|
||||
|
||||
// Also export for use by license-manager.js
|
||||
module.exports = { verifyCode, parseCode, VALID_DURATIONS, VERSION };
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.13.4",
|
||||
"version": "1.14.3",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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');
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ 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 { ValidationError } = require('../../src/utilities/errors');
|
||||
const { logError } = require('../../src/utils/logging');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
const { exists } = require('../../src/utilities/fs-helpers');
|
||||
const { logError } = require('../../src/utils/logging');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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/utils/responses');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
|
||||
@@ -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,7 @@ 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({
|
||||
@@ -55,7 +55,7 @@ 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');
|
||||
}
|
||||
ok(res, { template });
|
||||
@@ -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');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
|
||||
const { validateURL, validateToken } = require('../../input-validator');
|
||||
const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors');
|
||||
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');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { APP_PORTS } = require('../../constants');
|
||||
const { APP_PORTS } = require('../../src/utilities/constants');
|
||||
|
||||
/**
|
||||
* Arr helpers factory
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { APP_PORTS } = require('../../constants');
|
||||
const { APP_PORTS } = require('../../src/utilities/constants');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -196,5 +196,73 @@ module.exports = function(deps) {
|
||||
}
|
||||
}, 'auth-app-token'));
|
||||
|
||||
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
||||
router.get('/auth/login-page', (req, res) => {
|
||||
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
||||
const html = buildLoginPage(service);
|
||||
if (!html) return res.status(404).send('Unknown service');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(html);
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
function buildLoginPage(service) {
|
||||
const SHELL = (body) => `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>__TITLE__</title>
|
||||
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-space:pre-wrap}</style>
|
||||
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||
<script>(function(){
|
||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
||||
function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
|
||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
|
||||
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
||||
${body}
|
||||
})()</script></body></html>`;
|
||||
|
||||
const pages = {
|
||||
chat: {
|
||||
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
||||
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
||||
d.textContent='Fetching token from DashCaddy...';
|
||||
ft('chat').then(function(r){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){
|
||||
d.textContent+='\\n'+t.substring(0,300);
|
||||
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')}
|
||||
else{fail('Auto-login unavailable. <a href="/auth?nologin=1">Sign in manually</a>','No token field in response')}}
|
||||
catch(e){fail('Auto-login error. <a href="/auth?nologin=1">Sign in manually</a>','Parse error: '+e.message)}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/auth?nologin=1">Sign in manually</a>','Fetch error: '+e.message)})`
|
||||
},
|
||||
plex: {
|
||||
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
||||
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||
ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1')}
|
||||
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+e.message)})`
|
||||
},
|
||||
jellyfin: {
|
||||
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
||||
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/')}
|
||||
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+e.message)})`
|
||||
},
|
||||
emby: {
|
||||
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
||||
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/')}
|
||||
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+e.message)})`
|
||||
},
|
||||
};
|
||||
|
||||
const cfg = pages[service];
|
||||
if (!cfg) return null;
|
||||
return SHELL(cfg.body)
|
||||
.replace(/__TITLE__/g, cfg.title)
|
||||
.replace('__BG__', cfg.bg)
|
||||
.replace('__ACCENT__', cfg.accent);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
||||
const { ValidationError, AuthenticationError } = require('../../src/utilities/errors');
|
||||
const { ok, successMessage } = require('../../src/utils/responses');
|
||||
|
||||
/**
|
||||
@@ -37,6 +37,66 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
||||
});
|
||||
}, 'totp-config-get'));
|
||||
|
||||
// Recovery diagnostic (public, no auth required).
|
||||
//
|
||||
// Returns information a locked-out user needs to choose a recovery path:
|
||||
// - whether TOTP is configured at all (isSetUp)
|
||||
// - whether the stored secret is readable by the current encryption key
|
||||
// - a human-readable hint matching the situation
|
||||
//
|
||||
// Status values:
|
||||
// 'not_configured' — no TOTP setup yet, user should set it up
|
||||
// 'healthy' — secret present and decryptable, normal login
|
||||
// 'unreadable' — secret on disk but can't decrypt (key rotated)
|
||||
// 'corrupt' — entry exists but value is malformed
|
||||
//
|
||||
// This route never returns the secret itself — only metadata about it.
|
||||
router.get('/totp/recovery-info', asyncHandler(async (req, res) => {
|
||||
if (!ctx.totpConfig.isSetUp) {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'not_configured',
|
||||
isSetUp: false,
|
||||
hint: 'TOTP has not been set up on this server yet. Open settings to configure it.'
|
||||
});
|
||||
}
|
||||
|
||||
const diag = await ctx.credentialManager.diagnose('totp.secret');
|
||||
if (diag.status === 'ok') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'healthy',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is configured and the stored secret is readable. Enter your authenticator code to log in.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'unreadable') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'unreadable',
|
||||
isSetUp: true,
|
||||
hint: 'Your stored TOTP secret is on disk but cannot be decrypted — this usually means the encryption key changed during an upgrade. ' +
|
||||
'If you saved your Base32 secret when you first set up TOTP, paste it below to restore access. ' +
|
||||
'Otherwise you will need SSH access to the server to recover or rotate the key.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'missing') {
|
||||
// Config says isSetUp:true but no secret in store — corrupted config state
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is marked as configured but the secret is missing. Set up TOTP again with a fresh secret.'
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP storage is in an unexpected state. ' + (diag.error || '')
|
||||
});
|
||||
}, 'totp-recovery-info'));
|
||||
|
||||
// Generate new TOTP secret + QR code
|
||||
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* Auto-restart route factory
|
||||
|
||||
+184
-22
@@ -1,9 +1,13 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const fsp = require('fs').promises;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { success } = require('../src/utils/responses');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
|
||||
: 0;
|
||||
|
||||
/**
|
||||
* Backups routes factory
|
||||
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
runImmediately: backup.runImmediately || false,
|
||||
destination: backup.destination || 'local',
|
||||
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: backup.maxStorageBytes || null,
|
||||
lastRun: lastRun ? lastRun.toISOString() : null,
|
||||
nextRun: nextRun ? nextRun.toISOString() : null,
|
||||
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
|
||||
@@ -52,16 +57,21 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
// Create or update a scheduled backup for an app
|
||||
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
|
||||
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||
|
||||
if (!appId) {
|
||||
const { ValidationError } = require('../errors');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
throw new ValidationError('appId is required');
|
||||
}
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
if (!config.backups) config.backups = {};
|
||||
|
||||
|
||||
// Parse maxStorageBytes if provided as string (e.g. "10GB")
|
||||
const parsedMaxStorage = maxStorageBytes
|
||||
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
|
||||
: null;
|
||||
|
||||
// Build the backup config for this app
|
||||
const backupConfig = {
|
||||
enabled: enabled !== undefined ? enabled : true,
|
||||
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
destination: destination || 'local',
|
||||
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
|
||||
include: ['all'],
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }],
|
||||
maxStorageBytes: parsedMaxStorage
|
||||
};
|
||||
|
||||
config.backups[appId] = backupConfig;
|
||||
@@ -93,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');
|
||||
}
|
||||
|
||||
@@ -153,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');
|
||||
}
|
||||
|
||||
@@ -229,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');
|
||||
}
|
||||
|
||||
@@ -365,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');
|
||||
}
|
||||
|
||||
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
success(res, { history });
|
||||
}, 'backups-history'));
|
||||
|
||||
// Get storage info for backups destination
|
||||
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
|
||||
const storageInfo = await getStorageInfo();
|
||||
success(res, storageInfo);
|
||||
}, 'backups-storage-info'));
|
||||
|
||||
// Schedule a backup
|
||||
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
||||
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
||||
|
||||
if (!name || !schedule) {
|
||||
return res.status(400).json({ error: 'name and schedule are required' });
|
||||
}
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
|
||||
// Store maxStorageBytes in the backup config (converted to bytes)
|
||||
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
|
||||
? maxStorageBytes
|
||||
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
|
||||
|
||||
config.backups[name] = {
|
||||
...backupConfig,
|
||||
enabled: true,
|
||||
schedule,
|
||||
maxStorageBytes: maxBytes,
|
||||
destinations: backupConfig.destinations || [{ type: 'local' }]
|
||||
};
|
||||
|
||||
backupManager.updateConfig(config);
|
||||
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
||||
}, 'backups-schedule'));
|
||||
|
||||
// Restore from backup
|
||||
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||
@@ -502,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);
|
||||
@@ -512,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');
|
||||
}
|
||||
|
||||
@@ -544,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)) {
|
||||
@@ -585,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)) {
|
||||
@@ -616,7 +660,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
*/
|
||||
function calculateNextRun(lastRun, schedule) {
|
||||
if (!lastRun) return null;
|
||||
|
||||
|
||||
const intervals = {
|
||||
'hourly': 60 * 60 * 1000,
|
||||
'daily': 24 * 60 * 60 * 1000,
|
||||
@@ -625,7 +669,7 @@ function calculateNextRun(lastRun, schedule) {
|
||||
};
|
||||
|
||||
const baseInterval = intervals[schedule];
|
||||
|
||||
|
||||
if (baseInterval) {
|
||||
return new Date(lastRun.getTime() + baseInterval);
|
||||
}
|
||||
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage information for the backup directory
|
||||
*/
|
||||
async function getStorageInfo() {
|
||||
const result = {
|
||||
destination: DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
|
||||
usedBytes: 0,
|
||||
availableBytes: 0,
|
||||
usagePercent: 0,
|
||||
backupCount: 0,
|
||||
oldestBackup: null,
|
||||
newestBackup: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Get disk space info
|
||||
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
|
||||
result.availableBytes = diskSpace.available;
|
||||
|
||||
// Scan for backup files
|
||||
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
|
||||
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
|
||||
} else {
|
||||
result.maxStorageBytes = diskSpace.total || 0;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
let oldestTime = null;
|
||||
let newestTime = null;
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith('.backup')) {
|
||||
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalSize += stats.size;
|
||||
result.backupCount++;
|
||||
|
||||
const fileTime = new Date(stats.mtime);
|
||||
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
|
||||
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
|
||||
} catch (e) {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backup directory might not exist yet
|
||||
}
|
||||
|
||||
result.usedBytes = totalSize;
|
||||
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
|
||||
result.newestBackup = newestTime ? newestTime.toISOString() : null;
|
||||
|
||||
// Calculate available (total limit - used), or from disk space if no limit set
|
||||
if (result.maxStorageBytes > 0) {
|
||||
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
|
||||
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
|
||||
} else if (diskSpace.total) {
|
||||
result.availableBytes = diskSpace.available;
|
||||
result.usagePercent = diskSpace.total > 0
|
||||
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
|
||||
: 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupsRouter] Error getting storage info:', error.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk space info (filesystem-agnostic)
|
||||
*/
|
||||
async function getDiskSpaceInfo(dirPath) {
|
||||
try {
|
||||
const diskInfo = await fsp.statfs(dirPath);
|
||||
return {
|
||||
total: diskInfo.blocks * diskInfo.bsize,
|
||||
available: diskInfo.bfree * diskInfo.bsize,
|
||||
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
|
||||
};
|
||||
} catch (error) {
|
||||
// Directory might not exist or be accessible
|
||||
return { total: 0, available: 0, used: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storage size string like "10GB" to bytes
|
||||
*/
|
||||
function parseStorageSize(sizeStr) {
|
||||
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
|
||||
|
||||
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] || 'B').toUpperCase();
|
||||
|
||||
const multipliers = {
|
||||
'B': 1,
|
||||
'K': 1024,
|
||||
'KB': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
return Math.floor(value * (multipliers[unit] || 1));
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ 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');
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ const { ok } = require('../src/utils/responses');
|
||||
* @param {Object} deps.docker - Docker client
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docker }) {
|
||||
module.exports = function({ asyncHandler, ok, validateSecurePath, auditLogger, docker }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Parse browse roots from environment
|
||||
@@ -99,7 +99,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
|
||||
}
|
||||
|
||||
if (!await exists(resolvedPath)) {
|
||||
const { NotFoundError } = require('../errors');
|
||||
const { NotFoundError } = require('../src/utilities/errors');
|
||||
throw new NotFoundError('Path');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ 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');
|
||||
|
||||
@@ -19,7 +19,7 @@ module.exports = function(ctx) {
|
||||
if (await exists(certInfoPath)) {
|
||||
certInfoFile = certInfoPath;
|
||||
} else {
|
||||
const { NotFoundError } = require('../errors');
|
||||
const { NotFoundError } = require('../src/utilities/errors');
|
||||
throw new NotFoundError('CA certificate information');
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ module.exports = function(ctx) {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ module.exports = function(ctx) {
|
||||
if (await exists(certInfoPath)) {
|
||||
certInfoFile = certInfoPath;
|
||||
} else {
|
||||
const { NotFoundError } = require('../errors');
|
||||
const { NotFoundError } = require('../src/utilities/errors');
|
||||
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
|
||||
}
|
||||
|
||||
@@ -106,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})`);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* Config-drift route factory
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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');
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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');
|
||||
|
||||
@@ -380,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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const { DOCKER } = require('../constants');
|
||||
const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const { NotFoundError } = require('../errors');
|
||||
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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { NotFoundError, ValidationError } = require('../errors');
|
||||
const { NotFoundError, ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* Dependencies route factory
|
||||
|
||||
@@ -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 { 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('../errors');
|
||||
const { ValidationError, AuthenticationError, NotFoundError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* DNS routes factory
|
||||
@@ -553,7 +553,6 @@ module.exports = function({
|
||||
}
|
||||
|
||||
return ok(res, {
|
||||
success: anySuccess,
|
||||
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
|
||||
results
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../errors');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* Docker resources route factory (volumes, networks, disk usage)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const { exists } = require('../fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const { exists } = require('../src/utilities/fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||
const { success } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,10 @@ const { ok } = require('../src/utils/responses');
|
||||
* @param {Object} deps.updateManager - Update manager
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @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, dependencyManager, autoRestartManager, driftDetector, sslMonitor, dnsPropagationChecker }) {
|
||||
|
||||
@@ -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 { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||
const { success, error: errorResponse, errorResponse: sendError, ok, notFound } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../errors');
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -314,16 +314,26 @@ module.exports = function({
|
||||
// ===== HEALTH CHECK (health-checker module) =====
|
||||
|
||||
// Get current status for all services
|
||||
// Returns per-service status plus a summary for the System Overview widget:
|
||||
// { status: { ... }, summary: { healthy, unhealthy, total } }
|
||||
// Returns {status: {...per-service}} plus a {summary} block for the System Overview widget
|
||||
// — see skill references/totp-and-system-overview-pitfalls.md §3
|
||||
router.get('/health-checks/status', asyncHandler(async (req, res) => {
|
||||
const status = healthChecker.getCurrentStatus();
|
||||
// Build summary for the overview widget
|
||||
const entries = Object.values(status);
|
||||
const healthy = entries.filter(s => s.status === 'up' || s.status === 'healthy').length;
|
||||
const unhealthy = entries.filter(s => s.status === 'down' || s.status === 'unhealthy').length;
|
||||
const total = entries.length;
|
||||
success(res, { status, summary: { healthy, unhealthy, total } });
|
||||
const entries = Object.values(status || {});
|
||||
// Treat 'up'/'healthy' as healthy, everything else as unhealthy.
|
||||
// Health check status values come from healthChecker — typically 'up'/'down' but
|
||||
// also 'healthy'/'unhealthy' or 'online'/'offline' depending on the source. Be
|
||||
// permissive on the healthy side so a service in any positive state counts.
|
||||
const healthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'up' || st === 'healthy' || st === 'online';
|
||||
}).length;
|
||||
const unhealthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'down' || st === 'unhealthy' || st === 'offline' || st === 'error';
|
||||
}).length;
|
||||
const unknown = entries.length - healthy - unhealthy;
|
||||
const summary = { healthy, unhealthy, unknown, total: entries.length };
|
||||
success(res, { status, summary });
|
||||
}, 'health-check-status'));
|
||||
|
||||
// Get service statistics
|
||||
@@ -331,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 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../errors');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* License routes factory
|
||||
|
||||
@@ -2,9 +2,9 @@ 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');
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ const { ok } = require('../src/utils/responses');
|
||||
* @param {Object} deps.dockerMaintenance - Docker maintenance module (optional)
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }) {
|
||||
module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenance }) {
|
||||
const router = express.Router();
|
||||
|
||||
// List containers with logs
|
||||
@@ -48,7 +48,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
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;
|
||||
@@ -97,7 +97,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
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;
|
||||
@@ -232,7 +232,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
try {
|
||||
resolvedPath = await fsp.realpath(normalizedPath);
|
||||
} catch {
|
||||
const { NotFoundError } = require('../errors');
|
||||
const { NotFoundError } = require('../src/utilities/errors');
|
||||
throw new NotFoundError('Log file');
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
|
||||
}
|
||||
|
||||
if (!await exists(resolvedPath)) {
|
||||
const { NotFoundError } = require('../errors');
|
||||
const { NotFoundError } = require('../src/utilities/errors');
|
||||
throw new NotFoundError('Log file');
|
||||
}
|
||||
|
||||
|
||||
@@ -16,20 +16,19 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
|
||||
// ===== RESOURCE MONITORING ENDPOINTS =====
|
||||
|
||||
// Get all container stats (from resource monitor module)
|
||||
// Returns a flat summary format for the System Overview widget:
|
||||
// { containerId: { cpu: <percent>, memory: <percent>, memoryUsage: <bytes>, name } }
|
||||
// Flattened for the System Overview widget — see skill references/totp-and-system-overview-pitfalls.md §3
|
||||
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
|
||||
const raw = resourceMonitor.getAllStats();
|
||||
// Transform nested { current: { cpu: { percent }, memory: { percent, usage } } }
|
||||
// into flat { cpu: number, memory: number, memoryUsage: number } for the frontend widget
|
||||
const stats = {};
|
||||
for (const [id, data] of Object.entries(raw)) {
|
||||
for (const [id, data] of Object.entries(raw || {})) {
|
||||
const cur = data.current || {};
|
||||
const cpuObj = (cur.cpu && typeof cur.cpu === 'object') ? cur.cpu : null;
|
||||
const memObj = (cur.memory && typeof cur.memory === 'object') ? cur.memory : null;
|
||||
stats[id] = {
|
||||
name: data.name,
|
||||
cpu: typeof cur.cpu === 'object' ? (cur.cpu.percent ?? 0) : (Number(cur.cpu) || 0),
|
||||
memory: typeof cur.memory === 'object' ? (cur.memory.percent ?? 0) : (Number(cur.memory) || 0),
|
||||
memoryUsage: typeof cur.memory === 'object' ? (cur.memory.usage ?? 0) : 0,
|
||||
cpu: cpuObj ? (cpuObj.percent ?? 0) : (Number(cur.cpu) || 0),
|
||||
memory: memObj ? (memObj.percent ?? 0) : (Number(cur.memory) || 0),
|
||||
memoryUsage: memObj ? (memObj.usage ?? 0) : 0,
|
||||
};
|
||||
}
|
||||
success(res, { stats });
|
||||
@@ -39,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 });
|
||||
@@ -55,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);
|
||||
@@ -74,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 });
|
||||
@@ -92,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)) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -10,9 +10,10 @@ const { ok, successMessage } = require('../src/utils/responses');
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.notification - Notification manager
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ notification, asyncHandler }) {
|
||||
module.exports = function({ notification, asyncHandler, ok }) {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /config — Get notification configuration (sensitive data redacted)
|
||||
@@ -177,11 +178,13 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
default:
|
||||
throw new ValidationError('Unknown provider');
|
||||
}
|
||||
ok(res, { success: result.success, provider, error: result.error });
|
||||
// result.success reflects actual delivery; keep that semantic by using
|
||||
// res.json directly (ok() hardcodes success:true).
|
||||
res.json({ success: result.success, provider, error: result.error });
|
||||
} else {
|
||||
// Test all enabled providers
|
||||
const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info');
|
||||
ok(res, { success: true, ...result });
|
||||
ok(res, { ...result });
|
||||
}
|
||||
}, 'notifications-test'));
|
||||
|
||||
@@ -242,18 +245,20 @@ module.exports = function({ notification, asyncHandler }) {
|
||||
// POST /send — Manual test send (used by frontend "Send Test" button)
|
||||
router.post('/send', asyncHandler(async (req, res) => {
|
||||
const { event, data, type } = req.body;
|
||||
|
||||
|
||||
if (!event) {
|
||||
throw new ValidationError('Event type is required');
|
||||
}
|
||||
|
||||
// Use 'test' as the event for manual sends
|
||||
const result = await notification.send(event, data || {}, type || 'info');
|
||||
|
||||
ok(res, {
|
||||
success: result.success,
|
||||
|
||||
// result.success reflects actual per-provider delivery; ok() hardcodes true,
|
||||
// so use res.json to preserve the partial-failure semantic.
|
||||
res.json({
|
||||
success: result.success,
|
||||
event,
|
||||
results: result.results
|
||||
results: result.results
|
||||
});
|
||||
}, 'notifications-send'));
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ module.exports = function openClawRoutes(ctx) {
|
||||
const router = express.Router();
|
||||
const docker = ctx.docker;
|
||||
const asyncHandler = ctx.asyncHandler;
|
||||
const ok = ctx.ok;
|
||||
const log = ctx.log || console;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -28,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');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -32,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,
|
||||
@@ -61,7 +61,7 @@ module.exports = function(ctx) {
|
||||
|
||||
// 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}`);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 }) {
|
||||
@@ -269,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, '-')
|
||||
@@ -293,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;
|
||||
|
||||
@@ -4,12 +4,12 @@ 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 { 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');
|
||||
|
||||
@@ -197,12 +197,12 @@ module.exports = function({
|
||||
// ===== SERVICE CREDENTIAL ENDPOINTS =====
|
||||
|
||||
// Store credentials for a service
|
||||
router.post('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.post('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
|
||||
const { apiKey, username, password } = req.body;
|
||||
@@ -221,12 +221,12 @@ module.exports = function({
|
||||
}, 'store-service-creds'));
|
||||
|
||||
// Delete credentials for a service
|
||||
router.delete('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.delete('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
|
||||
await credentialManager.delete(`service.${serviceId}.apikey`);
|
||||
@@ -236,12 +236,12 @@ module.exports = function({
|
||||
}, 'delete-service-creds'));
|
||||
|
||||
// Check credential status for a service (what's stored)
|
||||
router.get('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Validate serviceId to prevent path traversal in credential keys
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid service ID');
|
||||
return errorResponse(res, 400, 'Invalid service ID');
|
||||
}
|
||||
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ const { ok, successMessage } = require('../src/utils/responses');
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addServiceToConfig, siteConfig, log }) {
|
||||
module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, addServiceToConfig, siteConfig, log }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Get Caddyfile contents
|
||||
@@ -261,11 +261,11 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
|
||||
}
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
const data = {
|
||||
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`
|
||||
};
|
||||
if (dnsWarning) responseData.warning = dnsWarning;
|
||||
ok(res, responseData);
|
||||
if (dnsWarning) data.warning = dnsWarning;
|
||||
ok(res, data);
|
||||
}, 'site-external'));
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const { TAILSCALE } = require('../constants');
|
||||
const { exists } = require('../fs-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -14,6 +14,7 @@ const { ok, successMessage, unauthorized } = require('../src/utils/responses');
|
||||
* @param {Object} deps.credentialManager - Credential manager
|
||||
* @param {Function} deps.buildDomain - Domain builder function
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @param {string} deps.SERVICES_FILE - Path to services.json
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @returns {express.Router}
|
||||
@@ -25,6 +26,7 @@ module.exports = function({
|
||||
credentialManager,
|
||||
buildDomain,
|
||||
asyncHandler,
|
||||
ok,
|
||||
SERVICES_FILE,
|
||||
log
|
||||
}) {
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -10,9 +10,10 @@ const { ok, successMessage } = require('../src/utils/responses');
|
||||
* @param {Object} deps.selfUpdater - DashCaddy self-update manager
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }) {
|
||||
module.exports = function({ updateManager, selfUpdater, asyncHandler, logError, ok }) {
|
||||
const router = express.Router();
|
||||
|
||||
// ===== UPDATE MANAGEMENT ENDPOINTS =====
|
||||
@@ -128,11 +129,11 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
||||
// constant-time compare to avoid timing leaks
|
||||
const presentedBuf = Buffer.from(presented);
|
||||
const expectedBuf = Buffer.from(expected);
|
||||
const ok = presentedBuf.length === expectedBuf.length &&
|
||||
const secretOk = presentedBuf.length === expectedBuf.length &&
|
||||
presentedBuf.length > 0 &&
|
||||
require('crypto').timingSafeEqual(presentedBuf, expectedBuf);
|
||||
if (!ok) {
|
||||
return unauthorized(res, 'Invalid notify secret');
|
||||
if (!secretOk) {
|
||||
return res.status(401).json({ success: false, error: 'Invalid notify secret' });
|
||||
}
|
||||
const result = selfUpdater.notifyAndApply('http-notify');
|
||||
ok(res, result);
|
||||
|
||||
@@ -7,46 +7,47 @@ const { ok } = require('../src/utils/responses');
|
||||
* @param {Object} deps.workflowEngine - WorkflowEngine instance
|
||||
* @param {Object} deps.licenseManager - License manager for premium gating
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.ok - Success response helper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
|
||||
module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok }) {
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
// Apply premium gating to all workflows routes
|
||||
router.use(licenseManager.requirePremium('workflows'));
|
||||
|
||||
|
||||
// ===== WORKFLOW MANAGEMENT ENDPOINTS =====
|
||||
|
||||
|
||||
// List all bundled workflows
|
||||
router.get('/workflows', asyncHandler(async (req, res) => {
|
||||
const workflows = workflowEngine.listWorkflows();
|
||||
ok(res, { workflows });
|
||||
}, 'workflows-list'));
|
||||
|
||||
|
||||
// Enable a workflow
|
||||
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
|
||||
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);
|
||||
}, 'workflows-disable'));
|
||||
|
||||
|
||||
// Manually trigger a workflow
|
||||
router.post('/workflows/:workflowId/run', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
const triggerData = req.body || {};
|
||||
triggerData.trigger = 'manual';
|
||||
|
||||
|
||||
const result = await workflowEngine.executeWorkflow(workflowId, triggerData);
|
||||
ok(res, { result });
|
||||
}, 'workflows-run'));
|
||||
|
||||
|
||||
// Get execution history for a workflow
|
||||
router.get('/workflows/:workflowId/history', asyncHandler(async (req, res) => {
|
||||
const { workflowId } = req.params;
|
||||
@@ -54,13 +55,13 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
|
||||
const history = workflowEngine.getHistory(workflowId, limit);
|
||||
ok(res, { history });
|
||||
}, 'workflows-history'));
|
||||
|
||||
|
||||
// Get all workflow execution history
|
||||
router.get('/workflows/history', asyncHandler(async (req, res) => {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const history = workflowEngine.getHistory(null, limit);
|
||||
ok(res, { history });
|
||||
}, 'workflows-all-history'));
|
||||
|
||||
|
||||
return router;
|
||||
};
|
||||
Regular → Executable
+6
-1
@@ -134,11 +134,16 @@ restart_container() {
|
||||
# Stop and remove existing container so new env var is applied
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# Re-create with same volumes and the SERVICES_FILE env var
|
||||
# Re-create with same volumes. CRITICAL: must include CREDENTIALS_FILE +
|
||||
# ENCRYPTION_KEY_FILE pointing at /app/data/ so the container reads the TOTP
|
||||
# secret from the bind-mounted host data dir (not image-local /app/credentials.json
|
||||
# which gets a fresh encryption key on every container recreate = TOTP breaks).
|
||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CREDENTIALS_FILE=/app/d...son \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
"$image"
|
||||
log "Container restarted with fresh env"
|
||||
}
|
||||
|
||||
@@ -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 '../<after_src>'
|
||||
new_path = '../' + after_src
|
||||
elif filepath.parts[-4] == 'src':
|
||||
# File is in src/X/Y/file.js - depth 4
|
||||
# Should be '../../<after_src>'
|
||||
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/<after_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/<after_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()
|
||||
@@ -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/<subdir>/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 <REPO>/dns-providers/x
|
||||
// which after move becomes <REPO>/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).`);
|
||||
+29
-23
@@ -3,6 +3,7 @@
|
||||
* Minimal startup script - all logic moved to src/
|
||||
*/
|
||||
const { createApp } = require('./src/app');
|
||||
const { fetchT } = require('./src/utils/http');
|
||||
const platformPaths = require('./platform-paths');
|
||||
|
||||
// Unhandled error handlers
|
||||
@@ -33,7 +34,7 @@ process.on('uncaughtException', (error) => {
|
||||
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,
|
||||
@@ -56,23 +57,27 @@ 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');
|
||||
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||||
|
||||
// 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
|
||||
@@ -85,7 +90,7 @@ process.on('uncaughtException', (error) => {
|
||||
// Create a context with needed services
|
||||
const workflowCtx = {
|
||||
docker: { client: require('dockerode')() },
|
||||
notification: require('./notification-manager')({
|
||||
notification: new (require('./src/managers/notification-manager'))({
|
||||
NOTIFICATIONS_FILE: process.env.NOTIFICATIONS_FILE || require('./platform-paths').notificationsFile,
|
||||
fetchT,
|
||||
log,
|
||||
@@ -137,10 +142,11 @@ process.on('uncaughtException', (error) => {
|
||||
// Health checker (with service sync)
|
||||
(async () => {
|
||||
try {
|
||||
const { syncHealthCheckerServices } = require('./startup-validator');
|
||||
const StateManager = require('./state-manager');
|
||||
const { syncHealthCheckerServices } = require('../src/utilities/startup-validator');
|
||||
const StateManager = require('../src/managers/state-manager');
|
||||
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||||
|
||||
|
||||
|
||||
await syncHealthCheckerServices({
|
||||
log,
|
||||
SERVICES_FILE,
|
||||
@@ -150,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();
|
||||
@@ -232,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();
|
||||
@@ -245,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 */ }
|
||||
|
||||
|
||||
+164
-81
@@ -12,44 +12,46 @@ const { assembleContext } = require('./context');
|
||||
const { createLogger, logError, safeErrorMessage } = require('./utils/logging');
|
||||
const { fetchT } = require('./utils/http');
|
||||
const { errorResponse, ok } = require('./utils/responses');
|
||||
// Note: 3-arg asyncHandler signature (logError, fn, context) preserved per Hermes review
|
||||
// — 49 route files still use this signature.
|
||||
const { asyncHandler } = require('./utils/async-handler');
|
||||
|
||||
// Managers and utilities
|
||||
const StateManager = require('../state-manager');
|
||||
const StateManager = require('.//state-manager');
|
||||
const platformPaths = require('../platform-paths');
|
||||
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 { validateStartupConfig, 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 { 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 { validateStartupConfig: _validateStartupConfig, 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');
|
||||
|
||||
// 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-maintenance'); } catch (_) { /* optional module */ }
|
||||
try { logDigest = require('.//log-digest'); } catch (_) { /* optional module */ }
|
||||
|
||||
// Workflow engine (bundled workflows)
|
||||
let bundledWorkflowsModule;
|
||||
let workflowEngine = null;
|
||||
try {
|
||||
bundledWorkflowsModule = require('../bundled-workflows');
|
||||
bundledWorkflowsModule = require('.//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('.//app-templates');
|
||||
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('.//recipe-templates');
|
||||
|
||||
// Route modules
|
||||
const healthRoutes = require('../routes/health');
|
||||
@@ -79,21 +81,22 @@ const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||
const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const DependencyManager = require('../dependency-manager');
|
||||
const DependencyManager = require('.//dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
const sslMonitorRoutes = require('../routes/ssl-monitor');
|
||||
const { AutoRestartManager } = require('../auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('../config-drift-detector');
|
||||
const SSLMonitor = require('../ssl-monitor');
|
||||
const DNSPropagationChecker = require('../dns-propagation');
|
||||
const { AutoRestartManager } = require('.//auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('.//config-drift-detector');
|
||||
const SSLMonitor = require('.//ssl-monitor');
|
||||
const DNSPropagationChecker = require('./dns/dns-propagation');
|
||||
|
||||
// Constants
|
||||
const { APP } = require('../constants');
|
||||
const { APP } = require('.//constants');
|
||||
|
||||
/**
|
||||
* Create and configure the Express application
|
||||
*/
|
||||
// eslint-disable-next-line require-await -- kept async for API consistency with other factory functions
|
||||
async function createApp() {
|
||||
const app = express();
|
||||
|
||||
@@ -182,11 +185,45 @@ async function createApp() {
|
||||
return first === 100 && second >= 64 && second <= 127;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
|
||||
// auto-login pages) historically call these endpoints under the pre-1.5.0
|
||||
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
|
||||
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
|
||||
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
|
||||
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
|
||||
// before configureMiddleware() so CSRF/auth see the canonical path. This is
|
||||
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
|
||||
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Configure middleware
|
||||
const middlewareResult = configureMiddleware(app, {
|
||||
siteConfig: config.siteConfig,
|
||||
@@ -196,15 +233,15 @@ async function createApp() {
|
||||
auditLogger,
|
||||
authManager,
|
||||
log,
|
||||
cryptoUtils: require('../crypto-utils'),
|
||||
cryptoUtils: require('.//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('.//constants').RATE_LIMITS,
|
||||
LIMITS: require('.//constants').LIMITS,
|
||||
APP: require('.//constants').APP,
|
||||
CACHE_CONFIGS: require('.//cache-config').CACHE_CONFIGS,
|
||||
createCache: require('.//cache-config').createCache,
|
||||
});
|
||||
|
||||
const { strictLimiter } = middlewareResult;
|
||||
@@ -215,8 +252,9 @@ async 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');
|
||||
const { readJsonFile } = require('.//fs-helpers');
|
||||
return readJsonFile(config.CONFIG_FILE, {});
|
||||
}
|
||||
|
||||
@@ -239,7 +277,7 @@ async function createApp() {
|
||||
|
||||
async function saveTotpConfig() {
|
||||
try {
|
||||
const { writeJsonFile } = require('../fs-helpers');
|
||||
const { writeJsonFile } = require('.//fs-helpers');
|
||||
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
|
||||
} catch (e) {
|
||||
log.error('config', 'Could not save TOTP config', { error: e.message });
|
||||
@@ -250,7 +288,9 @@ async function createApp() {
|
||||
// Stub - will be implemented
|
||||
}
|
||||
|
||||
async function resyncHealthChecker() {
|
||||
// Forwards the promise from syncHealthCheckerServices — intentionally not
|
||||
// `async` since there is no `await` inside. Callers use `.catch()` on it.
|
||||
function resyncHealthChecker() {
|
||||
return syncHealthCheckerServices({
|
||||
log,
|
||||
SERVICES_FILE: config.SERVICES_FILE,
|
||||
@@ -262,11 +302,13 @@ async function createApp() {
|
||||
});
|
||||
}
|
||||
|
||||
// Create bound logError function
|
||||
// Create bound logError function (3-arg signature: ctx, err, extra)
|
||||
// The unified logger module has its own ERROR_LOG_FILE from process.env,
|
||||
// so we just route through its logErrorWrapper.
|
||||
const boundLogError = (context, error, additionalInfo) =>
|
||||
logError(config.ERROR_LOG_FILE, config.MAX_ERROR_LOG_SIZE, context, error, additionalInfo, log);
|
||||
logError(context, error, additionalInfo);
|
||||
|
||||
// Create bound asyncHandler
|
||||
// Create bound asyncHandler (3-arg: logError, fn, context)
|
||||
const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context);
|
||||
|
||||
// Assemble context
|
||||
@@ -458,7 +500,8 @@ async function createApp() {
|
||||
}));
|
||||
apiRouter.use('/notifications', notificationRoutes({
|
||||
notification: ctx.notification,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok
|
||||
}));
|
||||
apiRouter.use('/containers', containerRoutes({
|
||||
docker: ctx.docker,
|
||||
@@ -502,7 +545,8 @@ async function createApp() {
|
||||
updateManager: ctx.updateManager,
|
||||
selfUpdater: ctx.selfUpdater,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError
|
||||
logError: ctx.logError,
|
||||
ok: ctx.ok
|
||||
}));
|
||||
apiRouter.use('/tailscale', tailscaleRoutes({
|
||||
tailscale: ctx.tailscale,
|
||||
@@ -511,11 +555,13 @@ async function createApp() {
|
||||
credentialManager: ctx.credentialManager,
|
||||
buildDomain: ctx.buildDomain,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
SERVICES_FILE: ctx.SERVICES_FILE,
|
||||
log: ctx.log
|
||||
}));
|
||||
apiRouter.use(sitesRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
caddy: ctx.caddy,
|
||||
dns: ctx.dns,
|
||||
fetchT: ctx.fetchT,
|
||||
@@ -533,6 +579,7 @@ async function createApp() {
|
||||
apiRouter.use('/openclaw', openClawRoutes(ctx));
|
||||
apiRouter.use(logsRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
docker: ctx.docker,
|
||||
logDigest: ctx.logDigest,
|
||||
dockerMaintenance: ctx.dockerMaintenance
|
||||
@@ -569,6 +616,7 @@ async function createApp() {
|
||||
healthChecker: ctx.healthChecker,
|
||||
updateManager: ctx.updateManager,
|
||||
logError: ctx.logError,
|
||||
ok: ctx.ok,
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
autoRestartManager: ctx.autoRestartManager,
|
||||
driftDetector: ctx.driftDetector,
|
||||
@@ -578,7 +626,8 @@ async function createApp() {
|
||||
apiRouter.use('/workflows', workflowsRoutes({
|
||||
workflowEngine: ctx.workflowEngine,
|
||||
licenseManager: ctx.licenseManager,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok
|
||||
}));
|
||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
@@ -605,10 +654,10 @@ async function createApp() {
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
|
||||
// Inline API routes
|
||||
apiRouter.get('/health', (req, res) => {
|
||||
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
// Inline API routes (mounted under /api/v1 below)
|
||||
// Note: /health lives at root only — see root-level health check below.
|
||||
// Probes (/healthz, /readyz, /health/live, /health/ready) also at root only.
|
||||
// Do NOT add another /api/v1/health route — it's been consolidated.
|
||||
|
||||
apiRouter.get('/csrf-token', (req, res) => {
|
||||
ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME });
|
||||
@@ -621,24 +670,33 @@ async function createApp() {
|
||||
// Mount at /api/v1 (canonical, single version)
|
||||
app.use('/api/v1', apiRouter);
|
||||
|
||||
// Root-level health check
|
||||
app.get('/health', (req, res) => {
|
||||
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
// ===========================================================================
|
||||
// Health probes — root-level, no auth, no CSRF, no rate limit.
|
||||
//
|
||||
// Two semantics, four paths:
|
||||
//
|
||||
// LIVENESS — "is the Node.js process alive?"
|
||||
// /health/live (explicit, recommended)
|
||||
// /healthz (k8s/Docker-standard alias)
|
||||
// READINESS — "are critical dependencies reachable?"
|
||||
// /health/ready (explicit, recommended)
|
||||
// /readyz (k8s/Docker-standard alias)
|
||||
//
|
||||
// k8s/Docker/Caddy call these to decide whether to RESTART or ROUTE TRAFFIC.
|
||||
// They MUST stay cheap (no DB queries, no logging side effects, no auth).
|
||||
//
|
||||
// Plain /health is kept for backwards compatibility and returns the same
|
||||
// payload as /health/live. Use /health/live or /healthz in new code.
|
||||
// ===========================================================================
|
||||
|
||||
// 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) => {
|
||||
// Liveness — pure process check, no deps.
|
||||
const livenessHandler = (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) => {
|
||||
// Readiness — checks critical dependencies (config, services file,
|
||||
// Docker daemon, Caddy admin). 200 if all OK, 503 if any failed.
|
||||
const readinessHandler = boundAsyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
let allOk = true;
|
||||
|
||||
@@ -704,12 +762,21 @@ async function createApp() {
|
||||
checks
|
||||
};
|
||||
ok(res, body, allOk ? 200 : 503);
|
||||
}));
|
||||
});
|
||||
|
||||
// Liveness paths
|
||||
app.get('/health', livenessHandler);
|
||||
app.get('/health/live', livenessHandler);
|
||||
app.get('/healthz', livenessHandler);
|
||||
|
||||
// Readiness paths
|
||||
app.get('/health/ready', readinessHandler);
|
||||
app.get('/readyz', readinessHandler);
|
||||
|
||||
// Lightweight probe endpoint
|
||||
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const { exists } = require('../fs-helpers');
|
||||
const { exists } = require('.//fs-helpers');
|
||||
|
||||
let service = null;
|
||||
if (id !== 'internet' && await exists(config.SERVICES_FILE)) {
|
||||
@@ -798,10 +865,33 @@ async function createApp() {
|
||||
res.status(statusCode).send();
|
||||
}, 'probe'));
|
||||
|
||||
// Scan OS network interfaces and classify the first LAN + Tailscale IPv4
|
||||
// addresses. Extracted to keep the route handler below ESLint's max-depth.
|
||||
function detectInterfaceIps() {
|
||||
const os = require('os');
|
||||
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
||||
const all = [];
|
||||
let lan = null;
|
||||
let tailscale = null;
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs || []) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const { address: ip } = addr;
|
||||
all.push({ name, ip });
|
||||
if (!tailscale && ip.startsWith('100.')) {
|
||||
tailscale = ip;
|
||||
} else if (!lan && LAN_RANGE.test(ip)) {
|
||||
lan = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lan, tailscale, all };
|
||||
}
|
||||
|
||||
// Network IPs endpoint
|
||||
app.get('/api/v1/network/ips', (req, res) => {
|
||||
try {
|
||||
const os = require('os');
|
||||
const envLan = process.env.HOST_LAN_IP;
|
||||
const envTailscale = process.env.HOST_TAILSCALE_IP;
|
||||
|
||||
@@ -813,19 +903,12 @@ async function createApp() {
|
||||
};
|
||||
|
||||
if (!envLan || !envTailscale) {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const ip = addr.address;
|
||||
result.all.push({ name, ip });
|
||||
|
||||
if (!result.tailscale && ip.startsWith('100.')) {
|
||||
result.tailscale = ip;
|
||||
} else if (!result.lan && (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./))) {
|
||||
result.lan = ip;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,7 +939,7 @@ async function createApp() {
|
||||
|
||||
app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => {
|
||||
const path = require('path');
|
||||
const { exists } = require('../fs-helpers');
|
||||
const { exists } = require('.//fs-helpers');
|
||||
const fsp = require('fs').promises;
|
||||
|
||||
const specPath = path.join(__dirname, '../openapi.yaml');
|
||||
@@ -869,7 +952,7 @@ async function createApp() {
|
||||
}, 'api-docs-spec'));
|
||||
|
||||
// Error handlers (MUST be last)
|
||||
const { notFoundHandler, errorMiddleware } = require('../error-handler');
|
||||
const { notFoundHandler, errorMiddleware } = require('.//error-handler');
|
||||
app.use('/api', notFoundHandler);
|
||||
app.use(errorMiddleware);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const _platformPaths = require('../../platform-paths');
|
||||
|
||||
const CURRENT_VERSION = 2;
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* updated config back, and the rest of the app only ever sees the current
|
||||
* schema.
|
||||
*/
|
||||
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 = {
|
||||
@@ -24,6 +24,32 @@ const siteConfig = {
|
||||
routingMode: 'subdomain'
|
||||
};
|
||||
|
||||
function applyConfigFields(raw) {
|
||||
siteConfig.tld = raw.tld || '.home';
|
||||
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
|
||||
siteConfig.caName = raw.caName || '';
|
||||
siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || '';
|
||||
siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT;
|
||||
siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`;
|
||||
siteConfig.timezone = raw.timezone || 'UTC';
|
||||
siteConfig.dnsServers = raw.dnsServers || {};
|
||||
siteConfig.configurationType = raw.configurationType || 'homelab';
|
||||
siteConfig.domain = raw.domain || '';
|
||||
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 {
|
||||
// Run migrations first — this handles config.json files from older
|
||||
@@ -31,29 +57,8 @@ function loadSiteConfig(CONFIG_FILE, log) {
|
||||
const raw = loadAndMigrate(CONFIG_FILE, log);
|
||||
|
||||
if (raw && Object.keys(raw).length > 0) {
|
||||
// Validate config and log any issues
|
||||
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
||||
if (log && log.warn) {
|
||||
if (!valid) {
|
||||
log.warn('config', 'Config validation errors', { errors: configErrors });
|
||||
}
|
||||
for (const w of configWarnings) {
|
||||
log.warn('config', w);
|
||||
}
|
||||
}
|
||||
|
||||
siteConfig.tld = raw.tld || '.home';
|
||||
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
|
||||
siteConfig.caName = raw.caName || '';
|
||||
siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || '';
|
||||
siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT;
|
||||
siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`;
|
||||
siteConfig.timezone = raw.timezone || 'UTC';
|
||||
siteConfig.dnsServers = raw.dnsServers || {};
|
||||
siteConfig.configurationType = raw.configurationType || 'homelab';
|
||||
siteConfig.domain = raw.domain || '';
|
||||
siteConfig.routingMode = raw.routingMode || 'subdomain';
|
||||
siteConfig.pylon = raw.pylon || null;
|
||||
validateAndLogConfig(raw, log);
|
||||
applyConfigFields(raw);
|
||||
}
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user