Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1528fd1a35 | ||
|
|
432e9635bc | ||
|
|
5b74536472 | ||
|
|
bb01a77ae7 | ||
|
|
88ff260e5e | ||
|
|
ff81d99021 | ||
|
|
5c02bfba1d | ||
|
|
bb20f02cbf | ||
|
|
dc788e5dd3 | ||
|
|
04f90d1505 | ||
|
|
bd13104362 | ||
|
|
dcf252e515 | ||
|
|
a7512b4a56 | ||
|
|
f5fc688185 | ||
|
|
1bc41bb2bc | ||
|
|
4dda005eb1 | ||
|
|
140aa5d4b1 | ||
|
|
bf1bcb1133 | ||
|
|
7b04bc1d3c | ||
|
|
191d3340a7 | ||
|
|
84f63a3261 | ||
|
|
f2c6fa69f5 | ||
|
|
c55abdab87 | ||
|
|
0bf4406253 | ||
|
|
cbc5dc96c8 | ||
|
|
e8b9dd5b91 | ||
|
|
baba762dab | ||
|
|
f9eaa324dd | ||
|
|
a667de7920 | ||
|
|
c1358df0ec | ||
|
|
55a50fdeb7 | ||
|
|
609ccd32c4 | ||
|
|
57ed09fe91 | ||
|
|
b3488f14ca | ||
|
|
8072c076e2 | ||
|
|
66e44606af | ||
|
|
a042645299 | ||
|
|
3a0a5bc897 | ||
|
|
9ab3452b19 | ||
|
|
a7057e4fba | ||
|
|
f8b088916b | ||
|
|
9b9711bf24 | ||
|
|
f154f501ff | ||
|
|
1c02131fe0 | ||
|
|
f89079804c | ||
|
|
7f6203b2f7 | ||
|
|
b40cb6458b | ||
|
|
54e8042764 | ||
|
|
fadbfc8eb5 | ||
|
|
d8f9df7e77 | ||
|
|
86df178022 | ||
|
|
d45ebb8f39 | ||
|
|
a2ab1f85eb | ||
|
|
be798a9bc2 | ||
|
|
8a512774d7 | ||
|
|
592a9fd939 | ||
|
|
6d5b1992b5 | ||
|
|
649c714aea | ||
|
|
0d46225efc | ||
|
|
140ef8726b | ||
|
|
0cc278abf1 | ||
|
|
003b152230 | ||
|
|
75f835641f | ||
|
|
872923dba2 | ||
|
|
10f2bf707b | ||
|
|
f42e761e52 | ||
|
|
e208e05b83 | ||
|
|
ba21dad550 | ||
|
|
09d2451f2c | ||
|
|
e69a93a825 | ||
|
|
96e2ef8609 | ||
|
|
d450580ef5 | ||
|
|
7143c36187 | ||
|
|
7682cb77bf |
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
+46
-9
@@ -180,10 +180,11 @@
|
||||
- **result:** Verified zero callers (grep + 38 test files scanned — no references to `./self-updater`). Discovered the file was actually gitignored, never committed — so `git rm` was unnecessary; plain `rm` did it. Tests: 1075/1075 still passing post-delete. Also synced `dashcaddy-api/VERSION` to `42376e2` (the DC-033 + release-bump commit) so the rebuilt container reports the right SHA. Live: `curl http://127.0.0.1:3001/api/v1/system/version` returns `{"name":"DashCaddy","version":"1.14.9","commit":"42376e2"}`.
|
||||
|
||||
### DC-037: Move `/etc/dashcaddy/sites/dashcaddy-api` symlink creation into the install script
|
||||
- **status:** in-progress
|
||||
- **status:** done
|
||||
- **owner:** krystie
|
||||
- **details:** DNS2 has the symlink manually created during this session (2026-07-05), but every other fresh DashCaddy install will hit the same `cp: cannot create directory '/etc/dashcaddy/sites/dashcaddy-api/routes': No such file or directory` failure when the first auto-update lands, because `dashcaddy-update.sh` defaults `apiSourceDir` to `${CADDY_BASE}/sites/dashcaddy-api` (= `/etc/dashcaddy/sites/dashcaddy-api`) while the actual install lives at `/opt/dashcaddy/dashcaddy-api`. Fix: add `mkdir -p /etc/dashcaddy/sites && ln -sfn /opt/dashcaddy/dashcaddy-api /etc/dashcaddy/sites/dashcaddy-api` to the install script (whichever of `dashcaddy-installer/install.sh` or `scripts/dashcaddy-install.sh` is canonical — verify which exists on a clean install). Make it idempotent (`ln -sfn`, not `ln -s`, so re-runs don't fail). Effort: ~10 min. Risk: very low.
|
||||
- **impact:** Prevents every future DashCaddy host from hitting the v1.14.4-class update failure on first auto-update.
|
||||
- **result:** Added `install_api_symlink()` to `dashcaddy-installer/install.sh`, called from `main()` right after `start_caddy` at end of Step 7. The function does `mkdir -p /opt/dashcaddy && ln -sfn "${API_DIR}" /opt/dashcaddy/dashcaddy-api` (idempotent: `-sfn` replaces stale links and does not fail on re-runs; `${API_DIR}` resolves to `/etc/dashcaddy/sites/dashcaddy-api` per the existing readonly constants at lines 23-26). The `mkdir -p /opt/dashcaddy` ensures the symlink's parent directory exists on a fresh host before `ln -sfn` runs. `bash -n install.sh` returns SYNTAX OK. The auto-updater's `DATA_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api/data` and other `/opt/dashcaddy/...` defaults now resolve cleanly through the symlink on fresh installs. Existing DNS2 host is unaffected (the symlink already exists there from the manual session 2026-07-05; `ln -sfn` would replace it with the same target if re-run).
|
||||
|
||||
---
|
||||
|
||||
@@ -250,7 +251,7 @@
|
||||
Tickets DC-033 through DC-041 were added after the DNS2 v1.14.4 / v1.14.8 / 0.0.0 incident. They are grounded in real evidence from that session — see DC-033's details for the full chain of reasoning (cross-checked by main agent + z.ai subagent).
|
||||
|
||||
### DC-044: Fix WorkflowEngine healthCheckService — servicesStateManager.getState bug (silent every-5min error spam)
|
||||
- **status:** in-progress
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `src/recipes/bundled-workflows.js:310` calls `servicesStateManager.getState()` which doesn't exist (StateManager exposes `read()`, not `getState()`). Combined with a missing `await`, the call returned a Promise (truthy), short-circuited via `|| []` to an empty array, then `for (const service of services)` silently iterated over zero services. Net effect: every `health-check-on-interval` workflow ran every 5 min, reported `Action health-check failed: servicesStateManager.getState is not a function`, sent a "Health check failed for {{serviceId}}" notification (with the unresolved template!), and produced `checked: 0, healthy: 0` results. Visible on BOTH DNS2 (production, 4 days) and test server dc-contabo-de (9 days). Fix: call `await servicesStateManager.read().catch(() => [])` — proper async + corruption-tolerant.
|
||||
- **impact:** Workflow health checks now actually check container health, instead of silently reporting 0/0 every cycle. Stops the "Health check failed" notification spam.
|
||||
@@ -323,25 +324,36 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
|
||||
- **prerequisite:** DC-042 + DC-043 (Tailscale manager + coord API shipped); DC-052 (license check); DC-048 (invite flow model).
|
||||
|
||||
### DC-054: License-keygen CLI improvements + Stripe webhook bridge script
|
||||
- **status:** todo
|
||||
- **owner:** unclaimed
|
||||
- **status:** in-progress
|
||||
- **owner:** hermes
|
||||
- **details:** Existing `dashcaddy-api/license-keygen.js` already supports durations [30, 90, 180, 365]. Three additions: (1) `--tier pro` flag (currently `--duration 30/90/180/365` — duration alone implies Pro, so the flag is just for CLI clarity). (2) `dashcaddy-api/scripts/stripe-license-bridge.js` — listens on `STRIPE_WEBHOOK_SECRET`, validates `checkout.session.completed` events, looks up the duration by SKU ID, generates a license key, emails it to the customer, returns `{delivered: true}` to Stripe. (3) `dashcaddy-api/license-keygen.js` validation path — already exists, no change. Sami generates initial keys via CLI for the launch.
|
||||
- **impact:** Closes the loop between Stripe payment and license-key delivery. Without this, every sale requires manual key generation by Sami.
|
||||
- **prerequisite:** None. Stripe-side can be set up in parallel with DC-052.
|
||||
|
||||
### DC-055: dashcaddy.net/pricing static page + Stripe Checkout integration
|
||||
- **status:** todo
|
||||
- **owner:** unclaimed
|
||||
- **status:** in-progress
|
||||
- **owner:** hermes
|
||||
- **details:** Static page at `/pricing` showing the 5-row tier table (Free / 1mo / 3mo / 6mo / 12mo). Stripe Checkout button per paid tier. On success, the page reveals the license key with copy-button + "Here's how to install it" link. Receipt email sent via Stripe's built-in. No account creation in this flow (Q10 decision — optional dashcaddy.net account is post-v1.0).
|
||||
- **impact:** The conversion surface. Without this, the product is real but unsellable.
|
||||
- **prerequisite:** DC-054 (Stripe webhook bridge so licenses auto-issue).
|
||||
- **result:** Hermes sprint 2026-08-02 shipped the public-routes-drift half of DC-055 (commit 86df178, grade A): public-routes-drift.test.js now correctly maps `routes/billing.js` to `/billing` prefix in the walker (was previously bare-mount, so `/api/v1/billing/checkout` was flagged as stale drift) and adds `routes/services.js` to directMounts (was missing — `/api/v1/services` + `/api/v1/services/status` were incorrectly flagged stale). Removed dead `/api/v1/billing/webhook` PUBLIC_ROUTES entry — webhooks are handled out-of-process by `scripts/stripe-license-bridge.js` and the merchant webhook secret never enters the API process. The drift test now has 14/14 pass and the dead entry is documented in code so a re-add would fail loudly. Krystie's prior uncommitted work (`src/billing/stripe-client.js`, `routes/billing.js`, `scripts/stripe-license-bridge.js`, public pricing page, license-keygen LICENSE_SECRET_FILE refactor) is now buildable: full Jest suite is 1486/1486 green, zero new ESLint errors. Remaining for the actual pricing UI commit: verify the standalone `scripts/stripe-license-bridge.js` works against a real Stripe sandbox end-to-end, then add the pricing page to the Caddy file routing.
|
||||
|
||||
### DC-057: Close checkout-to-license contract drift before public billing launch
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Codex audit found the current Stripe checkout and webhook bridge cannot interoperate: `dashcaddy-api/src/billing/stripe-client.js` emits `metadata: {tier, period, product}` while `dashcaddy-api/scripts/stripe-license-bridge.js` requires `metadata.sku`, so every paid Checkout completion returns `unknown-sku` and no license is delivered. The locked product decisions define one-time 30/90/180/365-day licenses at $20/$50/$70/$99, while the current pricing page and billing client present monthly/annual subscriptions. The product spec promises a license key on the success page, while the current page only redirects to Checkout and documents email-only delivery. The bridge comments and implementation also disagree about whether email failures are recorded for retry/idempotency.
|
||||
- **impact:** Current billing can accept payment without issuing a license, which blocks public release and risks paid-customer support incidents.
|
||||
- **prerequisite:** DC-054 and DC-055 working-tree billing artifacts are present but not yet released as a coherent, verified flow.
|
||||
- **acceptance:** Define one canonical paid-product catalog shared by Checkout, webhook fulfillment, tests, and pricing labels; use the locked USD prices and one-time payment/duration semantics; feed the exact Checkout metadata into the bridge in a cross-module contract test; make duplicate/retry events unable to issue two keys; persist a recoverable license before email delivery and provide an explicit, tested SMTP-failure recovery path; reconcile success-page behavior, one-license-per-host wording, and legal/product copy with the actual secure delivery mechanism.
|
||||
- **result:** Codex grade B. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Shipped as one coherent DC-057 commit (no partial worktree artifacts). Single canonical product catalog (`src/billing/catalog.js`) shared by Checkout client, webhook bridge, pricing page, and catalog-consistency test. Stripe Checkout rewritten for **one-time payment** keyed by `productId` (`pro-30d`/`pro-90d`/`pro-180d`/`pro-365d`) at $20/$50/$70/$99, with `metadata.productId` as the single contract feeding the bridge — no SKU drift possible. Webhook bridge now requires `payment_status === 'paid'` before fulfillment (rejects unpaid/no_payment_required/missing with ack 200) and handles the ACH/SEPA delayed-payment flow via `checkout.session.async_payment_succeeded`. License is persisted to the durable fulfillment-store **before** email delivery; on SMTP failure, the lookup endpoint serves the persisted code in `pending_email` state (the documented recovery path) so the customer can save it manually. Layer-1 (event-id-keyed) and layer-2 (session-id-keyed) idempotency prevent duplicate issuance — a second webhook for the same Checkout Session ID reuses the persisted code, never generating a second key. Stripe Checkout return URLs are derived from `STRIPE_PUBLIC_ORIGIN` env var or `STRIPE_ALLOWED_HOSTS` allowlist (not raw `Host` header) — closes the host-header-poisoning + session-ID-leak class of attack. New success page (`status/billing/success.html`) reveals the license key with a copy button and polls the lookup endpoint every 1.5s. New test files: `stripe-license-bridge.test.js` (24 tests — signature, parsing, catalog resolution, idempotency, SMTP recovery, async payment events, lookupSession), `billing-lookup.test.js` (8 tests — HTTP-level route coverage of `/api/v1/billing/lookup/:sessionId` via real Express server), `bridge-lookup-http.test.js` (5 tests — bridge's own `/lookup/:sessionId` HTTP endpoint, uses exported `createServer()` factory so the SAME dispatcher the production server uses is exercised), `pricing-page-catalog.test.js` (9 tests — enforces consistency between catalog and the hardcoded pricing page at the per-tier level, plus success-page existence + lookup-endpoint reference), `checkout-origin.test.js` (6 tests — covers `STRIPE_PUBLIC_ORIGIN`, `STRIPE_ALLOWED_HOSTS`, host-header injection rejection, javascript: scheme rejection, http:// in production rejection). All 3 stale test files from the rolled-back DC-055 attempt removed (`__tests__/stripe-license-bridge.test.js`, `__tests__/routes/billing.test.js`). Bridge code refactored: `handleWebhook` decomposed into `verifySignature` + `parseEventBody` + `checkEventIdempotency` + `fulfillCheckout` + `ensureLicensePersisted` step functions (under ESLint complexity=20 cap). Production server created via exported `createServer()` / `createRequestHandler()` factories guarded by `require.main === module` so test imports don't leak an HTTP server. Pricing page (`status/pricing/index.html`) rewritten as 4 hardcoded tier cards with `data-product-id` attributes; old monthly/annual subscription toggle removed. Success page (`status/billing/success.html`) new — copy-button reveal, 1.5s polling, TTL-aware messages. To deploy: set `STRIPE_PRICE_PRO_30D/90D/180D/365D` env vars + `STRIPE_PUBLIC_ORIGIN=https://status.sami` (or set `STRIPE_ALLOWED_HOSTS=status.sami` for header-based fallback); configure the Stripe webhook endpoint to point at the bridge's `:3010/webhook` URL with the bridge's `STRIPE_WEBHOOK_SECRET`. Deploy the new pricing + success pages to `/var/www/dashcaddy-status/`. Bridge runs as `scripts/stripe-license-bridge.js` on port 3010.
|
||||
|
||||
### DC-056: ToS + Privacy Policy pages — GDPR-aware, no SOC2/HIPAA for v1.0
|
||||
- **status:** todo
|
||||
- **owner:** unclaimed
|
||||
- **details:** Two static pages at `/legal/tos` and `/legal/privacy`. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (Stripe 30-day), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Two static pages at `/legal/tos` and `/legal/privacy`. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (pro-rated refunds within 14 days of initial purchase), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
|
||||
- **impact:** Legal compliance for taking money. Stripe can technically sell without these but payment processors flag accounts without them.
|
||||
- **prerequisite:** None.
|
||||
- **result:** Added responsive Terms and Privacy HTML at `status.sami/legal/{terms,privacy}`, a `tos` meta-refresh redirect to `terms`, dashboard footer links, and a DNS2 deploy script that rsyncs to `/var/www/dashcaddy-status/legal/` then validates each URL via curl. Terms apply the launch requirement of pro-rated refunds within 14 days. Single canonical host (status.sami) — the aspirational `legal.dashcaddy.net` is deferred to a v1.x deploy when DNS+Caddy vhost+LE cert infra is in place.
|
||||
|
||||
### Backlog note (2026-07-14)
|
||||
|
||||
@@ -354,6 +366,14 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
|
||||
- **impact:** Workflow engine now starts cleanly. Health-check-on-interval workflow now actually runs against real services instead of silently 0/0.
|
||||
- **result:** Hoisted `const NotificationManager = require(...)` and used `new NotificationManager({...})` in the server.js init block. Verified live on dc-contabo-de: workflow engine now logs `Workflow engine initialized` on startup; 90s of post-restart logs show zero `getState is not a function` errors, zero `WorkflowEngine Action health-check failed` spam, zero error-priority entries. Health check: 200 OK with uptime reporting.
|
||||
|
||||
### DC-058: Share UI — admin modal + public preview page (completes DC-053)
|
||||
- **status:** done
|
||||
- **owner:** hermes (graded B by codex-as-judge)
|
||||
- **details:** DC-053 shipped the full share backend (share-store + 8 routes, 53 tests, Pro tier-gate, Tailscale coordination, email delivery). The `BACKLOG.md` result explicitly says: "**UI side still pending** — no 'Share' button on service cards yet, modal not built (admin can still exercise via curl)." Two missing UI surfaces: (1) **Admin share modal** — a "Share" button on each service card (next to the existing options/delete buttons in `status/js/core/grid.js:264-281`) that opens a modal with two tabs: "Public link" (1h/24h/7d TTL picker → POST `/api/v1/share` → show returned URL with copy button + revoke list) and "Tailscale invite" (email input → POST `/api/v1/share/tailscale` → show delivered status + fallback URL on SMTP failure). Modal should also list outstanding shares for the service (GET `/api/v1/share`) with revoke buttons. (2) **Public share preview page** at `/share/:token` — standalone HTML (similar to `status/pricing/index.html` and `status/billing/success.html`) that hits GET `/api/v1/share/:token/preview`, renders service metadata + an "email me when status changes" subscribe form (POST `/api/v1/share/:token/subscribe`). The URL path is already returned by the issue endpoints as `urlPath` (e.g. `/share/<token>`) — the public-preview page just needs to live at that route. Zero Pro gating on the public page (only the admin modal needs Pro check, since issuing shares is Pro-only). Effort: ~2 hr. Risk: low — the API contract is fully tested.
|
||||
- **impact:** Closes the gap between the public sale surface (DC-057 pricing page) and the Pro feature it sells (DC-053 share API). Without this UI, paying customers have no way to actually use the feature they paid for. Manual `curl` is not a UX.
|
||||
- **prerequisite:** DC-053 (shipped). DC-052 (Pro gate, shipped).
|
||||
- **result:** Shipped codex-graded B. Admin modal (status/js/share-modal.js, 382 LOC, in features.js bundle) opens via the new share button on each service card (added in status/js/core/grid.js, gated on s.id !== internet same as siblings). Two tabs: Public link (1h/24h/7d TTL picker -> POST /api/v1/share) and Tailscale invite (email -> POST /api/v1/share/tailscale). Modal lists outstanding shares (GET /api/v1/share) with revoke buttons. 402 -> Pro upgrade prompt. 400 (no Tailscale) -> setup prompt. Public preview page (status/share/index.html, 253 LOC) extracts the token from /share/<token> URL path, fetches GET /api/v1/share/<token>/preview, renders service metadata + health badge + Open service CTA. For Tailscale shares, the CTA points to the service URL (the share token is the credential -- Caddy forward_auth checks the share store on each request, so no client-side redemption is needed). Subscribe form posts to /api/v1/share/<token>/subscribe. Caddy route required: DNS2 needs a rewrite /share/* /share/index.html rule to serve the page for any /share/<token> URL. Frontend tests: 3 new node --test files (status/tests/share-modal.test.js, share-preview.test.js, core-grid-share-button.test.js) covering IIFE registration, idempotency, DOM contract, callable openShareModal, source syntax check, public preview endpoint contracts, and the regression guard for the original bug codex flagged (redeem-tailscale must NOT be called from the client -- redemption is server-side). Total: 26 frontend tests pass (was 8 + 4 share-modal + 9 share-preview + 5 grid-button). 1498/1498 backend tests still pass; zero new ESLint warnings. Codex also flagged the original redeem-tailscale placeholder as a critical bug (JS fabricating random deviceIds and silently consuming the one-shot share) -- the redesigned page now leaves redemption entirely to the server.
|
||||
|
||||
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.
|
||||
@@ -363,3 +383,20 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
|
||||
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.
|
||||
|
||||
### DC-059: Joi validation library — schema-based body validation middleware
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Backend uses ad-hoc `if (!field) throw new ValidationError(...)` checks at every route entry point — 49 such checks across the codebase. They drift from the field semantics, allow unknown keys to flow through, and have no way to express structured types (CIDR, enum, port range). Tracked in `DC-PRODUCTION-GRADE-BACKLOG.md` as P1-1. Fix: `npm install joi@^18`, add `src/utilities/validate.js` exporting `validateBody(schema)` middleware factory + `schemas` object with reusable schemas. Apply to destructive routes: backups (schedule/restore/config), apps (deploy/restore/revert), assets (upload/logo). Add `__tests__/unit/validate.test.js` covering each schema's accept/reject/strip-unknown behaviour. Effort: ~2 hr.
|
||||
- **impact:** Closes P0-3 / P0-4 class of bugs at the schema layer instead of per-route. Prevents future routes from accepting arbitrary body fields. New routes copy-paste from `schemas.*` and get free validation.
|
||||
- **prerequisite:** None.
|
||||
- **result:** Shipped codex-graded B. New module `src/utilities/validate.js` (170 LOC) with `validateBody(schema, opts)` middleware + 9 Joi schemas. Every exported schema has direct unit tests (41 tests total) covering middleware semantics (not just `schema.validate`). Applied to 8 destructive routes: backups (schedule/restore/config), apps (deploy/restore/revert), assets (upload/logo). Key fixes during codex review: (1) IPv6 CIDR regex was permissive (accepted `::::/64`) — replaced with Joi's authoritative `string().ip({cidr: 'required'})`. (2) appRestore empty-body semantics broke under middleware `stripUnknown` default — replaced `Joi.object({}).max(0)` with `Joi.any().custom()` that enforces non-empty rejection even after strip. (3) appDeploy.config now uses `.unknown(true)` to preserve template-specific fields (`sslType`, `dnsType`, `plexClaimToken`) that the live frontend posts — without this, deployments would silently break. Removed redundant manual `appId` check in /backups/schedule and unused `mime` destructure in /assets/favicon. Duplicate legacy `/backups/schedule` handler (pre-existing) marked LEGACY with TODO note (Express only matches first registration). 1539/1539 Jest tests pass (was 1498, +41 new). ESLint warnings unchanged (416 total, all pre-existing).
|
||||
|
||||
### DC-060: Console→logger sweep for `src/managers/update-manager.js` (49 sites)
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Production code uses `console.log/warn/error` with `[UpdateManager]` prefixes in 49 places — these go to stdout/stderr directly, bypassing the unified logger (no structured JSON, no error.log file writes, no log-level filtering, no test capture). Tracked in `DC-PRODUCTION-GRADE-BACKLOG.md` as P1-2. Fix: import `log` from `../utils/logging`, replace every `console.log('[UpdateManager] X')` with `log.info('update', 'X')` (dropping the redundant `[UpdateManager]` tag), every `console.warn(...)` with `log.warn('update', ...)`, every `console.error('...', err.message)` with `log.error('update', err)` (passing the error object so it lands in error.log with stack + context). For mixed-content strings like `Stored old image digest: ${oldImageDigest.substring(0, 40)}...` extract the variable into the meta payload: `log.info('update', 'Stored old image digest', { digestPrefix })`. Effort: ~30 min. Risk: very low — pure logging refactor, no behavior change.
|
||||
- **impact:** Update manager events now flow through the same log pipeline as every other module: structured JSON in prod, pretty-printed in dev, error.log rotation for errors, log-level filtering, test capture via stderr spy. Operators get consistent log format and can grep across modules.
|
||||
- **prerequisite:** None.
|
||||
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ vi /opt/dashcaddy/services.json # live-reloaded by the watcher
|
||||
## Project Info
|
||||
|
||||
- **Name**: DashCaddy
|
||||
- **Version**: 1.13.4 (current; CHANGELOG.md `[Unreleased]` tracks the next bump)
|
||||
- **Version**: 1.15.0 (current; CHANGELOG.md `[Unreleased]` tracks the next bump)
|
||||
- **Purpose**: Unified management for Docker + Caddy + DNS
|
||||
- **Local TLD (Windows)**: `.sami`
|
||||
- **Local TLD (Linux, DNS2)**: `.home` (default; configurable via `siteConfig.tld`)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
# DashCaddy Production-Grade Backlog (v2)
|
||||
|
||||
> Generated 2026-08-12 from a full codebase audit.
|
||||
> v1 items (P0-1 through P2-7) are ALL DONE.
|
||||
> Current state: 1539 tests, 86.55% statement coverage, 0 ESLint errors, 173 warnings.
|
||||
|
||||
## Current Health Snapshot
|
||||
- **Tests:** 1539 passing across 63 suites
|
||||
- **Coverage:** Statements 86.55% | Branches 72.14% (below 80% gate) | Functions 80.8% | Lines 90.67%
|
||||
- **ESLint:** 0 errors, 173 warnings (all pre-existing)
|
||||
- **Remaining console.* calls in src/:** 21 across 10 files
|
||||
- **Dockerfile:** Runs as root (documented — needs Docker socket), no resource limits
|
||||
- **OpenAPI spec:** Present but stale (says v1.0.0, actual is v1.15.0)
|
||||
- **Unhandled rejection/exception handlers:** Present in server.js ✓
|
||||
- **Rate limiting:** Present on auth + general routes ✓
|
||||
- **npm audit:** 4 remaining vulns (semver-major transitive deps, deferred)
|
||||
|
||||
---
|
||||
|
||||
## P0 — Must Fix (blocks public release)
|
||||
|
||||
### DC-062: OpenAPI spec is stale — update to match actual v1.15.0 API surface
|
||||
- **status:** pending
|
||||
- **details:** `openapi.yaml` says `version: 1.0.0` and describes only a fraction of the API. Since DC-046/047 (auth providers), DC-053 (share), DC-055 (billing), DC-058 (share UI), and the tailscale-admin routes were added, the spec is significantly out of date. A stale spec is worse than no spec — it misleads API consumers and breaks any code generation from it. Fix: audit all route files (`grep -rn 'router\.\(get\|post\|put\|delete\|patch\)' routes/`), update openapi.yaml with every endpoint, bump version to 1.15.0, add it to the test suite (DC-017-style source-of-truth test that fails if a route exists but has no spec entry). Effort: ~3 hr.
|
||||
- **impact:** Public API trust. No paying customer can integrate against an undocumented API.
|
||||
|
||||
### DC-063: Branch coverage at 72% — below the 80% gate
|
||||
- **status:** pending
|
||||
- **details:** Jest coverage report shows branches at 72.14% (303/420), failing the 80% threshold. The uncovered branches are concentrated in error-handling paths (catch blocks, fallback returns, edge-case conditionals). Fix: run `npx jest --coverage --coverageReporters=text` to identify the files with the lowest branch coverage, then add targeted tests for the uncovered conditional paths. Priority files: backup-manager.js (multiple catch blocks), health-checker.js (timeout/retry branches), tailscale-coord.js (API error branches). Effort: ~2 hr.
|
||||
- **impact:** Error paths are where production incidents hide. Every untested catch block is a potential crash.
|
||||
|
||||
### DC-064: Dockerfile runs as root with no resource limits
|
||||
- **status:** pending
|
||||
- **details:** The Dockerfile has no `USER` directive and `start.sh` has no `--memory` or `--cpus` flags. While root is needed for Docker socket access, the container can still OOM the host. Fix: (1) Add `--memory=512m --memory-swap=1g --cpus=1.5` to the `docker run` in start.sh. (2) Create a non-root user `dashcaddy` for the application process, and use a Docker socket proxy (like `tecnativa/docker-socket-proxy`) that exposes a limited subset of Docker API endpoints — the app only needs read access for monitoring + controlled container lifecycle. (3) Add `--restart=unless-stopped` if not already present. Effort: ~2 hr. Risk: medium — socket proxy may break some Docker API calls, needs testing.
|
||||
- **impact:** Without limits, a memory leak in the API can take down the entire host. This is a production safety issue.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Code Quality & Reliability
|
||||
|
||||
### DC-065: Remaining 21 console.* calls — sweep to structured logger
|
||||
- **status:** pending
|
||||
- **details:** After DC-060 (update-manager) and P1-3 through P1-8, 21 console calls remain across 10 files: `error-handler.js` (2), `email.js` (1), `dns-providers/registry.js` (2), `audit-logger.js` (3), `csrf-protection.js` (3), `config-drift-detector.js` (1), `auto-restart-manager.js` (1), `http.js` (1), `logging.js` (6 intentional — the logger itself), `routes/backups.js` (1). The logging.js calls are fine (the logger IS console internally). The rest should route through `log.info/warn/error`. Some are fallbacks: `ctx.logError || ((_c, err) => console.error(err))` — these fire when ctx isn't available, which is exactly when structured logging matters most. Effort: ~45 min.
|
||||
- **impact:** Consistency. The logger write to error.log and supports structured JSON — console does not.
|
||||
|
||||
### DC-066: No API integration test for the billing flow end-to-end
|
||||
- **status:** pending
|
||||
- **details:** DC-057 shipped contract tests and unit tests for the Stripe bridge, but there is no test that exercises the full flow: pricing page → Stripe Checkout → webhook → license-key delivery → license activation → Pro unlock. Build a single integration test that mocks Stripe's API, walks the complete flow, and asserts the license works at the end. This is the revenue path — it must be tested as a chain, not just individual pieces. Effort: ~2 hr.
|
||||
- **impact:** Confidence in the revenue pipeline. A broken webhook or catalog mismatch silently loses sales.
|
||||
|
||||
### DC-067: No graceful shutdown — SIGTERM kills in-flight requests
|
||||
- **status:** pending
|
||||
- **details:** server.js handles `uncaughtException` and `unhandledRejection`, but there is no `SIGTERM` handler that calls `server.close()` to drain connections. Docker stop sends SIGTERM (the Dockerfile has `STOPSIGNAL SIGTERM`), but without a handler the process exits immediately, dropping any in-flight API calls. Fix: add a `SIGTERM` handler in server.js that (1) stops accepting new connections via `server.close()`, (2) waits up to 10s for in-flight requests, (3) closes DB/file handles, (4) exits cleanly. Also emit a `shutdown` event so managers (health checker, SSL monitor, workflow engine) can stop their timers. Effort: ~1 hr.
|
||||
- **impact:** Zero-downtime deployments. Currently, every `docker stop` drops active requests.
|
||||
|
||||
### DC-068: ESLint warnings sweep — 173 pre-existing warnings
|
||||
- **status:** pending
|
||||
- **details:** While there are 0 ESLint errors, 173 warnings remain. Top files: `dns-providers/base.js` (27), `update-manager.js` (14), `backup-manager.js` (10), `keychain-manager.js` (10), `bundled-workflows.js` (10), `auth/providers/base.js` (9), `log-digest.js` (8). Most are `no-unused-vars`, `require-await`, `no-nested-ternary`. Fix: sweep through the top 10 files, fix what's actionable (unused vars → remove, nested ternaries → extract to named variables, false-positive require-await → mark `_` or restructure). Set a ceiling: warnings should never increase. Effort: ~2 hr.
|
||||
- **impact:** Clean codebase. 173 warnings is noise that hides real issues when new ones are added.
|
||||
|
||||
### DC-069: Health check notification spam — add failure threshold + cooldown
|
||||
- **status:** pending
|
||||
- **details:** The workflow engine sends a notification on EVERY health check failure (every 15 min). If a service is down for a day, that's 96 identical notifications. There is no backoff, no deduplication, no "service recovered" message. Fix: (1) Only notify on state TRANSITIONS (up→down, down→up), not every failure. (2) Add a `consecutiveFailures` threshold (e.g., 2 failures before first alert) to avoid flapping noise. (3) Send a recovery notification when a service comes back up. (4) Optional: daily digest of uptime stats instead of per-failure alerts. Effort: ~1.5 hr.
|
||||
- **impact:** Operator sanity. The current notification volume is exactly why people mute alerting channels — and then miss real incidents.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Polish & Developer Experience
|
||||
|
||||
### DC-070: No CI/CD pipeline — tests run manually
|
||||
- **status:** pending
|
||||
- **details:** There is no GitHub Actions / CI configuration. Tests are run manually before push. This means a bad commit can reach main if someone forgets to test. Fix: add `.github/workflows/test.yml` (or Gitea Actions equivalent) that runs `npm ci && npx jest --coverage` on every PR and push to main. Cache node_modules. Upload coverage report as artifact. Block merge on test failure or coverage decrease. Effort: ~1 hr.
|
||||
- **impact:** Automated quality gate. No bad commit reaches production.
|
||||
|
||||
### DC-071: No error tracking / Sentry integration
|
||||
- **status:** pending
|
||||
- **details:** Errors go to `error.log` inside the container. If the container is recreated (DC-050 migration), the error log is lost. There is no external error tracking. Fix: add an optional Sentry (or GlitchTip for self-hosted) integration. If `SENTRY_DSN` env var is set, initialize Sentry before Express. Wrap async handlers to capture exceptions. The error-handler.js middleware should forward to Sentry before returning the generic error response. Make it opt-in (no DSN = no Sentry, zero behavior change). Effort: ~1 hr.
|
||||
- **impact:** Production visibility. Right now, errors are invisible unless someone SSHs in and reads the log.
|
||||
|
||||
### DC-072: Frontend bundle has no source maps in production
|
||||
- **status:** pending
|
||||
- **details:** `status/build.js` uses esbuild but the production build doesn't emit source maps. When a frontend error occurs in production, the stack trace points to minified bundle lines — useless for debugging. Fix: add `sourcemap: true` to the esbuild production config. Serve `.map` files from Caddy (they're already in `dist/`). Optionally upload source maps to Sentry (DC-071). Effort: ~30 min.
|
||||
- **impact:** Frontend bug reports become actionable instead of "line 1 of core.js".
|
||||
|
||||
### DC-073: No API request/response logging middleware for debugging
|
||||
- **status:** pending
|
||||
- **details:** While there is an audit logger for POST/PUT/DELETE, there's no request/response logging middleware for debugging purposes (like morgan or a custom equivalent). When an operator reports "the dashboard is slow" or "this endpoint returns 500 sometimes", there's no way to trace the request through the system. Fix: add an optional debug-level request logger that logs method, path, status, duration, and request ID. Gated behind `LOG_LEVEL=debug` so it's off in production by default. Effort: ~45 min.
|
||||
- **impact:** Drastically reduces time-to-resolution for production issues.
|
||||
|
||||
### DC-074: Docker image is not multi-stage — build artifacts bloat the image
|
||||
- **status:** pending
|
||||
- **details:** The Dockerfile copies source files into a single stage based on `node:20-alpine`. The image includes `devDependencies` because `npm install --production` still installs some optional deps, and there's no `.dockerignore` (so `__tests__/`, `.git/`, `node_modules/` from the host can leak in). Fix: (1) Add a `.dockerignore` file excluding `__tests__/`, `.git/`, `node_modules/`, `*.md`, `coverage/`. (2) Convert to multi-stage: build stage installs all deps, production stage copies only `node_modules/` (production) + source. (3) Pin Node.js version: `FROM node:20.10-alpine` instead of `node:20-alpine` (floating). Effort: ~1 hr.
|
||||
- **impact:** Smaller image = faster pulls = faster deploys. Current image size carries unnecessary weight.
|
||||
|
||||
### DC-075: No health check dashboard endpoint for operators
|
||||
- **status:** pending
|
||||
- **details:** The `/api/v1/monitoring/stats` endpoint returns container stats, but there's no single "is everything OK" endpoint that returns a human-readable system health summary. Fix: add `GET /api/v1/system/health` that returns `{ status: "healthy"|"degraded"|"unhealthy", checks: { database: "ok", diskSpace: "ok", memory: "ok", uptime: ..., activeServices: N/M, lastError: "..." } }`. This is useful for uptime monitoring services (UptimeRobot, BetterStack) and for a quick operator glance. Effort: ~1 hr.
|
||||
- **impact:** Operators can plug DashCaddy into external monitoring without parsing container stats.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Future & Nice-to-Have
|
||||
|
||||
### DC-076: WebSocket support for real-time dashboard updates
|
||||
- **status:** pending
|
||||
- **details:** The dashboard polls the API every N seconds for service status updates. For a "live" dashboard experience, WebSocket (or SSE) push would be better — status changes appear instantly without polling overhead. Fix: add a WebSocket server (using `ws` library) that pushes service status changes, health check results, and container events to connected dashboard clients. Keep polling as fallback for clients without WS support. Effort: ~3 hr.
|
||||
- **impact:** Dashboard feels "live". Reduces API load from polling.
|
||||
|
||||
### DC-077: Multi-language (i18n) support
|
||||
- **status:** pending
|
||||
- **details:** All UI text is hardcoded English. For a public product, internationalization is a step toward wider reach. Fix: extract all user-facing strings into a locale file, add an i18n library (like i18next), provide at minimum an English + Arabic locale (Sami's audience). Effort: ~4 hr.
|
||||
- **impact:** Market expansion. Arabic-speaking homelab community is underserved.
|
||||
|
||||
### DC-078: Backup and restore of DashCaddy's own configuration
|
||||
- **status:** pending
|
||||
- **details:** While DashCaddy can backup app data, there's no one-click "backup my entire DashCaddy setup" (services.json, config.json, health-config.json, credentials, Caddyfile, license) that could be restored on a fresh install. Fix: add `GET /api/v1/system/export` (returns a signed JSON bundle) and `POST /api/v1/system/import` (restores from bundle). The credentials file should be encrypted with a user-provided passphrase. Effort: ~2 hr.
|
||||
- **impact:** Migration story. "Moving DashCaddy to a new host" is currently a multi-hour manual process.
|
||||
|
||||
### DC-079: Mobile-responsive dashboard improvements
|
||||
- **status:** pending
|
||||
- **details:** While the dashboard is somewhat responsive, it's not optimized for mobile use. For operators checking services on their phone, the experience should be touch-first. Fix: audit all dashboard pages on mobile viewport, fix any horizontal scroll, ensure buttons are touch-target sized (min 44px), add a mobile-specific layout for the service grid. Effort: ~3 hr.
|
||||
- **impact:** Operators check services on their phone. Current mobile experience is usable but not polished.
|
||||
|
||||
### DC-080: Plugin/extension system for custom services
|
||||
- **status:** pending
|
||||
- **details:** DashCaddy supports a fixed set of service templates. A plugin system would allow community-contributed service definitions (e.g., "Home Assistant", "Vaultwarden", "Nextcloud") without modifying core code. Fix: define a plugin manifest schema (name, logo, health check URL pattern, config fields), load plugins from `/data/plugins/`, add a community plugin registry page. Effort: ~4 hr.
|
||||
- **impact:** Community growth. Extensibility is what makes a tool ecosystem vs. a product.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## P2.5 — Security Hardening (Deep Audit Findings)
|
||||
|
||||
### DC-081: 151 of 160 mutating routes have NO Joi input validation
|
||||
- **status:** pending
|
||||
- **details:** P1-1 added Joi validation to 8 routes, but a scan shows **151 out of 160** POST/PUT/PATCH/DELETE routes still accept raw `req.body` without schema validation. That's 94% of the mutation surface unvalidated. Routes like `POST /api/v1/services/:id`, `PUT /api/v1/config`, `POST /api/v1/tailscale/*`, `POST /api/v1/health/config/:id` all accept arbitrary input. Fix: extend `src/utilities/validate.js` with schemas for every mutating route, wire them in. This is the single highest-impact security improvement. Effort: ~4 hr (batch by route file).
|
||||
- **impact:** Input validation is the #1 defense against injection, abuse, and crashes. 94% gap is a P0 hiding as a P2.
|
||||
|
||||
### DC-082: Command injection surface in ca.js — 5 execSync calls with interpolation
|
||||
- **status:** pending
|
||||
- **details:** `routes/ca.js` has 5 `execSync()` calls with template-string interpolation: lines 164, 175, 180, 203, 263. P0-2 fixed the password injection (`execFileSync`), but the remaining calls interpolate file paths and subjects (`${certFile}`, `${keyFile}`, `${subject}`, `${configFile}`). If any of these contain user input (e.g., a service name with `;` or backticks), it's command injection. Fix: convert ALL `execSync(\`...\`)` calls to `execFileSync('openssl', [...args])` with no shell interpolation. Also fix `src/docker/self-updater.js:717` (`execSync(\`tar xzf \"${tarballPath}\"...`)`) and `src/utilities/backup-manager.js:8` (imported execSync). Effort: ~2 hr.
|
||||
- **impact:** Any execSync with interpolation is a potential RCE. This is the same class of bug P0-2 already fixed — finish the job.
|
||||
|
||||
### DC-083: 30 source files have zero test coverage
|
||||
- **status:** pending
|
||||
- **details:** The test gap scan found 30 source files with NO corresponding test file, including critical paths: `license-manager.js` (534 lines, the entire revenue validation path), `config-schema.js`, `middleware.js` (the auth/rate-limit/CORS stack), `startup-validator.js`, all 7 DNS provider modules (`technitium.js`, `cloudflare.js`, `rfc2136.js`, `manual.js`, `base.js`, `registry.js`, `email.js`), `docker-maintenance.js`, `config/migrations.js`, `event-workers.js`, `keychain-manager.js`, `event-store.js`, `host-registry.js`. Fix: prioritize license-manager.js (revenue path) and middleware.js (security stack) first, then work through the rest. Effort: ~8 hr (can be done incrementally, 2-3 files per PR).
|
||||
- **impact:** license-manager.js validates Pro licenses — an untested bug there could silently break activation for every paying customer.
|
||||
|
||||
### DC-084: No .dockerignore — test files and .git leak into Docker image
|
||||
- **status:** pending
|
||||
- **details:** There is no `.dockerignore` file. The Docker build context includes `__tests__/` (hundreds of test files), any `.git/` directory, `coverage/`, `node_modules/` from the host, and markdown files. This bloats the image (currently 249MB) and can leak sensitive test fixtures. Fix: create `.dockerignore` with: `__tests__/`, `.git/`, `node_modules/`, `coverage/`, `*.md`, `.eslintrc.js`, `jest.config.js`, `npm-debug.log*`, `.env*`, `openapi.yaml` (only needed at build time if at all). Also add `.dockerignore` to the git repo. Effort: ~15 min.
|
||||
- **impact:** Faster builds, smaller images, no test fixture leaks.
|
||||
|
||||
### DC-085: Math.random() used for security-sensitive IDs
|
||||
- **status:** pending
|
||||
- **details:** `health-checker.js:352` generates incident IDs with `Math.random().toString(36)`. `resource-monitor.js:143` uses `Math.random()` for sampling. `rfc2136.js:120` generates temp filenames with `Math.random()`. While these aren't crypto-level secrets, `Math.random()` is not collision-resistant and is predictable. Fix: use `crypto.randomUUID()` for incident IDs, `crypto.randomBytes()` for temp filenames, and a simple counter for sampling. Effort: ~30 min.
|
||||
- **impact:** Defense in depth. Predictable IDs can be exploited if they ever become user-facing.
|
||||
|
||||
---
|
||||
|
||||
## P3.5 — Operational Maturity
|
||||
|
||||
### DC-086: No structured error codes — errors are ad-hoc strings
|
||||
- **status:** pending
|
||||
- **details:** The HTTP status code audit shows only 6 distinct status codes used across routes (200, 201, 400, 401, 404, 429). Error responses are plain strings like `"Invalid input"` or `"Unauthorized"`. There is no error code system (like `INVALID_CONFIG`, `SERVICE_NOT_FOUND`, `LICENSE_EXPIRED`). Fix: define a canonical error code enum in `src/utilities/errors.js`, return `{ error: { code: "SERVICE_NOT_FOUND", message: "..." } }` in all error responses. This makes API integration programmable (consumers switch on `code`, not parse `message`). Effort: ~3 hr.
|
||||
- **impact:** API consumers can handle errors programmatically. Required for SDK generation and good DX.
|
||||
|
||||
### DC-087: No API client SDK / type definitions
|
||||
- **status:** pending
|
||||
- **details:** There is no TypeScript definitions file (`.d.ts`) or client SDK. Anyone integrating against the API has to read the source code to understand request/response shapes. Fix: (1) Generate TypeScript types from the OpenAPI spec (once DC-062 updates it) using `openapi-typescript`. (2) Ship a `@dashcaddy/api-types` npm package or include a `types/index.d.ts` in the repo. (3) Optionally, a thin JS client wrapper. Effort: ~2 hr (after DC-062).
|
||||
- **impact:** Developer adoption. A typed SDK lowers the barrier to integration.
|
||||
|
||||
### DC-088: No log rotation — error.log grows forever
|
||||
- **status:** pending
|
||||
- **details:** The logger has basic rotation (rename to `.1` when it hits a size limit), but only keeps ONE rotated file. In production, error.log can grow rapidly during incident bursts. There's no retention policy, no compression, no date-based rotation. Fix: (1) Add a max-size threshold (e.g., 10MB) and keep N rotated files (e.g., 5). (2) Compress rotated files with gzip. (3) Add date-based naming so logs are greppable by date. (4) Add a `GET /api/v1/system/logs` endpoint so operators can view recent logs without SSH. Effort: ~1.5 hr.
|
||||
- **impact:** Prevents disk fill during incident storms. Makes logs accessible without SSH access.
|
||||
|
||||
### DC-089: No rate limit on public license activation endpoint
|
||||
- **status:** pending
|
||||
- **details:** The rate limiter `skip` list includes `req.path === '/api/v1/license/status'` and `req.path.startsWith('/api/v1/license/feature/')` — meaning license checks bypass rate limiting. While these are GET endpoints, the license *activation* endpoint (`POST /api/v1/license/activate`) should have its own dedicated rate limit to prevent brute-force license key guessing. Fix: add a dedicated `licenseLimiter` with tighter limits (e.g., 10 attempts per 15 min per IP) on POST /license/activate. Effort: ~30 min.
|
||||
- **impact:** Prevents license key brute-forcing. Pro keys follow a predictable format (DC-XXX-XXXXX-XXXXXX) making them guessable without rate limiting.
|
||||
|
||||
### DC-090: Node.js version drift — Dockerfile says 20, host runs 22
|
||||
- **status:** pending
|
||||
- **details:** Dockerfile uses `FROM node:20-alpine` (floating). The development machine runs Node v22.22.3. The container uses whatever `node:20-alpine` resolves to at build time. This version drift can cause "works on my machine" bugs (especially around `fetch()`, `crypto`, and `structuredClone` which changed between 20 and 22). Fix: (1) Pin the exact version: `FROM node:20.10.0-alpine3.19`. (2) Add `.nvmrc` or `engines` field to package.json specifying the minimum version. (3) Optionally upgrade to Node 22 across the board. Effort: ~30 min.
|
||||
- **impact:** Reproducible builds. No surprise behavior from Node version drift.
|
||||
|
||||
### DC-091: No dependency update automation (Dependabot/Renovate)
|
||||
- **status:** pending
|
||||
- **details:** Dependencies are updated manually. The 21 production dependencies and 4 dev dependencies can fall behind silently. There's no automated PR for security patches or major version bumps. Fix: add either GitHub Dependabot config (`.github/dependabot.yml`) or Renovate config (`renovate.json`). Schedule weekly checks. Group minor/patch updates into one PR. Keep major updates separate for review. Effort: ~30 min.
|
||||
- **impact:** Security patches arrive automatically. No more manual `npm audit` sessions.
|
||||
|
||||
### DC-092: No health check for DashCaddy's own dependencies (disk space, memory)
|
||||
- **status:** pending
|
||||
- **details:** The Dockerfile has a HEALTHCHECK that hits `/health`, but that endpoint only checks if the Express server responds. It doesn't check: disk space (if `/app/data` is on a full disk), memory pressure (Node heap near limit), Docker socket connectivity (if Docker daemon is down), Caddy admin API reachability. Fix: extend the health endpoint to include dependency checks: `{ diskSpace: { free: ..., total: ... }, memory: { heapUsed: ..., heapTotal: ..., rss: ... }, docker: { reachable: true/false }, caddy: { reachable: true/false } }`. Return 503 if any critical dependency is down. Effort: ~1.5 hr.
|
||||
- **impact:** Catch systemic issues before they become outages. External monitoring can alert on `503`.
|
||||
|
||||
### DC-093: Workflow engine has no retry/backoff for failed actions
|
||||
- **status:** pending
|
||||
- **details:** When the workflow engine's health-check action fails, it logs the failure and moves on — no retry. If a service is temporarily down and recovers in 30s, the workflow reports it as failed for the entire 15-min cycle. Fix: add configurable retry logic to workflow actions (e.g., retry 2 times with 30s backoff before reporting failure). Also add a `maxRetries` config to the health-check workflow. Effort: ~1.5 hr.
|
||||
- **impact:** Fewer false-positive alerts. More resilient monitoring.
|
||||
|
||||
### DC-094: No audit trail for config changes (who changed what, when)
|
||||
- **status:** pending
|
||||
- **details:** The audit logger (`src/security/audit-logger.js`) captures POST/PUT/DELETE events, but config changes (services.json, health-config.json, config.json) are made via file writes, not API calls. There's no record of who changed a service URL, disabled a health check, or modified a workflow. Fix: (1) Route all config mutations through API endpoints that log to the audit trail. (2) Add a `GET /api/v1/system/audit-log` endpoint for viewing the trail. (3) Include a diff of what changed in each audit entry. Effort: ~2 hr.
|
||||
- **impact:** Accountability. When something breaks, you can trace who changed the config and when.
|
||||
|
||||
---
|
||||
|
||||
## P4 — Advanced Features
|
||||
|
||||
### DC-095: No multi-user support — single-admin only
|
||||
- **status:** pending
|
||||
- **details:** DashCaddy has one admin user. For teams or homelab groups, there's no way to add a second admin or a read-only viewer. Fix: (1) Add a `users.json` with role-based access (admin, editor, viewer). (2) Add user management endpoints. (3) Add per-service permissions (editor can manage services but not billing). This is a significant feature, not a quick fix. Effort: ~6 hr.
|
||||
- **impact:** Multi-admin is a requirement for team/enterprise adoption.
|
||||
|
||||
### DC-096: No API key management (create/revoke/scoped keys)
|
||||
- **status:** pending
|
||||
- **details:** API authentication uses session cookies or TOTP. There's no way to create scoped API keys for automation (e.g., a read-only key for monitoring, a key that can only manage one service). Fix: add `POST /api/v1/api-keys` (create with scopes), `GET /api/v1/api-keys` (list), `DELETE /api/v1/api-keys/:id` (revoke). Store hashed in credentials.json. Effort: ~2 hr.
|
||||
- **impact:** Enables automation and third-party integrations without sharing the admin password.
|
||||
|
||||
### DC-097: No Prometheus / Grafana metrics export
|
||||
- **status:** pending
|
||||
- **details:** There's a basic `/metrics` endpoint, but it returns JSON, not Prometheus format. Fix: (1) Add `prom-client` dependency. (2) Instrument key metrics: HTTP request duration histogram, active WebSocket connections, health check pass/fail counter, container count gauge, API error rate. (3) Expose `GET /metrics` in Prometheus exposition format alongside the existing JSON endpoint. (4) Ship a Grafana dashboard JSON as a reference. Effort: ~2 hr.
|
||||
- **impact:** Industry-standard observability. Drop-in Grafana dashboard for operators.
|
||||
|
||||
### DC-098: No changelog / release notes generation
|
||||
- **status:** pending
|
||||
- **details:** Releases are tracked via git commits and VERSION file, but there's no user-facing changelog. For a public product, customers need to know what changed between versions. Fix: (1) Add a `CHANGELOG.md` following Keep a Changelog format. (2) Auto-generate from conventional commits (if adopted) or git log. (3) Display "What's new" on the dashboard after updates. Effort: ~1.5 hr.
|
||||
- **impact:** Customer trust. Users won't update without knowing what changed.
|
||||
|
||||
### DC-099: No automated database migration system
|
||||
- **status:** pending
|
||||
- **details:** Config migrations exist (`src/config/migrations.js`) but are ad-hoc. As the data schema evolves (new fields in services.json, config.json), there's no versioned migration system. Fix: (1) Add a `schemaVersion` field to config files. (2) Create a migration runner that applies migrations sequentially on startup. (3) Log each migration. (4) Support rollback on failure. Effort: ~2 hr.
|
||||
- **impact:** Safe upgrades. No more manual config patching after updates.
|
||||
|
||||
### DC-100: No service discovery / auto-detect running containers
|
||||
- **status:** pending
|
||||
- **details:** Services are added manually by specifying URLs. DashCaddy doesn't auto-detect running Docker containers and suggest adding them as services. Fix: (1) Scan `docker ps` for containers with exposed ports. (2) Match against known app templates (Plex, Sonarr, etc.). (3) Show a "Detected services" panel with one-click add. (4) Periodically re-scan for new containers. Effort: ~3 hr.
|
||||
- **impact:** Zero-config onboarding. New users see their services auto-discovered.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## P5 — Product Vision: Self-Hosting Platform
|
||||
|
||||
> These tasks directly serve the vision from PRODUCT-VISION.md:
|
||||
> "Self-host anything in 30 seconds — no config files, no TLS headaches."
|
||||
|
||||
### DC-101: Disk Space Manager with user-configurable budget + dashboard widget
|
||||
- **status:** in-progress (backend done, needs UI + deployment)
|
||||
- **details:** Backend module (`src/monitoring/disk-space-monitor.js`) and routes (`routes/disk-space.js`) are written and pass tests. Still needs: (1) Dashboard widget showing disk usage gauge with budget line, breakdown by category (images/volumes/logs/build-cache), and "Cleanup now" button. (2) Settings page section for disk budget input. (3) Deploy to DNS2 production. API endpoints: GET /api/v1/disk, GET /api/v1/disk/breakdown, POST /api/v1/disk/config, POST /api/v1/disk/cleanup. Effort: ~2 hr remaining.
|
||||
- **impact:** Users set a disk budget (e.g., "DashCaddy gets 20GB") and the system auto-manages cleanup. The #1 reason people abandon self-hosting is disk filling up silently. This solves it.
|
||||
|
||||
### DC-102: One-click deploy should auto-generate Caddyfile entry + DNS record
|
||||
- **status:** pending
|
||||
- **details:** When a user deploys an app from the catalog, DashCaddy should automatically: (1) Create the Docker container, (2) Add a Caddyfile reverse_proxy block with TLS for `appname.tld`, (3) Create a DNS record pointing to the host, (4) Reload Caddy, (5) Add the service to the dashboard with health check. Currently steps 2-4 are manual. Fix: add a `deployApp(serviceId, options)` function that orchestrates the full chain. The Caddyfile generation can use the admin API (POST to :2019) so no file editing needed. DNS record creation uses the existing Technitium/Cloudflare DNS provider integration. Effort: ~4 hr.
|
||||
- **impact:** This is THE core value proposition. Without this, DashCaddy is just Portainer with extra steps. With this, it's a self-hosting platform.
|
||||
|
||||
### DC-103: Container auto-discovery with auto-route generation
|
||||
- **status:** pending
|
||||
- **details:** When DashCaddy detects a new running Docker container (via docker events API), it should: (1) Check if it matches a known app template (Plex, Sonarr, etc.), (2) Auto-generate a Caddy reverse proxy route, (3) Create a DNS record, (4) Add it to the dashboard, (5) Notify the user "Found Nextcloud on port 80 — added to your dashboard at https://nextcloud.yourdomain.com". This is the "zero-config" experience. Effort: ~4 hr.
|
||||
- **impact:** Magic. User installs Nextcloud via docker run → 10 seconds later it's on their dashboard with HTTPS.
|
||||
|
||||
### DC-104: App catalog with curated templates + one-click deploy
|
||||
- **status:** pending
|
||||
- **details:** The app templates exist (`src/docker/app-templates.js` has 50+ templates) but there's no polished catalog UI. Build a "App Store" page: grid of app cards with icons, descriptions, and "Install" buttons. Clicking install triggers DC-102's deploy chain. Include categories (Media, Productivity, Security, Development). Show "Popular" and "New" badges. Allow community templates via DC-080's plugin system. Effort: ~4 hr.
|
||||
- **impact:** This is the front door. The catalog IS the product for most users.
|
||||
|
||||
### DC-105: Smart defaults wizard — "What do you want to self-host?"
|
||||
- **status:** pending
|
||||
- **details:** Instead of asking users to configure DNS servers, TLD, Caddy paths, and auth — ask them ONE question: "What domain do you want to use?" Then auto-detect: (1) DNS server (check if Technitium is running locally), (2) TLD (.home, .local, or their domain), (3) Caddy installation, (4) Docker setup. Configure everything automatically. If something is missing, install it. The wizard should handle 90% of setups in under 5 questions. Effort: ~3 hr.
|
||||
- **impact:** First-run experience determines whether users stay. A 15-step config wizard kills adoption. A 1-question wizard creates delight.
|
||||
|
||||
### DC-106: Caddyfile-as-code — visual reverse proxy builder
|
||||
- **status:** pending
|
||||
- **details:** Instead of editing Caddyfile text, provide a visual builder: "I want requests to blog.yourdomain.com to go to container X on port 80, with authentication, rate limiting, and compression." Generate the Caddyfile block from the form. Show a live preview of the generated config. Apply via Caddy admin API. This eliminates the need to learn Caddyfile syntax entirely. Effort: ~3 hr.
|
||||
- **impact:** Caddyfile syntax is the #1 technical barrier. A visual builder makes reverse proxy configuration accessible to non-sysadmins.
|
||||
|
||||
### DC-107: Disaster recovery — one-click backup + restore of entire setup
|
||||
- **status:** pending
|
||||
- **details:** Extend DC-078 to include container definitions, Caddyfile, DNS zones, and all app data. The backup should be a single encrypted tarball. "Restore on new host" should bring back the entire DashCaddy setup + all apps in one command. This is the "set it and forget it" insurance policy. Effort: ~3 hr.
|
||||
- **impact:** Fear of losing setup is why people stick with SaaS. One-click backup + restore removes that fear.
|
||||
|
||||
### DC-108: Multi-host fleet management — deploy across multiple servers
|
||||
- **status:** pending
|
||||
- **details:** Currently DashCaddy manages one Docker host. For users with multiple servers (like Sami's DNS1/DNS2/DNS3 setup), DashCaddy should connect to remote Docker daemons (via TLS or SSH) and manage containers across all hosts from one dashboard. "Deploy Nextcloud on DNS2" or "Deploy Plex on SAMI-PC" from the same UI. Show per-host resource usage and health. Effort: ~6 hr.
|
||||
- **impact:** Power users have multiple servers. Managing them individually defeats the purpose of a unified platform.
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Effort | Theme |
|
||||
|----------|-------|--------|-------|
|
||||
| P0 | 3 (DC-062–064) | ~7 hr | Public release blockers |
|
||||
| P1 | 5 (DC-065–069) | ~7 hr | Reliability & code quality |
|
||||
| P2 | 6 (DC-070–075) | ~5.5 hr | Polish & DX |
|
||||
| P2.5 | 5 (DC-081–085) | ~15 hr | Security hardening (deep audit) |
|
||||
| P3 | 5 (DC-076–080) | ~16 hr | Future growth |
|
||||
| P3.5 | 9 (DC-086–094) | ~14.5 hr | Operational maturity |
|
||||
| P4 | 6 (DC-095–100) | ~16.5 hr | Advanced features |
|
||||
| P5 | 8 (DC-101–108) | ~29 hr | Product vision: self-hosting platform |
|
||||
| **Total** | **47** | **~110.5 hr** | |
|
||||
@@ -0,0 +1,107 @@
|
||||
# DashCaddy Product Vision
|
||||
|
||||
## The Problem
|
||||
|
||||
Self-hosting software is hard. To deploy a single app (Plex, Nextcloud, Vaultwarden, anything), you need to:
|
||||
|
||||
1. **Understand Docker** — images, containers, volumes, ports, networks, compose files
|
||||
2. **Configure a reverse proxy** — Caddy/Nginx/Traefik config files with obscure syntax
|
||||
3. **Set up TLS/HTTPS** — certificate generation, ACME, DNS challenges, trust stores
|
||||
4. **Configure DNS** — A records, CNAMEs, split-horizon DNS, DoH
|
||||
5. **Secure it** — firewall rules, auth, rate limiting, CSRF, CORS
|
||||
6. **Monitor it** — health checks, log rotation, disk space, restart policies
|
||||
7. **Maintain it** — updates, backups, migrations, disaster recovery
|
||||
|
||||
Each of these is a rabbit hole. A typical homelabber spends **hours per app** fighting configuration files, reading documentation, and debugging cryptic errors. This is why most people give up and just use SaaS.
|
||||
|
||||
## The Solution
|
||||
|
||||
**DashCaddy is a self-hosting platform.** It eliminates the complexity by fusing Docker, Caddy, and DNS management into one unified interface.
|
||||
|
||||
### Core Value: "Self-host anything in 30 seconds."
|
||||
|
||||
```
|
||||
User picks an app from the catalog
|
||||
↓
|
||||
DashCaddy deploys the Docker container
|
||||
↓
|
||||
DashCaddy generates the Caddy reverse proxy config automatically
|
||||
↓
|
||||
DashCaddy provisions TLS certificates
|
||||
↓
|
||||
DashCaddy configures DNS records
|
||||
↓
|
||||
DashCaddy sets up authentication (SSO gate)
|
||||
↓
|
||||
App is live at https://app.yourdomain.com — done.
|
||||
```
|
||||
|
||||
No editing config files. No Docker networking headaches. No TLS cert errors. No DNS archaeology.
|
||||
|
||||
## What Makes DashCaddy Different
|
||||
|
||||
### vs. Plain Docker / docker-compose
|
||||
- Docker gives you containers. DashCaddy gives you **containers + networking + TLS + DNS + auth + monitoring**.
|
||||
- Docker doesn't know about your domain. DashCaddy manages the full stack from DNS record to container port.
|
||||
- Docker doesn't tell you when your disk is full. DashCaddy monitors, alerts, and auto-cleans.
|
||||
|
||||
### vs. Portainer
|
||||
- Portainer is a **Docker UI**. DashCaddy is a **self-hosting platform**.
|
||||
- Portainer shows containers. DashCaddy shows services — with their URLs, health, certs, and auth.
|
||||
- Portainer doesn't manage Caddy, DNS, or TLS. DashCaddy fuses all three.
|
||||
- Portainer doesn't have a one-click app catalog with auto-configured reverse proxy + DNS + TLS.
|
||||
|
||||
### vs. CasaOS / Umbrel
|
||||
- These are **app stores**. DashCaddy is a **platform**.
|
||||
- They bundle their own Docker management. DashCaddy works with your existing Docker setup.
|
||||
- They don't manage Caddy or advanced DNS. DashCaddy handles the full network stack.
|
||||
- DashCaddy's SSO gate, credential injection, and security center are enterprise-grade features.
|
||||
|
||||
### vs. Yunohost / FreedomBox
|
||||
- These are **complete OS replacements**. DashCaddy is a **single Docker container**.
|
||||
- No OS install needed. Deploy DashCaddy on any Linux machine in 60 seconds.
|
||||
- DashCaddy works alongside your existing setup — it doesn't take over your machine.
|
||||
|
||||
## The Three Pillars
|
||||
|
||||
### 1. One-Click Deploy (The "Wow" moment)
|
||||
Pick an app → DashCaddy handles everything:
|
||||
- Docker container creation with optimal defaults
|
||||
- Caddy reverse proxy route with TLS
|
||||
- DNS record creation
|
||||
- SSO authentication gate
|
||||
- Health check configuration
|
||||
- Disk budget allocation
|
||||
|
||||
### 2. Zero-Config Networking (The "It just works" layer)
|
||||
- Automatic TLS via Caddy's ACME + Let's Encrypt
|
||||
- Automatic DNS via Technitium/Cloudflare integration
|
||||
- Automatic reverse proxy with sane defaults
|
||||
- Automatic SSO with credential injection
|
||||
- Automatic subdomain routing (subdomain or subdirectory mode)
|
||||
|
||||
### 3. Self-Healing Infrastructure (The "Set it and forget it" layer)
|
||||
- Health checks with retry/backoff and notification on state transitions
|
||||
- Auto-restart failed containers
|
||||
- Auto-cleanup when disk approaches budget
|
||||
- Config drift detection and correction
|
||||
- SSL certificate expiration monitoring
|
||||
- Container log rotation and size enforcement
|
||||
- Docker image cleanup — old images pruned automatically
|
||||
|
||||
## Who Is It For?
|
||||
|
||||
1. **Homelabbers** — tired of spending weekends on config files
|
||||
2. **Small businesses** — want self-hosted alternatives to SaaS without hiring a sysadmin
|
||||
3. **Privacy-conscious users** — want to own their data without the technical burden
|
||||
4. **Developers** — want a quick way to deploy side projects with TLS + auth
|
||||
|
||||
## Revenue Model
|
||||
|
||||
- **Free tier**: Up to 5 services, community support
|
||||
- **Pro license**: Unlimited services, email alerts, advanced health checks, priority updates
|
||||
- **Site license**: Multi-host, team accounts, API access
|
||||
|
||||
## North Star Metric
|
||||
|
||||
**Time-to-first-app-deploy** — how long from install to having a working self-hosted service with HTTPS. Target: under 60 seconds.
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**Self-hosted dashboard for managing Docker apps with automatic SSL, DNS, and reverse proxy configuration.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## What is DashCaddy?
|
||||
|
||||
@@ -397,7 +397,7 @@ Contributions are welcome! Please:
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
Proprietary software. All rights reserved. See [LICENSE](LICENSE) for the End-User License Agreement (EULA).
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
node_modules/
|
||||
__tests__/
|
||||
jest.config.js
|
||||
.env
|
||||
.encryption-key
|
||||
.git/
|
||||
.gitignore
|
||||
.dockerignore
|
||||
*.log
|
||||
node_modules/
|
||||
coverage/
|
||||
*.md
|
||||
docker-compose.yml
|
||||
.eslintrc.js
|
||||
jest.config.js
|
||||
npm-debug.log*
|
||||
.env*
|
||||
.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
dc.png
|
||||
|
||||
@@ -35,6 +35,7 @@ module.exports = {
|
||||
'complexity': ['warn', 20],
|
||||
|
||||
// Prevent common pitfalls
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-eval': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-new-func': 'error',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:20.11.1-alpine3.19
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
8ea41e0
|
||||
20260722-065235-cookie-only-session-653478a
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* DC-057 billing lookup endpoint tests.
|
||||
*
|
||||
* Tests the GET /api/v1/billing/lookup/:sessionId route handler with a
|
||||
* real fulfillment store on disk. Covers:
|
||||
*
|
||||
* - 404 for unknown sessionId
|
||||
* - processing state (record exists, no code yet)
|
||||
* - pending_email state — license persisted, email failed (SMTP recovery path)
|
||||
* - delivered state
|
||||
* - 404 past the 24h TTL
|
||||
* - Cache-Control: no-store on all responses
|
||||
* - Parameterized PUBLIC_ROUTES entry exists for this path
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-lookup-'));
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
|
||||
const billingRoutes = require('../../routes/billing');
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
// Mock asyncHandler that calls the inner fn synchronously.
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
const router = billingRoutes({ asyncHandler });
|
||||
app.use('/api/v1/billing', router);
|
||||
return app;
|
||||
}
|
||||
|
||||
function seedRecord(sessionId, overrides = {}) {
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
// Plant a record directly via the mutation API.
|
||||
return store.claim({
|
||||
eventId: overrides.eventId || 'evt_seed',
|
||||
sessionId,
|
||||
productId: overrides.productId || 'pro-30d',
|
||||
durationDays: overrides.durationDays || 30,
|
||||
email: overrides.email || 'alice@example.com',
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/v1/billing/lookup/:sessionId', () => {
|
||||
let app;
|
||||
beforeAll(() => {
|
||||
app = makeApp();
|
||||
});
|
||||
|
||||
function get(sessionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const port = server.address().port;
|
||||
const http = require('http');
|
||||
http.get(`http://127.0.0.1:${port}/api/v1/billing/lookup/${encodeURIComponent(sessionId)}`, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => { body += c; });
|
||||
res.on('end', () => {
|
||||
server.close();
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('returns 404 for unknown sessionId', async () => {
|
||||
const res = await get('cs_unknown_session');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toMatchObject({ success: false });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns 400 for invalid sessionId (too long)', async () => {
|
||||
const longId = 'x'.repeat(300);
|
||||
const res = await get(longId);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns processing state when record has no code yet', async () => {
|
||||
const sessionId = `cs_proc_${crypto.randomBytes(4).toString('hex')}`;
|
||||
await seedRecord(sessionId);
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toMatchObject({ status: 'processing', durationDays: 30, productId: 'pro-30d' });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns pending_email state with the persisted code (SMTP recovery)', async () => {
|
||||
const sessionId = `cs_pending_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_1', sessionId, productId: 'pro-90d', durationDays: 90, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-TEST-CODE-90D', codeId: 'cid_1' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
|
||||
await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_1', error: 'smtp-down' });
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({
|
||||
status: 'pending_email',
|
||||
durationDays: 90,
|
||||
productId: 'pro-90d',
|
||||
code: 'DC-TEST-CODE-90D',
|
||||
codeId: 'cid_1',
|
||||
});
|
||||
expect(res.body.data.lastError).toMatch(/smtp-down/);
|
||||
});
|
||||
|
||||
test('returns delivered state with the code + deliveredVia', async () => {
|
||||
const sessionId = `cs_delivered_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_2', sessionId, productId: 'pro-365d', durationDays: 365, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-TEST-CODE-365D', codeId: 'cid_2' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_2', deliveredVia: 'smtp' });
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({
|
||||
status: 'delivered',
|
||||
durationDays: 365,
|
||||
productId: 'pro-365d',
|
||||
code: 'DC-TEST-CODE-365D',
|
||||
codeId: 'cid_2',
|
||||
deliveredVia: 'smtp',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 404 past the 24h TTL', async () => {
|
||||
const sessionId = `cs_old_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_old', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_old', sessionId, code: 'DC-OLD', codeId: 'cid_old' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_old', deliveredVia: 'smtp' });
|
||||
|
||||
// Manually backdate the record's createdAt to be older than 24h.
|
||||
const fs = require('fs');
|
||||
const file = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE;
|
||||
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const r = state.bySessionId[sessionId];
|
||||
r.createdAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
||||
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUBLIC_ROUTES + CSRF allowlist for billing/lookup', () => {
|
||||
const fs = require('fs');
|
||||
test('PUBLIC_ROUTES includes /api/v1/billing/lookup/:sessionId', () => {
|
||||
const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'utilities', 'middleware.js'), 'utf8');
|
||||
expect(content).toMatch(/path:\s*['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
|
||||
});
|
||||
|
||||
test('CSRF excludedPaths includes /api/v1/billing/lookup/:sessionId', () => {
|
||||
const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'security', 'csrf-protection.js'), 'utf8');
|
||||
expect(content).toMatch(/['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* DC-057 bridge HTTP /lookup/:sessionId endpoint tests.
|
||||
*
|
||||
* Tests the bridge's own GET /lookup/:sessionId endpoint (separate from
|
||||
* the API route). The bridge endpoint is for out-of-band operator use —
|
||||
* the production customer lookup goes through routes/billing.js (covered
|
||||
* by billing-lookup.test.js). But the bridge must still serve /lookup/*
|
||||
* correctly for operator workflows and incident recovery.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-http-'));
|
||||
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
|
||||
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
|
||||
jest.mock('../../license-keygen', () => {
|
||||
// Use the built-in Date + Math.random instead of crypto so the jest.mock
|
||||
// factory stays in scope (jest.mock factory bodies cannot reference
|
||||
// outer-scope identifiers like `crypto`).
|
||||
const mockRandom = () => Math.random().toString(16).slice(2, 10).toUpperCase();
|
||||
let mockCounter = 0;
|
||||
return {
|
||||
VALID_DURATIONS: [30, 90, 180, 365],
|
||||
loadSecret: () => 'mock-secret',
|
||||
generateCodes: jest.fn(({ durationDays, count }) => {
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
codes.push({
|
||||
code: `DC-TEST-${durationDays}D-${mockRandom()}`,
|
||||
codeId: `cid_${Date.now()}_${i}_${++mockCounter}`,
|
||||
});
|
||||
}
|
||||
return codes;
|
||||
}),
|
||||
};
|
||||
});
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: () => ({ sendMail: jest.fn() }),
|
||||
}));
|
||||
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
const bridge = require('../../scripts/stripe-license-bridge');
|
||||
|
||||
let server;
|
||||
let port;
|
||||
|
||||
beforeAll((done) => {
|
||||
// Use the bridge's own createServer() factory so the test exercises the
|
||||
// SAME request dispatcher the production server uses (no duplicated
|
||||
// route decoding / status mapping in test code).
|
||||
server = bridge.createServer();
|
||||
server.listen(0, () => {
|
||||
port = server.address().port;
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
function get(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`http://127.0.0.1:${port}${path}`, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => { body += c; });
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('bridge GET /lookup/:sessionId', () => {
|
||||
test('returns 404 for unknown sessionId', async () => {
|
||||
const res = await get('/lookup/cs_unknown_session');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ status: 'not_found' });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns 400 for malformed percent-encoded sessionId', async () => {
|
||||
// %ZZ is not valid hex.
|
||||
const res = await get('/lookup/cs_%ZZ_bad');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe('invalid-session-id');
|
||||
});
|
||||
|
||||
test('returns delivered state with code + deliveredVia for planted record', async () => {
|
||||
const sessionId = `cs_test_delivered_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_1', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-X', codeId: 'cid_1' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_1', deliveredVia: 'smtp' });
|
||||
|
||||
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
status: 'delivered',
|
||||
durationDays: 30,
|
||||
productId: 'pro-30d',
|
||||
code: 'DC-X',
|
||||
codeId: 'cid_1',
|
||||
deliveredVia: 'smtp',
|
||||
});
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
test('returns pending_email state (SMTP recovery)', async () => {
|
||||
const sessionId = `cs_test_pending_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_2', sessionId, productId: 'pro-90d', durationDays: 90, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-Y', codeId: 'cid_2' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
|
||||
await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_2', error: 'smtp-down' });
|
||||
|
||||
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
status: 'pending_email',
|
||||
durationDays: 90,
|
||||
productId: 'pro-90d',
|
||||
code: 'DC-Y',
|
||||
codeId: 'cid_2',
|
||||
});
|
||||
expect(res.body.lastError).toMatch(/smtp-down/);
|
||||
});
|
||||
|
||||
test('returns 404 past the 24h TTL', async () => {
|
||||
const sessionId = `cs_test_old_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
await store.claim({ eventId: 'evt_3', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
await store.saveLicense({ eventId: 'evt_3', sessionId, code: 'DC-OLD', codeId: 'cid_3' });
|
||||
await store.claimDelivery({ sessionId, ownerToken: 'evt_3' });
|
||||
await store.markDelivered({ sessionId, ownerToken: 'evt_3', deliveredVia: 'smtp' });
|
||||
|
||||
// Backdate createdAt to be older than 24h.
|
||||
const file = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE;
|
||||
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const r = state.bySessionId[sessionId];
|
||||
r.createdAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
||||
|
||||
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ status: 'expired' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* DC-057 billing checkout origin resolution tests.
|
||||
*
|
||||
* The checkout endpoint embeds the success_url (and cancel_url) into the
|
||||
* Stripe Checkout Session. These URLs are what Stripe redirects the
|
||||
* customer's browser to after payment. They MUST be derived only from
|
||||
* trusted sources — otherwise a header-injection attacker could redirect
|
||||
* customers to their own origin and capture the session_id, which is
|
||||
* the bearer token for /api/v1/billing/lookup/:sessionId (and that
|
||||
* endpoint serves the customer's license code on success).
|
||||
*
|
||||
* The origin is resolved in this priority order:
|
||||
* 1. STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)
|
||||
* 2. Request Host header, but ONLY when the host is in
|
||||
* STRIPE_ALLOWED_HOSTS (operator-declared allowlist)
|
||||
* 3. undefined (Stripe falls back to its own defaults)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
// Save the fulfillment-store path so the route module captures the same
|
||||
// path the route would in production. (Tests below exercise the
|
||||
// stripe-client, not the fulfillment store, so the lookup endpoint can
|
||||
// share the same file.)
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-origin-'));
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
|
||||
const billingRoutes = require('../../routes/billing');
|
||||
const stripeClient = require('../../src/billing/stripe-client');
|
||||
|
||||
const REQUIRED_ENV = {
|
||||
STRIPE_SECRET_KEY: '«redacted:sk_test_…»',
|
||||
STRIPE_PRICE_PRO_30D: 'price_30d_test',
|
||||
STRIPE_PRICE_PRO_90D: 'price_90d_test',
|
||||
STRIPE_PRICE_PRO_180D: 'price_180d_test',
|
||||
STRIPE_PRICE_PRO_365D: 'price_365d_test',
|
||||
};
|
||||
|
||||
function setEnv(overrides = {}) {
|
||||
const all = { ...REQUIRED_ENV, ...overrides };
|
||||
for (const [k, v] of Object.entries(all)) {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
function clearEnv() {
|
||||
for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k];
|
||||
delete process.env.STRIPE_PUBLIC_ORIGIN;
|
||||
delete process.env.STRIPE_ALLOWED_HOSTS;
|
||||
delete process.env.NODE_ENV;
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(require('express').json());
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
const router = billingRoutes({ asyncHandler });
|
||||
app.use('/api/v1/billing', router);
|
||||
return app;
|
||||
}
|
||||
|
||||
function postCheckout(req, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = req.listen(0, () => {
|
||||
const port = server.address().port;
|
||||
const data = JSON.stringify(body);
|
||||
const headerLines = Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers })
|
||||
.map(([k, v]) => `${k}: ${v}`).join('\r\n');
|
||||
const req2 = http.request({
|
||||
hostname: '127.0.0.1', port, path: '/api/v1/billing/checkout', method: 'POST',
|
||||
headers: Object.fromEntries(Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }).map(([k, v]) => [k.toLowerCase(), v])),
|
||||
}, (res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => { buf += c; });
|
||||
res.on('end', () => {
|
||||
server.close();
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: buf ? JSON.parse(buf) : null });
|
||||
});
|
||||
});
|
||||
req2.on('error', reject);
|
||||
req2.write(data);
|
||||
req2.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('POST /api/v1/billing/checkout — origin resolution (DC-057 security)', () => {
|
||||
let app;
|
||||
beforeAll(() => {
|
||||
app = makeApp();
|
||||
setEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearEnv();
|
||||
setEnv();
|
||||
stripeClient._setStripeSdk(null);
|
||||
});
|
||||
|
||||
test('uses STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'https://status.sami' });
|
||||
const mockSession = { id: 'cs_test_orig_1', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_1' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
// The captured Stripe params must include the success_url + cancel_url
|
||||
// built from the operator-declared origin — NOT from the request's Host
|
||||
// header. This is the canonical deployment shape.
|
||||
expect(capturedParams.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
expect(capturedParams.cancel_url).toBe('https://status.sami/pricing');
|
||||
});
|
||||
|
||||
test('rejects Host header injection when STRIPE_ALLOWED_HOSTS is empty', async () => {
|
||||
// Attacker sets X-Forwarded-Host: evil.com. The request reaches our
|
||||
// endpoint. Without STRIPE_PUBLIC_ORIGIN + without STRIPE_ALLOWED_HOSTS,
|
||||
// the origin must be undefined — we MUST NOT trust the attacker header.
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: '' });
|
||||
const mockSession = { id: 'cs_test_orig_2', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_2' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'evil.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// origin must be undefined when allowlist is empty — the Stripe SDK
|
||||
// is called with undefined origin and the stripe-client falls back to
|
||||
// relative '/billing/success' which is safe (no host poisoning).
|
||||
expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
|
||||
});
|
||||
|
||||
test('accepts Host header when STRIPE_ALLOWED_HOSTS includes it', async () => {
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami,dashcaddy.net' });
|
||||
const mockSession = { id: 'cs_test_orig_3', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_3' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'status.sami',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedParams.success_url).toContain('status.sami');
|
||||
expect(capturedParams.success_url).toContain('/billing/success');
|
||||
});
|
||||
|
||||
test('rejects Host header when host is NOT in STRIPE_ALLOWED_HOSTS', async () => {
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami' });
|
||||
const mockSession = { id: 'cs_test_orig_4', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_4' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'evil.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// origin is undefined → relative /billing/success URL (safe).
|
||||
expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
|
||||
expect(capturedParams.success_url).not.toContain('evil.com');
|
||||
});
|
||||
|
||||
test('rejects javascript: scheme injection via STRIPE_PUBLIC_ORIGIN', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'javascript:alert(1)' });
|
||||
const mockSession = { id: 'cs_test_orig_5', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_5' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
// javascript: scheme is rejected; origin falls through to header-based
|
||||
// resolution, which is also gated by STRIPE_ALLOWED_HOSTS (empty here).
|
||||
expect(capturedParams.success_url).not.toMatch(/javascript:/);
|
||||
});
|
||||
|
||||
test('rejects http:// in production when NODE_ENV=production', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'http://status.sami', NODE_ENV: 'production' });
|
||||
const mockSession = { id: 'cs_test_orig_6', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_6' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
// http:// rejected in production; origin falls back to undefined.
|
||||
expect(capturedParams.success_url).not.toMatch(/^http:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* DC-057 pricing-page catalog consistency test.
|
||||
*
|
||||
* The pricing page at status/pricing/index.html hard-codes the 4 product
|
||||
* IDs, prices, and labels. This test asserts that those hard-coded values
|
||||
* exactly match the catalog in src/billing/catalog.js — preventing drift
|
||||
* between the two sources.
|
||||
*
|
||||
* If a new tier is added to the catalog, this test will fail until the
|
||||
* pricing page is updated. If the pricing page is updated, the catalog
|
||||
* must change in lockstep (or this test fails the other way).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
|
||||
const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
|
||||
|
||||
function extractTiersFromPage(html) {
|
||||
// Extract each `<div class="tier pro" data-product-id="...">` block, then
|
||||
// pull out the dollar amount in the `<div class="price">` element and
|
||||
// the durationDays from the "N-day Pro license" string. The regex is
|
||||
// anchored on the tier-class open + the matching buy-btn close so we
|
||||
// capture the full body of each tier card regardless of how many inner
|
||||
// divs it has.
|
||||
const tierRe = /<div class="tier pro" data-product-id="([^"]+)">([\s\S]*?)<button[^>]*class="buy-btn"[^>]*>\s*Buy/g;
|
||||
const tierBlocks = [...html.matchAll(tierRe)];
|
||||
return tierBlocks.map(([, productId, body]) => {
|
||||
const priceMatch = body.match(/<div class="price">\$(\d+)<\/div>/);
|
||||
const durMatch = body.match(/(\d+)-day Pro license/);
|
||||
return {
|
||||
productId,
|
||||
priceDollars: priceMatch ? parseInt(priceMatch[1], 10) : null,
|
||||
durationDays: durMatch ? parseInt(durMatch[1], 10) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTML body for one specific tier (from open div through the
|
||||
* buy-btn). Used by per-tier assertions that must NOT bleed across cards.
|
||||
*/
|
||||
function extractTierBody(html, productId) {
|
||||
const re = new RegExp(
|
||||
`<div class="tier pro" data-product-id="${productId}">([\\s\\S]*?)<button[^>]*class="buy-btn"[^>]*>\\s*Buy`,
|
||||
'i'
|
||||
);
|
||||
const m = html.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
describe('pricing page <-> catalog consistency (DC-057)', () => {
|
||||
let html;
|
||||
let pageTiers;
|
||||
|
||||
beforeAll(() => {
|
||||
html = fs.readFileSync(PRICING_PAGE_PATH, 'utf8');
|
||||
pageTiers = extractTiersFromPage(html);
|
||||
});
|
||||
|
||||
test('pricing page exists and is readable', () => {
|
||||
expect(html.length).toBeGreaterThan(1000);
|
||||
expect(pageTiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every catalog product is rendered on the pricing page', () => {
|
||||
const catalogIds = catalog.PRODUCTS.map((p) => p.id).sort();
|
||||
const pageIds = pageTiers.map((t) => t.productId).sort();
|
||||
expect(pageIds).toEqual(catalogIds);
|
||||
});
|
||||
|
||||
test('every pricing-page productId appears in the catalog', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(product).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page dollar amounts match catalog amountCents', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const expectedDollars = product.amountCents / 100;
|
||||
expect(tier.priceDollars).toBe(expectedDollars);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page duration strings match catalog durationDays', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(tier.durationDays).toBe(product.durationDays);
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog and pricing page agree on price label (scoped per tier card)', () => {
|
||||
// Per-tier priceLabel assertion: each tier card must include its
|
||||
// own catalog.priceLabel. A swap or misplaced label fails immediately
|
||||
// because the assertion checks the tier's own HTML body, not the page.
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const body = extractTierBody(html, tier.productId);
|
||||
expect(body).not.toBeNull();
|
||||
// The priceLabel appears in the price div of THIS tier only,
|
||||
// immediately followed by the closing </div> + the duration block.
|
||||
const labelRegex = new RegExp(`<div class="price">\\s*\\${product.priceLabel}\\s*</div>\\s*<div class="duration"`);
|
||||
expect(body).toMatch(labelRegex);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing page does not include the monthly/annual subscription toggle (one-time only)', () => {
|
||||
// DC-057 acceptance: locked spec is ONE-TIME 30/90/180/365-day licenses
|
||||
// at $20/$50/$70/$99. The old monthly/annual subscription toggle
|
||||
// would contradict the spec.
|
||||
expect(html).not.toMatch(/period-monthly|period-annual/);
|
||||
expect(html).not.toMatch(/Subscribe to Pro/);
|
||||
});
|
||||
|
||||
test('pricing page references the success-page endpoint', () => {
|
||||
// The success URL is constructed server-side in stripe-client.js
|
||||
// (${origin}/billing/success?session_id=...). The pricing page itself
|
||||
// doesn't need to embed it — but the FOOTER must reference it so the
|
||||
// customer knows where to go after Stripe redirects.
|
||||
expect(html.toLowerCase()).toContain('after payment');
|
||||
expect(html).toContain('/admin/license');
|
||||
expect(html).toContain('/api/v1/billing/checkout');
|
||||
});
|
||||
|
||||
test('success page (status/billing/success.html) exists and references the lookup endpoint', () => {
|
||||
const successPath = path.join(__dirname, '..', '..', '..', 'status', 'billing', 'success.html');
|
||||
const successHtml = fs.readFileSync(successPath, 'utf8');
|
||||
expect(successHtml).toContain('/api/v1/billing/lookup/');
|
||||
expect(successHtml.length).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* DC-055 + DC-057 billing/stripe-client tests.
|
||||
*
|
||||
* Strategy: inject a mock Stripe SDK via _setStripeSdk so no real network
|
||||
* calls ever happen. Cover the key behaviors of the one-time payment flow:
|
||||
*
|
||||
* 1. Configuration validation — missing STRIPE_SECRET_KEY fails loudly with 503.
|
||||
* 2. productId validation — unknown productId returns 400 INVALID_PRODUCT_ID.
|
||||
* 3. Product not configured — Stripe Price ID env var unset returns 503.
|
||||
* 4. Happy path — creates a session with mode:payment + correct price ID + URLs.
|
||||
* 5. Stripe SDK errors — surface as 502 to the customer, not 500.
|
||||
* 6. Metadata contract — emits metadata.productId that the bridge can read back.
|
||||
* 7. payment_intent_data — also carries productId metadata for downstream consumers.
|
||||
* 8. Catalog drives everything — _resolveProduct reads the catalog, not env.
|
||||
*/
|
||||
|
||||
const stripeClient = require('../../src/billing/stripe-client');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
|
||||
const REQUIRED_ENV = {
|
||||
STRIPE_SECRET_KEY: '«redacted:sk_test_…»',
|
||||
STRIPE_PRICE_PRO_30D: 'price_30d_test',
|
||||
STRIPE_PRICE_PRO_90D: 'price_90d_test',
|
||||
STRIPE_PRICE_PRO_180D: 'price_180d_test',
|
||||
STRIPE_PRICE_PRO_365D: 'price_365d_test',
|
||||
};
|
||||
|
||||
function setEnv(overrides = {}) {
|
||||
const all = { ...REQUIRED_ENV, ...overrides };
|
||||
for (const [k, v] of Object.entries(all)) {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
function clearEnv() {
|
||||
for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k];
|
||||
}
|
||||
|
||||
function makeMockStripe(sessionsCreateImpl) {
|
||||
const sessions = { create: jest.fn().mockImplementation(sessionsCreateImpl) };
|
||||
return jest.fn().mockReturnValue({ checkout: { sessions } });
|
||||
}
|
||||
|
||||
describe('billing/stripe-client', () => {
|
||||
afterEach(() => {
|
||||
clearEnv();
|
||||
stripeClient._setStripeSdk(null);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('throws STRIPE_NOT_CONFIGURED when STRIPE_SECRET_KEY is missing', async () => {
|
||||
setEnv({ STRIPE_SECRET_KEY: '' });
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'STRIPE_NOT_CONFIGURED',
|
||||
statusCode: 503,
|
||||
missing: expect.arrayContaining(['STRIPE_SECRET_KEY']),
|
||||
});
|
||||
});
|
||||
|
||||
test('throws STRIPE_NOT_CONFIGURED when 30d product Stripe Price ID is missing', async () => {
|
||||
setEnv({ STRIPE_PRICE_PRO_30D: '' });
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'STRIPE_NOT_CONFIGURED',
|
||||
missing: expect.arrayContaining(['STRIPE_PRICE_PRO_30D']),
|
||||
productId: 'pro-30d',
|
||||
});
|
||||
});
|
||||
|
||||
test('throws INVALID_PRODUCT_ID when productId is missing', async () => {
|
||||
setEnv();
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: '', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({ code: 'INVALID_PRODUCT_ID', statusCode: 400, field: 'productId' });
|
||||
});
|
||||
|
||||
test('throws INVALID_PRODUCT_ID when productId is unknown', async () => {
|
||||
setEnv();
|
||||
await expect(
|
||||
stripeClient.createCheckoutSession({ productId: 'pro-1000d', origin: 'https://status.sami' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'INVALID_PRODUCT_ID',
|
||||
statusCode: 400,
|
||||
field: 'productId',
|
||||
});
|
||||
});
|
||||
|
||||
test('happy path: pro-30d creates session with mode=payment + correct params', async () => {
|
||||
setEnv();
|
||||
const mockSession = { id: 'cs_test_abc123', url: 'https://checkout.stripe.com/c/pay/cs_test_abc123' };
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
// DC-057: one-time payment, NOT subscription.
|
||||
expect(params.mode).toBe('payment');
|
||||
expect(params.line_items).toEqual([{ price: 'price_30d_test', quantity: 1 }]);
|
||||
expect(params.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
expect(params.cancel_url).toBe('https://status.sami/pricing');
|
||||
// The bridge reads this metadata back to map session → product → duration.
|
||||
expect(params.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
|
||||
// payment_intent_data.metadata mirrors it for downstream Stripe→bridge consumers.
|
||||
expect(params.payment_intent_data).toBeDefined();
|
||||
expect(params.payment_intent_data.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
|
||||
// No subscription_data on one-time payment.
|
||||
expect(params.subscription_data).toBeUndefined();
|
||||
return mockSession;
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
const result = await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-30d',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
expect(result).toEqual({ id: 'cs_test_abc123', url: mockSession.url });
|
||||
expect(mockStripe).toHaveBeenCalledWith('«redacted:sk_test_…»');
|
||||
});
|
||||
|
||||
test('happy path: pro-365d uses 365d price ID', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.line_items[0].price).toBe('price_365d_test');
|
||||
expect(params.metadata.productId).toBe('pro-365d');
|
||||
return { id: 'cs_365_xyz', url: 'https://checkout.stripe.com/c/pay/cs_365_xyz' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
const result = await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-365d',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
expect(result.id).toBe('cs_365_xyz');
|
||||
});
|
||||
|
||||
test('forwards customerEmail when provided', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.customer_email).toBe('alice@example.com');
|
||||
return { id: 'cs_emailed', url: 'https://checkout.stripe.com/c/pay/cs_emailed' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-90d',
|
||||
customerEmail: 'alice@example.com',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
});
|
||||
|
||||
test('omits customer_email when not provided (no undefined leakage to Stripe)', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect('customer_email' in params).toBe(false);
|
||||
return { id: 'cs_no_email', url: 'https://checkout.stripe.com/c/pay/cs_no_email' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({
|
||||
productId: 'pro-30d',
|
||||
origin: 'https://status.sami',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses STRIPE_SUCCESS_URL override when set', async () => {
|
||||
setEnv({ STRIPE_SUCCESS_URL: 'https://custom.example.com/thanks' });
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.success_url).toBe('https://custom.example.com/thanks');
|
||||
return { id: 'cs_custom', url: 'x' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
|
||||
});
|
||||
|
||||
test('uses STRIPE_CANCEL_URL override when set', async () => {
|
||||
setEnv({ STRIPE_CANCEL_URL: 'https://custom.example.com/back' });
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.cancel_url).toBe('https://custom.example.com/back');
|
||||
return { id: 'cs_cancel', url: 'x' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
|
||||
});
|
||||
|
||||
test('works with relative origin (no host header)', async () => {
|
||||
setEnv();
|
||||
const mockStripe = makeMockStripe(async () => ({ id: 'x', url: 'x' }));
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
|
||||
const result = await stripeClient.createCheckoutSession({ productId: 'pro-30d' });
|
||||
expect(result.id).toBe('x');
|
||||
});
|
||||
|
||||
test('each catalog product drives a different price ID', async () => {
|
||||
setEnv();
|
||||
for (const product of catalog.PRODUCTS) {
|
||||
const mockStripe = makeMockStripe(async (params) => {
|
||||
expect(params.line_items[0].price).toBe(REQUIRED_ENV[product.priceEnv]);
|
||||
expect(params.metadata.productId).toBe(product.id);
|
||||
return { id: `cs_${product.id}`, url: 'x' };
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
await stripeClient.createCheckoutSession({ productId: product.id, origin: 'https://status.sami' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('billing/stripe-client — _resolveProduct unit', () => {
|
||||
test('resolves known productId with configured price', () => {
|
||||
setEnv();
|
||||
const result = stripeClient._resolveProduct('pro-30d');
|
||||
expect(result.product.id).toBe('pro-30d');
|
||||
expect(result.priceId).toBe('price_30d_test');
|
||||
});
|
||||
|
||||
test('returns INVALID_PRODUCT_ID error for unknown productId', () => {
|
||||
setEnv();
|
||||
expect(() => stripeClient._resolveProduct('pro-1000d')).toThrow();
|
||||
try { stripeClient._resolveProduct('pro-1000d'); } catch (e) {
|
||||
expect(e.code).toBe('INVALID_PRODUCT_ID');
|
||||
expect(e.statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns STRIPE_NOT_CONFIGURED error when product price is unset', () => {
|
||||
setEnv({ STRIPE_PRICE_PRO_180D: '' });
|
||||
try { stripeClient._resolveProduct('pro-180d'); } catch (e) {
|
||||
expect(e.code).toBe('STRIPE_NOT_CONFIGURED');
|
||||
expect(e.statusCode).toBe(503);
|
||||
expect(e.missing).toContain('STRIPE_PRICE_PRO_180D');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* DC-054 + DC-057 stripe-license-bridge tests.
|
||||
*
|
||||
* Strategy: no live network, no live Stripe SDK. We use `jest.mock` to
|
||||
* substitute license-keygen + nodemailer before the bridge loads, drive
|
||||
* handleWebhook() with crafted raw bodies + signatures.
|
||||
*
|
||||
* Coverage:
|
||||
* - Signature validation (pass / missing / wrong / out-of-tolerance)
|
||||
* - JSON parse failure
|
||||
* - Duplicate event-id → 200 idempotent
|
||||
* - Two different events for the SAME session → single license (layer-2 idempotency)
|
||||
* - License persisted BEFORE email (crash-safety)
|
||||
* - Email failure → markDeliveryFailed → returns 500 → customer can retrieve via lookup
|
||||
* - Retry from pending_email delivers the SAME code
|
||||
* - Concurrent lease (busy) returns 409
|
||||
* - Catalog resolution: missing productId → 400; unknown productId → 400;
|
||||
* product-not-configured → 400
|
||||
* - Lookup endpoint: not_found, processing, pending_email, delivered, expired TTL
|
||||
* - Layer-1 + Layer-2 idempotency under Stripe retry
|
||||
*/
|
||||
|
||||
// jest.mock must be hoisted before any require.
|
||||
jest.mock('../../license-keygen', () => {
|
||||
const crypto = require('crypto');
|
||||
let calls = 0;
|
||||
return {
|
||||
VALID_DURATIONS: [30, 90, 180, 365],
|
||||
loadSecret: () => 'mock-license-secret-' + crypto.randomBytes(8).toString('hex'),
|
||||
generateCodes: jest.fn(({ durationDays, count }) => {
|
||||
calls++;
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
codes.push({
|
||||
code: `DC-TEST-${durationDays}D-${crypto.randomBytes(4).toString('hex').toUpperCase()}`,
|
||||
codeId: `codeid_${Date.now()}_${i}_${calls}`,
|
||||
});
|
||||
}
|
||||
return codes;
|
||||
}),
|
||||
__resetGenerateCalls() { calls = 0; },
|
||||
__getGenerateCalls() { return calls; },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: jest.fn(() => ({
|
||||
sendMail: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Set up isolated tmp dirs BEFORE requiring the bridge (it captures paths at require time).
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-'));
|
||||
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
|
||||
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
// Use a unique webhook secret so tests don't pollute each other.
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
|
||||
// Configure all Stripe Prices so catalog.getConfiguredProducts() returns them.
|
||||
process.env.STRIPE_PRICE_PRO_30D = 'price_30d_test';
|
||||
process.env.STRIPE_PRICE_PRO_90D = 'price_90d_test';
|
||||
process.env.STRIPE_PRICE_PRO_180D = 'price_180d_test';
|
||||
process.env.STRIPE_PRICE_PRO_365D = 'price_365d_test';
|
||||
// Disable SMTP so the bridge falls back to dev-console unless a test
|
||||
// explicitly injects nodemailer.
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
|
||||
const licenseKeygenMock = require('../../license-keygen');
|
||||
const nodemailerMock = require('nodemailer');
|
||||
const bridge = require('../../scripts/stripe-license-bridge');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
licenseKeygenMock.__resetGenerateCalls();
|
||||
// Reset nodemailer.sendMail mock implementations between tests.
|
||||
nodemailerMock.createTransport.mockClear();
|
||||
});
|
||||
|
||||
// Helper: build a signed Stripe webhook payload.
|
||||
function buildSignedPayload(body, opts = {}) {
|
||||
const secret = opts.secret || process.env.STRIPE_WEBHOOK_SECRET;
|
||||
const ts = opts.timestamp || Math.floor(Date.now() / 1000);
|
||||
const rawBody = Buffer.from(JSON.stringify(body));
|
||||
const sig = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const header = `t=${ts},v1=${sig}`;
|
||||
return { rawBody, signatureHeader: header };
|
||||
}
|
||||
|
||||
function buildSessionEvent({ productId = 'pro-30d', sessionId, customerEmail = 'alice@example.com',
|
||||
eventId, lineItems, paymentStatus = 'paid' }) {
|
||||
const product = catalog.getProduct(productId);
|
||||
// For tests of "unknown productId" the catalog.getProduct returns null —
|
||||
// we still build a valid event so the bridge can return its own 400.
|
||||
const priceId = product ? catalog.getConfiguredPrice(product) : 'price_unconfigured';
|
||||
return {
|
||||
id: eventId || `evt_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: sessionId || `cs_test_${crypto.randomBytes(6).toString('hex')}`,
|
||||
customer_email: customerEmail,
|
||||
customer_details: { email: customerEmail },
|
||||
payment_status: paymentStatus,
|
||||
amount_total: product ? product.amountCents : 0,
|
||||
currency: 'usd',
|
||||
metadata: { productId, product: 'dashcaddy-pro' },
|
||||
line_items: { data: lineItems || [{ price: { id: priceId } }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function injectSmtp(impl) {
|
||||
nodemailerMock.createTransport.mockImplementation(() => ({
|
||||
sendMail: jest.fn().mockImplementation(impl),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('stripe-license-bridge signature verification', () => {
|
||||
test('rejects missing signature header', async () => {
|
||||
const { rawBody } = buildSignedPayload({ id: 'evt_1', type: 'x' });
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: '' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('signature-missing-signature');
|
||||
});
|
||||
|
||||
test('rejects wrong signature', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const rawBody = Buffer.from(JSON.stringify(event));
|
||||
const sig = crypto.createHmac('sha256', 'wrong').update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toMatch(/^signature-/);
|
||||
});
|
||||
|
||||
test('rejects out-of-tolerance timestamp', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const oldTs = Math.floor(Date.now() / 1000) - 3600; // 1h ago, > 300s tolerance
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event, { timestamp: oldTs });
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('signature-timestamp-out-of-tolerance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge event parsing', () => {
|
||||
test('rejects invalid JSON', async () => {
|
||||
const rawBody = Buffer.from('not json');
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('invalid-json');
|
||||
});
|
||||
|
||||
test('rejects event without id', async () => {
|
||||
const event = { type: 'checkout.session.completed', data: { object: {} } };
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('invalid-event');
|
||||
});
|
||||
|
||||
test('acks unknown event types with 200 (so Stripe stops retrying)', async () => {
|
||||
const event = { id: 'evt_unknown', type: 'customer.created', data: { object: {} } };
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.reason).toBe('ignored-event-type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge catalog resolution', () => {
|
||||
test('rejects session without productId metadata', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
delete event.data.object.metadata.productId;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('missing-productId');
|
||||
});
|
||||
|
||||
test('rejects unknown productId', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-1000d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('unknown-productId');
|
||||
});
|
||||
|
||||
test('rejects when product Stripe Price is unconfigured', async () => {
|
||||
const productId = 'pro-30d';
|
||||
const saved = process.env.STRIPE_PRICE_PRO_30D;
|
||||
delete process.env.STRIPE_PRICE_PRO_30D;
|
||||
try {
|
||||
const event = buildSessionEvent({ productId });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('product-not-configured');
|
||||
} finally {
|
||||
process.env.STRIPE_PRICE_PRO_30D = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects when customer email is missing', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', customerEmail: '' });
|
||||
delete event.data.object.customer_email;
|
||||
delete event.data.object.customer_details.email;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('missing-customer-email');
|
||||
});
|
||||
|
||||
test('accepts sessions without expanded line_items (Stripe webhook default)', async () => {
|
||||
// DC-057 acceptance: Stripe does NOT expand line_items in webhooks by
|
||||
// default — the bridge must accept the canonical metadata.productId
|
||||
// even when line_items is absent. (Price verification, when added,
|
||||
// should be an optional belt-and-suspenders via a separate API call,
|
||||
// not a hard requirement.)
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
delete event.data.object.line_items;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-30d');
|
||||
expect(result.body.durationDays).toBe(30);
|
||||
});
|
||||
|
||||
test('rejects unpaid sessions (no license until payment clears)', async () => {
|
||||
// DC-057: a checkout.session.completed event with payment_status='unpaid'
|
||||
// arrives when the customer closes the browser mid-checkout or for
|
||||
// delayed-payment methods (ACH/SEPA) before they clear. The bridge
|
||||
// MUST ack 200 (so Stripe stops retrying) but MUST NOT generate a
|
||||
// license. The async_payment_succeeded event will fire later.
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'unpaid' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-unpaid');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects no_payment_required sessions (DashCaddy does not sell free products)', async () => {
|
||||
// 'no_payment_required' is a Stripe-internal edge case for free
|
||||
// sessions. DashCaddy has no $0 product, so reject explicitly.
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'no_payment_required' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-no_payment_required');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects sessions with missing payment_status', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: '' });
|
||||
delete event.data.object.payment_status;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-confirmed');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('fulfills async_payment_succeeded events for delayed-payment methods', async () => {
|
||||
// ACH/SEPA: Stripe first sends checkout.session.completed (unpaid),
|
||||
// then async_payment_succeeded (paid) when the bank clears. The
|
||||
// bridge generates the license on the second event.
|
||||
const sessionId = `cs_test_ach_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const event = {
|
||||
id: `evt_ach_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.async_payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: sessionId,
|
||||
customer_email: 'alice@example.com',
|
||||
customer_details: { email: 'alice@example.com' },
|
||||
payment_status: 'paid',
|
||||
amount_total: 5000,
|
||||
currency: 'usd',
|
||||
metadata: { productId: 'pro-90d', product: 'dashcaddy-pro' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-90d');
|
||||
expect(result.body.durationDays).toBe(90);
|
||||
});
|
||||
|
||||
test('acks async_payment_failed events without generating a license', async () => {
|
||||
const event = {
|
||||
id: `evt_ach_fail_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.async_payment_failed',
|
||||
data: {
|
||||
object: {
|
||||
id: `cs_test_fail_${crypto.randomBytes(6).toString('hex')}`,
|
||||
payment_status: 'unpaid',
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('async-payment-failed');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge happy path', () => {
|
||||
test('generates + persists + delivers license (dev-console SMTP fallback)', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-90d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-90d');
|
||||
expect(result.body.durationDays).toBe(90);
|
||||
expect(result.body.codeId).toBeTruthy();
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
|
||||
// Fulfillment record exists.
|
||||
const record = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE })
|
||||
.readBySession(event.data.object.id);
|
||||
expect(record.status).toBe('delivered');
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.codeId).toBe(result.body.codeId);
|
||||
expect(record.deliveredVia).toBe('dev-console');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge idempotency', () => {
|
||||
test('duplicate eventId (Stripe retry) returns 200 without regenerating', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.delivered).toBe(true);
|
||||
|
||||
const second = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.deduplicated).toBe(true);
|
||||
// generateCodes called exactly once across both deliveries.
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
});
|
||||
|
||||
test('two events for the same session reuse the same license (layer-2 idempotency)', async () => {
|
||||
const sessionId = `cs_test_shared_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
|
||||
const payloadA = buildSignedPayload(eventA);
|
||||
const payloadB = buildSignedPayload(eventB);
|
||||
|
||||
const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
|
||||
const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });
|
||||
|
||||
expect(rA.status).toBe(200);
|
||||
expect(rA.body.delivered).toBe(true);
|
||||
// Second event hits layer-1 idempotency by eventId — different eventId,
|
||||
// so falls through to layer-2 by sessionId; sees existing delivered record.
|
||||
expect(rB.status).toBe(200);
|
||||
expect(rB.body.delivered).toBe(true);
|
||||
expect(rB.body.codeId).toBe(rA.body.codeId); // SAME license code
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); // only one code generated
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge SMTP failure recovery (DC-057 acceptance)', () => {
|
||||
beforeEach(() => {
|
||||
// Inject SMTP BEFORE each test so SMTP_HOST is set when deliverCode runs.
|
||||
injectSmtp(async () => { throw new Error('smtp-down'); });
|
||||
process.env.SMTP_HOST = 'smtp.example.com';
|
||||
process.env.SMTP_FROM = 'noreply@example.com';
|
||||
});
|
||||
|
||||
test('SMTP failure persists license, returns 500, but customer can retrieve via lookup', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-180d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.body.reason).toBe('email-failed');
|
||||
|
||||
// License IS persisted (the documented SMTP-failure recovery path).
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const record = store.readBySession(event.data.object.id);
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.status).toBe('pending_email');
|
||||
expect(record.lastError).toMatch(/smtp-down/);
|
||||
|
||||
// The lookup endpoint serves the persisted code ANYWAY.
|
||||
const lookup = bridge.lookupSession(event.data.object.id);
|
||||
expect(lookup.status).toBe('pending_email');
|
||||
expect(lookup.code).toBe(record.code);
|
||||
expect(lookup.durationDays).toBe(180);
|
||||
expect(lookup.productId).toBe('pro-180d');
|
||||
});
|
||||
|
||||
test('Stripe retry after SMTP failure keeps retrying (customer recovers via lookup)', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-365d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(first.status).toBe(500);
|
||||
|
||||
// Stripe retries with the SAME eventId. SMTP is still down → bridge
|
||||
// keeps retrying (returns 500) until either SMTP recovers or Stripe
|
||||
// gives up. The customer recovery path is via the lookup endpoint —
|
||||
// the license IS persisted in the fulfillment store regardless.
|
||||
const retry = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(retry.status).toBe(500);
|
||||
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const record = store.readBySession(event.data.object.id);
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.status).toBe('pending_email');
|
||||
|
||||
// Lookup serves the persisted code.
|
||||
const lookup = bridge.lookupSession(event.data.object.id);
|
||||
expect(lookup.code).toBe(record.code);
|
||||
|
||||
// Only one license generated across the retries (layer-2 idempotency).
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
});
|
||||
|
||||
test('SMTP recovers on a subsequent attempt (different eventId, same session) — still reuses the persisted code', async () => {
|
||||
let smtpCalls = 0;
|
||||
injectSmtp(async () => {
|
||||
smtpCalls++;
|
||||
if (smtpCalls === 1) throw new Error('smtp-temp-down');
|
||||
return { messageId: 'msg-ok' };
|
||||
});
|
||||
|
||||
const sessionId = `cs_test_recover_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
|
||||
const payloadA = buildSignedPayload(eventA);
|
||||
const payloadB = buildSignedPayload(eventB);
|
||||
|
||||
const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
|
||||
expect(rA.status).toBe(500); // first attempt: SMTP down
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
|
||||
// Read the persisted code from the store (rA.body doesn't include it on
|
||||
// failure — by design, we don't leak license material in error responses).
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const persistedCode = store.readBySession(sessionId).code;
|
||||
expect(persistedCode).toBeTruthy();
|
||||
|
||||
const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });
|
||||
expect(rB.status).toBe(200); // second event, same session: reuses persisted code, delivery succeeds
|
||||
expect(rB.body.delivered).toBe(true);
|
||||
// Same code reused, NOT a fresh generation.
|
||||
expect(rB.body.codeId).toBeTruthy();
|
||||
|
||||
// The store's codeId matches rB.body.codeId (proves reuse, not regeneration).
|
||||
expect(rB.body.codeId).toBe(store.readBySession(sessionId).codeId);
|
||||
|
||||
// No new license generated.
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
expect(smtpCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge lookupSession', () => {
|
||||
test('returns not_found for unknown sessionId', () => {
|
||||
expect(bridge.lookupSession('cs_unknown')).toEqual({ status: 'not_found' });
|
||||
});
|
||||
|
||||
test('returns expired for record past TTL', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
|
||||
// Far-future "now" past the 24h TTL.
|
||||
const future = Date.now() + 25 * 60 * 60 * 1000;
|
||||
const lookup = bridge.lookupSession(event.data.object.id, { nowMs: future });
|
||||
expect(lookup.status).toBe('expired');
|
||||
});
|
||||
|
||||
test('returns processing state for fresh claim without code', async () => {
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const sessionId = `cs_test_processing_${crypto.randomBytes(4).toString('hex')}`;
|
||||
await store.claim({ eventId: 'evt_pend', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
const lookup = bridge.lookupSession(sessionId);
|
||||
expect(lookup.status).toBe('processing');
|
||||
expect(lookup.durationDays).toBe(30);
|
||||
expect(lookup.productId).toBe('pro-30d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge constants', () => {
|
||||
test('LOOKUP_TTL_MS defaults to 24h', () => {
|
||||
expect(bridge.LOOKUP_TTL_MS).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('DELIVERY_LEASE_MS is exported', () => {
|
||||
expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -52,15 +52,15 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
|
||||
expect(readMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [] });
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
||||
});
|
||||
|
||||
test('returns checked/healthy counts from read() output', async () => {
|
||||
test('returns checked/healthy counts from read() output (all healthy)', async () => {
|
||||
const docker = {
|
||||
client: {
|
||||
getContainer: jest.fn((id) => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: id === 'c1' },
|
||||
State: { Running: true, Health: { Status: 'healthy' } },
|
||||
}),
|
||||
})),
|
||||
},
|
||||
@@ -79,10 +79,38 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
|
||||
expect(result.checked).toBe(2); // svc-3 skipped (no containerId)
|
||||
expect(result.healthy).toBe(1); // c1 is running, c2 is not
|
||||
expect(result.healthy).toBe(2); // both containers healthy
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0]).toMatchObject({ service: 'svc-1', healthy: true });
|
||||
expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: false });
|
||||
expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: true });
|
||||
expect(result.failing).toEqual([]);
|
||||
});
|
||||
|
||||
test('throws when any service is unhealthy — surfaces failing service IDs', async () => {
|
||||
const docker = {
|
||||
client: {
|
||||
getContainer: jest.fn((id) => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: id !== 'c2', Health: { Status: id === 'c2' ? 'unhealthy' : 'healthy' } },
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: {
|
||||
read: jest.fn().mockResolvedValue([
|
||||
{ id: 'svc-1', containerId: 'c1' },
|
||||
{ id: 'svc-2', containerId: 'c2' },
|
||||
]),
|
||||
},
|
||||
docker,
|
||||
});
|
||||
|
||||
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toThrow(/svc-2/);
|
||||
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toMatchObject({
|
||||
failingServices: ['svc-2'],
|
||||
workflowResult: expect.objectContaining({ checked: 2, healthy: 1, failing: ['svc-2'] }),
|
||||
});
|
||||
});
|
||||
|
||||
test('gracefully degrades if read() throws — empty services list, no crash', async () => {
|
||||
@@ -96,7 +124,7 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
// Before the fix, this rejected because .read() wasn't called and the
|
||||
// .catch(() => []) fallback didn't exist. Now it should resolve to empty.
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [] });
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
||||
});
|
||||
|
||||
test('servicesStateManager absent on ctx → no crash, empty result', async () => {
|
||||
@@ -111,7 +139,7 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
}
|
||||
|
||||
const result = await engine.healthCheckService('{{serviceId}}');
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [] });
|
||||
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
|
||||
});
|
||||
|
||||
test('single service (non-template serviceId) path still works', async () => {
|
||||
@@ -128,4 +156,245 @@ describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)',
|
||||
const result = await engine.healthCheckService('single-svc-id');
|
||||
expect(result).toEqual({ serviceId: 'single-svc-id', healthy: true });
|
||||
});
|
||||
|
||||
test('single-service check throws when container is unhealthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: {
|
||||
client: {
|
||||
getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(engine.healthCheckService('down-svc')).rejects.toMatchObject({
|
||||
failingServices: ['down-svc'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DC-044 root-cause fix tests: notify-on-failure gating + template interpolation.
|
||||
*
|
||||
* The original code in executeAction had TWO latent bugs:
|
||||
* 1. notify-on-failure sent unconditionally (its comment said "Only send if
|
||||
* previous action failed" but the code never checked).
|
||||
* 2. healthCheckService returned no serviceId field, so templates like
|
||||
* `Health check failed for {{serviceId}}` never interpolated and stayed
|
||||
* literal in every alert.
|
||||
*
|
||||
* These tests exercise the full executeWorkflow path with a stub workflow
|
||||
* that pairs `health-check` with `notify-on-failure`.
|
||||
*/
|
||||
describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + template)', () => {
|
||||
// Build an engine and call _runActions directly with arbitrary action
|
||||
// sequences. Bypasses BUNDLED_WORKFLOWS lookup so tests are isolated and
|
||||
// don't mutate module state.
|
||||
function makeEngine(opts = {}) {
|
||||
const ctx = {
|
||||
servicesStateManager: opts.servicesStateManager || { read: jest.fn().mockResolvedValue([]) },
|
||||
docker: opts.docker || { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }) })) } },
|
||||
notification: opts.notification || { send: jest.fn() },
|
||||
};
|
||||
const engine = new WorkflowEngine(ctx);
|
||||
if (engine.scheduledJobs) {
|
||||
for (const job of engine.scheduledJobs.values()) clearInterval(job);
|
||||
engine.scheduledJobs.clear();
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
test('notify-on-failure is a no-op when the previous action succeeded', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-1', containerId: 'c1' }]) },
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const results = await engine._runActions(
|
||||
[
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
|
||||
],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
const notifyResult = results.find(r => r.action === 'notify-on-failure');
|
||||
expect(notifyResult.success).toBe(true);
|
||||
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('notify-on-failure fires and interpolates {{failingServices}} when previous action failed', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-broken', containerId: 'c1' }]) },
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const results = await engine._runActions(
|
||||
[
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
|
||||
],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
const healthResult = results.find(r => r.action === 'health-check');
|
||||
const notifyResult = results.find(r => r.action === 'notify-on-failure');
|
||||
expect(healthResult.success).toBe(false);
|
||||
expect(healthResult.failingServices).toEqual(['svc-broken']);
|
||||
expect(notifyResult.success).toBe(true);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
// notification.send signature: (category, title, message, level)
|
||||
const sentMessage = notify.mock.calls[0][2];
|
||||
expect(sentMessage).toBe('Health check failed for svc-broken');
|
||||
expect(sentMessage).not.toContain('{{');
|
||||
});
|
||||
|
||||
test('notify (not notify-on-failure) fires unconditionally — regression guard', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({ notification: { send: notify } });
|
||||
|
||||
const results = await engine._runActions(
|
||||
[{ type: 'notify', message: 'always sent' }],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify.mock.calls[0][2]).toBe('always sent');
|
||||
expect(results[0].success).toBe(true);
|
||||
});
|
||||
|
||||
test('notify-on-failure as first action is a no-op (no previous result)', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({ notification: { send: notify } });
|
||||
|
||||
const results = await engine._runActions(
|
||||
[{ type: 'notify-on-failure', message: 'should not fire' }],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
const notifyResult = results[0];
|
||||
expect(notifyResult.success).toBe(true);
|
||||
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('multi-service batch failure: {{failingServices}} interpolates comma-joined list', async () => {
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([
|
||||
{ id: 'svc-ok', containerId: 'c1' },
|
||||
{ id: 'svc-broken-1', containerId: 'c2' },
|
||||
{ id: 'svc-broken-2', containerId: 'c3' },
|
||||
]) },
|
||||
docker: { client: { getContainer: jest.fn((id) => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: id === 'c1', Health: { Status: id === 'c1' ? 'healthy' : 'unhealthy' } },
|
||||
}),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const results = await engine._runActions(
|
||||
[
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Failing: {{failingServices}}' },
|
||||
],
|
||||
{ trigger: 'manual' }
|
||||
);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const sentMessage = notify.mock.calls[0][2];
|
||||
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
|
||||
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
|
||||
});
|
||||
|
||||
// B2 regression: hit the actual bundled health-check-on-interval workflow
|
||||
// end-to-end via executeWorkflow. The bundled template uses
|
||||
// {{failingServices}} (DC-044 fix). Earlier it used {{serviceId}} which
|
||||
// never resolved because no per-service ID is in workflow scope. This test
|
||||
// would have failed with the old template.
|
||||
test('executeWorkflow on bundled health-check-on-interval: no literal {{...}} in notification', async () => {
|
||||
const { BUNDLED_WORKFLOWS } = require('../src/recipes/bundled-workflows');
|
||||
expect(BUNDLED_WORKFLOWS['health-check-on-interval']).toBeDefined();
|
||||
|
||||
const notify = jest.fn();
|
||||
const engine = makeEngine({
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([
|
||||
{ id: 'svc-broken', containerId: 'c1' },
|
||||
]) },
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: false, Health: { Status: 'unhealthy' } },
|
||||
}),
|
||||
})) } },
|
||||
notification: { send: notify },
|
||||
});
|
||||
|
||||
const result = await engine.executeWorkflow('health-check-on-interval', { trigger: 'manual' });
|
||||
|
||||
// Either the bundled workflow fired notification (with interpolated
|
||||
// message) OR every action resolved — but in NO case may a literal
|
||||
// {{...}} template token leak into notification.send.
|
||||
if (notify.mock.calls.length > 0) {
|
||||
const sentMessage = notify.mock.calls[0][2];
|
||||
expect(sentMessage).not.toMatch(/\{\{/);
|
||||
expect(sentMessage).not.toMatch(/\}\}/);
|
||||
// The new bundled template substitutes failingServices — make sure
|
||||
// the actual service ID made it through.
|
||||
expect(sentMessage).toContain('svc-broken');
|
||||
}
|
||||
// Workflow must always complete (success or failure), never throw.
|
||||
expect(result).toBeDefined();
|
||||
expect(result.workflowId).toBe('health-check-on-interval');
|
||||
});
|
||||
|
||||
// B3 regression: a running container with Health.Status === 'unhealthy'
|
||||
// must be reported as unhealthy. Previously checkContainerHealth compared
|
||||
// info.State.Health itself (an object) to the string 'unhealthy', which
|
||||
// was always false — so any container with an explicit healthcheck was
|
||||
// always considered healthy. The fix reads info.State.Health.Status.
|
||||
test('checkContainerHealth treats running-but-unhealthy container as unhealthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
State: { Running: true, Health: { Status: 'unhealthy' } },
|
||||
}),
|
||||
})) } },
|
||||
});
|
||||
|
||||
const healthy = await engine.checkContainerHealth('running-but-unhealthy');
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
test('checkContainerHealth treats running-with-no-healthcheck as healthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
|
||||
})) } },
|
||||
});
|
||||
|
||||
const healthy = await engine.checkContainerHealth('no-healthcheck');
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
|
||||
test('checkContainerHealth treats stopped container as unhealthy', async () => {
|
||||
const engine = makeEngine({
|
||||
docker: { client: { getContainer: jest.fn(() => ({
|
||||
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
|
||||
})) } },
|
||||
});
|
||||
|
||||
const healthy = await engine.checkContainerHealth('stopped');
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Tests for dashcaddy-api/license-keygen.js
|
||||
*
|
||||
* Covers the programmatic API used by the Stripe webhook bridge and the
|
||||
* on-disk counter allocator. The CLI path is exercised through the
|
||||
* dedicated CLI regression describe block at the bottom of this file.
|
||||
*
|
||||
* - module.exports shape: verifyCode, parseCode, generateCode,
|
||||
* generateCodes, loadSecret, VALID_DURATIONS, VERSION
|
||||
* - generateCodes() validation: secret, duration, count
|
||||
* - generateCodes() counter allocator: init, increment, override via
|
||||
* startId, override via counterFile, atomic .tmp shape
|
||||
* - generateCodes() monotonic counter: 100-call ordering, range checks
|
||||
* - loadSecret() success and missing-file error
|
||||
* - generateCode() round-trip: codes verify back via verifyCode()
|
||||
* - CLI integration: omitted --start-id uses auto-counter, explicit
|
||||
* --start-id skips counter write, --lifetime/--duration mutual exclusion
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const keygen = require('../license-keygen');
|
||||
const {
|
||||
verifyCode,
|
||||
parseCode,
|
||||
generateCode,
|
||||
generateCodes,
|
||||
loadSecret,
|
||||
VALID_DURATIONS,
|
||||
VERSION,
|
||||
} = keygen;
|
||||
|
||||
function _tmpDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `dashcaddy-${prefix}-`));
|
||||
}
|
||||
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
const TEST_SECRET = 'a'.repeat(64); // 32 bytes hex
|
||||
|
||||
// ── Public surface ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: module.exports', () => {
|
||||
test('exports verifyCode, parseCode, generateCode, generateCodes, loadSecret, VALID_DURATIONS, VERSION', () => {
|
||||
expect(typeof verifyCode).toBe('function');
|
||||
expect(typeof parseCode).toBe('function');
|
||||
expect(typeof generateCode).toBe('function');
|
||||
expect(typeof generateCodes).toBe('function');
|
||||
expect(typeof loadSecret).toBe('function');
|
||||
expect(Array.isArray(VALID_DURATIONS)).toBe(true);
|
||||
expect(VALID_DURATIONS).toEqual([30, 90, 180, 365]);
|
||||
expect(VERSION).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCode / parseCode / verifyCode round-trip ────────────────────────
|
||||
|
||||
describe('license-keygen: generateCode round-trip', () => {
|
||||
test('generated code verifies back via verifyCode()', () => {
|
||||
const code = generateCode(TEST_SECRET, 90, 42);
|
||||
expect(code).toMatch(/^DC-([0-9A-Z]{5})(-[0-9A-Z]{5}){4}$/);
|
||||
const result = verifyCode(TEST_SECRET, code);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.durationDays).toBe(90);
|
||||
expect(result.codeId).toBe(42);
|
||||
});
|
||||
|
||||
test('verifyCode rejects a code from a different secret', () => {
|
||||
const code = generateCode(TEST_SECRET, 30, 1);
|
||||
const result = verifyCode('b'.repeat(64), code);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toMatch(/signature/i);
|
||||
});
|
||||
|
||||
test('parseCode returns version, duration, codeId, timestamp', () => {
|
||||
const code = generateCode(TEST_SECRET, 365, 9999);
|
||||
const parsed = parseCode(code);
|
||||
expect(parsed.version).toBe(VERSION);
|
||||
expect(parsed.durationDays).toBe(365);
|
||||
expect(parsed.codeId).toBe(9999);
|
||||
expect(typeof parsed.createdTs).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: validation ───────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes validation', () => {
|
||||
test('throws on missing secret', () => {
|
||||
expect(() => generateCodes({ secret: '', durationDays: 30 })).toThrow(/secret is required/);
|
||||
expect(() => generateCodes({ secret: 123, durationDays: 30 })).toThrow(/secret is required/);
|
||||
expect(() => generateCodes({ durationDays: 30 })).toThrow(/secret is required/);
|
||||
});
|
||||
|
||||
test('throws on invalid duration', () => {
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 7 })).toThrow(/invalid duration/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 31 })).toThrow(/invalid duration/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: -1 })).toThrow(/invalid duration/);
|
||||
});
|
||||
|
||||
test('accepts LIFETIME (durationDays: 0)', () => {
|
||||
const tmp = _tmpDir('kg-lifetime');
|
||||
try {
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 0,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
});
|
||||
expect(codes).toHaveLength(1);
|
||||
expect(codes[0].durationDays).toBe(0);
|
||||
} finally { _cleanup(tmp); }
|
||||
});
|
||||
|
||||
test('throws on invalid count', () => {
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 0 })).toThrow(/invalid count/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: -1 })).toThrow(/invalid count/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 10001 })).toThrow(/invalid count/);
|
||||
expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 1.5 })).toThrow(/invalid count/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: counter allocator ────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes counter', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-counter'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('initializes counter at 1 when file is missing', () => {
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
});
|
||||
expect(codes[0].codeId).toBe(1);
|
||||
expect(fs.readFileSync(path.join(tmp, '.counter'), 'utf8').trim()).toBe('1');
|
||||
});
|
||||
|
||||
test('increments counter on subsequent calls', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
counterFile,
|
||||
});
|
||||
expect(codes[0].codeId).toBe(i);
|
||||
}
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('3');
|
||||
});
|
||||
|
||||
test('respects startId override and does NOT touch the counter file', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, '100');
|
||||
const codes = generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
count: 3,
|
||||
startId: 500,
|
||||
counterFile,
|
||||
});
|
||||
expect(codes.map(c => c.codeId)).toEqual([500, 501, 502]);
|
||||
// Counter file unchanged — overrideStartId path skips the write.
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
|
||||
});
|
||||
|
||||
test('no leftover .tmp files after a successful call', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
|
||||
const entries = fs.readdirSync(tmp);
|
||||
expect(entries.filter(e => e.includes('.tmp'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('counter file uses per-call unique tmp suffix (no .tmp collisions)', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
const origWrite = fs.writeFileSync;
|
||||
const tmpNames = [];
|
||||
fs.writeFileSync = (p, data, opts) => {
|
||||
if (typeof p === 'string' && p.startsWith(counterFile) && p.includes('.tmp')) {
|
||||
tmpNames.push(p);
|
||||
}
|
||||
return origWrite.call(fs, p, data, opts);
|
||||
};
|
||||
try {
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile });
|
||||
expect(tmpNames).toHaveLength(2);
|
||||
expect(new Set(tmpNames).size).toBe(2);
|
||||
} finally {
|
||||
fs.writeFileSync = origWrite;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: monotonic counter ────────────────────────────────────────
|
||||
//
|
||||
// generateCodes() is synchronous. Node's single-threaded event loop means
|
||||
// two synchronous calls cannot interleave, so the counter is monotonically
|
||||
// incremented without any explicit locking. The atomic write helper
|
||||
// protects against process crashes between writeFileSync and renameSync.
|
||||
// These tests verify that ordering and atomicity hold across many calls.
|
||||
|
||||
describe('license-keygen: generateCodes monotonic counter', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-mono'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('100 sequential calls produce 100 unique codeIds in monotonic order', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
const codes = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
codes.push(generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
counterFile,
|
||||
})[0]);
|
||||
}
|
||||
const ids = codes.map(c => c.codeId);
|
||||
expect(ids).toHaveLength(100);
|
||||
expect(new Set(ids).size).toBe(100);
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
expect(ids[i]).toBe(ids[i - 1] + 1);
|
||||
}
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100');
|
||||
});
|
||||
|
||||
test('100 sequential calls each requesting 5 codes produce 500 unique IDs', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
const batches = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
batches.push(generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
count: 5,
|
||||
counterFile,
|
||||
}));
|
||||
}
|
||||
const allIds = batches.flat().map(c => c.codeId);
|
||||
expect(allIds).toHaveLength(500);
|
||||
expect(new Set(allIds).size).toBe(500);
|
||||
batches.forEach((batch, i) => {
|
||||
const start = i * 5 + 1;
|
||||
expect(batch.map(c => c.codeId)).toEqual([start, start + 1, start + 2, start + 3, start + 4]);
|
||||
});
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('500');
|
||||
});
|
||||
|
||||
test('startId override is range-checked (negative throws)', () => {
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
startId: -1,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
})).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
test('startId override is range-checked (over 32-bit throws)', () => {
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
startId: 0x100000000,
|
||||
counterFile: path.join(tmp, '.counter'),
|
||||
})).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
test('startId override is rejected for non-integer values', () => {
|
||||
// Codex round 2: Number.isInteger(overrideStartId) returned false for
|
||||
// floats/NaN/null/strings, silently falling through to auto-counter.
|
||||
// The Object.prototype.hasOwnProperty check above fixes the dispatch.
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, '99');
|
||||
for (const bad of [1.5, NaN, null, '100', undefined, false]) {
|
||||
const prevValue = fs.readFileSync(counterFile, 'utf8').trim();
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
startId: bad,
|
||||
counterFile,
|
||||
})).toThrow(/out of range|non-integer/);
|
||||
// Counter file must NOT be touched when the call throws.
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe(prevValue);
|
||||
}
|
||||
});
|
||||
|
||||
test('count that would push codeId past 32-bit throws', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, String(0xFFFFFFFF - 5));
|
||||
expect(() => generateCodes({
|
||||
secret: TEST_SECRET,
|
||||
durationDays: 30,
|
||||
count: 10,
|
||||
counterFile,
|
||||
})).toThrow(/32-bit limit/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: counterFile override ─────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes counterFile override', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-cf'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('counterFile option overrides LICENSE_COUNTER_FILE env', () => {
|
||||
const cf = path.join(tmp, '.counter');
|
||||
const prev = process.env.LICENSE_COUNTER_FILE;
|
||||
try {
|
||||
process.env.LICENSE_COUNTER_FILE = path.join(tmp, 'env-counter');
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile: cf });
|
||||
expect(fs.existsSync(cf)).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmp, 'env-counter'))).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
|
||||
else process.env.LICENSE_COUNTER_FILE = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('LICENSE_COUNTER_FILE env overrides the default __dirname counter', () => {
|
||||
const tmpForEnv = _tmpDir('kg-env');
|
||||
try {
|
||||
const target = path.join(tmpForEnv, 'env-counter');
|
||||
const prev = process.env.LICENSE_COUNTER_FILE;
|
||||
process.env.LICENSE_COUNTER_FILE = target;
|
||||
try {
|
||||
const codes = generateCodes({ secret: TEST_SECRET, durationDays: 30 });
|
||||
expect(codes[0].codeId).toBeLessThanOrEqual(1); // fresh env
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE;
|
||||
else process.env.LICENSE_COUNTER_FILE = prev;
|
||||
}
|
||||
} finally { _cleanup(tmpForEnv); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── loadSecret ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: loadSecret', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-secret'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('returns trimmed contents of an existing secret file', () => {
|
||||
const file = path.join(tmp, '.license-secret');
|
||||
fs.writeFileSync(file, ' abc123 \n');
|
||||
expect(loadSecret(file)).toBe('abc123');
|
||||
});
|
||||
|
||||
test('throws on missing file with helpful message', () => {
|
||||
const file = path.join(tmp, 'does-not-exist');
|
||||
expect(() => loadSecret(file)).toThrow(/not found/i);
|
||||
expect(() => loadSecret(file)).toThrow(/--init-secret/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateCodes: failure modes ────────────────────────────────────────────
|
||||
|
||||
describe('license-keygen: generateCodes failure modes', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-fail'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
test('throws when counter file exists but contains non-numeric data', () => {
|
||||
const counterFile = path.join(tmp, '.counter');
|
||||
fs.writeFileSync(counterFile, 'not-a-number');
|
||||
expect(() =>
|
||||
generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }),
|
||||
).toThrow(/non-numeric/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CLI regression: spawn the real binary and verify argument handling ───────
|
||||
//
|
||||
// Codex round 4 caught a regression: main() always passed
|
||||
// `startId: overrideStartId` to generateCodes(), even when --start-id was
|
||||
// omitted. The new hasOwnProperty-based validation then rejected the call
|
||||
// because startId was an explicit (undefined) value. The fix is to omit
|
||||
// the startId property from the options object when --start-id is absent.
|
||||
// These tests exercise the actual CLI binary to make sure the local fix
|
||||
// wires up correctly.
|
||||
|
||||
const KEYGEN_BIN = path.resolve(__dirname, '..', 'license-keygen.js');
|
||||
|
||||
function _runCli(args, env) {
|
||||
return execFileSync('node', [KEYGEN_BIN, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
describe('license-keygen: CLI regression', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = _tmpDir('kg-cli'); });
|
||||
afterEach(() => { _cleanup(tmp); });
|
||||
|
||||
function _setupSecret() {
|
||||
fs.writeFileSync(path.join(tmp, '.license-secret'), TEST_SECRET);
|
||||
return path.join(tmp, '.license-secret');
|
||||
}
|
||||
|
||||
test('omitted --start-id uses the auto-counter path (CLI integration)', () => {
|
||||
const secretFile = _setupSecret();
|
||||
const counterFile = path.join(tmp, '.license-counter');
|
||||
|
||||
// First call: no --start-id, expects counter to be created at 1.
|
||||
const out1 = _runCli(['--duration', '30', '--count', '1', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
});
|
||||
const codes1 = JSON.parse(out1.split('Generated')[0]);
|
||||
|
||||
expect(codes1.length).toBe(1);
|
||||
expect(codes1[0].durationDays).toBe(30);
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('1');
|
||||
|
||||
// Second call: counter should auto-increment to 2.
|
||||
const out2 = _runCli(['--duration', '30', '--count', '1', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
});
|
||||
const codes2 = JSON.parse(out2.split('Generated')[0]);
|
||||
|
||||
expect(codes2[0].codeId).toBeGreaterThan(codes1[0].codeId);
|
||||
});
|
||||
|
||||
test('--start-id override skips counter file update (CLI integration)', () => {
|
||||
const secretFile = _setupSecret();
|
||||
const counterFile = path.join(tmp, '.license-counter');
|
||||
fs.writeFileSync(counterFile, '99');
|
||||
|
||||
const out = _runCli(['--duration', '30', '--start-id', '500', '--count', '2', '--json'], {
|
||||
LICENSE_COUNTER_FILE: counterFile,
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
});
|
||||
const codes = JSON.parse(out.split('Generated')[0]);
|
||||
|
||||
expect(codes.length).toBe(2);
|
||||
expect(codes[0].codeId).toBe(500);
|
||||
expect(codes[1].codeId).toBe(501);
|
||||
// Counter file remains untouched at '99' (override skips auto-update).
|
||||
expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99');
|
||||
});
|
||||
|
||||
test('--lifetime and --duration are mutually exclusive (CLI integration)', () => {
|
||||
const secretFile = _setupSecret();
|
||||
expect(() =>
|
||||
_runCli(['--duration', '30', '--lifetime', '--count', '1'], {
|
||||
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
}),
|
||||
).toThrow(/mutually exclusive/);
|
||||
});
|
||||
|
||||
test('--tier pro without --duration or --lifetime still requires one of them', () => {
|
||||
const secretFile = _setupSecret();
|
||||
expect(() =>
|
||||
_runCli(['--tier', 'pro', '--count', '1'], {
|
||||
LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'),
|
||||
LICENSE_SECRET_FILE: secretFile,
|
||||
}),
|
||||
).toThrow(/--duration is required/);
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ function readMountedRoutes() {
|
||||
'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/billing.js', // DC-055: apiRouter.use('/billing', billingRoutes({...}))
|
||||
'routes/health.js', // apiRouter.use(healthRoutes({...})) // bare mount
|
||||
'routes/monitoring.js', // apiRouter.use(monitoringRoutes({...})) // bare mount
|
||||
'routes/updates.js', // apiRouter.use(updatesRoutes({...})) // bare mount
|
||||
@@ -130,12 +130,14 @@ function readMountedRoutes() {
|
||||
'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount
|
||||
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
|
||||
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
|
||||
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount — needed for /api/v1/services + /api/v1/services/status PUBLIC_ROUTES
|
||||
];
|
||||
// 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/billing.js': '/billing', // DC-055: apiRouter.use('/billing', billingRoutes({...})) in src/app.js
|
||||
'routes/tailscale.js': '/tailscale',
|
||||
'routes/ca.js': '/ca',
|
||||
'routes/openclaw.js': '/openclaw',
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* 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 }
|
||||
* - valid TOTP → 200 + session cookie + CSRF token + SSO handoff token
|
||||
* - check-session with valid session → 200 { success: true, authenticated: true }
|
||||
* - check-session without session → 401 (AuthenticationError)
|
||||
*
|
||||
* Uses real otplib for code generation (so we exercise the actual TOTP math)
|
||||
@@ -79,6 +79,7 @@ function createApp(depsOverride = {}) {
|
||||
sessionStore.delete(ip);
|
||||
}),
|
||||
clearCookie: jest.fn(),
|
||||
createHandoffToken: jest.fn(() => 'mock-sso-handoff-token'),
|
||||
isValid: jest.fn((req) => {
|
||||
const ip = session.getClientIP(req);
|
||||
const entry = sessionStore.get(ip);
|
||||
@@ -303,8 +304,10 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||
expect(res.body.csrfToken).toBe('mock-csrf-token');
|
||||
expect(res.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||
expect(deps.session.create).toHaveBeenCalled();
|
||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -350,7 +353,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
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 });
|
||||
expect(res.body).toEqual({ success: true, authenticated: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -450,24 +453,23 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
||||
expect(loginRes.status).toBe(200);
|
||||
expect(loginRes.body.csrfToken).toBeDefined();
|
||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 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 });
|
||||
expect(checkRes.body).toEqual({ success: true, 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 401 (bypass removed for security)
|
||||
// unless the user still holds a valid session, in which case it's 200.
|
||||
// The login step (4) may or may not have granted one depending on test order.
|
||||
// 7. After disable, check-session deterministically rejects before
|
||||
// checking session validity because TOTP protection is disabled.
|
||||
const afterRes = await request(app).get('/api/totp/check-session');
|
||||
// After disable, TOTP is off AND we may or may not have an active session.
|
||||
// The new contract: bypass is gone, but a valid session still authenticates.
|
||||
expect([200, 401]).toContain(afterRes.status);
|
||||
expect(afterRes.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
|
||||
|
||||
@@ -78,23 +78,19 @@ describe('SelfUpdater.getLocalVersion() — DC-033 regression', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('commit is a git SHA (7-40 hex chars), not null', () => {
|
||||
test('commit contains a git SHA and is not null', () => {
|
||||
expect(result.commit).not.toBeNull();
|
||||
expect(result.commit).toMatch(/^[0-9a-f]{7,40}$/);
|
||||
expect(result.commit).toMatch(/(?:^|-)[0-9a-f]{7,40}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('candidate-path resolution survives missing sibling files', () => {
|
||||
// If we shadow __dirname by requiring the module through a different
|
||||
// require() chain, the function should still find package.json via its
|
||||
// candidate-list fallback. This catches the case where someone refactors
|
||||
// the file to a deeper subdirectory and forgets to update the candidates.
|
||||
test('getLocalVersion works regardless of how the module is required', () => {
|
||||
describe('repeat construction uses the same resolved metadata', () => {
|
||||
test('a second instance resolves the same non-fallback version metadata', () => {
|
||||
const mod = require(path.join(API_ROOT, 'src', 'docker', 'self-updater.js'));
|
||||
const Cls = mod.SelfUpdater || mod.default || mod;
|
||||
const result = new Cls({}).getLocalVersion();
|
||||
expect(result.version).not.toBe('0.0.0');
|
||||
expect(result.commit).toMatch(/^[0-9a-f]{7,40}$/);
|
||||
expect(result.commit).toMatch(/(?:^|-)[0-9a-f]{7,40}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
const configureMiddleware = require('../src/utilities/middleware');
|
||||
|
||||
function buildSession() {
|
||||
const app = {
|
||||
param: jest.fn(),
|
||||
set: jest.fn(),
|
||||
use: jest.fn(),
|
||||
};
|
||||
|
||||
return configureMiddleware(app, {
|
||||
siteConfig: { dashboardHost: 'status.sami', tld: '.sami' },
|
||||
totpConfig: { enabled: true, sessionDuration: '24h' },
|
||||
tailscaleConfig: { enabled: false, requireAuth: false },
|
||||
metrics: { recordRequest: jest.fn() },
|
||||
auditLogger: { middleware: () => (_req, _res, next) => next() },
|
||||
authManager: { verifyJWT: jest.fn(), verifyAPIKey: jest.fn() },
|
||||
log: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
cryptoUtils: { loadOrCreateKey: () => Buffer.alloc(32, 7) },
|
||||
isValidContainerId: () => true,
|
||||
isTailscaleIP: () => false,
|
||||
getTailscaleStatus: async () => null,
|
||||
});
|
||||
}
|
||||
|
||||
function captureCookie(setCookie) {
|
||||
const headers = {};
|
||||
setCookie({ setHeader: (name, value) => { headers[name.toLowerCase()] = value; } }, '24h');
|
||||
return headers['set-cookie'];
|
||||
}
|
||||
|
||||
describe('TOTP session cookie scope', () => {
|
||||
test('primary login cookie is host-only for custom TLD deployments', () => {
|
||||
const session = buildSession();
|
||||
const cookie = captureCookie(session.setSessionCookie);
|
||||
|
||||
expect(cookie).toContain('dashcaddy_session=');
|
||||
expect(cookie).toContain('HttpOnly');
|
||||
expect(cookie).toContain('Secure');
|
||||
expect(cookie).toContain('SameSite=Lax');
|
||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||
});
|
||||
|
||||
test('SSO exchange uses the same host-only cookie contract', () => {
|
||||
const session = buildSession();
|
||||
const cookie = captureCookie(session.setHostOnlySessionCookie);
|
||||
|
||||
expect(cookie).toContain('dashcaddy_session=');
|
||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||
});
|
||||
|
||||
test('logout clears the host-only secure cookie', () => {
|
||||
const session = buildSession();
|
||||
const headers = {};
|
||||
session.clearSessionCookie({
|
||||
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
||||
});
|
||||
|
||||
expect(headers['set-cookie']).toContain('Max-Age=0');
|
||||
expect(headers['set-cookie']).toContain('Secure');
|
||||
expect(headers['set-cookie']).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Tests for the graceful shutdown coordinator (DC-067).
|
||||
*
|
||||
* Covers:
|
||||
* - Constructor rejects bad inputs
|
||||
* - shutdown() emits 'shutdown' event with the signal name
|
||||
* - shutdown() stops each manager in declaration order
|
||||
* - shutdown() is idempotent — second call logs and returns
|
||||
* - shutdown() force-exits after drainTimeoutMs if server.close never fires
|
||||
* - shutdown() clears the force-exit timer when server.close fires first
|
||||
* - shutdown() catches manager.stop() throws so one bad manager doesn't
|
||||
* prevent the others from being stopped
|
||||
* - installSignalHandlers() registers for SIGTERM and SIGINT by default
|
||||
*
|
||||
* process.exit is mocked so tests don't actually kill the test runner.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const {
|
||||
createShutdownCoordinator,
|
||||
installSignalHandlers,
|
||||
DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
ShutdownCoordinator,
|
||||
} = require('../src/utilities/shutdown');
|
||||
|
||||
describe('ShutdownCoordinator (DC-067)', () => {
|
||||
let exitMock;
|
||||
let exitCalls;
|
||||
|
||||
beforeEach(() => {
|
||||
exitCalls = [];
|
||||
// Use jest.spyOn so the mock is restored in afterEach (via clearMocks +
|
||||
// restoreMocks: true in jest.config.js). Direct assignment to process.exit
|
||||
// doesn't suppress Jest's process.exit watchlist which fails the test.
|
||||
exitMock = jest.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
exitCalls.push(code);
|
||||
// Returning undefined prevents the test runner from actually exiting.
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitMock.mockRestore();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
function makeFakeServer({ closeBehavior = 'sync' } = {}) {
|
||||
// 'sync' close calls back immediately.
|
||||
// 'never' close never calls back (used to test force-exit).
|
||||
if (closeBehavior === 'never') {
|
||||
return { close: jest.fn() };
|
||||
}
|
||||
return { close: jest.fn((cb) => { cb(); }) };
|
||||
}
|
||||
|
||||
function makeFakeLog() {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('constructor', () => {
|
||||
test('throws if server is missing', () => {
|
||||
expect(() => createShutdownCoordinator({ log: makeFakeLog(), managers: [] }))
|
||||
.toThrow('server is required');
|
||||
});
|
||||
|
||||
test('throws if log is missing or invalid', () => {
|
||||
expect(() => createShutdownCoordinator({ server: makeFakeServer(), managers: [] }))
|
||||
.toThrow('log must have info');
|
||||
expect(() => createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: { foo: 'bar' },
|
||||
managers: [],
|
||||
})).toThrow('log must have info');
|
||||
expect(() => createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: { info: () => {}, warn: () => {} }, // missing error
|
||||
managers: [],
|
||||
})).toThrow('log must have info');
|
||||
// A log with all three methods should NOT throw.
|
||||
expect(() => createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
managers: [],
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
test('uses DEFAULT_DRAIN_TIMEOUT_MS when drainTimeoutMs is not finite positive', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
drainTimeoutMs: 0,
|
||||
managers: [],
|
||||
});
|
||||
expect(c.drainTimeoutMs).toBe(DEFAULT_DRAIN_TIMEOUT_MS);
|
||||
|
||||
const c2 = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
drainTimeoutMs: NaN,
|
||||
managers: [],
|
||||
});
|
||||
expect(c2.drainTimeoutMs).toBe(DEFAULT_DRAIN_TIMEOUT_MS);
|
||||
|
||||
const c3 = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
drainTimeoutMs: 5000,
|
||||
managers: [],
|
||||
});
|
||||
expect(c3.drainTimeoutMs).toBe(5000);
|
||||
});
|
||||
|
||||
test('defaults managers to [] when not an array', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
});
|
||||
expect(c.managers).toEqual([]);
|
||||
});
|
||||
|
||||
test('is an EventEmitter', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
managers: [],
|
||||
});
|
||||
expect(c).toBeInstanceOf(EventEmitter);
|
||||
expect(c).toBeInstanceOf(ShutdownCoordinator);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown()', () => {
|
||||
test('emits shutdown event with signal name', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
managers: [],
|
||||
});
|
||||
const handler = jest.fn();
|
||||
c.on('shutdown', handler);
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith('SIGTERM');
|
||||
});
|
||||
|
||||
test('swallows exceptions thrown by shutdown event listeners', () => {
|
||||
const log = makeFakeLog();
|
||||
const server = makeFakeServer();
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log,
|
||||
managers: [],
|
||||
});
|
||||
c.on('shutdown', () => { throw new Error('listener boom'); });
|
||||
|
||||
// shutdown() must NOT propagate the exception — that would abort
|
||||
// the entire shutdown sequence before server.close is even called.
|
||||
expect(() => c.shutdown('SIGTERM')).not.toThrow();
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
'shutdown',
|
||||
"event listener for 'shutdown' threw",
|
||||
expect.objectContaining({ error: 'listener boom' }),
|
||||
);
|
||||
// server.close should still have been called.
|
||||
expect(server.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('swallows exceptions thrown by closed event listeners', async () => {
|
||||
const log = makeFakeLog();
|
||||
const server = makeFakeServer();
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log,
|
||||
managers: [],
|
||||
});
|
||||
c.on('closed', () => { throw new Error('closed listener boom'); });
|
||||
|
||||
// process.exit is mocked; we just verify the throw doesn't bubble.
|
||||
c.shutdown('SIGTERM');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
// The closed listener threw but the exit still got recorded.
|
||||
expect(exitCalls).toEqual([0]);
|
||||
});
|
||||
|
||||
test('calls server.close() once', () => {
|
||||
const server = makeFakeServer();
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log: makeFakeLog(),
|
||||
managers: [],
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
|
||||
expect(server.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('stops each manager in declaration order AFTER server.close fires', async () => {
|
||||
const order = [];
|
||||
const managers = [
|
||||
{ name: 'first', stop: jest.fn(() => { order.push('first'); }) },
|
||||
{ name: 'second', stop: jest.fn(() => { order.push('second'); }) },
|
||||
{ name: 'third', stop: jest.fn(() => { order.push('third'); }) },
|
||||
];
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
managers,
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
// Wait for the async chain (server.close → _stopManagersInOrder →
|
||||
// process.exit) to settle. The mock exit is synchronous so this
|
||||
// resolves once all microtasks drain.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(order).toEqual(['first', 'second', 'third']);
|
||||
});
|
||||
|
||||
test('does NOT stop managers until server.close callback fires', () => {
|
||||
const order = [];
|
||||
// Use a server whose close callback fires only when we manually call it.
|
||||
let deferredCloseCb;
|
||||
const server = {
|
||||
close: jest.fn((cb) => { deferredCloseCb = cb; }),
|
||||
};
|
||||
const managers = [
|
||||
{ name: 'first', stop: jest.fn(() => { order.push('first'); }) },
|
||||
];
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log: makeFakeLog(),
|
||||
managers,
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
|
||||
// server.close has been called but its callback hasn't fired yet.
|
||||
expect(server.close).toHaveBeenCalledTimes(1);
|
||||
// Manager has NOT been stopped yet — server is still draining.
|
||||
expect(order).toEqual([]);
|
||||
|
||||
// Now fire the deferred callback to simulate drain completion.
|
||||
deferredCloseCb();
|
||||
|
||||
// Manager stopped AFTER server.close fired.
|
||||
expect(order).toEqual(['first']);
|
||||
});
|
||||
|
||||
test('continues stopping remaining managers if one throws', async () => {
|
||||
const order = [];
|
||||
const managers = [
|
||||
{ name: 'first', stop: jest.fn(() => { order.push('first'); }) },
|
||||
{ name: 'broken', stop: jest.fn(() => { throw new Error('boom'); }) },
|
||||
{ name: 'third', stop: jest.fn(() => { order.push('third'); }) },
|
||||
];
|
||||
const log = makeFakeLog();
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log,
|
||||
managers,
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(order).toEqual(['first', 'third']);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'shutdown',
|
||||
'manager stop failed: broken',
|
||||
expect.objectContaining({ error: 'boom' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('is idempotent — second shutdown() returns without re-running', () => {
|
||||
const server = makeFakeServer();
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log: makeFakeLog(),
|
||||
managers: [{ name: 'm', stop: jest.fn() }],
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
c.shutdown('SIGTERM');
|
||||
c.shutdown('SIGINT');
|
||||
|
||||
expect(server.close).toHaveBeenCalledTimes(1);
|
||||
expect(c.isShuttingDown()).toBe(true);
|
||||
});
|
||||
|
||||
test('isShuttingDown() flips false→true on first shutdown call', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
managers: [],
|
||||
});
|
||||
expect(c.isShuttingDown()).toBe(false);
|
||||
c.shutdown('SIGTERM');
|
||||
expect(c.isShuttingDown()).toBe(true);
|
||||
});
|
||||
|
||||
test('force-exits after drainTimeoutMs if server.close never fires', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const server = makeFakeServer({ closeBehavior: 'never' });
|
||||
const log = makeFakeLog();
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log,
|
||||
drainTimeoutMs: 1000,
|
||||
managers: [],
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
expect(exitCalls).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(999);
|
||||
expect(exitCalls).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(2);
|
||||
expect(exitCalls).toEqual([0]);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'shutdown',
|
||||
expect.stringContaining('drain timeout (1000ms) reached before HTTP server closed'),
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('force-exits after drainTimeoutMs if manager.stop() hangs after HTTP close', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const server = makeFakeServer(); // calls back immediately
|
||||
const log = makeFakeLog();
|
||||
// Manager that NEVER resolves — simulates a hung cleanup.
|
||||
const hungManager = {
|
||||
name: 'hung',
|
||||
stop: jest.fn(() => new Promise(() => {})), // never resolves
|
||||
};
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log,
|
||||
drainTimeoutMs: 1000,
|
||||
managers: [hungManager],
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
// After the synchronous shutdown() call: server.close has fired
|
||||
// (serverClosed=true), but hungManager.stop() has been called and
|
||||
// its promise is pending. managersStopped is still false.
|
||||
// process.exit should NOT have been called yet.
|
||||
expect(exitCalls).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(1001);
|
||||
// Now the safety-net timer fires — force-exit because manager hung.
|
||||
expect(exitCalls).toEqual([0]);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'shutdown',
|
||||
expect.stringContaining('after HTTP close (manager hung)'),
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('clears force-exit timer when manager drain completes promptly', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const server = makeFakeServer(); // calls back on the same tick
|
||||
const log = makeFakeLog();
|
||||
// Quick-stopping manager. The close callback awaits stop(),
|
||||
// which resolves immediately, so managersStopped flips true
|
||||
// and the safety-net timer is cleared before it can fire.
|
||||
const fastManager = {
|
||||
name: 'fast',
|
||||
stop: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
const c = createShutdownCoordinator({
|
||||
server,
|
||||
log,
|
||||
drainTimeoutMs: 1000,
|
||||
managers: [fastManager],
|
||||
});
|
||||
|
||||
c.shutdown('SIGTERM');
|
||||
|
||||
// Flush microtasks so the close callback's await stop() resolves,
|
||||
// managersStopped flips true, the timer is cleared, and
|
||||
// process.exit(0) is recorded exactly once.
|
||||
return Promise.resolve().then(() => Promise.resolve()).then(() => {
|
||||
expect(exitCalls).toEqual([0]);
|
||||
|
||||
// Advance well past the drain timeout — no extra exit should fire.
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(exitCalls).toEqual([0]);
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('installSignalHandlers()', () => {
|
||||
// Track listeners added during each test so we can remove them in
|
||||
// afterEach. process.on() listeners leak across tests otherwise.
|
||||
let addedListeners;
|
||||
let originalProcessOn;
|
||||
|
||||
beforeEach(() => {
|
||||
addedListeners = [];
|
||||
originalProcessOn = process.on;
|
||||
// Wrap process.on to record every (signal, listener) pair we add.
|
||||
// Must capture originalProcessOn at wrap time so we can call it.
|
||||
const realOn = originalProcessOn;
|
||||
process.on = function patchedOn(signal, listener) {
|
||||
addedListeners.push({ signal, listener });
|
||||
return realOn.call(process, signal, listener);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.on = originalProcessOn;
|
||||
for (const { signal, listener } of addedListeners) {
|
||||
originalProcessOn.call(process, signal, listener); // ensure clean slate
|
||||
process.removeListener(signal, listener);
|
||||
}
|
||||
addedListeners = [];
|
||||
});
|
||||
|
||||
test('registers listeners on the given signals', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
managers: [],
|
||||
});
|
||||
const shutdownSpy = jest.spyOn(c, 'shutdown');
|
||||
|
||||
installSignalHandlers(c);
|
||||
|
||||
// Emit fake signals through process.emit to verify the listener was
|
||||
// registered (process.on listens to the process EventEmitter).
|
||||
process.emit('SIGTERM');
|
||||
process.emit('SIGINT');
|
||||
|
||||
expect(shutdownSpy).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(shutdownSpy).toHaveBeenCalledWith('SIGINT');
|
||||
});
|
||||
|
||||
test('accepts custom signal list', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
managers: [],
|
||||
});
|
||||
const shutdownSpy = jest.spyOn(c, 'shutdown');
|
||||
|
||||
installSignalHandlers(c, ['SIGHUP']);
|
||||
|
||||
process.emit('SIGHUP');
|
||||
expect(shutdownSpy).toHaveBeenCalledWith('SIGHUP');
|
||||
});
|
||||
|
||||
test('is idempotent — calling installSignalHandlers twice does not double-register', () => {
|
||||
const c = createShutdownCoordinator({
|
||||
server: makeFakeServer(),
|
||||
log: makeFakeLog(),
|
||||
managers: [],
|
||||
});
|
||||
const shutdownSpy = jest.spyOn(c, 'shutdown');
|
||||
|
||||
installSignalHandlers(c);
|
||||
installSignalHandlers(c); // second call
|
||||
installSignalHandlers(c); // third call
|
||||
|
||||
// The installedSignals tracker should have one entry per signal.
|
||||
expect(c._installedSignals).toEqual(['SIGTERM', 'SIGINT']);
|
||||
|
||||
process.emit('SIGTERM');
|
||||
expect(shutdownSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||
|
||||
function createApp({ redeem = true } = {}) {
|
||||
const app = express();
|
||||
const session = {
|
||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||
setCookieHostOnly: jest.fn((res) => {
|
||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||
}),
|
||||
isValid: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
||||
const router = createSsoRouter({
|
||||
totpConfig: { enabled: true, sessionDuration: '24h' },
|
||||
session,
|
||||
asyncHandler,
|
||||
errorResponse,
|
||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
getAppSession: jest.fn(),
|
||||
appSessionCache: new Map(),
|
||||
credentialManager: { retrieve: jest.fn() },
|
||||
fetchT: jest.fn(),
|
||||
getServiceById: jest.fn(),
|
||||
licenseManager: {
|
||||
hasFeature: jest.fn().mockReturnValue(true),
|
||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||
},
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||
});
|
||||
app.use('/api/v1', router);
|
||||
return { app, session };
|
||||
}
|
||||
|
||||
describe('cross-host SSO exchange redirect', () => {
|
||||
test('sets a host-only cookie and redirects to a relative service path', async () => {
|
||||
const { app, session } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'one-time', return: '/settings?tab=network#dns' });
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
||||
});
|
||||
|
||||
test.each([
|
||||
'https://evil.example/phish',
|
||||
'//evil.example/phish',
|
||||
'/\\evil.example/phish',
|
||||
])('rejects cross-origin return value %s', async (returnValue) => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'one-time', return: returnValue });
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.location).toBe('/');
|
||||
});
|
||||
|
||||
test('keeps the existing JSON exchange behavior when no return is supplied', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'one-time' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ success: true, authenticated: true });
|
||||
});
|
||||
|
||||
test('does not set a cookie or redirect for an invalid token', async () => {
|
||||
const { app, session } = createApp({ redeem: false });
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: 'bad', return: '/settings' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Unit tests for the Joi validation middleware + schema definitions.
|
||||
* Verifies that valid inputs pass through and invalid inputs throw
|
||||
* ValidationError with descriptive messages.
|
||||
*/
|
||||
const { validateBody, schemas } = require('../../src/utilities/validate');
|
||||
|
||||
function mockReq(body) {
|
||||
return { body };
|
||||
}
|
||||
|
||||
describe('validateBody middleware', () => {
|
||||
test('passes valid body through and strips unknown keys', () => {
|
||||
const schema = schemas.assetUpload;
|
||||
const req = mockReq({ filename: 'logo.png', data: 'data:image/png;base64,abc', extra: true });
|
||||
const next = jest.fn();
|
||||
validateBody(schema)(req, {}, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.body).toHaveProperty('filename', 'logo.png');
|
||||
expect(req.body).not.toHaveProperty('extra');
|
||||
});
|
||||
|
||||
test('throws ValidationError on missing required field', () => {
|
||||
const req = mockReq({});
|
||||
expect(() => validateBody(schemas.assetUpload)(req, {}, jest.fn())).toThrow(/filename/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.backupConfigUpdate', () => {
|
||||
test('accepts valid patch', () => {
|
||||
const { error, value } = schemas.backupConfigUpdate.validate({
|
||||
backups: { app1: { enabled: true, schedule: 'daily' } },
|
||||
defaultRetention: { keep: 7 },
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).toHaveProperty('backups');
|
||||
});
|
||||
|
||||
test('strips unknown top-level keys', () => {
|
||||
const { error, value } = schemas.backupConfigUpdate.validate({
|
||||
backups: {},
|
||||
malicious: true,
|
||||
}, { stripUnknown: true });
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).not.toHaveProperty('malicious');
|
||||
});
|
||||
|
||||
test('rejects unknown keys inside per-app backup config', () => {
|
||||
const { error } = schemas.backupConfigUpdate.validate({
|
||||
backups: { app1: { enabled: true, schedule: 'daily', rce: 'yes' } },
|
||||
}, { stripUnknown: false });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('rejects invalid retention.keep', () => {
|
||||
const { error } = schemas.backupConfigUpdate.validate({
|
||||
defaultRetention: { keep: 0 },
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
expect(error.details[0].message).toMatch(/keep/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.backupScheduleCreate', () => {
|
||||
test('requires appId', () => {
|
||||
const { error } = schemas.backupScheduleCreate.validate({});
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('accepts full valid body', () => {
|
||||
const { error, value } = schemas.backupScheduleCreate.validate({
|
||||
appId: 'plex',
|
||||
enabled: true,
|
||||
schedule: 'daily',
|
||||
retention: { keep: 7 },
|
||||
destination: 'local',
|
||||
maxStorageBytes: '10GB',
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
expect(value.appId).toBe('plex');
|
||||
});
|
||||
|
||||
test('rejects invalid destination', () => {
|
||||
const { error } = schemas.backupScheduleCreate.validate({
|
||||
appId: 'plex',
|
||||
destination: 'malicious-cloud',
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('accepts numeric custom schedule (e.g. "30m", "6h")', () => {
|
||||
const { error: e1 } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: '30m' });
|
||||
expect(e1).toBeUndefined();
|
||||
const { error: e2 } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: '6h' });
|
||||
expect(e2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rejects garbage schedule string', () => {
|
||||
const { error } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: 'abc' });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.appDeploy', () => {
|
||||
test('requires appId + config.subdomain', () => {
|
||||
const { error } = schemas.appDeploy.validate({});
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('accepts minimal valid deploy', () => {
|
||||
const { error, value } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex' },
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
expect(value.config.subdomain).toBe('plex');
|
||||
});
|
||||
|
||||
test('rejects port out of range', () => {
|
||||
const { error } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', port: 99999 },
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('accepts valid IP in allowedIPs', () => {
|
||||
const { error } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', allowedIPs: ['192.168.1.1', '10.0.0.0/24'] },
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rejects malformed CIDR in allowedIPs', () => {
|
||||
const { error } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', allowedIPs: ['999.999.999.999/99'] },
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('rejects malformed IPv6 CIDR (regression: hex/colon regex was permissive)', () => {
|
||||
// Old regex /^[0-9a-fA-F:]+\/(\d{1,3})$/ accepted these; Joi's authoritative
|
||||
// CIDR validator must reject them.
|
||||
const { error: e1 } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', allowedIPs: ['::::/64'] },
|
||||
});
|
||||
expect(e1).toBeDefined();
|
||||
const { error: e2 } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', allowedIPs: ['zzzz:::/64'] },
|
||||
});
|
||||
expect(e2).toBeDefined();
|
||||
const { error: e3 } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', allowedIPs: ['not-an-ip'] },
|
||||
});
|
||||
expect(e3).toBeDefined();
|
||||
});
|
||||
|
||||
test('accepts valid IPv6 CIDR', () => {
|
||||
const { error } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: { subdomain: 'plex', allowedIPs: ['2001:db8::/32'] },
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('accepts valid customVolumes objects', () => {
|
||||
const { error } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: {
|
||||
subdomain: 'plex',
|
||||
customVolumes: [{ hostPath: '/data/movies', containerPath: '/movies' }],
|
||||
},
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preserves unknown template-specific config fields (forward-compat)', () => {
|
||||
// appDeploy.config uses .unknown(true) so future template-specific fields
|
||||
// (e.g. a new app that posts `apiKey`, `databaseType`, ...) survive validation.
|
||||
const { error, value } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: {
|
||||
subdomain: 'plex',
|
||||
aFutureTemplateField: 'xyz',
|
||||
apiKey: 'secret',
|
||||
},
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
expect(value.config).toHaveProperty('aFutureTemplateField', 'xyz');
|
||||
expect(value.config).toHaveProperty('apiKey', 'secret');
|
||||
});
|
||||
|
||||
test('preserves template-specific config fields (sslType, dnsType, plexClaimToken)', () => {
|
||||
// The frontend posts these — they must survive validation or deployments break.
|
||||
const { error, value } = schemas.appDeploy.validate({
|
||||
appId: 'plex',
|
||||
config: {
|
||||
subdomain: 'plex',
|
||||
sslType: 'self-signed',
|
||||
dnsType: 'private',
|
||||
plexClaimToken: 'claim-abc-123',
|
||||
},
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
expect(value.config).toHaveProperty('sslType', 'self-signed');
|
||||
expect(value.config).toHaveProperty('dnsType', 'private');
|
||||
expect(value.config).toHaveProperty('plexClaimToken', 'claim-abc-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.assetUpload', () => {
|
||||
test('requires both fields', () => {
|
||||
const { error: e1 } = schemas.assetUpload.validate({ filename: 'logo.png' });
|
||||
expect(e1).toBeDefined();
|
||||
const { error: e2 } = schemas.assetUpload.validate({ data: 'abc' });
|
||||
expect(e2).toBeDefined();
|
||||
});
|
||||
|
||||
test('accepts valid upload', () => {
|
||||
const { error } = schemas.assetUpload.validate({
|
||||
filename: 'logo.png',
|
||||
data: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.backupRestoreFile', () => {
|
||||
test('accepts empty body', () => {
|
||||
const { error } = schemas.backupRestoreFile.validate({});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('accepts optional fields', () => {
|
||||
const { error } = schemas.backupRestoreFile.validate({
|
||||
encryptionKey: 'secret',
|
||||
restartContainers: true,
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('strips unknown keys', () => {
|
||||
const { error, value } = schemas.backupRestoreFile.validate({
|
||||
encryptionKey: 'secret',
|
||||
malicious: 'yes',
|
||||
}, { stripUnknown: true });
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).not.toHaveProperty('malicious');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.backupRestore', () => {
|
||||
test('accepts empty body (all fields optional)', () => {
|
||||
const { error, value } = schemas.backupRestore.validate({});
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).toEqual({});
|
||||
});
|
||||
|
||||
test('accepts known control flags', () => {
|
||||
const { error } = schemas.backupRestore.validate({
|
||||
encryptionKey: 'secret',
|
||||
restartContainers: true,
|
||||
services: true,
|
||||
config: true,
|
||||
credentials: true,
|
||||
volumes: true,
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('strips unknown keys', () => {
|
||||
const { error, value } = schemas.backupRestore.validate({
|
||||
services: true,
|
||||
shellCommand: 'rm -rf /',
|
||||
}, { stripUnknown: true });
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).not.toHaveProperty('shellCommand');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.appRestore', () => {
|
||||
test('accepts empty body via middleware', () => {
|
||||
const req = mockReq({});
|
||||
const next = jest.fn();
|
||||
validateBody(schemas.appRestore)(req, {}, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects body with any key via middleware', () => {
|
||||
const req = mockReq({ filename: 'backup.tar' });
|
||||
expect(() => validateBody(schemas.appRestore)(req, {}, jest.fn())).toThrow(/empty/);
|
||||
});
|
||||
|
||||
test('rejects non-object body via middleware', () => {
|
||||
const req = mockReq('just a string');
|
||||
expect(() => validateBody(schemas.appRestore)(req, {}, jest.fn())).toThrow(/empty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.appRevert', () => {
|
||||
test('accepts empty body', () => {
|
||||
const { error } = schemas.appRevert.validate({});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('accepts optional encryption key + restart flag', () => {
|
||||
const { error } = schemas.appRevert.validate({
|
||||
encryptionKey: 'secret',
|
||||
restartContainers: true,
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('strips unknown keys (no shell injection vector)', () => {
|
||||
const { error, value } = schemas.appRevert.validate({
|
||||
encryptionKey: 'secret',
|
||||
path: '/etc/passwd',
|
||||
shellCommand: 'rm -rf /',
|
||||
}, { stripUnknown: true });
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).not.toHaveProperty('path');
|
||||
expect(value).not.toHaveProperty('shellCommand');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemas.logoUpload', () => {
|
||||
test('requires at least one field', () => {
|
||||
const { error } = schemas.logoUpload.validate({});
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('accepts single data field', () => {
|
||||
const { error } = schemas.logoUpload.validate({ data: 'data:image/png;base64,abc' });
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('accepts dataDark + dataLight pair', () => {
|
||||
const { error } = schemas.logoUpload.validate({
|
||||
dataDark: 'data:image/png;base64,dark',
|
||||
dataLight: 'data:image/png;base64,light',
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('accepts position enum', () => {
|
||||
const { error } = schemas.logoUpload.validate({
|
||||
data: 'data:image/png;base64,abc',
|
||||
position: 'center',
|
||||
});
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rejects invalid position', () => {
|
||||
const { error } = schemas.logoUpload.validate({
|
||||
data: 'data:image/png;base64,abc',
|
||||
position: 'diagonal',
|
||||
});
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
test('rejects empty-string data fields', () => {
|
||||
const { error } = schemas.logoUpload.validate({ data: '' });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 B |
+261
-40
@@ -16,12 +16,21 @@ 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');
|
||||
// Master secret file — lives only on admin machine, NEVER shipped.
|
||||
// Default is `path.join(__dirname, '.license-secret')`. The path is
|
||||
// overridable via the `LICENSE_SECRET_FILE` env var so the CLI can be
|
||||
// driven from CI / isolated test environments without polluting the
|
||||
// source directory (mirrors the `LICENSE_COUNTER_FILE` override pattern).
|
||||
// The Stripe bridge uses the same env var to point at its own secret file
|
||||
// on the bridge host.
|
||||
function _defaultSecretFile() {
|
||||
return process.env.LICENSE_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
|
||||
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE
|
||||
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(40bit)
|
||||
// Total: 120 bits = 15 bytes, base32-encoded into 5 groups of 5 chars
|
||||
// (25 base32 chars = 125 bits, comfortably fits 120 bits of data)
|
||||
|
||||
const VALID_DURATIONS = [30, 90, 180, 365];
|
||||
const LIFETIME_DURATION = 0; // Admin-only, not publicly available
|
||||
@@ -60,22 +69,202 @@ function base32Decode(str) {
|
||||
}
|
||||
|
||||
function getSecret() {
|
||||
if (!fs.existsSync(SECRET_FILE)) {
|
||||
console.error('No master secret found. Run with --init-secret first.');
|
||||
const file = _defaultSecretFile();
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error('No master secret found at', file);
|
||||
console.error('Run with --init-secret first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
return fs.readFileSync(file, 'utf8').trim();
|
||||
}
|
||||
|
||||
// Counter location: the default is `path.join(__dirname, '.license-counter')`.
|
||||
// That's adjacent to this source file on the admin machine (not the secret
|
||||
// file — the secret and counter share a directory on the developer's
|
||||
// workstation, but they are independent files). The CLI does not merge them.
|
||||
// When this module is required from a packaged/installed location where
|
||||
// __dirname might be read-only, override the counter location via the
|
||||
// `LICENSE_COUNTER_FILE` env var. The Stripe bridge uses this same path.
|
||||
function _defaultCounterFile() {
|
||||
return process.env.LICENSE_COUNTER_FILE || path.join(__dirname, '.license-counter');
|
||||
}
|
||||
|
||||
// Atomic counter write — write to a uniquely-named .tmp then rename. The
|
||||
// .tmp suffix includes pid + Date.now() + Math.random so two concurrent
|
||||
// calls in overlapping event-loop ticks (e.g. a Stripe webhook fan-out)
|
||||
// can't collide on the temp name. POSIX rename is atomic on the same
|
||||
// filesystem, so the live counter file is never observed in a half-written
|
||||
// state. If writeFileSync throws, we re-throw without renaming — the
|
||||
// original counter file is intact. If renameSync throws, we attempt to
|
||||
// unlink the .tmp so it doesn't accumulate.
|
||||
function _atomicWriteCounter(counterFile, value) {
|
||||
const tmpFile = `${counterFile}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, String(value));
|
||||
} catch (err) {
|
||||
throw new Error(`generateCodes: failed to write counter tmp file ${tmpFile}: ${err.message}`);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(tmpFile, counterFile);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(tmpFile); } catch (_) { /* best effort cleanup */ }
|
||||
throw new Error(`generateCodes: failed to rename counter tmp to ${counterFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrency note: this module is single-threaded JavaScript. Two
|
||||
// synchronous calls to generateCodes() within the same event-loop tick
|
||||
// cannot interleave — fs.*Sync blocks the thread and the second call runs
|
||||
// only after the first returns. The "atomic" part of the counter write
|
||||
// protects against a process crash between writeFileSync and renameSync
|
||||
// (the original counter file is intact because rename never happened)
|
||||
// and against OS-level write atomicity. It does NOT protect against a
|
||||
// concurrent process — license-keygen.js is a single-instance admin tool
|
||||
// and must not be invoked from multiple processes simultaneously.
|
||||
// Callers needing cross-process safety (which is none currently) would
|
||||
// need OS-level locking via fcntl or flock — out of scope.
|
||||
|
||||
/**
|
||||
* Programmatic equivalent of the CLI's "generate codes" path.
|
||||
*
|
||||
* Differs from the CLI in two ways:
|
||||
* 1. No console output — returns the resulting array.
|
||||
* 2. Persists the counter file atomically (write to a uniquely-named
|
||||
* .tmp, rename) so a crash mid-write doesn't leave the counter in a
|
||||
* half-bumped state, and so concurrent calls don't collide on the
|
||||
* same .tmp name.
|
||||
*
|
||||
* Concurrency: relies on Node's single-threaded event loop. Two
|
||||
* synchronous calls in the same tick cannot interleave — the second call
|
||||
* reads the post-write counter value. The atomic write helper protects
|
||||
* against process crashes between writeFileSync and renameSync, and the
|
||||
* unique .tmp suffix prevents filename collisions across ticks. Cross-process
|
||||
* races are still possible — license-keygen.js is a single-instance admin
|
||||
* tool, so callers must not invoke it from multiple processes simultaneously.
|
||||
*
|
||||
* Returns synchronously. The underlying counter allocator uses fs.*Sync,
|
||||
* so the function never throws asynchronously. Wrap with Promise.resolve()
|
||||
* if your caller needs a Promise.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.secret The master secret (hex string). Callers
|
||||
* are responsible for loading it via
|
||||
* loadSecret() or getSecret().
|
||||
* @param {number} opts.durationDays 30, 90, 180, 365, or 0 for LIFETIME.
|
||||
* Validated against VALID_DURATIONS / LIFETIME.
|
||||
* @param {number} [opts.count=1] Number of codes to mint.
|
||||
* @param {number} [opts.startId] Override the auto counter. If omitted,
|
||||
* reads + increments the counter file.
|
||||
* @param {string} [opts.counterFile] Override the counter file path.
|
||||
* Defaults to env LICENSE_COUNTER_FILE or
|
||||
* path.join(__dirname, '.license-counter').
|
||||
* @returns {Array<{code: string, codeId: number, durationDays: number}>}
|
||||
*/
|
||||
// Throws on bad opts. Returns { secret, durationDays, count } with defaults applied.
|
||||
function _validateGenerateOpts(opts) {
|
||||
if (!opts || !opts.secret || typeof opts.secret !== 'string') {
|
||||
throw new Error('generateCodes: secret is required');
|
||||
}
|
||||
const { secret, count = 1 } = opts;
|
||||
const { durationDays } = opts;
|
||||
// LIFETIME (0) is accepted; non-LIFETIME must be in the allowed list.
|
||||
if (durationDays !== 0 && !VALID_DURATIONS.includes(durationDays)) {
|
||||
throw new Error(`generateCodes: invalid duration ${durationDays}. Valid: ${VALID_DURATIONS.join(', ')}`);
|
||||
}
|
||||
if (!Number.isInteger(count) || count < 1 || count > 10000) {
|
||||
throw new Error(`generateCodes: invalid count ${count} (must be 1..10000)`);
|
||||
}
|
||||
return { secret, durationDays, count };
|
||||
}
|
||||
|
||||
// Resolves the next startId. startIdProvided=true means the caller passed
|
||||
// opts.startId (even if the value is invalid — validation happens here).
|
||||
// Reads the counter file on the auto path; throws on parse/IO error.
|
||||
function _resolveStartId(startIdProvided, overrideStartId, counterFile) {
|
||||
if (startIdProvided) {
|
||||
if (!Number.isInteger(overrideStartId) || overrideStartId < 0 || overrideStartId > 0xFFFFFFFF) {
|
||||
throw new Error(`generateCodes: startId out of range or non-integer (must be 0..0xFFFFFFFF, got ${overrideStartId})`);
|
||||
}
|
||||
return overrideStartId;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(counterFile)) {
|
||||
const raw = fs.readFileSync(counterFile, 'utf8').trim();
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error(`counter file ${counterFile} contains non-numeric value '${raw}'`);
|
||||
}
|
||||
return parseInt(raw, 10) + 1;
|
||||
}
|
||||
return 1;
|
||||
} catch (err) {
|
||||
if (err.message && err.message.startsWith('counter file ')) throw err;
|
||||
throw new Error(`generateCodes: failed to read counter file ${counterFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCodes(opts) {
|
||||
const { secret, durationDays, count } = _validateGenerateOpts(opts);
|
||||
const overrideCounterFile = opts && opts.counterFile;
|
||||
const counterFile = overrideCounterFile || _defaultCounterFile();
|
||||
|
||||
// Validate startId BEFORE selecting the allocation path. Any explicitly
|
||||
// supplied startId (including floats, NaN, null, numeric strings) must
|
||||
// either be a valid integer in range or throw — we use
|
||||
// Object.prototype.hasOwnProperty to distinguish "caller passed startId"
|
||||
// from "caller omitted startId" so the overrideStartId validation runs
|
||||
// regardless of value.
|
||||
const startIdProvided = opts && Object.prototype.hasOwnProperty.call(opts, 'startId');
|
||||
const overrideStartId = startIdProvided ? opts.startId : undefined;
|
||||
const startId = _resolveStartId(startIdProvided, overrideStartId, counterFile);
|
||||
|
||||
// Validate that the requested range fits in the code_id field (32 bits).
|
||||
const lastCodeId = startId + count - 1;
|
||||
if (lastCodeId > 0xFFFFFFFF) {
|
||||
throw new Error(`generateCodes: codeId range exceeds 32-bit limit (startId=${startId}, count=${count}, lastCodeId=${lastCodeId})`);
|
||||
}
|
||||
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const codeId = startId + i;
|
||||
const code = generateCode(secret, durationDays, codeId);
|
||||
codes.push({ code, codeId, durationDays });
|
||||
}
|
||||
|
||||
// Persist the new counter value (skipped when startId was overridden).
|
||||
if (!startIdProvided) {
|
||||
_atomicWriteCounter(counterFile, lastCodeId);
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the master secret from disk. Exported so the Stripe bridge can
|
||||
* call it without going through getSecret() (which prints to stderr and
|
||||
* exits on missing-secret — wrong semantics for a library call).
|
||||
*
|
||||
* @param {string} [overridePath] Defaults to the SECRET_FILE constant.
|
||||
* @returns {string} The hex secret.
|
||||
* @throws If the file is missing or unreadable.
|
||||
*/
|
||||
function loadSecret(overridePath) {
|
||||
const file = overridePath || _defaultSecretFile();
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`Master secret file not found at ${file}. Run --init-secret first.`);
|
||||
}
|
||||
return fs.readFileSync(file, 'utf8').trim();
|
||||
}
|
||||
|
||||
function initSecret() {
|
||||
if (fs.existsSync(SECRET_FILE)) {
|
||||
console.error('Master secret already exists at', SECRET_FILE);
|
||||
const file = _defaultSecretFile();
|
||||
if (fs.existsSync(file)) {
|
||||
console.error('Master secret already exists at', 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);
|
||||
fs.writeFileSync(file, secret, { mode: 0o600 });
|
||||
console.log('Master secret generated and saved to', 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.');
|
||||
}
|
||||
@@ -193,19 +382,23 @@ function main() {
|
||||
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
|
||||
node license-keygen.js --init-secret Initialize master secret (first time only)
|
||||
node license-keygen.js --duration <days> [options] Generate Pro license codes
|
||||
node license-keygen.js --lifetime [options] Generate a LIFETIME code (creator-only)
|
||||
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)
|
||||
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation, mutually exclusive with --lifetime)
|
||||
--tier <tier> Tier label; only 'pro' is supported (optional label; valid in combination with --duration or --lifetime)
|
||||
--lifetime Generate a LIFETIME code — REJECTED at activation on production hosts
|
||||
--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
|
||||
Valid tiers: pro (cosmetic alias; does not change generation behavior)
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -244,9 +437,31 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
|
||||
// Generate codes
|
||||
const isLifetime = args.includes('--lifetime');
|
||||
|
||||
// --tier is a cosmetic label right now (only 'pro' is supported). It does
|
||||
// NOT change generation behavior — every code minted with --duration is
|
||||
// already a Pro code, and --lifetime is enforced separately at activation
|
||||
// time. The flag exists to make operator intent obvious in shell history
|
||||
// and to reserve a forward-compatible hook for a future tier that needs
|
||||
// to alter code generation (e.g. a 'free' tier with a different prefix).
|
||||
// It is only meaningful in combination with --duration or --lifetime —
|
||||
// by itself, generation still requires one of those flags.
|
||||
const tierIndex = args.indexOf('--tier');
|
||||
if (tierIndex !== -1) {
|
||||
const tier = (args[tierIndex + 1] || '').toLowerCase();
|
||||
if (tier !== 'pro') {
|
||||
console.error(`Invalid tier: '${tier}'. Supported: pro.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const durationIndex = args.indexOf('--duration');
|
||||
if (!isLifetime && durationIndex === -1) {
|
||||
console.error('--duration is required. Use --help for usage.');
|
||||
console.error('--duration is required (or use --lifetime). Use --help for usage.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (isLifetime && durationIndex !== -1) {
|
||||
console.error('--lifetime and --duration are mutually exclusive.');
|
||||
process.exit(1);
|
||||
}
|
||||
const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 1]);
|
||||
@@ -258,29 +473,20 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
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 overrideStartId = startIdIndex !== -1 ? parseInt(args[startIdIndex + 1]) : undefined;
|
||||
|
||||
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 });
|
||||
// Only pass startId when --start-id was supplied on the CLI. generateCodes
|
||||
// uses Object.prototype.hasOwnProperty.call(opts, 'startId') to distinguish
|
||||
// "caller passed startId" from "caller omitted startId" and rejects
|
||||
// non-integer values. Passing startId: undefined would mean "caller passed
|
||||
// undefined", which the validation path then rejects.
|
||||
const generateOpts = { secret, durationDays: duration, count };
|
||||
if (overrideStartId !== undefined) {
|
||||
generateOpts.startId = overrideStartId;
|
||||
}
|
||||
|
||||
// Save counter
|
||||
fs.writeFileSync(counterFile, String(startId + count - 1));
|
||||
const codes = generateCodes(generateOpts);
|
||||
|
||||
// Output
|
||||
const outputIndex = args.indexOf('--output');
|
||||
@@ -302,11 +508,26 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`);
|
||||
const lastCodeId = codes[codes.length - 1].codeId;
|
||||
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${lastCodeId + 1}`);
|
||||
}
|
||||
|
||||
// Also export for use by license-manager.js
|
||||
module.exports = { verifyCode, parseCode, VALID_DURATIONS, VERSION };
|
||||
// Also export for use by license-manager.js and the Stripe webhook bridge.
|
||||
// `generateCode` is exported so the bridge can mint codes in-process rather
|
||||
// than spawning a child process (faster, atomic counter, easier to test).
|
||||
// `generateCodes` (note the trailing 's') is the bulk-friendly wrapper that
|
||||
// handles the counter-file write and returns a stable array of {code, codeId,
|
||||
// durationDays} records — used by the bridge when one Stripe event must
|
||||
// produce one code (typical case is just 1, but the API is uniform).
|
||||
module.exports = {
|
||||
verifyCode,
|
||||
parseCode,
|
||||
generateCode,
|
||||
generateCodes,
|
||||
loadSecret,
|
||||
VALID_DURATIONS,
|
||||
VERSION,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
|
||||
Generated
+736
-207
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
"version": "1.15.0",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "jest",
|
||||
@@ -26,6 +29,7 @@
|
||||
"express": "^4.22.1",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"helmet": "^8.1.0",
|
||||
"joi": "^18.2.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lru-cache": "^10.4.3",
|
||||
|
||||
@@ -9,6 +9,7 @@ const platformPaths = require('../../platform-paths');
|
||||
const { ValidationError } = require('../../src/utilities/errors');
|
||||
const { logError } = require('../../src/utils/logging');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
const { validateBody, schemas: valSchemas } = require('../../src/utilities/validate');
|
||||
/**
|
||||
* Apps deployment routes factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
@@ -251,17 +252,8 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
}, 'check-existing'));
|
||||
|
||||
// Deploy new app
|
||||
router.post('/deploy', asyncHandler(async (req, res) => {
|
||||
router.post('/deploy', validateBody(valSchemas.appDeploy), asyncHandler(async (req, res) => {
|
||||
const { appId, config } = req.body;
|
||||
if (!appId || typeof appId !== 'string') {
|
||||
throw new ValidationError('appId is required');
|
||||
}
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new ValidationError('config object is required');
|
||||
}
|
||||
if (!config.subdomain || typeof config.subdomain !== 'string') {
|
||||
throw new ValidationError('config.subdomain is required');
|
||||
}
|
||||
try {
|
||||
log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
|
||||
const template = ctx.APP_TEMPLATES[appId];
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { DOCKER } = require('../../src/utilities/constants');
|
||||
const { ok, validationError, notFound, errorResponse } = require('../../src/utils/responses');
|
||||
const { validateBody, schemas: valSchemas } = require('../../src/utilities/validate');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
|
||||
@@ -21,7 +22,8 @@ const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..',
|
||||
* @param {Function} deps.buildServiceUrl - Service URL builder
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers, APP_TEMPLATES, dns, buildServiceUrl }) {
|
||||
module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers, APP_TEMPLATES, dns, buildServiceUrl, backupManager }) {
|
||||
if (!backupManager) throw new Error('routes/apps/restore: backupManager dependency is required');
|
||||
const router = express.Router();
|
||||
|
||||
const ctx = {
|
||||
@@ -35,7 +37,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
||||
* Pulls image, creates container, starts it, recreates Caddy config.
|
||||
* Skips if container is already running.
|
||||
*/
|
||||
router.post('/:appId/restore', asyncHandler(async (req, res) => {
|
||||
router.post('/:appId/restore', validateBody(valSchemas.appRestore), asyncHandler(async (req, res) => {
|
||||
const { appId } = req.params;
|
||||
const services = await servicesStateManager.read();
|
||||
const service = services.find(s => s.id === appId);
|
||||
@@ -182,9 +184,9 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
||||
}, 'apps-backup-points'));
|
||||
|
||||
// Revert a specific app to a backup file (point-in-time restore)
|
||||
router.post('/:appId/revert/:filename', asyncHandler(async (req, res) => {
|
||||
router.post('/:appId/revert/:filename', validateBody(valSchemas.appRevert), asyncHandler(async (req, res) => {
|
||||
const { appId, filename } = req.params;
|
||||
const { encryptionKey, restartContainers } = req.body || {};
|
||||
const { encryptionKey, restartContainers } = req.body;
|
||||
|
||||
// Security: prevent path traversal
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
@@ -292,7 +294,11 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
errorResponse(res, 500, err.message);
|
||||
// P0-5 fix: was `errorResponse(res, 500, err.message)` which leaks internal
|
||||
// error details (paths, stack traces, library error codes) to the client.
|
||||
// Log the actual error server-side and return a generic message.
|
||||
log.error('apps-revert', 'Revert failed', { error: err.message, stack: err.stack });
|
||||
errorResponse(res, 500, 'Revert failed');
|
||||
}
|
||||
}, 'apps-revert'));
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
|
||||
break;
|
||||
case 'router': {
|
||||
// Validate baseUrl is a safe hostname before using in shell command
|
||||
if (!baseUrl || typeof baseUrl !== 'string' || !/^https?:\/\/[a-zA-Z0-9]([a-zA-Z0-9.\-]{0,253}[a-zA-Z0-9])?(:\d{1,5})?(\/|$)/.test(baseUrl)) {
|
||||
if (!baseUrl || typeof baseUrl !== 'string' || !/^https?:\/\/[a-zA-Z0-9]([a-zA-Z0-9.-]{0,253}[a-zA-Z0-9])?(:\d{1,5})?(\/|$)/.test(baseUrl)) {
|
||||
log.warn('auth', 'Router auto-login rejected: invalid baseUrl', { serviceId, baseUrl: String(baseUrl).substring(0, 50) });
|
||||
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants');
|
||||
const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Auth SSO gate routes factory
|
||||
@@ -9,10 +10,10 @@ const { AuthenticationError, NotFoundError } = require('../../src/utilities/erro
|
||||
*/
|
||||
module.exports = function(deps) {
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
// Extract dependencies
|
||||
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps;
|
||||
|
||||
|
||||
// Create ctx-like object for compatibility
|
||||
const ctx = {
|
||||
credentialManager,
|
||||
@@ -202,6 +203,37 @@ module.exports = function(deps) {
|
||||
}
|
||||
}, 'auth-app-token'));
|
||||
|
||||
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
||||
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
|
||||
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
||||
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
||||
// browsers treat "sami" as the effective public suffix and refuse to set
|
||||
// a cookie scoped to it) — see middleware.js for the full explanation.
|
||||
// Public route (no session required to call it) since a fresh visitor to
|
||||
// a gated service has no session yet by definition; the token itself is
|
||||
// the credential, and it's one-time-use with a 60s TTL.
|
||||
router.get('/auth/sso-exchange', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const token = req.query.token;
|
||||
if (!session.redeemHandoffToken(token)) {
|
||||
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
||||
}
|
||||
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
||||
if (req.query.return) {
|
||||
let returnPath = '/';
|
||||
try {
|
||||
const parsed = new URL(req.query.return, 'https://dashcaddy.invalid');
|
||||
if (parsed.origin === 'https://dashcaddy.invalid') {
|
||||
returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch (_) {
|
||||
// Invalid or cross-origin return values fall back to the service root.
|
||||
}
|
||||
return res.redirect(303, returnPath);
|
||||
}
|
||||
ok(res, { authenticated: true });
|
||||
});
|
||||
|
||||
// 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, '');
|
||||
@@ -209,6 +241,14 @@ module.exports = function(deps) {
|
||||
if (!html) return res.status(404).send('Unknown service');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
// This page is a server-rendered shell whose entire auto-login logic runs
|
||||
// in an inline <script> (no external bundle - it's built per-service in
|
||||
// buildLoginPage()). The app-wide Helmet CSP sets script-src 'self' with
|
||||
// no inline exception, which silently blocks that script from ever
|
||||
// running - no console-visible error on the page, no JS timeout fires,
|
||||
// it just sits on "Signing in to ..." forever. Relax script-src for this
|
||||
// one response only; every other route keeps the strict app-wide policy.
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'");
|
||||
res.send(html);
|
||||
});
|
||||
|
||||
@@ -222,57 +262,93 @@ function buildLoginPage(service) {
|
||||
// session and we render the auto-login body; if 401, the meta-refresh kicks
|
||||
// in and sends them to status.sami to authenticate first.
|
||||
const SHELL = (body) => `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta http-equiv="Cache-Control" content="no-store"><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()}]}))}
|
||||
// Pre-check session before attempting auto-login. If the user is not logged
|
||||
// in, redirect to status.sami for TOTP auth first. The return= param sends
|
||||
// them back to this login page after authenticating so auto-login can run.
|
||||
fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store'}).then(function(r){return r.json()}).then(function(st){
|
||||
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
||||
${body}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+e.message)})
|
||||
})()</script></body></html>`;
|
||||
<html><head><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
|
||||
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items: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-wrap: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');
|
||||
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
||||
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
||||
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
||||
// authenticated but app-token fails for any reason (no creds stored,
|
||||
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
||||
// stale token exists in localStorage, instead of failing silently.
|
||||
function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
||||
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
|
||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
|
||||
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()}]}))}
|
||||
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
||||
// within 15s, force-redirect to status.sami so the user can re-auth.
|
||||
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000);
|
||||
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
||||
// with this origin (Domain=.sami cookies are silently rejected by real
|
||||
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
||||
// effective public suffix). Instead status.sami hands us a one-time token
|
||||
// in the URL after a successful TOTP verify; exchange it here for a cookie
|
||||
// scoped to just this host, then strip it from the URL so it can't be
|
||||
// reused or leak via history/referrer. If there's no token (or the
|
||||
// exchange fails - expired, already used, etc.) this is a no-op and we
|
||||
// fall through to the normal check-session flow below exactly as before.
|
||||
var dcParams=new URLSearchParams(location.search);
|
||||
var dcToken=dcParams.get('dc_token');
|
||||
var preExchange=Promise.resolve();
|
||||
if(dcToken){
|
||||
dcParams.delete('dc_token');
|
||||
var dcQs=dcParams.toString();
|
||||
try{history.replaceState({},'',location.pathname+(dcQs?'?'+dcQs:''))}catch(_){}
|
||||
preExchange=fetch('/dashcaddy-api/api/auth/sso-exchange?token='+encodeURIComponent(dcToken),{credentials:'include',signal:withTimeout(5000)}).catch(function(){});
|
||||
}
|
||||
// Pre-check session before attempting auto-login. If the user is not logged
|
||||
// in, redirect to status.sami for TOTP auth first. The return= param sends
|
||||
// them back to this login page after authenticating so auto-login can run.
|
||||
preExchange.then(function(){
|
||||
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
|
||||
}).then(function(r){return r.json()}).then(function(st){
|
||||
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
||||
${body}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))})
|
||||
})()</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="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','No token field in response')}}
|
||||
catch(e){fail('Auto-login error. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Parse error: '+e.message)}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Fetch error: '+e.message)})`
|
||||
ft('chat').then(function(r){return r.text()}).then(function(t){
|
||||
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
|
||||
// No token but chat is reachable — fall through to manual UI link below
|
||||
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
||||
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/?direct=1">Open Chat manually</a>','Fetch error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
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> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
||||
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
||||
// No token returned. Three fallbacks in priority order:
|
||||
// 1. Stale token in localStorage — Plex may still accept it.
|
||||
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||
// 2. Manual link so the user is never trapped on this page.
|
||||
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
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> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
||||
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
|
||||
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
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> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
||||
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
|
||||
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -220,8 +220,17 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
// Rotate CSRF token for the new session
|
||||
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
|
||||
|
||||
// Cross-subdomain SSO handoff token (see middleware.js "Cross-subdomain
|
||||
// SSO token handoff" for why): the Domain=.sami cookie set above is
|
||||
// silently dropped by real browsers on any OTHER *.sami subdomain, so
|
||||
// status.sami's login-page frontend appends this token to the redirect
|
||||
// URL when bouncing the user back to a gated service. That service's
|
||||
// login page exchanges it via /auth/sso-exchange for its own host-only
|
||||
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
||||
const ssoToken = ctx.session.createHandoffToken();
|
||||
|
||||
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
|
||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
||||
}, 'totp-verify'));
|
||||
|
||||
// Check session validity (used by Caddy forward_auth)
|
||||
@@ -243,7 +252,10 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
const valid = ctx.session.isValid(req);
|
||||
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
|
||||
if (valid) {
|
||||
return res.status(200).json({ authenticated: true });
|
||||
// Response contract: { success: true, authenticated: true } — login-page
|
||||
// consumer in /api/v1/auth/login-page reads `if(!st.success||!st.authenticated)`
|
||||
// and would otherwise redirect valid sessions to status.sami in a TOTP loop.
|
||||
return ok(res, { authenticated: true });
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Session expired or invalid');
|
||||
|
||||
@@ -3,6 +3,7 @@ const fsp = require('fs').promises;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { validateBody, schemas } = require('../src/utilities/validate');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||
@@ -56,14 +57,10 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
}, 'backups-schedule-list'));
|
||||
|
||||
// Create or update a scheduled backup for an app
|
||||
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
||||
router.post('/backups/schedule', premiumGating, validateBody(schemas.backupScheduleCreate), asyncHandler(async (req, res) => {
|
||||
// appId is guaranteed present by the Joi schema (backupScheduleCreate requires it)
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||
|
||||
if (!appId) {
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
throw new ValidationError('appId is required');
|
||||
}
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
if (!config.backups) config.backups = {};
|
||||
|
||||
@@ -234,7 +231,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
}, 'backups-files-app'));
|
||||
|
||||
// Restore from a specific backup file on disk
|
||||
router.post('/backups/restore-file/:filename', asyncHandler(async (req, res) => {
|
||||
router.post('/backups/restore-file/:filename', validateBody(schemas.backupRestoreFile), asyncHandler(async (req, res) => {
|
||||
const { filename } = req.params;
|
||||
const { encryptionKey, restartContainers } = req.body || {};
|
||||
|
||||
@@ -483,8 +480,15 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
}, 'backups-config-get'));
|
||||
|
||||
// Update backup configuration
|
||||
router.post('/backups/config', asyncHandler(async (req, res) => {
|
||||
backupManager.updateConfig(req.body);
|
||||
router.post('/backups/config', validateBody(schemas.backupConfigUpdate), asyncHandler(async (req, res) => {
|
||||
// P0-3 fix: was `backupManager.updateConfig(req.body)` which allowed
|
||||
// arbitrary keys from HTTP request body to be merged into persisted config.
|
||||
// Now destructure only the two known top-level fields.
|
||||
const { backups, defaultRetention } = req.body || {};
|
||||
const patch = {};
|
||||
if (backups !== undefined) patch.backups = backups;
|
||||
if (defaultRetention !== undefined) patch.defaultRetention = defaultRetention;
|
||||
backupManager.updateConfig(patch);
|
||||
success(res, { message: 'Backup configuration updated' });
|
||||
}, 'backups-config-update'));
|
||||
|
||||
@@ -508,6 +512,11 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
}, 'backups-storage-info'));
|
||||
|
||||
// Schedule a backup
|
||||
// LEGACY: this is a duplicate registration of POST /backups/schedule (see also line 60,
|
||||
// which uses the appId-keyed schema and is the route the frontend actually calls).
|
||||
// Express only matches the first registered handler per METHOD+PATH, so this handler
|
||||
// is unreachable. It is preserved for now to avoid removing a route any unknown
|
||||
// integration might still POST to. TODO: audit + remove in a dedicated cleanup PR.
|
||||
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
||||
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
||||
|
||||
@@ -532,10 +541,10 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
backupManager.updateConfig(config);
|
||||
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
||||
}, 'backups-schedule'));
|
||||
}, 'backups-schedule-legacy'));
|
||||
|
||||
// Restore from backup
|
||||
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
||||
router.post('/backups/restore/:backupId', validateBody(schemas.backupRestore), asyncHandler(async (req, res) => {
|
||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||
success(res, { result });
|
||||
}, 'backups-restore'));
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DC-055 + DC-057 Billing routes — `/api/v1/billing/*`
|
||||
*
|
||||
* Two public endpoints:
|
||||
*
|
||||
* POST /api/v1/billing/checkout
|
||||
* Body: { productId: 'pro-30d'|'pro-90d'|'pro-180d'|'pro-365d', customerEmail?: string }
|
||||
* Returns: { id, url } — url is the Stripe-hosted Checkout page.
|
||||
* Auth: PUBLIC (customer hasn't paid yet → no session). CSRF-exempt.
|
||||
*
|
||||
* GET /api/v1/billing/lookup/:sessionId
|
||||
* Returns: { status: 'not_found'|'expired'|'processing'|'pending_email'|'delivered', code?, codeId?, durationDays?, productId?, deliveredVia? }
|
||||
* Auth: PUBLIC. CSRF-exempt.
|
||||
*
|
||||
* The lookup serves the persisted license in BOTH `delivered` AND
|
||||
* `pending_email` states. This is the documented SMTP-failure recovery
|
||||
* path (the customer pastes their key even if email failed).
|
||||
*
|
||||
* Security model:
|
||||
* - sessionId is a bearer-style secret returned by Stripe ONLY to the
|
||||
* customer who completed payment (single-use, expires after 24h).
|
||||
* - The endpoint enforces a 24h TTL (LOOKUP_TTL_MS) — after that,
|
||||
* 404 even with a valid sessionId.
|
||||
* - Cache-Control: no-store on all responses.
|
||||
* - Rate-limited via the general limiter (10/min/IP).
|
||||
*
|
||||
* The inbound webhook side lives in scripts/stripe-license-bridge.js
|
||||
* (DC-054 + DC-057). That runs as its own process on port 3010 so the
|
||||
* merchant webhook secret never enters the API host's process tree.
|
||||
* The bridge writes to the SAME fulfillment-store file the lookup endpoint
|
||||
* reads (platformPaths.dataDir + 'stripe-fulfillments.json'), so the API
|
||||
* sees the license as soon as the bridge saves it.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
const { createCheckoutSession } = require('../src/billing/stripe-client');
|
||||
const { createFulfillmentStore } = require('../src/billing/fulfillment-store');
|
||||
|
||||
// One fulfillment-store instance per process. Reads from the same file the
|
||||
// bridge writes to — IPC via the bind-mounted data dir. Override path via
|
||||
// STRIPE_BRIDGE_FULFILLMENT_STORE_FILE (the bridge reads the same env var
|
||||
// at startup) — both processes target the same file. In tests we point
|
||||
// at a tmp dir.
|
||||
const fulfillmentStore = createFulfillmentStore({
|
||||
filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE,
|
||||
});
|
||||
|
||||
const LOOKUP_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours — matches Stripe's
|
||||
// default Checkout session expiry.
|
||||
|
||||
/**
|
||||
* Compute the public origin to embed in Stripe Checkout success/cancel
|
||||
* URLs.
|
||||
*
|
||||
* SECURITY: the success_url is what Stripe redirects the customer's
|
||||
* browser to after payment. If we let the request's Host header
|
||||
* influence it unchecked, a header-injection attacker could redirect
|
||||
* customers to their own origin — and the session_id in the URL is
|
||||
* the bearer token for /api/v1/billing/lookup/:sessionId (the customer
|
||||
* would then leak their own license to the attacker). So we derive
|
||||
* the origin only from TRUSTED SOURCES:
|
||||
*
|
||||
* 1. `STRIPE_PUBLIC_ORIGIN` env var (preferred — operator-declared)
|
||||
* 2. `STRIPE_ALLOWED_HOSTS` allowlist + request Host header
|
||||
* (fallback for operators who don't set the env var)
|
||||
* 3. `undefined` → Stripe Checkout falls back to its default
|
||||
* success/cancel URL behavior (still safe; just loses the
|
||||
* success-page reveal flow).
|
||||
*
|
||||
* We also enforce scheme allowlist (https only by default; http only
|
||||
* for explicit dev mode) to prevent javascript:/file:/data: smuggling.
|
||||
*/
|
||||
function _resolvePublicOrigin(req) {
|
||||
// 1. Explicit env-var override (canonical deployment shape).
|
||||
if (process.env.STRIPE_PUBLIC_ORIGIN) {
|
||||
const raw = process.env.STRIPE_PUBLIC_ORIGIN.trim();
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol === 'https:' || (u.protocol === 'http:' && process.env.NODE_ENV !== 'production')) {
|
||||
return `${u.protocol}//${u.host}`;
|
||||
}
|
||||
} catch (_) { /* fall through to header-based resolution */ }
|
||||
}
|
||||
|
||||
// 2. Header-based fallback, gated by STRIPE_ALLOWED_HOSTS allowlist.
|
||||
const allowedHosts = (process.env.STRIPE_ALLOWED_HOSTS || '')
|
||||
.split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
|
||||
if (allowedHosts.length === 0) return undefined;
|
||||
|
||||
const host = (req.get('x-forwarded-host') || req.get('host') || '').toLowerCase();
|
||||
// Strip :port for comparison; port is added back when building the URL.
|
||||
const hostNoPort = host.split(':')[0];
|
||||
if (!hostNoPort) return undefined;
|
||||
if (!allowedHosts.includes(hostNoPort) && !allowedHosts.includes(host)) return undefined;
|
||||
|
||||
const rawProto = (req.get('x-forwarded-proto') || req.protocol || 'https').toLowerCase();
|
||||
const proto = rawProto === 'http' && process.env.NODE_ENV !== 'production' ? 'http' : 'https';
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing routes factory
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.asyncHandler - async route handler wrapper
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ asyncHandler }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/billing/checkout
|
||||
*
|
||||
* Body: { productId: 'pro-30d'|'pro-90d'|'pro-180d'|'pro-365d', customerEmail?: string }
|
||||
* Returns: { id, url }
|
||||
*
|
||||
* The customer is redirected to `url`. On success Stripe redirects to
|
||||
* {origin}/billing/success?session_id={CHECKOUT_SESSION_ID}. The
|
||||
* webhook bridge (scripts/stripe-license-bridge.js) generates the
|
||||
* license on `checkout.session.completed`, persists it to the
|
||||
* fulfillment store, emails it, and the success page polls the lookup
|
||||
* endpoint below to reveal it.
|
||||
*
|
||||
* SECURITY: The success_url is embedded in the Stripe Checkout Session
|
||||
* and shown to the customer in their browser. If an attacker can
|
||||
* control the `Host` / `X-Forwarded-Host` header, they can poison
|
||||
* Stripe's redirect to their own origin — and the customer's session
|
||||
* ID (which is the bearer token for /api/v1/billing/lookup/:sessionId)
|
||||
* lands in the attacker's URL bar. The lookup endpoint would then
|
||||
* serve the license to the attacker's browser.
|
||||
*
|
||||
* To prevent this, the API derives the origin from an explicit
|
||||
* `STRIPE_PUBLIC_ORIGIN` env var when set (the canonical deployment
|
||||
* shape). If unset, we fall back to the request's Host header, BUT
|
||||
* only when the Host is in the explicit `STRIPE_ALLOWED_HOSTS` allowlist
|
||||
* (comma-separated). This means a fresh operator MUST either set the
|
||||
* env var OR explicitly allowlist their hostname before checkout can
|
||||
* create sessions — a hostile header alone is not enough.
|
||||
*/
|
||||
router.post('/checkout', asyncHandler(async (req, res) => {
|
||||
const { productId, customerEmail } = req.body || {};
|
||||
|
||||
const origin = _resolvePublicOrigin(req);
|
||||
|
||||
try {
|
||||
if (!productId) throw new ValidationError('productId is required', 'productId');
|
||||
const session = await createCheckoutSession({ productId, customerEmail, origin });
|
||||
ok(res, { data: session });
|
||||
} catch (err) {
|
||||
if (err.code === 'STRIPE_NOT_CONFIGURED' || err.code === 'INVALID_PRODUCT_ID') {
|
||||
return errorResponse(res, err.statusCode || 500, err.message);
|
||||
}
|
||||
if (err.code === 'DC-400' || err instanceof ValidationError) {
|
||||
return errorResponse(res, err.statusCode || 400, err.message);
|
||||
}
|
||||
// Stripe SDK throws Stripe-specific errors; surface message but don't leak
|
||||
// Stripe's full response (may include internal IDs we don't want exposed).
|
||||
if (err.type && err.type.startsWith('Stripe')) {
|
||||
return errorResponse(res, 502, 'Payment provider error. Please try again.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, 'billing-checkout'));
|
||||
|
||||
/**
|
||||
* GET /api/v1/billing/lookup/:sessionId
|
||||
*
|
||||
* Returns the fulfillment state for a Stripe Checkout session. The
|
||||
* success page polls this every 1.5s until `delivered` or `pending_email`.
|
||||
*
|
||||
* - 200 + { status: 'processing', durationDays } — license being generated
|
||||
* - 200 + { status: 'pending_email', code, codeId, durationDays, productId, ... }
|
||||
* — license persisted (SMTP failure recovery)
|
||||
* - 200 + { status: 'delivered', code, codeId, durationDays, productId, deliveredVia }
|
||||
* — license delivered by email
|
||||
* - 404 + { status: 'not_found' } — no payment record for this sessionId
|
||||
* - 404 + { status: 'expired' } — record exists but past the 24h TTL
|
||||
*
|
||||
* Cache-Control: no-store. CSRF-exempt. PUBLIC_ROUTES allowlist.
|
||||
*/
|
||||
router.get('/lookup/:sessionId', asyncHandler(async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
res.set('Cache-Control', 'no-store');
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string' || sessionId.length > 256) {
|
||||
return errorResponse(res, 400, 'invalid sessionId');
|
||||
}
|
||||
|
||||
const record = fulfillmentStore.readBySession(sessionId);
|
||||
if (!record) {
|
||||
return errorResponse(res, 404, 'no record for that sessionId');
|
||||
}
|
||||
|
||||
const createdAt = record.createdAt ? Date.parse(record.createdAt) : Date.now();
|
||||
const ageMs = Date.now() - createdAt;
|
||||
if (Number.isFinite(ageMs) && ageMs > LOOKUP_TTL_MS) {
|
||||
return errorResponse(res, 404, 'record past lookup TTL');
|
||||
}
|
||||
|
||||
if (record.status === 'generating' || (!record.code && record.status !== 'delivered')) {
|
||||
return ok(res, { data: { status: 'processing', durationDays: record.durationDays, productId: record.productId } });
|
||||
}
|
||||
|
||||
if (record.status === 'delivered') {
|
||||
return ok(res, { data: { status: 'delivered', durationDays: record.durationDays, productId: record.productId, code: record.code, codeId: record.codeId, deliveredVia: record.deliveredVia || 'unknown' } });
|
||||
}
|
||||
|
||||
// pending_email OR delivering — license is durably persisted.
|
||||
return ok(res, {
|
||||
data: {
|
||||
status: 'pending_email',
|
||||
durationDays: record.durationDays,
|
||||
productId: record.productId,
|
||||
code: record.code,
|
||||
codeId: record.codeId,
|
||||
deliveredVia: record.deliveredVia,
|
||||
lastError: record.lastError,
|
||||
},
|
||||
});
|
||||
}, 'billing-lookup'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { execSync, execFileSync } = require('child_process');
|
||||
const { exists } = require('../src/utilities/fs-helpers');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
const { ok } = require('../src/utils/responses');
|
||||
@@ -207,7 +207,9 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
||||
const rootCertContent = await fsp.readFile(rootCert, 'utf8');
|
||||
await fsp.writeFile(fullChainFile, serverCertContent + '\n' + intermediateCertContent + '\n' + rootCertContent);
|
||||
|
||||
execSync(`openssl pkcs12 -export -out "${pfxFile}" -inkey "${keyFile}" -in "${certFile}" -certfile "${intermediateCert}" -password "pass:${password}"`, { stdio: 'pipe' });
|
||||
// P0-2 fix: was execSync(`... -password "pass:${password}"`) which interpolates the user-controlled
|
||||
// password into a shell string. execFileSync passes it as an argv element instead, no shell parsing.
|
||||
execFileSync('openssl', ['pkcs12', '-export', '-out', pfxFile, '-inkey', keyFile, '-in', certFile, '-certfile', intermediateCert, '-password', `pass:${password}`], { stdio: 'pipe' });
|
||||
|
||||
const keyContent = await fsp.readFile(keyFile, 'utf8');
|
||||
await fsp.writeFile(pemFile, keyContent + '\n' + serverCertContent + '\n' + intermediateCertContent);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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');
|
||||
const { validateBody, schemas: valSchemas } = require('../../src/utilities/validate');
|
||||
/**
|
||||
* Config assets routes factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
@@ -15,6 +16,30 @@ const { ok, successMessage } = require('../../src/utils/responses');
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
|
||||
// P0-4: image upload guard rails.
|
||||
// We allow a small whitelist of image MIME types and cap decoded bytes at 5 MB.
|
||||
const ALLOWED_IMAGE_TYPES = new Set(['png', 'jpeg', 'jpg', 'svg+xml', 'webp', 'ico', 'x-icon']);
|
||||
const MAX_ASSET_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function decodeImageData(data) {
|
||||
if (typeof data !== 'string') {
|
||||
throw new ValidationError('Invalid image data format');
|
||||
}
|
||||
const matches = data.match(/^data:image\/([a-zA-Z0-9+.-]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new ValidationError('Invalid image data format');
|
||||
}
|
||||
const mime = matches[1].toLowerCase();
|
||||
if (!ALLOWED_IMAGE_TYPES.has(mime)) {
|
||||
throw new ValidationError(`Unsupported image type: ${mime}`);
|
||||
}
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
if (buffer.length > MAX_ASSET_BYTES) {
|
||||
throw new ValidationError(`File too large (max ${MAX_ASSET_BYTES / 1024 / 1024}MB)`);
|
||||
}
|
||||
return { mime, buffer };
|
||||
}
|
||||
|
||||
// Image processing for favicon conversion (optional)
|
||||
let sharp, pngToIco;
|
||||
try {
|
||||
@@ -30,27 +55,19 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
|
||||
// ===== ASSET UPLOAD =====
|
||||
|
||||
router.post('/assets/upload', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
|
||||
router.post('/assets/upload', express.json({ limit: LIMITS.BODY_UPLOAD }), validateBody(valSchemas.assetUpload), asyncHandler(async (req, res) => {
|
||||
const { filename, data } = req.body;
|
||||
|
||||
if (!filename || !data) {
|
||||
throw new ValidationError('filename and data are required');
|
||||
}
|
||||
|
||||
// Validate filename to prevent directory traversal
|
||||
const safeFilename = path.basename(filename);
|
||||
if (safeFilename !== filename || filename.includes('..')) {
|
||||
throw new ValidationError('Invalid filename - must not contain path separators');
|
||||
}
|
||||
|
||||
// Extract base64 data
|
||||
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new ValidationError('Invalid image data format');
|
||||
}
|
||||
|
||||
const base64Data = matches[2];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
// P0-4 fix: use the helper that validates MIME type (whitelist), caps decoded bytes
|
||||
// at 5 MB, and rejects anything that isn't a string. The old inline regex allowed
|
||||
// `image/<anything>;base64,...` without a size cap and without MIME restriction.
|
||||
const { buffer } = decodeImageData(data);
|
||||
|
||||
// Determine assets path (mounted volume)
|
||||
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||
@@ -109,13 +126,9 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
// Upload custom logo(s) and/or update position and title
|
||||
// Supports: dataDark/dataLight (separate variants) or data (single logo for both)
|
||||
// eslint-disable-next-line complexity
|
||||
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
|
||||
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), validateBody(valSchemas.logoUpload), asyncHandler(async (req, res) => {
|
||||
const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
|
||||
|
||||
if (!data && !dataDark && !dataLight && !position && !dashboardTitle) {
|
||||
throw new ValidationError('Image data, position, or title is required');
|
||||
}
|
||||
|
||||
const config = await ctx.readConfig();
|
||||
let pathDark = null, pathLight = null;
|
||||
|
||||
@@ -220,15 +233,10 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
|
||||
return ctx.errorResponse(res, 500, 'Image processing not available');
|
||||
}
|
||||
|
||||
// Extract base64 data
|
||||
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new ValidationError('Invalid image data format');
|
||||
}
|
||||
|
||||
const base64Data = matches[2];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
// P0-4: validate MIME type + enforce 5MB buffer size cap (mime validated inside decodeImageData)
|
||||
const { buffer } = decodeImageData(data);
|
||||
|
||||
// Determine assets path (mounted volume)
|
||||
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
|
||||
if (!await exists(assetsPath)) {
|
||||
await fsp.mkdir(assetsPath, { recursive: true });
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Shared route context — holds all dependencies needed by route modules.
|
||||
* Populated once by server.js at startup, then passed to each route factory.
|
||||
*
|
||||
* Usage in a route module:
|
||||
* module.exports = function(ctx) {
|
||||
* const router = require('express').Router();
|
||||
* router.get('/status', ctx.asyncHandler(async (req, res) => { ... }));
|
||||
* return router;
|
||||
* };
|
||||
*
|
||||
* Namespaces: ctx.docker.*, ctx.caddy.*, ctx.dns.*, ctx.session.*,
|
||||
* ctx.notification.*, ctx.tailscale.*
|
||||
*/
|
||||
const ctx = {
|
||||
// ── Namespaced groups ──
|
||||
docker: {
|
||||
client: null, // Dockerode instance
|
||||
pull: null, // dockerPull(imageName, timeoutMs)
|
||||
findContainer: null, // findContainerByName(name, opts)
|
||||
getUsedPorts: null, // getUsedPorts() → Set<number>
|
||||
security: null, // dockerSecurity module
|
||||
},
|
||||
caddy: {
|
||||
modify: null, // modifyCaddyfile(modifyFn) → {success, error?}
|
||||
read: null, // readCaddyfile() → string
|
||||
reload: null, // reloadCaddy(content)
|
||||
generateConfig: null, // generateCaddyConfig(subdomain, ip, port, opts)
|
||||
verifySite: null, // verifySiteAccessible(domain, maxAttempts)
|
||||
adminUrl: null, // CADDY_ADMIN_URL string
|
||||
filePath: null, // CADDYFILE_PATH string
|
||||
},
|
||||
dns: {
|
||||
call: null, // callDns(server, apiPath, params)
|
||||
buildUrl: null, // buildDnsUrl(server, apiPath, params)
|
||||
requireToken: null, // requireDnsToken(providedToken)
|
||||
ensureToken: null, // ensureValidDnsToken()
|
||||
createRecord: null, // createDnsRecord(subdomain, ip)
|
||||
getToken: null, // () => dnsToken
|
||||
setToken: null, // (t) => { dnsToken = t }
|
||||
getTokenExpiry: null, // () => dnsTokenExpiry
|
||||
setTokenExpiry: null, // (e) => { dnsTokenExpiry = e }
|
||||
getTokenForServer: null, // getTokenForServer(serverIp)
|
||||
refresh: null, // refreshDnsToken()
|
||||
credentialsFile: null,// DNS_CREDENTIALS_FILE path
|
||||
},
|
||||
session: {
|
||||
ipSessions: null, // Map of IP → session
|
||||
durations: null, // SESSION_DURATIONS map
|
||||
getClientIP: null, // getClientIP(req)
|
||||
create: null, // createIPSession(ip, duration)
|
||||
setCookie: null, // setSessionCookie(res, duration)
|
||||
clear: null, // clearIPSession(ip)
|
||||
clearCookie: null, // clearSessionCookie(res)
|
||||
isValid: null, // isSessionValid(req)
|
||||
},
|
||||
notification: {
|
||||
getConfig: null, // () => notificationConfig
|
||||
saveConfig: null, // saveNotificationConfig()
|
||||
send: null, // sendNotification(event, title, message, type)
|
||||
sendDiscord: null, // sendDiscordNotification(title, message, type)
|
||||
sendTelegram: null, // sendTelegramNotification(title, message, type)
|
||||
sendNtfy: null, // sendNtfyNotification(title, message, type)
|
||||
getHistory: null, // () => notificationHistory
|
||||
clearHistory: null, // () => { notificationHistory = [] }
|
||||
startHealthDaemon: null, // startHealthCheckDaemon()
|
||||
stopHealthDaemon: null, // stopHealthCheckDaemon()
|
||||
checkHealth: null, // checkContainerHealth()
|
||||
getHealthState: null, // () => containerHealthState
|
||||
},
|
||||
tailscale: {
|
||||
config: null, // tailscaleConfig object
|
||||
save: null, // saveTailscaleConfig()
|
||||
getStatus: null, // getTailscaleStatus()
|
||||
getLocalIP: null, // getLocalTailscaleIP()
|
||||
isTailscaleIP: null, // isTailscaleIP(ip)
|
||||
getAccessToken: null, // getTailscaleAccessToken()
|
||||
syncAPI: null, // syncFromTailscaleAPI()
|
||||
startSync: null, // startTailscaleSyncTimer()
|
||||
stopSync: null, // stopTailscaleSyncTimer()
|
||||
},
|
||||
|
||||
// ── Flat (shared across domains) ──
|
||||
app: null,
|
||||
siteConfig: null,
|
||||
servicesStateManager: null,
|
||||
configStateManager: null,
|
||||
credentialManager: null,
|
||||
authManager: null,
|
||||
|
||||
// Feature modules
|
||||
healthChecker: null,
|
||||
updateManager: null,
|
||||
backupManager: null,
|
||||
resourceMonitor: null,
|
||||
auditLogger: null,
|
||||
portLockManager: null,
|
||||
selfUpdater: null,
|
||||
|
||||
// Templates
|
||||
APP_TEMPLATES: null,
|
||||
TEMPLATE_CATEGORIES: null,
|
||||
DIFFICULTY_LEVELS: null,
|
||||
|
||||
// Shared helpers
|
||||
asyncHandler: null,
|
||||
errorResponse: null,
|
||||
ok: null,
|
||||
fetchT: null,
|
||||
log: null,
|
||||
logError: null,
|
||||
safeErrorMessage: null,
|
||||
buildDomain: null,
|
||||
buildServiceUrl: null,
|
||||
getServiceById: null,
|
||||
readConfig: null,
|
||||
saveConfig: null,
|
||||
addServiceToConfig: null,
|
||||
resyncHealthChecker: null,
|
||||
validateURL: null,
|
||||
|
||||
// Middleware
|
||||
strictLimiter: null,
|
||||
|
||||
// TOTP (flat — used alongside session namespace)
|
||||
totpConfig: null,
|
||||
saveTotpConfig: null,
|
||||
|
||||
// Config lifecycle
|
||||
loadSiteConfig: null,
|
||||
loadDnsCredentials: null,
|
||||
loadNotificationConfig: null,
|
||||
|
||||
// Config paths (flat)
|
||||
SERVICES_FILE: null,
|
||||
CONFIG_FILE: null,
|
||||
TOTP_CONFIG_FILE: null,
|
||||
TAILSCALE_CONFIG_FILE: null,
|
||||
NOTIFICATIONS_FILE: null,
|
||||
ERROR_LOG_FILE: null,
|
||||
};
|
||||
|
||||
module.exports = ctx;
|
||||
@@ -0,0 +1,64 @@
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Disk space management routes
|
||||
*
|
||||
* GET /disk — current usage snapshot (budget, breakdown, status)
|
||||
* GET /disk/breakdown — detailed breakdown incl. per-container log sizes
|
||||
* GET /disk/config — get disk budget settings
|
||||
* POST /disk/config — update disk budget settings
|
||||
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
|
||||
*/
|
||||
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Current disk usage snapshot
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const snapshot = await diskSpaceMonitor.getSnapshot();
|
||||
success(res, snapshot);
|
||||
}, 'disk-get'));
|
||||
|
||||
// Detailed breakdown (includes per-container log sizes)
|
||||
router.get('/breakdown', asyncHandler(async (req, res) => {
|
||||
const breakdown = await diskSpaceMonitor.getDetailedBreakdown();
|
||||
success(res, breakdown);
|
||||
}, 'disk-breakdown'));
|
||||
|
||||
// Get disk budget config
|
||||
router.get('/config', asyncHandler(async (req, res) => {
|
||||
success(res, diskSpaceMonitor.getConfig());
|
||||
}, 'disk-config-get'));
|
||||
|
||||
// Update disk budget config
|
||||
router.post('/config', asyncHandler(async (req, res) => {
|
||||
const { diskBudgetGB, warningThresholdPct, criticalThresholdPct, autoCleanup, enabled, cleanupAggressivePct } = req.body;
|
||||
|
||||
const updates = {};
|
||||
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
|
||||
if (typeof warningThresholdPct === 'number') updates.warningThresholdPct = Math.min(Math.max(warningThresholdPct, 50), 99);
|
||||
if (typeof criticalThresholdPct === 'number') updates.criticalThresholdPct = Math.min(Math.max(criticalThresholdPct, 60), 99);
|
||||
if (typeof cleanupAggressivePct === 'number') updates.cleanupAggressivePct = Math.min(Math.max(cleanupAggressivePct, 70), 99);
|
||||
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
|
||||
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||
|
||||
const config = diskSpaceMonitor.configure(updates);
|
||||
log.info('disk', 'Disk budget updated', updates);
|
||||
|
||||
success(res, { message: 'Disk budget updated', config });
|
||||
}, 'disk-config-set'));
|
||||
|
||||
// Manual cleanup trigger
|
||||
router.post('/cleanup', asyncHandler(async (req, res) => {
|
||||
const level = req.body?.level || 'standard';
|
||||
if (!['standard', 'aggressive', 'logs-only'].includes(level)) {
|
||||
return errorResponse(res, 'Invalid cleanup level. Use: standard, aggressive, or logs-only', 400);
|
||||
}
|
||||
|
||||
log.info('disk', 'Manual cleanup triggered', { level, by: req.auth?.user || 'api' });
|
||||
const result = await diskSpaceMonitor.performCleanup(level);
|
||||
success(res, result);
|
||||
}, 'disk-cleanup'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -552,7 +552,7 @@ module.exports = function({
|
||||
}
|
||||
}
|
||||
|
||||
return ok(res, {
|
||||
return success(res, {
|
||||
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
|
||||
results
|
||||
});
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
// Dedicated rate limiter for license activation — prevents brute-force key guessing.
|
||||
// Pro keys follow a predictable format (DC-XXX-XXXXX-XXXXXX), so without rate
|
||||
// limiting an attacker could enumerate valid keys.
|
||||
const licenseActivateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // 10 attempts per window per IP
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many license activation attempts. Please try again later.' },
|
||||
skip: () => process.env.NODE_ENV === 'test',
|
||||
});
|
||||
|
||||
/**
|
||||
* License routes factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
@@ -13,7 +26,7 @@ module.exports = function({ licenseManager, asyncHandler }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Activate a license code
|
||||
router.post('/activate', asyncHandler(async (req, res) => {
|
||||
router.post('/activate', licenseActivateLimiter, asyncHandler(async (req, res) => {
|
||||
const { code } = req.body;
|
||||
if (!code) {
|
||||
throw new ValidationError('License code is required');
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# DC-056 legal-pages deploy.
|
||||
#
|
||||
# Publishes the static Terms + Privacy HTML pages to DNS2 so they are
|
||||
# reachable from the dashboard footer and from the pricing/checkout flow.
|
||||
#
|
||||
# Deployment targets:
|
||||
# /var/www/dashcaddy-status/legal/{terms,tos,privacy}/index.html
|
||||
# served at https://status.sami/legal/{terms,tos,privacy}
|
||||
#
|
||||
# A separate `legal.dashcaddy.net` subdomain is INTENTIONALLY NOT created
|
||||
# at v1.0 — it would need its own DNS record + Caddy vhost + LE cert, and
|
||||
# the status.sami/legal/... mount covers the launch requirement without
|
||||
# extra infra. Operators that want the dedicated subdomain can run a
|
||||
# second rsync to a future root-mounted target with relative paths.
|
||||
#
|
||||
# Verification curls status.sami/legal/{terms,tos,privacy} — not the
|
||||
# (not-yet-existing) legal.dashcaddy.net — so the post-deploy gate
|
||||
# matches the actually-served routes.
|
||||
DNS2_HOST="${DNS2_HOST:-root@100.121.150.22}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LEGAL_SOURCE="$REPO_ROOT/status/legal"
|
||||
declare -a PAGES=(terms tos privacy)
|
||||
for page in "${PAGES[@]}"; do
|
||||
test -s "$LEGAL_SOURCE/$page/index.html" || { echo "Missing legal page: $page" >&2; exit 1; }
|
||||
done
|
||||
ssh "$DNS2_HOST" 'install -d -m 0755 /var/www/dashcaddy-status/legal'
|
||||
for page in "${PAGES[@]}"; do
|
||||
ssh "$DNS2_HOST" "install -d -m 0755 /var/www/dashcaddy-status/legal/$page"
|
||||
rsync -az --delete "$LEGAL_SOURCE/$page/" "$DNS2_HOST:/var/www/dashcaddy-status/legal/$page/"
|
||||
done
|
||||
ssh "$DNS2_HOST" 'caddy validate --config /etc/caddy/Caddyfile && caddy reload --config /etc/caddy/Caddyfile'
|
||||
PUBLIC_STATUS_URL="${PUBLIC_STATUS_URL:-https://status.sami}"
|
||||
# Page-specific markers so a misrouted Terms page doesn't pass for Privacy.
|
||||
# We use a temp file instead of `curl | grep -q` because grep -q exits early and
|
||||
# can trigger SIGPIPE under pipefail, producing false-positive verification
|
||||
# failures on otherwise-successful deploys (set -o pipefail amplifies this).
|
||||
declare -A PAGE_MARKERS=(
|
||||
[terms]="Terms of Service"
|
||||
[tos]="Terms of Service" # alias page content
|
||||
[privacy]="Privacy Policy"
|
||||
)
|
||||
TMP_CURL_BODY="$(mktemp)"
|
||||
trap 'rm -f "$TMP_CURL_BODY"' EXIT
|
||||
for path in "${PAGES[@]}"; do
|
||||
marker="${PAGE_MARKERS[$path]}"
|
||||
if ! curl --fail --silent --show-error --location "${PUBLIC_STATUS_URL}/legal/${path}" -o "$TMP_CURL_BODY"; then
|
||||
echo "Post-deploy verification failed: ${PUBLIC_STATUS_URL}/legal/${path} (HTTP error)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qF "${marker}" "$TMP_CURL_BODY"; then
|
||||
echo "Post-deploy verification failed: ${PUBLIC_STATUS_URL}/legal/${path} (expected '${marker}')" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
printf 'Legal pages deployed to status.sami/legal.\n'
|
||||
@@ -1,489 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Comprehensive DashCaddy Security Test Suite
|
||||
* Tests all 11 security fixes with detailed verification
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const API_BASE = process.env.API_BASE || 'http://localhost:3001';
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
magenta: '\x1b[35m'
|
||||
};
|
||||
|
||||
const testResults = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
warnings: 0,
|
||||
total: 0,
|
||||
details: []
|
||||
};
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function logSection(title) {
|
||||
console.log(`\n${colors.cyan}${'═'.repeat(60)}${colors.reset}`);
|
||||
console.log(`${colors.cyan} ${title}${colors.reset}`);
|
||||
console.log(`${colors.cyan}${'═'.repeat(60)}${colors.reset}\n`);
|
||||
}
|
||||
|
||||
function recordTest(name, passed, message, warning = false) {
|
||||
testResults.total++;
|
||||
if (warning) {
|
||||
testResults.warnings++;
|
||||
log(` ⚠ ${name}: ${message}`, 'yellow');
|
||||
} else if (passed) {
|
||||
testResults.passed++;
|
||||
log(` ✓ ${name}: ${message}`, 'green');
|
||||
} else {
|
||||
testResults.failed++;
|
||||
log(` ✗ ${name}: ${message}`, 'red');
|
||||
}
|
||||
testResults.details.push({ name, passed, message, warning });
|
||||
}
|
||||
|
||||
async function makeRequest(path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, API_BASE);
|
||||
const requestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 80,
|
||||
path: url.pathname + url.search,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000
|
||||
};
|
||||
|
||||
const req = http.request(requestOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: data,
|
||||
data: data && (data.startsWith('{') || data.startsWith('[')) ?
|
||||
(() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Test 1: Startup Validation & Health Checks
|
||||
async function testStartupValidation() {
|
||||
logSection('TEST 1: Startup Validation & Health Checks');
|
||||
|
||||
try {
|
||||
const response = await makeRequest('/health');
|
||||
if (response.statusCode === 200 && response.data?.status === 'ok') {
|
||||
recordTest('Health Endpoint', true, `Server healthy (${response.data.timestamp})`);
|
||||
} else {
|
||||
recordTest('Health Endpoint', false, `Unexpected response: ${response.statusCode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('Health Endpoint', false, `Error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Check for startup validation in logs (requires Docker access)
|
||||
log('\n Manual check: Run "docker logs dashcaddy-api | grep validation"', 'yellow');
|
||||
log(' Expected: "✓ Startup configuration validation passed"', 'yellow');
|
||||
}
|
||||
|
||||
// Test 2: CSRF Protection
|
||||
async function testCSRFProtection() {
|
||||
logSection('TEST 2: CSRF Protection');
|
||||
|
||||
// Test 2a: CSRF cookie is set
|
||||
try {
|
||||
const response = await makeRequest('/api/services');
|
||||
const csrfCookie = response.headers['set-cookie']?.find(c => c.includes('dashcaddy_csrf'));
|
||||
|
||||
if (csrfCookie) {
|
||||
const hasMaxAge = csrfCookie.includes('Max-Age');
|
||||
const hasSameSite = csrfCookie.includes('SameSite=Strict');
|
||||
|
||||
if (hasMaxAge && hasSameSite) {
|
||||
recordTest('CSRF Cookie', true, 'Cookie set with correct attributes (Max-Age, SameSite=Strict)');
|
||||
} else {
|
||||
recordTest('CSRF Cookie', true, 'Cookie set but missing some attributes', true);
|
||||
}
|
||||
} else {
|
||||
recordTest('CSRF Cookie', false, 'CSRF cookie not set in response');
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('CSRF Cookie', false, `Error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test 2b: POST without CSRF token is blocked
|
||||
try {
|
||||
const response = await makeRequest('/api/test-endpoint', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { test: 'data' }
|
||||
});
|
||||
|
||||
if (response.data?.error?.includes('CSRF') || response.data?.message?.includes('CSRF')) {
|
||||
recordTest('CSRF Validation', true, 'POST blocked without CSRF token');
|
||||
} else if (response.statusCode === 401) {
|
||||
recordTest('CSRF Validation', true, 'Request requires authentication (CSRF check bypassed)', true);
|
||||
} else {
|
||||
recordTest('CSRF Validation', false, `Unexpected: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('CSRF Validation', false, `Error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test 2c: CSRF token endpoint (may require auth)
|
||||
try {
|
||||
const response = await makeRequest('/api/csrf-token');
|
||||
|
||||
if (response.statusCode === 200 && response.data?.token) {
|
||||
recordTest('CSRF Token Endpoint', true, 'Token endpoint returns valid token');
|
||||
} else if (response.statusCode === 401) {
|
||||
recordTest('CSRF Token Endpoint', true, 'Endpoint requires authentication (expected with TOTP)', true);
|
||||
} else {
|
||||
recordTest('CSRF Token Endpoint', false, `Unexpected response: ${response.statusCode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('CSRF Token Endpoint', false, `Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Request Size Limits
|
||||
async function testRequestSizeLimits() {
|
||||
logSection('TEST 3: Request Size Limits');
|
||||
|
||||
// Test 3a: Small payload (should work)
|
||||
try {
|
||||
const smallPayload = { data: 'a'.repeat(100) };
|
||||
const response = await makeRequest('/api/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(smallPayload)
|
||||
});
|
||||
|
||||
if (response.statusCode !== 413) {
|
||||
recordTest('Small Payload', true, `Accepted (${response.statusCode})`);
|
||||
} else {
|
||||
recordTest('Small Payload', false, 'Small payload rejected as too large');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!error.message.includes('413')) {
|
||||
recordTest('Small Payload', true, 'Accepted (non-size error)');
|
||||
} else {
|
||||
recordTest('Small Payload', false, `Rejected: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3b: Check if large payloads are rejected (without actually sending 2MB)
|
||||
log('\n Info: Testing large payload rejection requires actual 2MB POST', 'blue');
|
||||
log(' Expected behavior: Payloads > 1MB rejected with 413', 'blue');
|
||||
recordTest('Large Payload Rejection', true, 'Mechanism in place (verified in logs)', true);
|
||||
}
|
||||
|
||||
// Test 4: Enhanced Error Logging
|
||||
async function testErrorLogging() {
|
||||
logSection('TEST 4: Enhanced Error Logging (Request IDs)');
|
||||
|
||||
try {
|
||||
const response = await makeRequest('/api/services');
|
||||
const requestId = response.headers['x-request-id'];
|
||||
|
||||
if (requestId) {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (uuidRegex.test(requestId)) {
|
||||
recordTest('Request ID Header', true, `Valid UUID: ${requestId.substring(0, 13)}...`);
|
||||
} else {
|
||||
recordTest('Request ID Header', false, `Invalid UUID format: ${requestId}`);
|
||||
}
|
||||
} else {
|
||||
recordTest('Request ID Header', false, 'X-Request-ID header not present');
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('Request ID Header', false, `Error: ${error.message}`);
|
||||
}
|
||||
|
||||
log('\n Manual check: Error logs should include IP, User-Agent, Method, Path', 'yellow');
|
||||
log(' Run: docker logs dashcaddy-api | grep -i "error" | tail -5', 'yellow');
|
||||
}
|
||||
|
||||
// Test 5: Authentication Layer
|
||||
async function testAuthentication() {
|
||||
logSection('TEST 5: Authentication Layer');
|
||||
|
||||
// Test 5a: Auth endpoints exist
|
||||
try {
|
||||
const response = await makeRequest('/api/auth/keys');
|
||||
|
||||
if (response.statusCode === 401) {
|
||||
recordTest('Auth Endpoints', true, 'Auth required (TOTP enabled)');
|
||||
} else if (response.statusCode === 200) {
|
||||
recordTest('Auth Endpoints', true, 'Endpoint accessible (TOTP disabled)', true);
|
||||
} else {
|
||||
recordTest('Auth Endpoints', false, `Unexpected status: ${response.statusCode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('Auth Endpoints', false, `Error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test 5b: Check AuthManager in logs
|
||||
log('\n Manual check: Verify AuthManager initialized', 'yellow');
|
||||
log(' Run: docker logs dashcaddy-api | grep AuthManager', 'yellow');
|
||||
log(' Expected: "[AuthManager] Initialized"', 'yellow');
|
||||
}
|
||||
|
||||
// Test 6: Port Locking
|
||||
async function testPortLocking() {
|
||||
logSection('TEST 6: Port Locking Mechanism');
|
||||
|
||||
log(' Manual check: Port lock directory created in container', 'yellow');
|
||||
log(' Run: docker logs dashcaddy-api | grep PortLockManager', 'yellow');
|
||||
log(' Expected: "[PortLockManager] Created lock directory: /app/.port-locks"', 'yellow');
|
||||
log(' Expected: "[PortLockManager] Cleanup complete: X stale locks removed"', 'yellow');
|
||||
|
||||
// Check if module exists locally
|
||||
const modulePath = path.join(__dirname, 'port-lock-manager.js');
|
||||
if (fs.existsSync(modulePath)) {
|
||||
recordTest('Port Lock Module', true, 'port-lock-manager.js exists');
|
||||
} else {
|
||||
recordTest('Port Lock Module', false, 'port-lock-manager.js not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Docker Security Module
|
||||
async function testDockerSecurity() {
|
||||
logSection('TEST 7: Docker Image Verification');
|
||||
|
||||
const modulePath = path.join(__dirname, 'docker-security.js');
|
||||
if (fs.existsSync(modulePath)) {
|
||||
recordTest('Docker Security Module', true, 'docker-security.js exists');
|
||||
} else {
|
||||
recordTest('Docker Security Module', false, 'docker-security.js not found');
|
||||
}
|
||||
|
||||
log('\n Manual check: Docker security initialized', 'yellow');
|
||||
log(' Run: docker logs dashcaddy-api | grep DockerSecurity', 'yellow');
|
||||
log(' Expected: "[DockerSecurity] Initialized in verify mode"', 'yellow');
|
||||
}
|
||||
|
||||
// Test 8: Hardcoded Secrets Removal
|
||||
async function testSecretsRemoval() {
|
||||
logSection('TEST 8: Hardcoded Secrets Removal');
|
||||
|
||||
try {
|
||||
const templatesPath = path.join(__dirname, 'app-templates.js');
|
||||
const content = fs.readFileSync(templatesPath, 'utf8');
|
||||
|
||||
const changeMe123 = (content.match(/changeme123/g) || []).length;
|
||||
const secretsConfigs = (content.match(/secrets:\s*\[/g) || []).length;
|
||||
|
||||
if (changeMe123 === 0) {
|
||||
recordTest('Hardcoded Secrets', true, 'No "changeme123" found in templates');
|
||||
} else {
|
||||
recordTest('Hardcoded Secrets', false, `Found ${changeMe123} instances of "changeme123"`);
|
||||
}
|
||||
|
||||
if (secretsConfigs >= 10) {
|
||||
recordTest('Secrets Configurations', true, `Found ${secretsConfigs} secrets configs`);
|
||||
} else {
|
||||
recordTest('Secrets Configurations', false, `Only ${secretsConfigs} configs (expected 14+)`);
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('Hardcoded Secrets', false, `Error reading templates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 9: LRU Cache Implementation
|
||||
async function testLRUCache() {
|
||||
logSection('TEST 9: Session Management (LRU Cache)');
|
||||
|
||||
// Check if cache-config exists
|
||||
const cacheConfigPath = path.join(__dirname, 'cache-config.js');
|
||||
if (fs.existsSync(cacheConfigPath)) {
|
||||
recordTest('LRU Cache Module', true, 'cache-config.js exists');
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(cacheConfigPath, 'utf8');
|
||||
if (content.includes('LRUCache')) {
|
||||
recordTest('LRU Implementation', true, 'Uses LRUCache from lru-cache package');
|
||||
} else {
|
||||
recordTest('LRU Implementation', false, 'LRUCache not found in cache-config.js');
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('LRU Implementation', false, `Error: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
recordTest('LRU Cache Module', false, 'cache-config.js not found');
|
||||
}
|
||||
|
||||
// Check server.js for cache usage
|
||||
try {
|
||||
const serverPath = path.join(__dirname, 'server.js');
|
||||
const content = fs.readFileSync(serverPath, 'utf8');
|
||||
|
||||
const cacheUsage = (content.match(/createCache\(/g) || []).length;
|
||||
if (cacheUsage >= 4) {
|
||||
recordTest('Cache Usage', true, `Found ${cacheUsage} cache instances in server.js`);
|
||||
} else {
|
||||
recordTest('Cache Usage', false, `Only ${cacheUsage} instances (expected 4+)`);
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('Cache Usage', false, `Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 10: Frontend CSRF Integration
|
||||
async function testFrontendCSRF() {
|
||||
logSection('TEST 10: Frontend CSRF Integration');
|
||||
|
||||
try {
|
||||
const indexPath = path.join(__dirname, '..', 'status', 'index.html');
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
recordTest('Frontend File', false, 'index.html not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(indexPath, 'utf8');
|
||||
|
||||
// Check for CSRF helper functions
|
||||
if (content.includes('getCSRFToken') && content.includes('secureFetch')) {
|
||||
recordTest('CSRF Helpers', true, 'getCSRFToken() and secureFetch() found');
|
||||
} else {
|
||||
recordTest('CSRF Helpers', false, 'CSRF helper functions not found');
|
||||
}
|
||||
|
||||
// Check for secureFetch usage
|
||||
const secureFetchUsage = (content.match(/secureFetch\(/g) || []).length;
|
||||
if (secureFetchUsage >= 30) {
|
||||
recordTest('Frontend Integration', true, `${secureFetchUsage} secureFetch calls found`);
|
||||
} else {
|
||||
recordTest('Frontend Integration', false, `Only ${secureFetchUsage} calls (expected 30+)`);
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('Frontend CSRF', false, `Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 11: Path Traversal Protection
|
||||
async function testPathTraversal() {
|
||||
logSection('TEST 11: Path Traversal Protection');
|
||||
|
||||
// Check if validateSecurePath exists in input-validator
|
||||
try {
|
||||
const validatorPath = path.join(__dirname, 'input-validator.js');
|
||||
const content = fs.readFileSync(validatorPath, 'utf8');
|
||||
|
||||
if (content.includes('validateSecurePath')) {
|
||||
recordTest('Path Validation Function', true, 'validateSecurePath() found in input-validator.js');
|
||||
|
||||
if (content.includes('fs.promises.realpath') || content.includes('realpath')) {
|
||||
recordTest('Realpath Implementation', true, 'Uses fs.realpath() for symlink resolution');
|
||||
} else {
|
||||
recordTest('Realpath Implementation', false, 'Does not use realpath()');
|
||||
}
|
||||
} else {
|
||||
recordTest('Path Validation Function', false, 'validateSecurePath() not found');
|
||||
}
|
||||
} catch (error) {
|
||||
recordTest('Path Traversal Protection', false, `Error: ${error.message}`);
|
||||
}
|
||||
|
||||
log('\n Note: Path traversal endpoints require authentication to test', 'yellow');
|
||||
}
|
||||
|
||||
// Main test runner
|
||||
async function runAllTests() {
|
||||
log('\n╔════════════════════════════════════════════════════════════╗', 'magenta');
|
||||
log('║ DashCaddy Comprehensive Security Test Suite ║', 'magenta');
|
||||
log('╚════════════════════════════════════════════════════════════╝', 'magenta');
|
||||
|
||||
log(`\nAPI Base: ${API_BASE}`, 'blue');
|
||||
log(`Test Time: ${new Date().toISOString()}`, 'blue');
|
||||
log('\nRunning comprehensive security tests...\n', 'blue');
|
||||
|
||||
await testStartupValidation();
|
||||
await testCSRFProtection();
|
||||
await testRequestSizeLimits();
|
||||
await testErrorLogging();
|
||||
await testAuthentication();
|
||||
await testPortLocking();
|
||||
await testDockerSecurity();
|
||||
await testSecretsRemoval();
|
||||
await testLRUCache();
|
||||
await testFrontendCSRF();
|
||||
await testPathTraversal();
|
||||
|
||||
// Summary
|
||||
logSection('TEST SUMMARY');
|
||||
|
||||
const passRate = testResults.total > 0
|
||||
? ((testResults.passed / testResults.total) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
log(`Total Tests: ${testResults.total}`, 'blue');
|
||||
log(`Passed: ${testResults.passed}`, 'green');
|
||||
log(`Failed: ${testResults.failed}`, testResults.failed > 0 ? 'red' : 'green');
|
||||
log(`Warnings: ${testResults.warnings}`, 'yellow');
|
||||
log(`Success Rate: ${passRate}%`, passRate >= 80 ? 'green' : 'yellow');
|
||||
|
||||
if (testResults.failed > 0) {
|
||||
log('\nFailed Tests:', 'red');
|
||||
testResults.details
|
||||
.filter(t => !t.passed && !t.warning)
|
||||
.forEach(t => log(` ✗ ${t.name}: ${t.message}`, 'red'));
|
||||
}
|
||||
|
||||
if (testResults.warnings > 0) {
|
||||
log('\nWarnings (Manual Verification Needed):', 'yellow');
|
||||
testResults.details
|
||||
.filter(t => t.warning)
|
||||
.forEach(t => log(` ⚠ ${t.name}: ${t.message}`, 'yellow'));
|
||||
}
|
||||
|
||||
log('\n' + '═'.repeat(60), 'cyan');
|
||||
|
||||
if (testResults.failed === 0) {
|
||||
log('\n✅ ALL AUTOMATED TESTS PASSED!', 'green');
|
||||
log('Review warnings above for manual verification steps.\n', 'yellow');
|
||||
} else {
|
||||
log('\n⚠️ Some tests failed. Review details above.\n', 'yellow');
|
||||
}
|
||||
|
||||
process.exit(testResults.failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
if (require.main === module) {
|
||||
runAllTests().catch(error => {
|
||||
log(`\nFatal error: ${error.message}`, 'red');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runAllTests };
|
||||
@@ -1,386 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Automated Testing Script for DashCaddy Security Fixes
|
||||
*
|
||||
* Tests all implemented security improvements:
|
||||
* 1. Path traversal protection
|
||||
* 2. Request size limits
|
||||
* 3. Startup validation
|
||||
* 4. Port locking
|
||||
* 5. Session management (LRU cache)
|
||||
* 6. Enhanced error logging
|
||||
* 7. Hardcoded secrets removal
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const API_BASE = process.env.API_BASE || 'http://localhost:3001';
|
||||
const TEST_RESULTS = [];
|
||||
|
||||
// Color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function logTest(name) {
|
||||
console.log(`\n${colors.cyan}━━━ Testing: ${name} ━━━${colors.reset}`);
|
||||
}
|
||||
|
||||
function logResult(passed, message) {
|
||||
const icon = passed ? '✓' : '✗';
|
||||
const color = passed ? 'green' : 'red';
|
||||
log(` ${icon} ${message}`, color);
|
||||
TEST_RESULTS.push({ passed, message });
|
||||
}
|
||||
|
||||
async function makeRequest(path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, API_BASE);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const requestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
...options
|
||||
};
|
||||
|
||||
const req = client.request(requestOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: data,
|
||||
data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (options.body) {
|
||||
req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Test 1: Path Traversal Protection
|
||||
async function testPathTraversal() {
|
||||
logTest('Path Traversal Protection');
|
||||
|
||||
const attacks = [
|
||||
{ path: '/api/browse/directories?path=../../../../../../etc/passwd', desc: 'Unix path traversal' },
|
||||
{ path: '/api/browse/directories?path=..\\..\\..\\Windows\\System32', desc: 'Windows path traversal' },
|
||||
{ path: '/api/browse/directories?path=%2e%2e%2f%2e%2e%2fetc%2fpasswd', desc: 'URL-encoded traversal' },
|
||||
{ path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' }
|
||||
];
|
||||
|
||||
for (const attack of attacks) {
|
||||
try {
|
||||
const response = await makeRequest(attack.path);
|
||||
if (response.statusCode === 403 || response.statusCode === 400) {
|
||||
logResult(true, `Blocked: ${attack.desc}`);
|
||||
} else {
|
||||
logResult(false, `NOT BLOCKED (${response.statusCode}): ${attack.desc}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(false, `Error testing ${attack.desc}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Request Size Limits
|
||||
async function testRequestSizeLimits() {
|
||||
logTest('Request Size Limits');
|
||||
|
||||
// Test 1: Small payload (should work)
|
||||
try {
|
||||
const smallPayload = { data: 'a'.repeat(100) };
|
||||
const response = await makeRequest('/api/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(smallPayload)
|
||||
});
|
||||
logResult(true, 'Small payload accepted (100 bytes)');
|
||||
} catch (error) {
|
||||
logResult(false, `Small payload rejected: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test 2: Large payload on general endpoint (should fail)
|
||||
try {
|
||||
const largePayload = { data: 'a'.repeat(2 * 1024 * 1024) }; // 2MB
|
||||
const response = await makeRequest('/api/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(largePayload)
|
||||
});
|
||||
if (response.statusCode === 413 || response.statusCode === 400) {
|
||||
logResult(true, 'Large payload rejected on general endpoint (2MB)');
|
||||
} else {
|
||||
logResult(false, `Large payload NOT rejected (status: ${response.statusCode})`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('413') || error.message.includes('ECONNRESET')) {
|
||||
logResult(true, 'Large payload rejected (connection reset)');
|
||||
} else {
|
||||
logResult(false, `Unexpected error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Large payload on logo endpoint (should work)
|
||||
try {
|
||||
const largeImage = 'a'.repeat(5 * 1024 * 1024); // 5MB
|
||||
const response = await makeRequest('/api/logo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ logo: largeImage })
|
||||
});
|
||||
if (response.statusCode !== 413) {
|
||||
logResult(true, 'Large payload accepted on logo endpoint (5MB)');
|
||||
} else {
|
||||
logResult(false, 'Large payload rejected on logo endpoint');
|
||||
}
|
||||
} catch (error) {
|
||||
// May fail for other reasons (auth, validation), but not size
|
||||
if (!error.message.includes('413')) {
|
||||
logResult(true, 'Logo endpoint accepts large payloads (failed for non-size reason)');
|
||||
} else {
|
||||
logResult(false, `Logo endpoint rejected large payload: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Startup Validation
|
||||
async function testStartupValidation() {
|
||||
logTest('Startup Validation');
|
||||
|
||||
// Check if server is running (implies validation passed)
|
||||
try {
|
||||
const response = await makeRequest('/health');
|
||||
if (response.statusCode === 200) {
|
||||
logResult(true, 'Server started successfully (validation passed)');
|
||||
} else {
|
||||
logResult(false, `Server health check failed: ${response.statusCode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(false, `Cannot reach server: ${error.message}`);
|
||||
}
|
||||
|
||||
// Check for validation logs (requires access to logs)
|
||||
log(' → Check Docker logs for: "✓ Startup configuration validation passed"', 'yellow');
|
||||
}
|
||||
|
||||
// Test 4: Enhanced Error Logging (Request ID)
|
||||
async function testEnhancedLogging() {
|
||||
logTest('Enhanced Error Logging');
|
||||
|
||||
try {
|
||||
// Make a request that will be logged
|
||||
const response = await makeRequest('/api/services');
|
||||
|
||||
// Check if X-Request-ID header is present
|
||||
if (response.headers['x-request-id']) {
|
||||
const requestId = response.headers['x-request-id'];
|
||||
const isValidUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId);
|
||||
|
||||
if (isValidUUID) {
|
||||
logResult(true, `Request ID header present and valid: ${requestId.substring(0, 8)}...`);
|
||||
} else {
|
||||
logResult(false, `Request ID present but invalid format: ${requestId}`);
|
||||
}
|
||||
} else {
|
||||
logResult(false, 'Request ID header not present');
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(false, `Error testing logging: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Session Management (LRU Cache)
|
||||
async function testSessionManagement() {
|
||||
logTest('Session Management (LRU Cache)');
|
||||
|
||||
log(' → This test requires code inspection (cannot test cache behavior externally)', 'yellow');
|
||||
log(' → Manual verification: Check server.js for LRUCache usage', 'yellow');
|
||||
|
||||
// We can test that sessions still work
|
||||
try {
|
||||
const response = await makeRequest('/api/totp/setup', { method: 'POST' });
|
||||
if (response.statusCode === 200 || response.statusCode === 401) {
|
||||
logResult(true, 'Session-based endpoints still functional');
|
||||
} else {
|
||||
logResult(false, `Unexpected response from session endpoint: ${response.statusCode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(false, `Error testing session endpoints: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Hardcoded Secrets Removal
|
||||
async function testSecretsRemoval() {
|
||||
logTest('Hardcoded Secrets Removal');
|
||||
|
||||
try {
|
||||
// Read app-templates.js and check for "changeme123"
|
||||
const fs = require('fs');
|
||||
const templatesPath = require('path').join(__dirname, 'app-templates.js');
|
||||
const content = fs.readFileSync(templatesPath, 'utf8');
|
||||
|
||||
const matches = content.match(/changeme123/g);
|
||||
if (!matches || matches.length === 0) {
|
||||
logResult(true, 'No hardcoded "changeme123" passwords found');
|
||||
} else {
|
||||
logResult(false, `Found ${matches.length} instances of "changeme123" still in templates`);
|
||||
}
|
||||
|
||||
// Check for secrets arrays
|
||||
const secretsMatches = content.match(/secrets:\s*\[/g);
|
||||
if (secretsMatches && secretsMatches.length >= 10) {
|
||||
logResult(true, `Found ${secretsMatches.length} secrets configurations`);
|
||||
} else {
|
||||
logResult(false, `Only found ${secretsMatches?.length || 0} secrets configurations (expected 14+)`);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(false, `Error reading templates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Port Locking Mechanism
|
||||
async function testPortLocking() {
|
||||
logTest('Port Locking Mechanism');
|
||||
|
||||
try {
|
||||
// Check if .port-locks directory exists
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const locksDir = path.join(__dirname, '.port-locks');
|
||||
|
||||
if (fs.existsSync(locksDir)) {
|
||||
logResult(true, 'Port locks directory exists');
|
||||
|
||||
// Check if it's writable
|
||||
try {
|
||||
const testFile = path.join(locksDir, 'test-write');
|
||||
fs.writeFileSync(testFile, 'test');
|
||||
fs.unlinkSync(testFile);
|
||||
logResult(true, 'Port locks directory is writable');
|
||||
} catch (error) {
|
||||
logResult(false, `Port locks directory not writable: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
logResult(false, 'Port locks directory does not exist');
|
||||
}
|
||||
|
||||
// Check if PortLockManager module exists
|
||||
const portLockPath = path.join(__dirname, 'port-lock-manager.js');
|
||||
if (fs.existsSync(portLockPath)) {
|
||||
logResult(true, 'PortLockManager module exists');
|
||||
} else {
|
||||
logResult(false, 'PortLockManager module not found');
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(false, `Error testing port locking: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: Docker Security Module
|
||||
async function testDockerSecurity() {
|
||||
logTest('Docker Image Verification');
|
||||
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Check if docker-security.js exists
|
||||
const securityPath = path.join(__dirname, 'docker-security.js');
|
||||
if (fs.existsSync(securityPath)) {
|
||||
logResult(true, 'DockerSecurity module exists');
|
||||
} else {
|
||||
logResult(false, 'DockerSecurity module not found');
|
||||
}
|
||||
|
||||
// Check if config file exists
|
||||
const configPath = path.join(__dirname, 'docker-security-config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
logResult(true, `Security config exists (mode: ${config.verificationMode || 'not set'})`);
|
||||
} else {
|
||||
log(' → Security config will be created on first use', 'yellow');
|
||||
logResult(true, 'Config will be auto-created');
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(false, `Error testing Docker security: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Main test runner
|
||||
async function runTests() {
|
||||
log('\n╔════════════════════════════════════════════════════╗', 'cyan');
|
||||
log('║ DashCaddy Security Fixes - Test Suite ║', 'cyan');
|
||||
log('╚════════════════════════════════════════════════════╝', 'cyan');
|
||||
|
||||
log(`\nAPI Base URL: ${API_BASE}`, 'blue');
|
||||
log('Starting tests...\n', 'blue');
|
||||
|
||||
// Run all tests
|
||||
await testStartupValidation();
|
||||
await testPathTraversal();
|
||||
await testRequestSizeLimits();
|
||||
await testEnhancedLogging();
|
||||
await testSessionManagement();
|
||||
await testSecretsRemoval();
|
||||
await testPortLocking();
|
||||
await testDockerSecurity();
|
||||
|
||||
// Summary
|
||||
log('\n╔════════════════════════════════════════════════════╗', 'cyan');
|
||||
log('║ Test Summary ║', 'cyan');
|
||||
log('╚════════════════════════════════════════════════════╝', 'cyan');
|
||||
|
||||
const passed = TEST_RESULTS.filter(r => r.passed).length;
|
||||
const failed = TEST_RESULTS.filter(r => !r.passed).length;
|
||||
const total = TEST_RESULTS.length;
|
||||
|
||||
log(`\nTotal Tests: ${total}`, 'blue');
|
||||
log(`Passed: ${passed}`, 'green');
|
||||
log(`Failed: ${failed}`, failed > 0 ? 'red' : 'green');
|
||||
log(`Success Rate: ${((passed / total) * 100).toFixed(1)}%\n`, failed === 0 ? 'green' : 'yellow');
|
||||
|
||||
if (failed > 0) {
|
||||
log('Failed tests:', 'red');
|
||||
TEST_RESULTS.filter(r => !r.passed).forEach(r => {
|
||||
log(` ✗ ${r.message}`, 'red');
|
||||
});
|
||||
}
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Run tests if executed directly
|
||||
if (require.main === module) {
|
||||
runTests().catch(error => {
|
||||
log(`\nFatal error: ${error.message}`, 'red');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runTests };
|
||||
@@ -0,0 +1,763 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DashCaddy Stripe license bridge — DC-054 + DC-057.
|
||||
*
|
||||
* Tiny HTTP webhook listener that converts a Stripe Checkout completion into
|
||||
* a DashCaddy Pro license code + a confirmation email. Runs as its own
|
||||
* process (NOT inside the DashCaddy API) so the merchant's Stripe secret
|
||||
* material stays out of the host-side process tree.
|
||||
*
|
||||
* # Wire format
|
||||
*
|
||||
* POST /webhook
|
||||
* Stripe-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
|
||||
* <raw JSON body>
|
||||
*
|
||||
* The body for `checkout.session.completed` carries:
|
||||
* { id, customer_email, metadata: { productId }, amount_total, currency, ... }
|
||||
*
|
||||
* # Catalog contract (DC-057)
|
||||
*
|
||||
* `metadata.productId` is one of the IDs in src/billing/catalog.js:
|
||||
* pro-30d | pro-90d | pro-180d | pro-365d
|
||||
* The bridge maps productId → duration via the catalog (single source of
|
||||
* truth shared with the Checkout client + pricing page). The catalog's
|
||||
* configured Stripe Price is read via `STRIPE_PRICE_PRO_*D` env vars
|
||||
* (already documented in the catalog) and is used to validate that the
|
||||
* product is purchasable (configuredPriceId is not empty) — NOT to
|
||||
* cross-validate the price against the customer's Stripe session. Price
|
||||
* verification is intentionally omitted because (a) Stripe webhooks do
|
||||
* not include expanded line_items by default and (b) trusting the price
|
||||
* would block legitimate customers during a price rollover.
|
||||
*
|
||||
* The previous `STRIPE_SKU_*` env vars are REMOVED in DC-057 — operators
|
||||
* who set them should migrate to `STRIPE_PRICE_PRO_*D`.
|
||||
*
|
||||
* # Generation flow
|
||||
*
|
||||
* 1. Verify Stripe-Signature (constant-time HMAC-SHA256 compare).
|
||||
* Reject with 400 if the timestamp is more than TOLERANCE_SECONDS
|
||||
* old, or if any v1 signature is missing.
|
||||
* 2. Look up the event in the per-event idempotency file
|
||||
* (data/stripe-events.json). If seen, replay the previous response
|
||||
* status (200) WITHOUT regenerating. This is the layer-1 idempotency.
|
||||
* 3. If the event type isn't `checkout.session.completed`, ack with
|
||||
* `{delivered: false, reason: "ignored-event-type"}` so Stripe stops
|
||||
* retrying, and record the event.
|
||||
* 4. Parse the session metadata; resolve productId via the catalog.
|
||||
* 5. CLAIM the fulfillment record (atomic, sessionId-keyed).
|
||||
* - If the record exists for this sessionId in `pending_email` or
|
||||
* `delivered` state (e.g. a previous webhook delivery succeeded OR
|
||||
* saved a license but email failed), we REUSE the persisted license
|
||||
* code — never generate a second one. This is the layer-2
|
||||
* idempotency keyed by Checkout Session ID (globally unique, never
|
||||
* reused even when the same event is replayed).
|
||||
* - If the record exists in `generating` state and the lease is held
|
||||
* by a DIFFERENT event (concurrent webhook fan-out), respond 409
|
||||
* so Stripe retries — only one delivery wins.
|
||||
* 6. SAVE the license code into the fulfillment record
|
||||
* (data/stripe-fulfillments.json) BEFORE attempting email. This is
|
||||
* the crash-safety guarantee: even if email fails AND the process
|
||||
* is killed, the license is durably persisted.
|
||||
* 7. DELIVER the code via SMTP (or dev-console fallback).
|
||||
* - On success: markDelivered. Lookup endpoint serves the code on
|
||||
* the success page.
|
||||
* - On failure: markDeliveryFailed → reverts to pending_email. Lookup
|
||||
* endpoint serves the code ANYWAY (with a "(email delivery didn't
|
||||
* complete)" notice) — customer can save it manually. Subsequent
|
||||
* webhook retries reuse the same persisted code via step 5.
|
||||
* 8. Respond 500 to Stripe ONLY if the license save succeeded but email
|
||||
* failed AND no successful delivery record exists — Stripe will
|
||||
* retry. If delivery already succeeded earlier, ack 200.
|
||||
*
|
||||
* # SMTP transport
|
||||
*
|
||||
* Reuses the same env vars as DashCaddy's notification system:
|
||||
* SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_FROM / SMTP_SECURE
|
||||
* If HOST or FROM is missing, delivery falls back to dev-console mode
|
||||
* (the bridge logs the full email body so the operator can deliver it
|
||||
* manually). This is the documented dev path; do NOT enable it in
|
||||
* production.
|
||||
*
|
||||
* # Exit codes
|
||||
*
|
||||
* 0 — clean shutdown
|
||||
* 1 — fatal startup error (missing secret, port bind failure, no
|
||||
* products configured)
|
||||
* 2 — runtime error while handling a request (logged, 500 returned)
|
||||
*
|
||||
* # Security notes
|
||||
*
|
||||
* - Webhook signature MUST verify BEFORE any JSON parsing. The raw body
|
||||
* is opaque until HMAC checks out.
|
||||
* - Timing-safe signature comparison (crypto.timingSafeEqual).
|
||||
* - The fulfillment-store file lives in platformPaths.dataDir (bind-
|
||||
* mounted in production). Atomic writes + per-mutation mutex.
|
||||
*
|
||||
* Tested in __tests__/billing/stripe-license-bridge.test.js (no live network).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { generateCodes, loadSecret } = require('../license-keygen');
|
||||
const platformPaths = require('../platform-paths');
|
||||
const catalog = require('../src/billing/catalog');
|
||||
const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store');
|
||||
|
||||
// ── Configuration (env-driven) ──────────────────────────────────────────────
|
||||
|
||||
const PORT = parseInt(process.env.STRIPE_BRIDGE_PORT || '3010', 10);
|
||||
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
|
||||
const TOLERANCE_SECONDS = parseInt(process.env.STRIPE_BRIDGE_TOLERANCE || '300', 10);
|
||||
|
||||
// SMTP. Falls back to dev-console mode if HOST or FROM is missing.
|
||||
// Read at function-call time (not module load) so tests can toggle SMTP
|
||||
// behavior between cases without re-requiring the bridge.
|
||||
function _smtpConfig() {
|
||||
return {
|
||||
host: process.env.SMTP_HOST || '',
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
username: process.env.SMTP_USERNAME || '',
|
||||
password: process.env.SMTP_PASSWORD || '',
|
||||
from: process.env.SMTP_FROM || '',
|
||||
};
|
||||
}
|
||||
|
||||
// State files (atomic write). Override paths in tests.
|
||||
const STATE_DIR = process.env.STRIPE_BRIDGE_STATE_DIR || platformPaths.dataDir;
|
||||
const EVENTS_FILE = process.env.STRIPE_BRIDGE_EVENTS_FILE || path.join(STATE_DIR, 'stripe-events.json');
|
||||
const FULFILLMENT_STORE = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE
|
||||
|| path.join(STATE_DIR, 'stripe-fulfillments.json');
|
||||
|
||||
// Lookup TTL: after a license has been "delivered" for this long, the
|
||||
// /api/v1/billing/lookup/:sessionId endpoint returns 404 even with a valid
|
||||
// sessionId. 24 hours matches Stripe's default Checkout session expiry and
|
||||
// is far longer than any customer needs to paste their key.
|
||||
const LOOKUP_TTL_MS = parseInt(process.env.STRIPE_BRIDGE_LOOKUP_TTL_MS || String(24 * 60 * 60 * 1000), 10);
|
||||
|
||||
// Build the fulfillment store singleton used by both bridge writes and
|
||||
// lookup reads (the API's lookup endpoint reads from the SAME file via
|
||||
// its own createFulfillmentStore() instance — file is the IPC channel).
|
||||
const fulfillmentStore = createFulfillmentStore({ filePath: FULFILLMENT_STORE });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function log(level, msg, meta) {
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...(meta || {}) });
|
||||
process.stdout.write(line + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Stripe-Signature header. Returns { ok: true } if the signature is
|
||||
* well-formed, within tolerance, AND matches at least one v1 entry.
|
||||
* Returns { ok: false, reason } otherwise — callers MUST 400 on failure.
|
||||
*
|
||||
* Format: t=<unix_ts>,v1=<hex>[,v1=<hex>]*
|
||||
*
|
||||
* The signed payload is `${t}.${rawBody}`. We recompute HMAC-SHA256 of that
|
||||
* exact byte sequence with the webhook secret, then timing-safe-compare
|
||||
* against each v1 entry until one matches. Multiple v1 entries are allowed
|
||||
* during Stripe secret rotation; we just need one to verify.
|
||||
*/
|
||||
function verifyStripeSignature(rawBody, header, secret, nowSec) {
|
||||
if (!header || typeof header !== 'string') return { ok: false, reason: 'missing-signature' };
|
||||
if (!secret) return { ok: false, reason: 'no-server-secret' };
|
||||
|
||||
const parts = header.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
let timestamp = null;
|
||||
const v1List = [];
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = part.slice(0, eq);
|
||||
const val = part.slice(eq + 1);
|
||||
if (key === 't') timestamp = parseInt(val, 10);
|
||||
else if (key === 'v1') v1List.push(val);
|
||||
}
|
||||
if (!Number.isFinite(timestamp)) return { ok: false, reason: 'missing-timestamp' };
|
||||
if (v1List.length === 0) return { ok: false, reason: 'missing-v1' };
|
||||
|
||||
const skew = Math.abs((nowSec || Math.floor(Date.now() / 1000)) - timestamp);
|
||||
if (skew > TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp-out-of-tolerance' };
|
||||
|
||||
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest();
|
||||
for (const v1 of v1List) {
|
||||
let got;
|
||||
try {
|
||||
got = Buffer.from(v1, 'hex');
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (got.length !== expected.length) continue;
|
||||
if (crypto.timingSafeEqual(got, expected)) return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: 'no-matching-signature' };
|
||||
}
|
||||
|
||||
// ── Idempotency store (event-id-keyed layer 1) ─────────────────────────────
|
||||
|
||||
function readEvents() {
|
||||
try {
|
||||
const raw = fs.readFileSync(EVENTS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.events && typeof parsed.events === 'object') {
|
||||
return parsed;
|
||||
}
|
||||
return { events: {} };
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return { events: {} };
|
||||
// Treat any parse error as an empty store — the next successful write
|
||||
// will replace the file. Worst case we re-deliver; Stripe tolerates
|
||||
// duplicate emails.
|
||||
return { events: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeEvents(state) {
|
||||
// Atomic write: tmp + rename.
|
||||
const tmp = `${EVENTS_FILE}.tmp.${process.pid}.${Date.now()}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.renameSync(tmp, EVENTS_FILE);
|
||||
}
|
||||
|
||||
function recordEvent(eventId, meta) {
|
||||
const state = readEvents();
|
||||
if (state.events[eventId]) return false; // already delivered
|
||||
state.events[eventId] = {
|
||||
receivedAt: new Date().toISOString(),
|
||||
...(meta || {}),
|
||||
};
|
||||
writeEvents(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
function eventSeen(eventId) {
|
||||
const state = readEvents();
|
||||
return Boolean(state.events[eventId]);
|
||||
}
|
||||
|
||||
// ── Email delivery ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send the license key email. If SMTP is configured, real send via
|
||||
* nodemailer; if not, log the full email body to stdout so the operator
|
||||
* can deliver manually in dev/test environments.
|
||||
*
|
||||
* Returns { delivered: bool, via: 'smtp' | 'dev-console' }.
|
||||
*/
|
||||
async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
const subject = `Your DashCaddy Pro license (${durationDays} days)`;
|
||||
const text = [
|
||||
'Thank you for purchasing DashCaddy Pro.',
|
||||
'',
|
||||
`Your license key is valid for ${durationDays} days:`,
|
||||
'',
|
||||
` ${code}`,
|
||||
'',
|
||||
'To install on your DashCaddy host:',
|
||||
' 1. Open https://<your-host>/admin/license',
|
||||
' 2. Paste the key into the "Activate license" field',
|
||||
' 3. Submit — Pro features unlock immediately.',
|
||||
'',
|
||||
'The same key is also revealed on your purchase success page; keep it safe.',
|
||||
'',
|
||||
'Need help? Reply to this email and we will assist.',
|
||||
'',
|
||||
`Reference: ${eventId}`,
|
||||
`Product: ${productId}`,
|
||||
].join('\n');
|
||||
|
||||
const smtp = _smtpConfig();
|
||||
if (!smtp.host || !smtp.from) {
|
||||
// Dev-console fallback: log the full email body to stdout so the
|
||||
// operator can deliver manually in dev/test environments. The
|
||||
// fulfillment record is marked `delivered` with `via: 'dev-console'`
|
||||
// so the lookup endpoint serves the code on the success page — the
|
||||
// operator seeing the bridge logs IS the documented delivery path
|
||||
// when SMTP is unconfigured. In production, the bridge refuses to
|
||||
// boot without SMTP configured (see checkFatalConfig).
|
||||
log('info', 'smtp-not-configured, falling back to dev-console delivery', { to, durationDays, code });
|
||||
return { delivered: true, via: 'dev-console' };
|
||||
}
|
||||
|
||||
// Lazy-load nodemailer so the test suite doesn't pull it into coverage.
|
||||
const nodemailer = require('nodemailer');
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
|
||||
tls: { rejectUnauthorized: process.env.NODE_ENV === 'production' },
|
||||
});
|
||||
await transporter.sendMail({ from: smtp.from, to, subject, text });
|
||||
return { delivered: true, via: 'smtp' };
|
||||
}
|
||||
|
||||
// ── Request handler ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a Stripe session to a catalog product + duration.
|
||||
*
|
||||
* The source of truth is `metadata.productId` — which the stripe-client
|
||||
* sets when it creates the Checkout Session (see stripe-client.js).
|
||||
* The customer is identified by the product they intended to buy, NOT
|
||||
* by the Stripe Price ID at fulfillment time, because:
|
||||
*
|
||||
* - A repoint of STRIPE_PRICE_PRO_30D to a new Stripe Price affects
|
||||
* NEW Checkout Sessions only. Existing sessions retain their
|
||||
* original line_items.price.id; their metadata.productId is
|
||||
* unchanged. Trusting price would force the operator to keep the
|
||||
* old Price ID configured indefinitely (or forever block customers
|
||||
* who started checkout before the rollover).
|
||||
* - Stripe does not include expanded line_items in webhook payloads
|
||||
* by default. To get them we'd need either a separate
|
||||
* stripe.checkout.sessions.retrieve() call per webhook or Stripe's
|
||||
* webhook-expansion feature. Neither is worth the cost when the
|
||||
* metadata is already a complete canonical identifier.
|
||||
*
|
||||
* Returns { product, durationDays } on success or { error, ...details } on failure.
|
||||
*/
|
||||
function resolveProductFromSession(session) {
|
||||
if (!session || typeof session !== 'object') return null;
|
||||
const productId = session.metadata && session.metadata.productId;
|
||||
if (!productId) return { error: 'missing-productId' };
|
||||
|
||||
const product = catalog.getProduct(productId);
|
||||
if (!product) return { error: 'unknown-productId', productId };
|
||||
|
||||
const configuredPriceId = catalog.getConfiguredPrice(product);
|
||||
if (!configuredPriceId) return { error: 'product-not-configured', productId, missing: product.priceEnv };
|
||||
|
||||
return { product, durationDays: product.durationDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one webhook delivery. Pure-ish: takes the raw body, signature
|
||||
* header, and event id; returns an HTTP-friendly result object.
|
||||
*
|
||||
* Exported for tests. The HTTP wrapper below calls this with the parsed
|
||||
* inputs and turns the result into a response.
|
||||
*
|
||||
* Composed of small step functions to keep individual cyclomatic complexity
|
||||
* under ESLint's limit of 20.
|
||||
*/
|
||||
async function handleWebhook({ rawBody, signatureHeader, eventId }) {
|
||||
const sigResult = verifySignature(rawBody, signatureHeader);
|
||||
if (sigResult) return sigResult;
|
||||
|
||||
const event = parseEventBody(rawBody);
|
||||
if (event.error) return event.error;
|
||||
|
||||
const id = eventId || event.body.id;
|
||||
|
||||
const dup = checkEventIdempotency(id);
|
||||
if (dup) return dup;
|
||||
|
||||
// Only handle the FULFILLMENT_EVENT_TYPES below. Everything else falls
|
||||
// into the ACK_ONLY_EVENT_TYPES or the catch-all ignored branch.
|
||||
//
|
||||
// - `checkout.session.completed` — fires for ALL completed sessions
|
||||
// (paid OR unpaid). We only fulfill when payment_status='paid'.
|
||||
// - `checkout.session.async_payment_succeeded` — fires for delayed
|
||||
// payment methods (ACH/SEPA/bank debits) when the bank clears.
|
||||
// Stripe sends `checkout.session.completed` first (unpaid), then
|
||||
// this event when the payment confirms. payment_status is always
|
||||
// 'paid' on this event.
|
||||
// - `checkout.session.async_payment_failed` — ack-only; the customer
|
||||
// must retry from the pricing page.
|
||||
const FULFILLMENT_EVENT_TYPES = new Set([
|
||||
'checkout.session.completed',
|
||||
'checkout.session.async_payment_succeeded',
|
||||
]);
|
||||
const ACK_ONLY_EVENT_TYPES = new Set([
|
||||
'checkout.session.async_payment_failed',
|
||||
]);
|
||||
|
||||
if (!FULFILLMENT_EVENT_TYPES.has(event.body.type)) {
|
||||
if (ACK_ONLY_EVENT_TYPES.has(event.body.type)) {
|
||||
// Permanent failure for delayed payments. Ack 200 so Stripe
|
||||
// stops retrying; the customer must retry from the pricing page.
|
||||
recordEvent(id, { ignoredType: event.body.type });
|
||||
return { status: 200, body: { delivered: false, reason: 'async-payment-failed' } };
|
||||
}
|
||||
recordEvent(id, { ignoredType: event.body.type });
|
||||
return { status: 200, body: { delivered: false, reason: 'ignored-event-type' } };
|
||||
}
|
||||
|
||||
const session = event.body.data && event.body.data.object;
|
||||
if (!session || typeof session !== 'object') {
|
||||
return { status: 400, body: { delivered: false, reason: 'missing-session' } };
|
||||
}
|
||||
|
||||
// Guard: only fulfill PAID sessions. Stripe sends
|
||||
// `checkout.session.completed` for BOTH paid AND unpaid events (e.g.
|
||||
// when the customer closes the browser mid-checkout). The `payment_status`
|
||||
// field on the session object disambiguates:
|
||||
// - 'paid' — payment succeeded; we generate the license.
|
||||
// - 'unpaid' — delayed-payment method (ACH/SEPA) not yet cleared;
|
||||
// the async_payment_succeeded event will fire later and we
|
||||
// generate the license then. Ack 200 here so Stripe stops
|
||||
// retrying (the async event will be the fulfillment trigger).
|
||||
// - 'no_payment_required' — Stripe-internal edge case for free
|
||||
// sessions. DashCaddy doesn't sell any, so we reject.
|
||||
// - absent — Stripe sometimes omits it on incomplete sessions;
|
||||
// reject to be safe.
|
||||
const paymentStatus = session.payment_status;
|
||||
if (paymentStatus !== 'paid') {
|
||||
recordEvent(id, { ignoredType: event.body.type, paymentStatus });
|
||||
return { status: 200, body: { delivered: false, reason: `payment-not-${paymentStatus || 'confirmed'}` } };
|
||||
}
|
||||
|
||||
return await fulfillCheckout({ id, session });
|
||||
}
|
||||
|
||||
function verifySignature(rawBody, signatureHeader) {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const sigCheck = verifyStripeSignature(rawBody, signatureHeader, WEBHOOK_SECRET, nowSec);
|
||||
if (!sigCheck.ok) {
|
||||
return { status: 400, body: { delivered: false, reason: `signature-${sigCheck.reason}` } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseEventBody(rawBody) {
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(rawBody.toString('utf8'));
|
||||
} catch (_) {
|
||||
return { error: { status: 400, body: { delivered: false, reason: 'invalid-json' } } };
|
||||
}
|
||||
if (!event || typeof event !== 'object' || !event.id) {
|
||||
return { error: { status: 400, body: { delivered: false, reason: 'invalid-event' } } };
|
||||
}
|
||||
return { body: event };
|
||||
}
|
||||
|
||||
function checkEventIdempotency(id) {
|
||||
if (eventSeen(id)) {
|
||||
return { status: 200, body: { delivered: true, deduplicated: true } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the catalog → claim → deliver pipeline for one checkout session.
|
||||
* Returns the final HTTP-friendly result.
|
||||
*/
|
||||
async function fulfillCheckout({ id, session }) {
|
||||
const resolution = resolveProductFromSession(session);
|
||||
if (!resolution || resolution.error) {
|
||||
const reason = resolution && resolution.error ? resolution.error : 'missing-productId';
|
||||
log('warn', 'catalog-resolution-failed', { eventId: id, reason, ...(resolution || {}) });
|
||||
return { status: 400, body: { delivered: false, reason } };
|
||||
}
|
||||
const { product, durationDays } = resolution;
|
||||
|
||||
const email = session.customer_email || (session.customer_details && session.customer_details.email) || '';
|
||||
if (!email) return { status: 400, body: { delivered: false, reason: 'missing-customer-email' } };
|
||||
|
||||
const sessionId = session.id || '';
|
||||
if (!sessionId) return { status: 400, body: { delivered: false, reason: 'missing-session-id' } };
|
||||
|
||||
const claim = await fulfillmentStore.claim({
|
||||
eventId: id, sessionId, productId: product.id, durationDays, email,
|
||||
});
|
||||
if (claim.busy) {
|
||||
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
|
||||
}
|
||||
|
||||
const licenseResult = await ensureLicensePersisted({ id, sessionId, product, claim, durationDays });
|
||||
if (licenseResult.error) return licenseResult.error;
|
||||
const { code, codeId } = licenseResult;
|
||||
|
||||
const deliveryClaim = await fulfillmentStore.claimDelivery({ sessionId, ownerToken: id });
|
||||
if (deliveryClaim.busy) {
|
||||
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
|
||||
}
|
||||
|
||||
let delivery;
|
||||
try {
|
||||
delivery = await deliverCode({ to: email, code, durationDays, eventId: id, productId: product.id });
|
||||
} catch (err) {
|
||||
log('error', 'email-delivery-failed', { eventId: id, sessionId, error: err.message });
|
||||
await fulfillmentStore.markDeliveryFailed({ sessionId, ownerToken: id, error: err.message });
|
||||
return { status: 500, body: { delivered: false, reason: 'email-failed', error: err.message } };
|
||||
}
|
||||
|
||||
await fulfillmentStore.markDelivered({ sessionId, ownerToken: id, deliveredVia: delivery.via });
|
||||
recordEvent(id, { durationDays, codeId, productId: product.id, email, deliveredVia: delivery.via });
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { delivered: true, codeId, productId: product.id, durationDays, deliveredVia: delivery.via },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Either reuse an existing persisted code (layer-2 idempotency) or
|
||||
* generate + persist a fresh one. Returns { code, codeId } or
|
||||
* { error: <http-result> }.
|
||||
*/
|
||||
async function ensureLicensePersisted({ id, sessionId, product, claim, durationDays }) {
|
||||
const existing = claim.record;
|
||||
if (existing.code) {
|
||||
// Reusing an existing license (from a previous successful or failed
|
||||
// attempt for the SAME session). This is the retry-safe path.
|
||||
log('info', 'license-reused-from-fulfillment-store', {
|
||||
eventId: id, sessionId, productId: product.id, status: existing.status,
|
||||
});
|
||||
return { code: existing.code, codeId: existing.codeId };
|
||||
}
|
||||
|
||||
let secret;
|
||||
try {
|
||||
secret = loadSecret();
|
||||
} catch (err) {
|
||||
log('error', 'license-secret-missing', { error: err.message });
|
||||
return { error: { status: 500, body: { delivered: false, reason: 'server-not-configured' } } };
|
||||
}
|
||||
|
||||
const codes = generateCodes({ secret, durationDays, count: 1 });
|
||||
const code = codes[0].code;
|
||||
const codeId = codes[0].codeId;
|
||||
|
||||
// Persist BEFORE attempting email. From this point on, the license is
|
||||
// durable even if the process dies or email fails.
|
||||
const saveResult = await fulfillmentStore.saveLicense({ eventId: id, sessionId, code, codeId });
|
||||
if (!saveResult.saved) {
|
||||
log('error', 'license-save-rejected', { eventId: id, sessionId, record: saveResult.record });
|
||||
return { error: { status: 500, body: { delivered: false, reason: 'save-rejected' } } };
|
||||
}
|
||||
return { code, codeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a fulfillment record for the public lookup endpoint.
|
||||
*
|
||||
* Returns:
|
||||
* { status: 'not_found' } — no record exists (session not paid / unknown)
|
||||
* { status: 'expired' } — record exists but is past the lookup TTL
|
||||
* { status: 'processing', durationDays } — license being generated
|
||||
* { status: 'pending_email', durationDays, code, codeId, deliveredVia? } — license persisted, email failed
|
||||
* { status: 'delivered', durationDays, code, codeId, deliveredVia } — license delivered
|
||||
*
|
||||
* The lookup endpoint serves the persisted code in BOTH pending_email AND
|
||||
* delivered states — that is the documented SMTP-failure recovery path
|
||||
* (the customer pastes their key even if email failed).
|
||||
*/
|
||||
function lookupSession(sessionId, { nowMs = Date.now() } = {}) {
|
||||
const record = fulfillmentStore.readBySession(sessionId);
|
||||
if (!record) return { status: 'not_found' };
|
||||
|
||||
const createdAt = record.createdAt ? Date.parse(record.createdAt) : nowMs;
|
||||
const ageMs = nowMs - createdAt;
|
||||
if (Number.isFinite(ageMs) && ageMs > LOOKUP_TTL_MS) {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
if (record.status === 'generating') {
|
||||
return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
|
||||
}
|
||||
if (!record.code) {
|
||||
// Should not happen after saveLicense() succeeds, but defensive.
|
||||
return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
|
||||
}
|
||||
const base = {
|
||||
durationDays: record.durationDays,
|
||||
code: record.code,
|
||||
codeId: record.codeId,
|
||||
productId: record.productId,
|
||||
};
|
||||
if (record.status === 'delivered') {
|
||||
return { status: 'delivered', deliveredVia: record.deliveredVia || 'unknown', ...base };
|
||||
}
|
||||
// pending_email OR delivering — license is durably persisted.
|
||||
return { status: 'pending_email', deliveredVia: record.deliveredVia, lastError: record.lastError, ...base };
|
||||
}
|
||||
|
||||
// ── HTTP server ────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB — Stripe events are small.
|
||||
|
||||
function readRawBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
reject(new Error('body-too-large'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function writeJson(res, status, body, extraHeaders = {}) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
// License codes are bearer-style secrets — never cache them.
|
||||
'Cache-Control': 'no-store',
|
||||
...extraHeaders,
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP request handler for the bridge (testable as a factory).
|
||||
*
|
||||
* Routes:
|
||||
* POST /webhook — Stripe checkout.session.completed webhooks
|
||||
* GET /lookup/<sessionId> — operator / incident-recovery lookup
|
||||
*
|
||||
* Exported for tests so they can drive the SAME handler logic the
|
||||
* production bridge server uses (instead of duplicating the route
|
||||
* dispatch in test code).
|
||||
*/
|
||||
function createRequestHandler() {
|
||||
return async function handleBridgeRequest(req, res) {
|
||||
if (req.method === 'POST' && req.url === '/webhook') {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await readRawBody(req);
|
||||
} catch (err) {
|
||||
writeJson(res, 413, { delivered: false, reason: 'body-too-large' });
|
||||
return;
|
||||
}
|
||||
|
||||
const signatureHeader = req.headers['stripe-signature'] || '';
|
||||
let result;
|
||||
try {
|
||||
result = await handleWebhook({ rawBody, signatureHeader });
|
||||
} catch (err) {
|
||||
log('error', 'handler-threw', { error: err.message, stack: err.stack });
|
||||
writeJson(res, 500, { delivered: false, reason: 'handler-error' });
|
||||
return;
|
||||
}
|
||||
writeJson(res, result.status, result.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url && req.url.startsWith('/lookup/')) {
|
||||
// The bridge exposes a /lookup/<sessionId> endpoint as a convenience
|
||||
// for out-of-band operators (manual incident recovery, cron jobs that
|
||||
// scan pending_email records, etc.). The PRODUCTION lookup endpoint
|
||||
// for the success page is the API route at /api/v1/billing/lookup/*,
|
||||
// which reads the same fulfillment-store file but lives in the API
|
||||
// process (so the customer-facing response path doesn't depend on the
|
||||
// bridge being up). This endpoint is only useful when the API is
|
||||
// unreachable but the bridge is — and the bridge itself can fail to
|
||||
// boot without it.
|
||||
//
|
||||
// decodeURIComponent throws on malformed percent-encoding. We catch
|
||||
// that explicitly to surface a clean 400 instead of a 500.
|
||||
const rawSessionId = req.url.slice('/lookup/'.length).split('?')[0];
|
||||
let sessionId;
|
||||
try {
|
||||
sessionId = decodeURIComponent(rawSessionId);
|
||||
} catch (_) {
|
||||
writeJson(res, 400, { delivered: false, reason: 'invalid-session-id' });
|
||||
return;
|
||||
}
|
||||
const result = lookupSession(sessionId);
|
||||
const httpStatus = result.status === 'not_found' || result.status === 'expired' ? 404 : 200;
|
||||
writeJson(res, httpStatus, result);
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { delivered: false, reason: 'not-found' });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP server bound to the bridge request handler. Returns the
|
||||
* server WITHOUT starting it — callers call `.listen(port)` themselves.
|
||||
*
|
||||
* Production entrypoint uses this factory; tests can use
|
||||
* `bridge.createRequestHandler()` to wire the same dispatch logic
|
||||
* without spinning up an HTTP server.
|
||||
*/
|
||||
function createServer() {
|
||||
return http.createServer(createRequestHandler());
|
||||
}
|
||||
|
||||
// Module-level server variable. Created by `createServer()` only when the
|
||||
// bridge is the entrypoint (`require.main === module`); tests + libraries
|
||||
// that require the bridge leave this unset.
|
||||
let server;
|
||||
|
||||
if (require.main === module) {
|
||||
server = createServer();
|
||||
}
|
||||
|
||||
// ── Bootstrap ──────────────────────────────────────────────────────────────
|
||||
|
||||
function checkFatalConfig() {
|
||||
const missing = [];
|
||||
if (!WEBHOOK_SECRET) missing.push('STRIPE_WEBHOOK_SECRET');
|
||||
// At least one product must be configured for purchase.
|
||||
const configured = catalog.getConfiguredProducts().filter((p) => p.priceId);
|
||||
if (configured.length === 0) {
|
||||
const allEnvs = catalog.PRODUCTS.map((p) => p.priceEnv);
|
||||
missing.push(`at-least-one-of-${allEnvs.join('|')}`);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module exports — for tests. Production entrypoint is the `if
|
||||
* (require.main === module)` block below.
|
||||
*/
|
||||
module.exports = {
|
||||
verifyStripeSignature,
|
||||
handleWebhook,
|
||||
readEvents,
|
||||
eventSeen,
|
||||
recordEvent,
|
||||
writeEvents,
|
||||
resolveProductFromSession,
|
||||
lookupSession,
|
||||
// Server factories — tests can call createRequestHandler() to wire
|
||||
// the same dispatch logic the production server uses, without
|
||||
// duplicating route decoding / status mapping in test code.
|
||||
createRequestHandler,
|
||||
createServer,
|
||||
// Constants exposed so tests can pin them when running in parallel.
|
||||
TOLERANCE_SECONDS,
|
||||
LOOKUP_TTL_MS,
|
||||
DELIVERY_LEASE_MS,
|
||||
MAX_BODY_BYTES,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const missing = checkFatalConfig();
|
||||
if (missing.length > 0) {
|
||||
log('error', 'startup-misconfigured', { missing });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
const smtp = _smtpConfig();
|
||||
log('info', 'stripe-license-bridge-listening', {
|
||||
port: PORT,
|
||||
configuredProducts: catalog.getConfiguredProducts()
|
||||
.filter((p) => p.priceId)
|
||||
.map((p) => ({ id: p.id, durationDays: p.durationDays, amountCents: p.amountCents })),
|
||||
smtpConfigured: Boolean(smtp.host && smtp.from),
|
||||
eventsFile: EVENTS_FILE,
|
||||
fulfillmentFile: FULFILLMENT_STORE,
|
||||
lookupTtlMs: LOOKUP_TTL_MS,
|
||||
});
|
||||
});
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TERMS="$ROOT/status/legal/terms/index.html"
|
||||
PRIVACY="$ROOT/status/legal/privacy/index.html"
|
||||
TOS_ALIAS="$ROOT/status/legal/tos/index.html"
|
||||
require() { grep -Eqi "$2" "$1" || { echo "Missing required content in $1: $2" >&2; exit 1; }; }
|
||||
test -s "$TERMS" && test -s "$PRIVACY" && test -s "$TOS_ALIAS"
|
||||
for section in 'License grant' 'Acceptable use' 'best-effort' 'Refund policy' 'Termination' 'Limitation of liability' 'Governing law'; do require "$TERMS" "$section"; done
|
||||
require "$TERMS" 'within 14 calendar days'
|
||||
for section in 'GDPR' 'lawful bases' 'Stripe' 'Tailscale' 'data portability|portability' '30 days after cancellation' 'privacy@sami-ahmed.net'; do require "$PRIVACY" "$section"; done
|
||||
# Reject any SOC 2 / HIPAA compliance claims (the launch explicitly excludes them).
|
||||
# Negated `! grep` does not trigger errexit under `set -e` (ShellCheck SC2251), so use an
|
||||
# explicit if/then to make the forbidden-claim guard actually fail the script.
|
||||
# Regex covers: SOC 2 / SOC-2 / SOC2 + (certified|compliant|compliance|compliant),
|
||||
# HIPAA + (certified|compliant|compliance|compliant), with optional hyphen.
|
||||
if grep -Eqi 'SOC[ -]?2[[:space:]-]+(certified|compliant|compliance)|HIPAA[[:space:]-]+(certified|compliant|compliance)' "$TERMS" "$PRIVACY"; then
|
||||
echo "Forbidden SOC 2/HIPAA compliance language detected in Terms or Privacy pages." >&2
|
||||
exit 1
|
||||
fi
|
||||
require "$ROOT/status/index.html" 'href="/legal/terms"'
|
||||
require "$ROOT/status/index.html" 'href="/legal/privacy"'
|
||||
require "$TOS_ALIAS" 'url=/legal/terms'
|
||||
echo 'Legal page sanity checks passed.'
|
||||
+43
-34
@@ -252,43 +252,52 @@ process.on('uncaughtException', (error) => {
|
||||
log.info('server', 'All feature modules initialized');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = (signal) => {
|
||||
log.info('shutdown', `${signal} received, draining connections...`);
|
||||
|
||||
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();
|
||||
healthChecker.stop();
|
||||
updateManager.stop();
|
||||
selfUpdater.stop();
|
||||
|
||||
try {
|
||||
const dockerMaintenance = require('./src/docker/docker-maintenance');
|
||||
dockerMaintenance.stop();
|
||||
} catch { /* optional */ }
|
||||
|
||||
try {
|
||||
const logDigest = require('./src/security/log-digest');
|
||||
logDigest.stop();
|
||||
} catch { /* optional */ }
|
||||
// Graceful shutdown (DC-067) — drains in-flight HTTP connections, stops
|
||||
// each manager in deterministic order, emits a 'shutdown' event for any
|
||||
// additional listeners, and force-exits after a 10s drain timeout.
|
||||
// Idempotent: a second SIGTERM during shutdown is a no-op.
|
||||
const {
|
||||
createShutdownCoordinator,
|
||||
installSignalHandlers,
|
||||
DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
} = require('./src/utilities/shutdown');
|
||||
|
||||
server.close(() => {
|
||||
log.info('shutdown', 'HTTP server closed');
|
||||
process.exit(0);
|
||||
const optionalManagers = [];
|
||||
try {
|
||||
optionalManagers.push({
|
||||
name: 'docker-maintenance',
|
||||
stop: () => require('./src/docker/docker-maintenance').stop(),
|
||||
});
|
||||
|
||||
// Force exit after 5s if connections don't drain
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
};
|
||||
} catch { /* optional module */ }
|
||||
try {
|
||||
optionalManagers.push({
|
||||
name: 'log-digest',
|
||||
stop: () => require('./src/security/log-digest').stop(),
|
||||
});
|
||||
} catch { /* optional module */ }
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
const coordinator = createShutdownCoordinator({
|
||||
server,
|
||||
log,
|
||||
drainTimeoutMs: DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
managers: [
|
||||
{ name: 'resource-monitor', stop: () => require('./src/managers/resource-monitor').stop() },
|
||||
{ name: 'backup-manager', stop: () => require('./src/utilities/backup-manager').stop() },
|
||||
{ name: 'health-checker', stop: () => require('./src/monitoring/health-checker').stop() },
|
||||
{ name: 'update-manager', stop: () => require('./src/managers/update-manager').stop() },
|
||||
{ name: 'self-updater', stop: () => require('./src/docker/self-updater').stop() },
|
||||
...optionalManagers,
|
||||
],
|
||||
});
|
||||
|
||||
// Expose the shutdown signal as an event so additional listeners can
|
||||
// subscribe without touching this file. The coordinator is an
|
||||
// EventEmitter and emits 'shutdown' on SIGTERM/SIGINT.
|
||||
coordinator.on('shutdown', (signal) => {
|
||||
log.info('shutdown', 'shutdown event observed', { signal });
|
||||
});
|
||||
|
||||
installSignalHandlers(coordinator);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FATAL] Server startup failed:', error);
|
||||
|
||||
@@ -85,13 +85,16 @@ const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const securityRoutes = require('../routes/security');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
const sslMonitorRoutes = require('../routes/ssl-monitor');
|
||||
const diskSpaceRoutes = require('../routes/disk-space');
|
||||
const { AutoRestartManager } = require('./managers/auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('./managers/config-drift-detector');
|
||||
const SSLMonitor = require('./monitoring/ssl-monitor');
|
||||
const { DiskSpaceMonitor } = require('./monitoring/disk-space-monitor');
|
||||
const DNSPropagationChecker = require('./dns/dns-propagation');
|
||||
|
||||
// Constants
|
||||
@@ -133,6 +136,7 @@ async function createApp() {
|
||||
// simply blocks creation via the route-level _requirePro gate.
|
||||
const shareStore = createShareStore({
|
||||
dataDir: platformPaths.dataDir,
|
||||
platformPaths,
|
||||
log,
|
||||
});
|
||||
|
||||
@@ -216,15 +220,23 @@ async function createApp() {
|
||||
// /api/v1/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (drift)
|
||||
// /api/auth/totp/check-session -> /api/v1/totp/check-session (mounted at /totp/check-session — no /auth prefix)
|
||||
// /api/v1/auth/totp/check-session->/api/v1/totp/check-session (drift)
|
||||
// /api/auth/sso-exchange -> /api/v1/auth/sso-exchange (mounted at /auth/sso-exchange, same shape as gate/app-token)
|
||||
//
|
||||
// The totp case drops `/auth` because the canonical route is /totp/check-session
|
||||
// (no /auth prefix) but the legacy JS still uses /api/auth/totp/check-session
|
||||
// (and a stale-browser version of the page uses /api/v1/auth/totp/check-session).
|
||||
// Without these rewrites the JS gets a 404 and the page hangs at
|
||||
// "Signing in to Plex..." forever (user-reported 2026-07-09).
|
||||
//
|
||||
// sso-exchange added 2026-07-24: same Caddy handle_path /dashcaddy-api/*
|
||||
// strips only the /dashcaddy-api prefix, so the login-page JS's fetch to
|
||||
// /dashcaddy-api/api/auth/sso-exchange arrives here as /api/auth/sso-exchange
|
||||
// — needs the same rewrite as gate/app-token, not the check-session one
|
||||
// (this route's canonical mount already includes /auth/).
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/')
|
||||
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')) {
|
||||
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')
|
||||
|| req.url.startsWith('/api/auth/sso-exchange')) {
|
||||
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
|
||||
// Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session
|
||||
@@ -445,6 +457,12 @@ async function createApp() {
|
||||
sslMonitor.start(3600000); // 1 hour
|
||||
log.info('app', 'SSL monitor initialized');
|
||||
|
||||
// Initialize disk space monitor (disk budget + auto-cleanup)
|
||||
const diskSpaceMonitor = new DiskSpaceMonitor({ log, config: ctx.siteConfig });
|
||||
ctx.diskSpaceMonitor = diskSpaceMonitor;
|
||||
diskSpaceMonitor.start(600000); // 10 min
|
||||
log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB });
|
||||
|
||||
// Initialize DNS propagation checker
|
||||
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
|
||||
ctx.dnsPropagationChecker = dnsPropagationChecker;
|
||||
@@ -519,6 +537,11 @@ async function createApp() {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
log: ctx.log,
|
||||
}));
|
||||
// DC-055: billing is PUBLIC (customer hasn't paid yet → no session).
|
||||
// Stripe-session creation only; the webhook side runs in scripts/stripe-license-bridge.js.
|
||||
apiRouter.use('/billing', billingRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
}));
|
||||
apiRouter.use('/dns', dnsRoutes({
|
||||
dns: ctx.dns,
|
||||
siteConfig: ctx.siteConfig,
|
||||
@@ -694,6 +717,11 @@ async function createApp() {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use('/disk', diskSpaceRoutes({
|
||||
diskSpaceMonitor: ctx.diskSpaceMonitor,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
log: ctx.log,
|
||||
}));
|
||||
|
||||
// Inline API routes (mounted under /api/v1 below)
|
||||
// Note: /health lives at root only — see root-level health check below.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Canonical DashCaddy Pro product catalog.
|
||||
*
|
||||
* Keep product identity, license duration, USD amount, and the Stripe price
|
||||
* environment variable in one place. Checkout (src/billing/stripe-client.js),
|
||||
* the webhook bridge (scripts/stripe-license-bridge.js), the public pricing
|
||||
* page (status/pricing/index.html), and tests must NOT maintain separate
|
||||
* product lists — all of them import from here.
|
||||
*
|
||||
* Pricing source of truth: PRODUCT-SPEC-DECISIONS.md (locked 2026-07-20).
|
||||
*
|
||||
* Lifecycle:
|
||||
* - To add a new tier: append a new frozen product entry below, then add
|
||||
* the matching STRIPE_PRICE_PRO_*<duration>D environment variable to
|
||||
* the deployment. The pricing page (status/pricing/index.html) and
|
||||
* the catalog consistency test (__tests__/billing/pricing-page-catalog.test.js)
|
||||
* will both fail until the pricing page is updated in lockstep — this
|
||||
* is the documented drift guard.
|
||||
* - To change a price: edit the matching product entry AND the pricing
|
||||
* page's hardcoded price label (status/pricing/index.html). The
|
||||
* pricing-page-catalog.test.js enforces the two match.
|
||||
*
|
||||
* Note: The pricing page hard-codes the 4 product IDs, prices, and
|
||||
* duration strings (rather than being server-rendered from this catalog).
|
||||
* The hard-coding is intentional — the page is served as static HTML from
|
||||
* `status.sami/pricing` and never touches the live API. The
|
||||
* pricing-page-catalog.test.js enforces consistency between the two
|
||||
* sources, so any drift fails the test suite.
|
||||
*/
|
||||
|
||||
const PRODUCTS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'pro-30d',
|
||||
durationDays: 30,
|
||||
amountCents: 2000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_30D',
|
||||
label: '1 month',
|
||||
priceLabel: '$20',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-90d',
|
||||
durationDays: 90,
|
||||
amountCents: 5000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_90D',
|
||||
label: '3 months',
|
||||
priceLabel: '$50',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-180d',
|
||||
durationDays: 180,
|
||||
amountCents: 7000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_180D',
|
||||
label: '6 months',
|
||||
priceLabel: '$70',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-365d',
|
||||
durationDays: 365,
|
||||
amountCents: 9900,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_365D',
|
||||
label: '12 months',
|
||||
priceLabel: '$99',
|
||||
}),
|
||||
]);
|
||||
|
||||
const BY_ID = new Map(PRODUCTS.map((product) => [product.id, product]));
|
||||
|
||||
function listProducts() {
|
||||
return PRODUCTS.slice();
|
||||
}
|
||||
|
||||
function getProduct(productId) {
|
||||
return BY_ID.get(productId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Stripe Price ID configured for a product. Returns '' if unset
|
||||
* (caller treats empty string as "this tier is not configured").
|
||||
*/
|
||||
function getConfiguredPrice(product, env = process.env) {
|
||||
if (!product) return '';
|
||||
return env[product.priceEnv] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Stripe Price ID back to a product. Used by the webhook bridge to
|
||||
* validate that a Checkout session's price matches a configured product
|
||||
* (defense against Stripe price-ID drift / repointing).
|
||||
*
|
||||
* Returns null when the price ID is unset or doesn't match any configured
|
||||
* product.
|
||||
*/
|
||||
function findProductByPriceId(priceId, env = process.env) {
|
||||
if (!priceId || typeof priceId !== 'string') return null;
|
||||
for (const product of PRODUCTS) {
|
||||
const configured = getConfiguredPrice(product, env);
|
||||
if (configured && configured === priceId) return product;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getConfiguredPrice but returns the full product list with the
|
||||
* resolved Stripe Price ID merged in. Useful for the pricing page renderer.
|
||||
*/
|
||||
function getConfiguredProducts(env = process.env) {
|
||||
return PRODUCTS.map((product) => ({ ...product, priceId: getConfiguredPrice(product, env) }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRODUCTS,
|
||||
listProducts,
|
||||
getProduct,
|
||||
getConfiguredPrice,
|
||||
getConfiguredProducts,
|
||||
findProductByPriceId,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Durable Stripe fulfillment state.
|
||||
*
|
||||
* The bridge and the API share this file through the host data mount. A
|
||||
* generated license is persisted before email delivery so a webhook retry can
|
||||
* resend the same key instead of minting a second valid key.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const DELIVERY_LEASE_MS = 5 * 60 * 1000;
|
||||
|
||||
function createFulfillmentStore(options = {}) {
|
||||
const filePath = options.filePath
|
||||
|| process.env.STRIPE_FULFILLMENT_FILE
|
||||
|| path.join(platformPaths.dataDir, 'stripe-fulfillments.json');
|
||||
let queue = Promise.resolve();
|
||||
|
||||
function emptyState() {
|
||||
return { version: 1, byEventId: {}, bySessionId: {} };
|
||||
}
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return emptyState();
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object'
|
||||
|| !parsed.byEventId || typeof parsed.byEventId !== 'object'
|
||||
|| !parsed.bySessionId || typeof parsed.bySessionId !== 'object') {
|
||||
throw new Error('fulfillment state has an invalid shape');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return emptyState();
|
||||
throw new Error(`Stripe fulfillment state unavailable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
try { fs.chmodSync(filePath, 0o600); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
function mutate(mutator) {
|
||||
const run = queue.then(async () => {
|
||||
const state = readState();
|
||||
const result = await mutator(state);
|
||||
if (result && result.changed) writeState(state);
|
||||
return result;
|
||||
});
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function readBySession(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const record = readState().bySessionId[sessionId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
function readByEvent(eventId) {
|
||||
if (!eventId) return null;
|
||||
const record = readState().byEventId[eventId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
async function claim({ eventId, sessionId, productId, durationDays, email }) {
|
||||
if (!eventId || !sessionId) throw new Error('eventId and sessionId are required');
|
||||
return mutate((state) => {
|
||||
const existing = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
if (existing.status === 'generating' && existing.leaseUntil > now && existing.claimToken !== eventId) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...existing } };
|
||||
}
|
||||
if (existing.status === 'generating' && existing.leaseUntil <= now) {
|
||||
existing.claimToken = eventId;
|
||||
existing.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
state.byEventId[eventId] = existing;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...existing } };
|
||||
}
|
||||
return { changed: false, claimed: false, busy: false, record: { ...existing } };
|
||||
}
|
||||
const record = {
|
||||
eventId, sessionId, productId, durationDays, email,
|
||||
status: 'generating', claimToken: eventId, leaseUntil: now + DELIVERY_LEASE_MS,
|
||||
createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
|
||||
};
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLicense({ eventId, sessionId, code, codeId }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
if (!record || record.claimToken !== eventId) return { changed: false, saved: false, record: record ? { ...record } : null };
|
||||
record.code = code;
|
||||
record.codeId = codeId;
|
||||
record.status = 'pending_email';
|
||||
record.leaseUntil = 0;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[record.sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function claimDelivery({ sessionId, ownerToken }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || !record.code) return { changed: false, claimed: false, record: record ? { ...record } : null };
|
||||
const now = Date.now();
|
||||
if (record.status === 'delivered') return { changed: false, claimed: false, record: { ...record } };
|
||||
if (record.status === 'delivering' && record.leaseUntil > now && record.leaseOwner !== ownerToken) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...record } };
|
||||
}
|
||||
record.status = 'delivering';
|
||||
record.leaseOwner = ownerToken;
|
||||
record.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
record.updatedAt = new Date(now).toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDelivered({ sessionId, ownerToken, deliveredVia }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'delivered';
|
||||
record.deliveredVia = deliveredVia;
|
||||
record.deliveredAt = new Date().toISOString();
|
||||
record.lastError = null;
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDeliveryFailed({ sessionId, ownerToken, error }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'pending_email';
|
||||
record.lastError = String(error || 'email delivery failed').slice(0, 500);
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
return { filePath, readBySession, readByEvent, claim, saveLicense, claimDelivery, markDelivered, markDeliveryFailed };
|
||||
}
|
||||
|
||||
module.exports = { createFulfillmentStore, DELIVERY_LEASE_MS };
|
||||
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DashCaddy Stripe client — DC-055 + DC-057.
|
||||
*
|
||||
* Thin wrapper around the Stripe SDK for the OUTBOUND side of one-time
|
||||
* license purchasing: creating Checkout Sessions that drive customers to
|
||||
* Stripe's hosted payment page.
|
||||
*
|
||||
* Inbound (webhook) handling lives in scripts/stripe-license-bridge.js
|
||||
* (DC-054 + DC-057) — that runs as its own process so the merchant's
|
||||
* Stripe webhook secret doesn't have to be loaded into the DashCaddy API
|
||||
* host process.
|
||||
*
|
||||
* # Pricing contract
|
||||
*
|
||||
* One-time payments keyed by `productId` from src/billing/catalog.js:
|
||||
*
|
||||
* pro-30d → $20 USD, 30-day license
|
||||
* pro-90d → $50 USD, 90-day license
|
||||
* pro-180d → $70 USD, 180-day license
|
||||
* pro-365d → $99 USD, 365-day license
|
||||
*
|
||||
* `mode: 'payment'` (NOT 'subscription'). The license is generated once
|
||||
* per Checkout completion and the customer pastes it into their host.
|
||||
* No recurring billing, no Stripe Customer object retained beyond the
|
||||
* session.
|
||||
*
|
||||
* # Configuration (env vars)
|
||||
*
|
||||
* Required to create sessions — failures are loud:
|
||||
* STRIPE_SECRET_KEY — Stripe API secret (sk_live_... | sk_test_...)
|
||||
* STRIPE_PRICE_PRO_30D — Stripe Price ID for the 30-day product
|
||||
* STRIPE_PRICE_PRO_90D — Stripe Price ID for the 90-day product
|
||||
* STRIPE_PRICE_PRO_180D — Stripe Price ID for the 180-day product
|
||||
* STRIPE_PRICE_PRO_365D — Stripe Price ID for the 365-day product
|
||||
* STRIPE_SUCCESS_URL — (optional) success page URL override
|
||||
* STRIPE_CANCEL_URL — (optional) cancel page URL override
|
||||
*
|
||||
* The Stripe Price IDs map 1:1 to catalog products. A product whose
|
||||
* Stripe Price ID is unset cannot be purchased (returns
|
||||
* STRIPE_NOT_CONFIGURED).
|
||||
*
|
||||
* # Metadata contract (DC-057)
|
||||
*
|
||||
* The Checkout session carries `metadata.productId` (= one of the
|
||||
* catalog IDs). The webhook bridge reads this field back, maps to the
|
||||
* catalog, and generates the matching license duration.
|
||||
*
|
||||
* Why productId and not (e.g.) durationDays: the catalog is the single
|
||||
* source of truth. If pricing changes (e.g. new tier added), only the
|
||||
* catalog and the bridge change — the Checkout metadata stays abstract.
|
||||
*
|
||||
* Tested in __tests__/billing/stripe-client.test.js with mocked Stripe SDK.
|
||||
*/
|
||||
|
||||
let stripeSdk = null;
|
||||
function _loadStripeSdk() {
|
||||
if (stripeSdk) return stripeSdk;
|
||||
// Lazy require so tests can install a mock BEFORE first call.
|
||||
stripeSdk = require('stripe');
|
||||
return stripeSdk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock Stripe SDK. Used by tests; never call in production code.
|
||||
* @param {Object} mockSdk - Object with `checkout.sessions.create` (and any other surface) the tests want to stub.
|
||||
*/
|
||||
function _setStripeSdk(mockSdk) {
|
||||
stripeSdk = mockSdk;
|
||||
}
|
||||
|
||||
const catalog = require('./catalog');
|
||||
|
||||
/**
|
||||
* Read the active configuration. Throws if STRIPE_SECRET_KEY is missing
|
||||
* OR if no product has its Stripe Price ID configured — both are loud
|
||||
* failures so an operator notices instead of seeing silent 500s.
|
||||
*
|
||||
* @param {Object} [env] - process.env by default; tests pass custom env.
|
||||
* @returns {Object} config snapshot for this invocation
|
||||
*/
|
||||
function _readConfig(env = process.env) {
|
||||
const secretKey = env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
const err = new Error(
|
||||
'Stripe billing is not configured. Missing env var: STRIPE_SECRET_KEY. ' +
|
||||
'Set it in /opt/dashcaddy/.env and restart the API.'
|
||||
);
|
||||
err.code = 'STRIPE_NOT_CONFIGURED';
|
||||
err.statusCode = 503;
|
||||
err.missing = ['STRIPE_SECRET_KEY'];
|
||||
throw err;
|
||||
}
|
||||
return { secretKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the requested productId and resolve its Stripe Price ID.
|
||||
* Throws with a structured error if the productId is unknown OR if the
|
||||
* product's Stripe Price ID env var is not configured.
|
||||
*
|
||||
* @param {string} productId
|
||||
* @param {Object} env
|
||||
* @returns {Object} catalog product entry
|
||||
*/
|
||||
function _resolveProduct(productId, env = process.env) {
|
||||
if (!productId || typeof productId !== 'string') {
|
||||
const err = new Error('productId is required');
|
||||
err.code = 'INVALID_PRODUCT_ID';
|
||||
err.statusCode = 400;
|
||||
err.field = 'productId';
|
||||
throw err;
|
||||
}
|
||||
const product = catalog.getProduct(productId);
|
||||
if (!product) {
|
||||
const err = new Error(`Unknown productId: ${productId}. Valid: ${catalog.PRODUCTS.map(p => p.id).join(', ')}`);
|
||||
err.code = 'INVALID_PRODUCT_ID';
|
||||
err.statusCode = 400;
|
||||
err.field = 'productId';
|
||||
throw err;
|
||||
}
|
||||
const priceId = catalog.getConfiguredPrice(product, env);
|
||||
if (!priceId) {
|
||||
const err = new Error(
|
||||
`Product ${productId} is not configured for purchase. Missing env var: ${product.priceEnv}. ` +
|
||||
`Create the Stripe Price and set the env var in /opt/dashcaddy/.env, then restart the API.`
|
||||
);
|
||||
err.code = 'STRIPE_NOT_CONFIGURED';
|
||||
err.statusCode = 503;
|
||||
err.missing = [product.priceEnv];
|
||||
err.productId = productId;
|
||||
throw err;
|
||||
}
|
||||
return { product, priceId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session for a one-time DashCaddy Pro purchase.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.productId - catalog id: 'pro-30d' | 'pro-90d' | 'pro-180d' | 'pro-365d'
|
||||
* @param {string} [opts.customerEmail] - email to prefill on Checkout (optional)
|
||||
* @param {string} [opts.origin] - request origin (e.g. 'https://status.sami') used to build success/cancel URLs
|
||||
* @returns {Promise<{ id: string, url: string }>}
|
||||
* @throws Error with `.code` and `.statusCode` on configuration/validation failure
|
||||
*/
|
||||
async function createCheckoutSession({ productId, customerEmail, origin }) {
|
||||
const config = _readConfig();
|
||||
const { product, priceId } = _resolveProduct(productId);
|
||||
const stripe = _loadStripeSdk();
|
||||
const api = stripe(config.secretKey);
|
||||
|
||||
const successUrl = process.env.STRIPE_SUCCESS_URL
|
||||
|| (origin ? `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}` : '/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
const cancelUrl = process.env.STRIPE_CANCEL_URL
|
||||
|| (origin ? `${origin}/pricing` : '/pricing');
|
||||
|
||||
// mode: 'payment' (one-time, NOT subscription). The license is generated
|
||||
// once on `checkout.session.completed` and the customer pastes it into
|
||||
// their host. No Customer object, no recurring billing.
|
||||
//
|
||||
// metadata.productId is the contract with the webhook bridge — it maps
|
||||
// back to a catalog entry to get the license duration. If the catalog
|
||||
// grows, only the bridge needs to change.
|
||||
const params = {
|
||||
mode: 'payment',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: {
|
||||
productId: product.id,
|
||||
product: 'dashcaddy-pro',
|
||||
},
|
||||
// payment_intent_data carries metadata to the PaymentIntent too, so
|
||||
// any downstream Stripe→bridge plumbing that reads PI metadata still
|
||||
// gets the productId. (Stripe's webhook includes the PI on
|
||||
// checkout.session.completed for retrieval but the canonical metadata
|
||||
// field for session-level events is the top-level metadata.)
|
||||
payment_intent_data: {
|
||||
metadata: { productId: product.id, product: 'dashcaddy-pro' },
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
if (customerEmail) {
|
||||
params.customer_email = customerEmail;
|
||||
}
|
||||
|
||||
const session = await api.checkout.sessions.create(params);
|
||||
|
||||
return { id: session.id, url: session.url };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCheckoutSession,
|
||||
// Test seams
|
||||
_setStripeSdk,
|
||||
_readConfig,
|
||||
_resolveProduct,
|
||||
};
|
||||
@@ -33,6 +33,9 @@ function assembleContext({
|
||||
// State managers
|
||||
servicesStateManager,
|
||||
configStateManager,
|
||||
|
||||
// DC-053 share store
|
||||
shareStore,
|
||||
|
||||
// Managers
|
||||
credentialManager,
|
||||
@@ -191,6 +194,9 @@ function assembleContext({
|
||||
// State managers
|
||||
servicesStateManager,
|
||||
configStateManager,
|
||||
|
||||
// DC-053 share store
|
||||
shareStore,
|
||||
|
||||
// Managers
|
||||
credentialManager,
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
function createSessionContext(middlewareResult) {
|
||||
const { ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult;
|
||||
const {
|
||||
ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie,
|
||||
clearIPSession, clearSessionCookie, isSessionValid,
|
||||
createHandoffToken, redeemHandoffToken, setHostOnlySessionCookie
|
||||
} = middlewareResult;
|
||||
|
||||
return {
|
||||
ipSessions,
|
||||
@@ -15,6 +19,9 @@ function createSessionContext(middlewareResult) {
|
||||
clear: clearIPSession,
|
||||
clearCookie: clearSessionCookie,
|
||||
isValid: isSessionValid,
|
||||
createHandoffToken,
|
||||
redeemHandoffToken,
|
||||
setCookieHostOnly: setHostOnlySessionCookie,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const crypto = require('crypto');
|
||||
const dns = require('dns');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
@@ -117,7 +118,7 @@ class RFC2136Provider extends BaseDNSProvider {
|
||||
*/
|
||||
async _runNsupdate(commands) {
|
||||
const script = commands.join('\n') + '\n';
|
||||
const tmpFile = path.join(os.tmpdir(), `nsupdate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cmd`);
|
||||
const tmpFile = path.join(os.tmpdir(), `nsupdate-${crypto.randomBytes(4).toString('hex')}.cmd`);
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(tmpFile, script, { mode: 0o600 });
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const EventEmitter = require('events');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { log } = require('../utils/logging');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
@@ -86,7 +87,7 @@ class SelfUpdater extends EventEmitter {
|
||||
start() {
|
||||
if (!this.config.enabled || this.checkTimer) return;
|
||||
|
||||
console.log('[SelfUpdater] Starting auto-update checks every %ds', this.config.checkInterval / 1000);
|
||||
log.info('updater', 'Starting auto-update checks', { intervalMs: this.config.checkInterval });
|
||||
|
||||
// First check after a short delay (let server finish startup)
|
||||
setTimeout(() => {
|
||||
@@ -124,7 +125,7 @@ class SelfUpdater extends EventEmitter {
|
||||
return { version: pkg.version, commit };
|
||||
} catch { /* try next candidate */ }
|
||||
}
|
||||
console.error('[SelfUpdater] getLocalVersion failed: no candidate package.json found');
|
||||
log.error('updater', 'getLocalVersion failed: no candidate package.json found');
|
||||
return { version: '0.0.0', commit: null };
|
||||
}
|
||||
|
||||
@@ -158,7 +159,7 @@ class SelfUpdater extends EventEmitter {
|
||||
// Fire-and-forget; the response shouldn't block on the container rebuild.
|
||||
setImmediate(() => {
|
||||
this._autoCheckAndApply().catch(err =>
|
||||
console.error('[SelfUpdater] %s-triggered update error: %s', triggeredBy, err.message)
|
||||
log.error('updater', err, { triggeredBy })
|
||||
);
|
||||
});
|
||||
return { accepted: true, triggeredBy };
|
||||
@@ -174,7 +175,7 @@ class SelfUpdater extends EventEmitter {
|
||||
try {
|
||||
remote = await this._fetchJson(`${this.config.updateUrl}/version.json`);
|
||||
} catch (primaryErr) {
|
||||
console.warn('[SelfUpdater] Primary server failed:', primaryErr.message, '— trying mirror');
|
||||
log.warn('updater', 'Primary server failed, trying mirror', { error: primaryErr.message });
|
||||
try {
|
||||
remote = await this._fetchJson(`${this.config.mirrorUrl}/version.json`);
|
||||
sourceUrl = this.config.mirrorUrl;
|
||||
@@ -240,7 +241,7 @@ class SelfUpdater extends EventEmitter {
|
||||
try {
|
||||
await this._downloadFile(primaryUrl, tarballPath);
|
||||
} catch (dlErr) {
|
||||
console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
|
||||
log.warn('updater', 'Primary download failed, trying mirror', { error: dlErr.message });
|
||||
// Ensure file is fully cleaned up before mirror attempt
|
||||
try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
|
||||
await this._downloadFile(mirrorUrl, tarballPath);
|
||||
@@ -468,11 +469,11 @@ class SelfUpdater extends EventEmitter {
|
||||
try {
|
||||
const result = await this.checkForUpdate();
|
||||
if (result.available && result.remote) {
|
||||
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
|
||||
log.info('updater', 'Update available', { localVersion: result.local.version, remoteVersion: result.remote.version });
|
||||
await this.applyUpdate(result.remote);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SelfUpdater] Auto-update error:', e.message);
|
||||
log.error('updater', e, { phase: 'autoUpdate' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +607,7 @@ class SelfUpdater extends EventEmitter {
|
||||
fs.mkdirSync(path.dirname(this.notifySecretFile), { recursive: true });
|
||||
fs.writeFileSync(this.notifySecretFile, `${secret}\n`, { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.warn('[SelfUpdater] Failed to persist notify secret:', error.message);
|
||||
log.warn('updater', 'Failed to persist notify secret', { error: error.message });
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
@@ -626,7 +627,7 @@ class SelfUpdater extends EventEmitter {
|
||||
fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
|
||||
fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
|
||||
log.warn('updater', 'Failed to persist instance ID', { error: error.message });
|
||||
}
|
||||
return instanceId;
|
||||
}
|
||||
@@ -644,7 +645,7 @@ class SelfUpdater extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
|
||||
} catch (e) {
|
||||
console.error('[SelfUpdater] Failed to save history:', e.message);
|
||||
log.error('updater', e, { operation: 'saveHistory' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const credentialManager = require('./credential-manager');
|
||||
const cryptoUtils = require('../security/crypto-utils');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
// JWT signing secret - derived from encryption key for consistency
|
||||
const JWT_SECRET = cryptoUtils.loadOrCreateKey();
|
||||
@@ -19,7 +20,7 @@ const API_KEY_METADATA_NAMESPACE = 'auth.metadata';
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
this.keyMetadataCache = new Map(); // Cache for API key metadata
|
||||
console.log('[AuthManager] Initialized');
|
||||
log.info('auth', 'Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,10 +45,10 @@ class AuthManager {
|
||||
{ expiresIn }
|
||||
);
|
||||
|
||||
console.log(`[AuthManager] Generated JWT for user: ${payload.sub}, expires in: ${expiresIn}`);
|
||||
log.info('auth', 'Generated JWT', { user: payload.sub, expiresIn });
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] JWT generation failed:', error.message);
|
||||
log.error('auth', error, { operation: 'jwtGenerate' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -68,11 +69,11 @@ class AuthManager {
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
console.log('[AuthManager] JWT token expired');
|
||||
log.info('auth', 'JWT token expired');
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
console.log('[AuthManager] JWT token invalid:', error.message);
|
||||
log.info('auth', 'JWT token invalid', { error: error.message });
|
||||
} else {
|
||||
console.error('[AuthManager] JWT verification failed:', error.message);
|
||||
log.error('auth', error, { operation: 'jwtVerify' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -116,7 +117,7 @@ class AuthManager {
|
||||
// Cache metadata
|
||||
this.keyMetadataCache.set(keyId, metadata);
|
||||
|
||||
console.log(`[AuthManager] Generated API key: ${name} (${keyId})`);
|
||||
log.info('auth', 'Generated API key', { name, keyId });
|
||||
|
||||
return {
|
||||
key: apiKey,
|
||||
@@ -126,7 +127,7 @@ class AuthManager {
|
||||
createdAt: metadata.createdAt
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] API key generation failed:', error.message);
|
||||
log.error('auth', error, { operation: 'apiKeyGenerate' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -154,30 +155,30 @@ class AuthManager {
|
||||
// Retrieve stored hash
|
||||
const storedHash = await credentialManager.retrieve(credentialKey);
|
||||
if (!storedHash) {
|
||||
console.log(`[AuthManager] API key not found: ${keyId}`);
|
||||
log.info('auth', 'API key not found', { keyId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify key matches stored hash
|
||||
const providedHash = crypto.createHash('sha256').update(key).digest('hex');
|
||||
if (!crypto.timingSafeEqual(Buffer.from(storedHash), Buffer.from(providedHash))) {
|
||||
console.log(`[AuthManager] API key hash mismatch: ${keyId}`);
|
||||
log.info('auth', 'API key hash mismatch', { keyId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
const metadata = await this.getKeyMetadata(keyId);
|
||||
if (!metadata) {
|
||||
console.log(`[AuthManager] API key metadata not found: ${keyId}`);
|
||||
log.info('auth', 'API key metadata not found', { keyId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used timestamp (non-blocking)
|
||||
this.updateLastUsed(keyId, metadata).catch(err =>
|
||||
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message)
|
||||
log.error('auth', err, { keyId, operation: 'updateLastUsed' })
|
||||
);
|
||||
|
||||
console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`);
|
||||
log.info('auth', 'API key verified', { name: metadata.name, keyId });
|
||||
|
||||
return {
|
||||
keyId,
|
||||
@@ -185,7 +186,7 @@ class AuthManager {
|
||||
name: metadata.name
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] API key verification failed:', error.message);
|
||||
log.error('auth', error, { operation: 'apiKeyVerify' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -205,10 +206,10 @@ class AuthManager {
|
||||
|
||||
this.keyMetadataCache.delete(keyId);
|
||||
|
||||
console.log(`[AuthManager] Revoked API key: ${keyId}`);
|
||||
log.info('auth', 'Revoked API key', { keyId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[AuthManager] Failed to revoke API key ${keyId}:`, error.message);
|
||||
log.error('auth', error, { keyId, operation: 'revoke' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -233,7 +234,7 @@ class AuthManager {
|
||||
|
||||
return keys;
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] Failed to list API keys:', error.message);
|
||||
log.error('auth', error, { operation: 'listApiKeys' });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -262,7 +263,7 @@ class AuthManager {
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.error(`[AuthManager] Failed to get metadata for ${keyId}:`, error.message);
|
||||
log.error('auth', error, { keyId, operation: 'getMetadata' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -285,7 +286,7 @@ class AuthManager {
|
||||
|
||||
this.keyMetadataCache.set(keyId, updatedMetadata);
|
||||
} catch (error) {
|
||||
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, error.message);
|
||||
log.error('auth', error, { keyId, operation: 'updateLastUsed' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +295,7 @@ class AuthManager {
|
||||
*/
|
||||
clearCache() {
|
||||
this.keyMetadataCache.clear();
|
||||
console.log('[AuthManager] Cache cleared');
|
||||
log.info('auth', 'Cache cleared');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const keychainManager = require('../security/keychain-manager');
|
||||
const cryptoUtils = require('../security/crypto-utils');
|
||||
const lockfile = require('proper-lockfile');
|
||||
const fs = require('fs');
|
||||
const { log } = require('../utils/logging');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
@@ -33,7 +34,7 @@ class CredentialManager {
|
||||
stale: 30000
|
||||
};
|
||||
|
||||
console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`);
|
||||
log.info('cred', 'Initialized', { storage: this.useKeychain ? 'keychain' : 'file' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,19 +61,19 @@ class CredentialManager {
|
||||
// Store metadata separately in file
|
||||
await this.storeMetadata(key, metadata);
|
||||
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
||||
console.log(`[CredentialManager] Stored '${key}' in OS keychain`);
|
||||
log.info('cred', 'Stored credential in keychain', { key });
|
||||
return true;
|
||||
}
|
||||
console.warn(`[CredentialManager] Keychain storage failed for '${key}', falling back to encrypted file`);
|
||||
log.warn('cred', 'Keychain storage failed, falling back to encrypted file', { key });
|
||||
}
|
||||
|
||||
// Fallback to encrypted file storage
|
||||
await this.storeInFile(key, value, metadata);
|
||||
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
||||
console.log(`[CredentialManager] Stored '${key}' in encrypted file`);
|
||||
log.info('cred', 'Stored credential in encrypted file', { key });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[CredentialManager] Failed to store '${key}':`, error.message);
|
||||
log.error('cred', error, { key, operation: 'store' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +110,7 @@ class CredentialManager {
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`[CredentialManager] Failed to retrieve '${key}':`, error.message);
|
||||
log.error('cred', error, { key, operation: 'retrieve' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -132,10 +133,10 @@ class CredentialManager {
|
||||
// Remove from file storage
|
||||
await this.deleteFromFile(key);
|
||||
|
||||
console.log(`[CredentialManager] Deleted '${key}'`);
|
||||
log.info('cred', 'Deleted credential', { key });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[CredentialManager] Failed to delete '${key}':`, error.message);
|
||||
log.error('cred', error, { key, operation: 'delete' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -149,7 +150,7 @@ class CredentialManager {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
return Object.keys(credentials);
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Failed to list credentials:', error.message);
|
||||
log.error('cred', error, { operation: 'list' });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -175,7 +176,7 @@ class CredentialManager {
|
||||
async rotateEncryptionKey() {
|
||||
let release;
|
||||
try {
|
||||
console.log('[CredentialManager] Starting encryption key rotation...');
|
||||
log.info('cred', 'Starting encryption key rotation');
|
||||
|
||||
// Ensure file exists before locking
|
||||
this._ensureFileExists();
|
||||
@@ -186,7 +187,7 @@ class CredentialManager {
|
||||
const keys = Object.keys(credentials);
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('[CredentialManager] No credentials to rotate');
|
||||
log.info('cred', 'No credentials to rotate');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -219,10 +220,10 @@ class CredentialManager {
|
||||
// Clear cache to force reload
|
||||
this.cache.clear();
|
||||
|
||||
console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`);
|
||||
log.info('cred', 'Rotated credentials', { count: keys.length });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Key rotation failed:', error.message);
|
||||
log.error('cred', error, { operation: 'rotate' });
|
||||
return false;
|
||||
} finally {
|
||||
if (release) {
|
||||
@@ -255,12 +256,12 @@ class CredentialManager {
|
||||
|
||||
if (migrated > 0) {
|
||||
this.cache.clear();
|
||||
console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`);
|
||||
log.info('cred', 'Migrated plaintext credentials', { count: migrated });
|
||||
}
|
||||
|
||||
return { migrated, skipped, total: migrated + skipped };
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Migration failed:', error.message);
|
||||
log.error('cred', error, { operation: 'migrate' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -365,14 +366,11 @@ class CredentialManager {
|
||||
// Most common cause: the encryption key on disk is different from
|
||||
// the key that originally encrypted this entry (rotated by a
|
||||
// container recreate that didn't preserve CREDENTIALS_FILE env).
|
||||
console.warn(
|
||||
`[CredentialManager] '${key}' is present but cannot be decrypted ` +
|
||||
`(likely encryption-key mismatch): ${decryptErr.message}`
|
||||
);
|
||||
log.warn('cred', 'Credential present but cannot be decrypted (likely encryption-key mismatch)', { key, error: decryptErr.message });
|
||||
return { status: 'unreadable', value: null, error: decryptErr.message };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[CredentialManager] diagnose('${key}') failed:`, err.message);
|
||||
log.error('cred', err, { key, operation: 'diagnose' });
|
||||
return { status: 'malformed', value: null, error: err.message };
|
||||
}
|
||||
}
|
||||
@@ -404,7 +402,7 @@ class CredentialManager {
|
||||
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Failed to load credentials file:', error.message);
|
||||
log.error('cred', error, { operation: 'loadFile' });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -440,10 +438,10 @@ class CredentialManager {
|
||||
await this._lockedUpdate(() => backup.credentials);
|
||||
this.cache.clear();
|
||||
|
||||
console.log('[CredentialManager] Successfully imported backup');
|
||||
log.info('cred', 'Successfully imported backup');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Failed to import backup:', error.message);
|
||||
log.error('cred', error, { operation: 'importBackup' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,314 +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');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
// Master secret file — lives only on admin machine, NEVER shipped
|
||||
const SECRET_FILE = process.env.LICENSE_SECRET_FILE || path.join(platformPaths.dataDir, '.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 = process.env.LICENSE_COUNTER_FILE || path.join(platformPaths.dataDir, '.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();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const lockfile = require('proper-lockfile');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
const LOCK_DIR = process.env.PORT_LOCK_DIR || path.join(platformPaths.dataDir, '.port-locks');
|
||||
const LOCK_TIMEOUT = 120000; // 2 minutes
|
||||
@@ -35,7 +36,7 @@ class PortLockManager {
|
||||
ensureLockDirectory() {
|
||||
if (!fs.existsSync(LOCK_DIR)) {
|
||||
fs.mkdirSync(LOCK_DIR, { recursive: true });
|
||||
console.log('[PortLockManager] Created lock directory:', LOCK_DIR);
|
||||
log.info('portlock', 'Created lock directory', { dir: LOCK_DIR });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ class PortLockManager {
|
||||
const releaseFunctions = [];
|
||||
|
||||
try {
|
||||
console.log(`[PortLockManager] Acquiring locks for ports: ${sortedPorts.join(', ')}`);
|
||||
log.info('portlock', 'Acquiring locks', { ports: sortedPorts });
|
||||
|
||||
// Acquire locks in sorted order to prevent deadlocks
|
||||
for (const port of sortedPorts) {
|
||||
@@ -83,7 +84,7 @@ class PortLockManager {
|
||||
acquiredLocks.push(port);
|
||||
releaseFunctions.push(release);
|
||||
|
||||
console.log(`[PortLockManager] Locked port ${port}`);
|
||||
log.info('portlock', 'Locked port', { port });
|
||||
}
|
||||
|
||||
// Store lock information
|
||||
@@ -93,18 +94,18 @@ class PortLockManager {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log(`[PortLockManager] Successfully acquired all locks (ID: ${lockId})`);
|
||||
log.info('portlock', 'Acquired all locks', { lockId });
|
||||
return lockId;
|
||||
|
||||
} catch (error) {
|
||||
// Release any locks we managed to acquire
|
||||
console.error(`[PortLockManager] Failed to acquire all locks:`, error.message);
|
||||
log.error('portlock', error, { operation: 'acquire', lockId });
|
||||
|
||||
for (const release of releaseFunctions) {
|
||||
try {
|
||||
await release();
|
||||
} catch (releaseError) {
|
||||
console.error(`[PortLockManager] Error releasing lock during cleanup:`, releaseError.message);
|
||||
log.error('portlock', releaseError, { operation: 'releaseCleanup', lockId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,11 +121,11 @@ class PortLockManager {
|
||||
const lockInfo = this.activeLocks.get(lockId);
|
||||
|
||||
if (!lockInfo) {
|
||||
console.warn(`[PortLockManager] Lock ID ${lockId} not found (may have been released already)`);
|
||||
log.warn('portlock', 'Lock ID not found', { lockId });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[PortLockManager] Releasing locks for ports: ${lockInfo.ports.join(', ')}`);
|
||||
log.info('portlock', 'Releasing locks', { lockId, ports: lockInfo.ports });
|
||||
|
||||
const errors = [];
|
||||
|
||||
@@ -133,16 +134,16 @@ class PortLockManager {
|
||||
await release();
|
||||
} catch (error) {
|
||||
errors.push(error.message);
|
||||
console.error(`[PortLockManager] Error releasing lock:`, error.message);
|
||||
log.error('portlock', error, { operation: 'release', lockId });
|
||||
}
|
||||
}
|
||||
|
||||
this.activeLocks.delete(lockId);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn(`[PortLockManager] Released locks with ${errors.length} errors`);
|
||||
log.warn('portlock', 'Released locks with errors', { lockId, errorCount: errors.length });
|
||||
} else {
|
||||
console.log(`[PortLockManager] Successfully released all locks (ID: ${lockId})`);
|
||||
log.info('portlock', 'Released all locks', { lockId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +152,7 @@ class PortLockManager {
|
||||
* Removes locks older than LOCK_STALE_THRESHOLD
|
||||
*/
|
||||
async cleanupStaleLocks() {
|
||||
console.log('[PortLockManager] Cleaning up stale locks...');
|
||||
log.info('portlock', 'Cleaning up stale locks');
|
||||
|
||||
this.ensureLockDirectory();
|
||||
|
||||
@@ -174,20 +175,20 @@ class PortLockManager {
|
||||
// Lock is stale or not locked, safe to remove
|
||||
fs.unlinkSync(lockFilePath);
|
||||
cleaned++;
|
||||
console.log(`[PortLockManager] Removed stale lock: ${file}`);
|
||||
log.info('portlock', 'Removed stale lock', { file });
|
||||
}
|
||||
} catch (error) {
|
||||
// File might not exist or might have been removed by another process
|
||||
if (error.code !== 'ENOENT') {
|
||||
errors++;
|
||||
console.warn(`[PortLockManager] Error checking lock ${file}:`, error.message);
|
||||
log.warn('portlock', 'Error checking lock', { file, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PortLockManager] Cleanup complete: ${cleaned} stale locks removed, ${errors} errors`);
|
||||
log.info('portlock', 'Cleanup complete', { cleaned, errors });
|
||||
} catch (error) {
|
||||
console.error('[PortLockManager] Error during cleanup:', error.message);
|
||||
log.error('portlock', error, { operation: 'cleanup' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
@@ -59,17 +60,17 @@ class ResourceMonitor extends EventEmitter {
|
||||
*/
|
||||
start() {
|
||||
if (this.monitoring) {
|
||||
console.log('[ResourceMonitor] Already monitoring');
|
||||
log.info('monitor', 'Already monitoring');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ResourceMonitor] Starting container monitoring');
|
||||
log.info('monitor', 'Starting container monitoring');
|
||||
this.monitoring = true;
|
||||
this.monitoringInterval = setInterval(() => this.collectStats(), MONITORING_INTERVAL);
|
||||
|
||||
// Hourly rollup — fires once an hour, computes the previous full hour
|
||||
this.hourlyRollupTimer = setInterval(() => {
|
||||
try { this.rollupHourly(); } catch (e) { console.error('[ResourceMonitor] hourly rollup error:', e.message); }
|
||||
try { this.rollupHourly(); } catch (e) { log.error('monitor', e, { rollup: 'hourly' }); }
|
||||
}, ROLLUP_HOURLY_INTERVAL);
|
||||
|
||||
// Daily rollup — schedule first run at the next midnight, then fire every 24h
|
||||
@@ -77,9 +78,9 @@ class ResourceMonitor extends EventEmitter {
|
||||
const nextMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 5);
|
||||
const msUntilMidnight = nextMidnight.getTime() - now.getTime();
|
||||
setTimeout(() => {
|
||||
try { this.rollupDaily(); } catch (e) { console.error('[ResourceMonitor] daily rollup error:', e.message); }
|
||||
try { this.rollupDaily(); } catch (e) { log.error('monitor', e, { rollup: 'daily' }); }
|
||||
this.dailyRollupTimer = setInterval(() => {
|
||||
try { this.rollupDaily(); } catch (e) { console.error('[ResourceMonitor] daily rollup error:', e.message); }
|
||||
try { this.rollupDaily(); } catch (e) { log.error('monitor', e, { rollup: 'daily' }); }
|
||||
}, ROLLUP_DAILY_INTERVAL);
|
||||
}, msUntilMidnight);
|
||||
|
||||
@@ -93,7 +94,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
stop() {
|
||||
if (!this.monitoring) return;
|
||||
|
||||
console.log('[ResourceMonitor] Stopping container monitoring');
|
||||
log.info('monitor', 'Stopping container monitoring');
|
||||
this.monitoring = false;
|
||||
|
||||
if (this.monitoringInterval) {
|
||||
@@ -131,7 +132,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
this.checkAlerts(containerInfo.Id, containerInfo.Names[0], stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ResourceMonitor] Error collecting stats for ${containerInfo.Names[0]}:`, error.message);
|
||||
log.error('monitor', error, { container: containerInfo.Names[0] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
this.saveStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error collecting container stats:', error.message);
|
||||
log.error('monitor', error, { phase: 'collectStats' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +330,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
// Send notification if manager is configured
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.sendAlert(alertPayload).catch(err => {
|
||||
console.error('[ResourceMonitor] Failed to send alert notification:', err.message);
|
||||
log.error('monitor', err, { phase: 'sendAlert' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -357,7 +358,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
*/
|
||||
async restartContainer(containerId, containerName, alerts) {
|
||||
try {
|
||||
console.log(`[ResourceMonitor] Auto-restarting ${containerName} due to alerts:`, alerts.map(a => a.type).join(', '));
|
||||
log.info('monitor', 'Auto-restarting container', { container: containerName, alerts: alerts.map(a => a.type) });
|
||||
|
||||
const container = docker.getContainer(containerId);
|
||||
await container.restart();
|
||||
@@ -377,11 +378,11 @@ class ResourceMonitor extends EventEmitter {
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: alerts
|
||||
}).catch(err => {
|
||||
console.error('[ResourceMonitor] Failed to send auto-restart notification:', err.message);
|
||||
log.error('monitor', err, { phase: 'sendAutoRestart' });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message);
|
||||
log.error('monitor', error, { container: containerName, phase: 'restart' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +391,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
*/
|
||||
triggerWorkflows(eventType, eventData) {
|
||||
if (!this.workflowEngine) {
|
||||
console.log('[ResourceMonitor] Workflow engine not set, skipping workflow trigger');
|
||||
log.info('monitor', 'Workflow engine not set, skipping workflow trigger');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -398,14 +399,14 @@ class ResourceMonitor extends EventEmitter {
|
||||
this.workflowEngine.triggerForEvent(eventType, eventData)
|
||||
.then(results => {
|
||||
if (results && results.length > 0) {
|
||||
console.log(`[ResourceMonitor] Triggered ${results.length} workflow(s) for ${eventType}`);
|
||||
log.info('monitor', `Triggered workflows for ${eventType}`, { count: results.length });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[ResourceMonitor] Workflow trigger error:', err.message);
|
||||
log.error('monitor', err, { phase: 'workflowTrigger' });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error triggering workflows:', error.message);
|
||||
log.error('monitor', error, { phase: 'workflowTrigger' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +415,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
*/
|
||||
setWorkflowEngine(workflowEngine) {
|
||||
this.workflowEngine = workflowEngine;
|
||||
console.log('[ResourceMonitor] Workflow engine configured');
|
||||
log.info('monitor', 'Workflow engine configured');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,10 +563,10 @@ class ResourceMonitor extends EventEmitter {
|
||||
if (fs.existsSync(ALERT_HISTORY_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(ALERT_HISTORY_FILE, 'utf8'));
|
||||
this.alertHistory = Array.isArray(data) ? data : [];
|
||||
console.log(`[ResourceMonitor] Loaded ${this.alertHistory.length} alert history entries`);
|
||||
log.info('monitor', 'Loaded alert history', { count: this.alertHistory.length });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error loading alert history:', error.message);
|
||||
log.error('monitor', error, { operation: 'loadAlertHistory' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,7 +577,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(ALERT_HISTORY_FILE, JSON.stringify(this.alertHistory, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error saving alert history:', error.message);
|
||||
log.error('monitor', error, { operation: 'saveAlertHistory' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,10 +607,10 @@ class ResourceMonitor extends EventEmitter {
|
||||
if (fs.existsSync(STATS_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(STATS_FILE, 'utf8'));
|
||||
this.stats = new Map(Object.entries(data));
|
||||
console.log(`[ResourceMonitor] Loaded stats for ${this.stats.size} containers`);
|
||||
log.info('monitor', 'Loaded stats', { containerCount: this.stats.size });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error loading stats:', error.message);
|
||||
log.error('monitor', error, { operation: 'loadStats' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +622,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
const data = Object.fromEntries(this.stats);
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error saving stats:', error.message);
|
||||
log.error('monitor', error, { operation: 'saveStats' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,10 +634,10 @@ class ResourceMonitor extends EventEmitter {
|
||||
if (fs.existsSync(ALERT_CONFIG_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(ALERT_CONFIG_FILE, 'utf8'));
|
||||
this.alerts = new Map(Object.entries(data));
|
||||
console.log(`[ResourceMonitor] Loaded alert config for ${this.alerts.size} containers`);
|
||||
log.info('monitor', 'Loaded alert config', { containerCount: this.alerts.size });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error loading alert config:', error.message);
|
||||
log.error('monitor', error, { operation: 'loadAlertConfig' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +649,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
const data = Object.fromEntries(this.alerts);
|
||||
fs.writeFileSync(ALERT_CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error saving alert config:', error.message);
|
||||
log.error('monitor', error, { operation: 'saveAlertConfig' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,10 +903,10 @@ class ResourceMonitor extends EventEmitter {
|
||||
if (fs.existsSync(STATS_HOURLY_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(STATS_HOURLY_FILE, 'utf8'));
|
||||
this.hourlyHistory = new Map(Object.entries(data));
|
||||
console.log(`[ResourceMonitor] Loaded hourly rollups for ${this.hourlyHistory.size} containers`);
|
||||
log.info('monitor', 'Loaded hourly rollups', { containerCount: this.hourlyHistory.size });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error loading hourly stats:', error.message);
|
||||
log.error('monitor', error, { operation: 'loadHourlyStats' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,7 +918,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
const data = Object.fromEntries(this.hourlyHistory);
|
||||
fs.writeFileSync(STATS_HOURLY_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error saving hourly stats:', error.message);
|
||||
log.error('monitor', error, { operation: 'saveHourlyStats' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,10 +930,10 @@ class ResourceMonitor extends EventEmitter {
|
||||
if (fs.existsSync(STATS_DAILY_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(STATS_DAILY_FILE, 'utf8'));
|
||||
this.dailyHistory = new Map(Object.entries(data));
|
||||
console.log(`[ResourceMonitor] Loaded daily rollups for ${this.dailyHistory.size} containers`);
|
||||
log.info('monitor', 'Loaded daily rollups', { containerCount: this.dailyHistory.size });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error loading daily stats:', error.message);
|
||||
log.error('monitor', error, { operation: 'loadDailyStats' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,7 +945,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
const data = Object.fromEntries(this.dailyHistory);
|
||||
fs.writeFileSync(STATS_DAILY_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ResourceMonitor] Error saving daily stats:', error.message);
|
||||
log.error('monitor', error, { operation: 'saveDailyStats' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
@@ -33,7 +34,7 @@ class UpdateManager extends EventEmitter {
|
||||
start() {
|
||||
if (this.checking) return;
|
||||
|
||||
console.log('[UpdateManager] Starting update checks');
|
||||
log.info('update', 'Starting update checks');
|
||||
this.checking = true;
|
||||
|
||||
// Initial check
|
||||
@@ -52,7 +53,7 @@ class UpdateManager extends EventEmitter {
|
||||
stop() {
|
||||
if (!this.checking) return;
|
||||
|
||||
console.log('[UpdateManager] Stopping update checks');
|
||||
log.info('update', 'Stopping update checks');
|
||||
this.checking = false;
|
||||
|
||||
if (this.checkInterval) {
|
||||
@@ -70,22 +71,22 @@ class UpdateManager extends EventEmitter {
|
||||
*/
|
||||
triggerWorkflows(eventType, eventData) {
|
||||
if (!this.workflowEngine) {
|
||||
console.log('[UpdateManager] Workflow engine not set, skipping workflow trigger');
|
||||
log.info('update', 'Workflow engine not set, skipping workflow trigger');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
this.workflowEngine.triggerForEvent(eventType, eventData)
|
||||
.then(results => {
|
||||
if (results && results.length > 0) {
|
||||
console.log(`[UpdateManager] Triggered ${results.length} workflow(s) for ${eventType}`);
|
||||
log.info('update', `Triggered workflows for ${eventType}`, { count: results.length });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[UpdateManager] Workflow trigger error:', err.message);
|
||||
log.error('update', err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UpdateManager] Error triggering workflows:', error.message);
|
||||
log.error('update', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ class UpdateManager extends EventEmitter {
|
||||
*/
|
||||
setWorkflowEngine(workflowEngine) {
|
||||
this.workflowEngine = workflowEngine;
|
||||
console.log('[UpdateManager] Workflow engine configured');
|
||||
log.info('update', 'Workflow engine configured');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,13 +132,13 @@ class UpdateManager extends EventEmitter {
|
||||
this.availableUpdates.delete(containerInfo.Id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[UpdateManager] Error checking ${containerInfo.Names[0]}:`, error.message);
|
||||
log.error('update', error, null, { containerName: containerInfo.Names[0] });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[UpdateManager] Found ${this.availableUpdates.size} updates available`);
|
||||
|
||||
log.info('update', 'Checked for updates', { availableCount: this.availableUpdates.size });
|
||||
} catch (error) {
|
||||
console.error('[UpdateManager] Error checking for updates:', error.message);
|
||||
log.error('update', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,10 +169,10 @@ class UpdateManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// gcr.io / quay.io / registry.gitlab.com — currently unsupported
|
||||
console.warn(`[UpdateManager] Custom registry not yet supported: ${remainder}`);
|
||||
log.warn('update', 'Custom registry not yet supported', { remainder });
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[UpdateManager] Error getting digest for ${imageName}:`, error.message);
|
||||
log.error('update', error, null, { imageName });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -338,7 +339,7 @@ class UpdateManager extends EventEmitter {
|
||||
async updateContainer(containerId, options = {}) {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`[UpdateManager] Starting update for container ${containerId}`);
|
||||
log.info('update', 'Starting update for container', { containerId });
|
||||
this.emit('update-start', { containerId, timestamp: new Date().toISOString() });
|
||||
|
||||
try {
|
||||
@@ -355,9 +356,9 @@ class UpdateManager extends EventEmitter {
|
||||
const oldImage = docker.getImage(oldImageId);
|
||||
const oldImageInspect = await oldImage.inspect();
|
||||
oldImageDigest = oldImageInspect.RepoDigests?.[0] || oldImageId;
|
||||
console.log(`[UpdateManager] Stored old image digest: ${oldImageDigest.substring(0, 40)}...`);
|
||||
log.info('update', 'Stored old image digest', { digestPrefix: oldImageDigest.substring(0, 40) });
|
||||
} catch (error) {
|
||||
console.warn(`[UpdateManager] Could not get old image digest: ${error.message}`);
|
||||
log.warn('update', 'Could not get old image digest', { error: error.message });
|
||||
}
|
||||
|
||||
// Create backup of current state
|
||||
@@ -375,24 +376,24 @@ class UpdateManager extends EventEmitter {
|
||||
|
||||
// Emit pre-update event for bundled workflows (e.g., backup-before-update)
|
||||
this.emit('pre-update', { containerId, containerName, imageName, backup });
|
||||
|
||||
|
||||
// Also trigger workflows for pre-update event directly
|
||||
this.triggerWorkflows('pre-update', { containerId, containerName, appId: containerName, imageName });
|
||||
|
||||
// Pull latest image
|
||||
console.log(`[UpdateManager] Pulling latest image: ${imageName}`);
|
||||
log.info('update', 'Pulling latest image', { imageName });
|
||||
await this.pullImage(imageName);
|
||||
|
||||
// Stop container
|
||||
console.log(`[UpdateManager] Stopping container: ${containerName}`);
|
||||
log.info('update', 'Stopping container', { containerName });
|
||||
await container.stop();
|
||||
|
||||
// Remove old container
|
||||
console.log(`[UpdateManager] Removing old container: ${containerName}`);
|
||||
log.info('update', 'Removing old container', { containerName });
|
||||
await container.remove();
|
||||
|
||||
// Create new container with same configuration
|
||||
console.log(`[UpdateManager] Creating new container: ${containerName}`);
|
||||
log.info('update', 'Creating new container', { containerName });
|
||||
const newContainer = await docker.createContainer({
|
||||
name: containerName,
|
||||
Image: imageName,
|
||||
@@ -401,11 +402,11 @@ class UpdateManager extends EventEmitter {
|
||||
});
|
||||
|
||||
// Start new container
|
||||
console.log(`[UpdateManager] Starting new container: ${containerName}`);
|
||||
log.info('update', 'Starting new container', { containerName });
|
||||
await newContainer.start();
|
||||
|
||||
// Extended verification with health checks and port accessibility
|
||||
console.log(`[UpdateManager] Performing extended verification...`);
|
||||
log.info('update', 'Performing extended verification');
|
||||
await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000);
|
||||
|
||||
// Get new image ID
|
||||
@@ -415,12 +416,12 @@ class UpdateManager extends EventEmitter {
|
||||
// Remove old image only after successful verification
|
||||
if (oldImageId !== newImageId) {
|
||||
try {
|
||||
console.log(`[UpdateManager] Removing old image: ${oldImageId.substring(0, 12)}`);
|
||||
log.info('update', 'Removing old image', { oldImageIdPrefix: oldImageId.substring(0, 12) });
|
||||
const oldImage = docker.getImage(oldImageId);
|
||||
await oldImage.remove({ force: false });
|
||||
console.log(`[UpdateManager] Old image removed successfully`);
|
||||
log.info('update', 'Old image removed successfully');
|
||||
} catch (error) {
|
||||
console.warn(`[UpdateManager] Could not remove old image (may be in use): ${error.message}`);
|
||||
log.warn('update', 'Could not remove old image (may be in use)', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +443,7 @@ class UpdateManager extends EventEmitter {
|
||||
this.availableUpdates.delete(containerId);
|
||||
|
||||
this.emit('update-complete', historyEntry);
|
||||
console.log(`[UpdateManager] Update completed in ${duration}ms`);
|
||||
log.info('update', 'Update completed', { durationMs: duration });
|
||||
|
||||
return historyEntry;
|
||||
} catch (error) {
|
||||
@@ -461,11 +462,11 @@ class UpdateManager extends EventEmitter {
|
||||
|
||||
// Attempt rollback
|
||||
if (options.autoRollback !== false) {
|
||||
console.log(`[UpdateManager] Attempting rollback for ${containerId}`);
|
||||
log.info('update', 'Attempting rollback', { containerId });
|
||||
try {
|
||||
await this.rollbackUpdate(containerId);
|
||||
} catch (rollbackError) {
|
||||
console.error(`[UpdateManager] Rollback failed:`, rollbackError.message);
|
||||
log.error('update', rollbackError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,7 +539,7 @@ class UpdateManager extends EventEmitter {
|
||||
const maxAttempts = Math.floor(timeout / 2000); // Check every 2 seconds
|
||||
let lastError = null;
|
||||
|
||||
console.log(`[UpdateManager] Extended verification with ${maxAttempts} attempts over ${timeout/1000}s`);
|
||||
log.info('update', 'Extended verification', { maxAttempts, timeoutSec: timeout / 1000 });
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
@@ -553,14 +554,14 @@ class UpdateManager extends EventEmitter {
|
||||
// Step 2: Check Docker health check if available
|
||||
if (inspect.State.Health) {
|
||||
if (inspect.State.Health.Status === 'healthy') {
|
||||
console.log(`[UpdateManager] Container health check: healthy`);
|
||||
log.info('update', 'Container health check: healthy');
|
||||
return true;
|
||||
} else if (inspect.State.Health.Status === 'unhealthy') {
|
||||
lastError = 'Container health check failed (unhealthy)';
|
||||
throw new Error(lastError);
|
||||
}
|
||||
// Status is 'starting' - continue waiting
|
||||
console.log(`[UpdateManager] Health check status: ${inspect.State.Health.Status} (attempt ${attempt + 1}/${maxAttempts})`);
|
||||
log.info('update', 'Health check status', { status: inspect.State.Health.Status, attempt: attempt + 1, maxAttempts });
|
||||
} else {
|
||||
// Step 3: No Docker health check - verify HTTP port accessibility
|
||||
const ports = this.extractPorts(inspect);
|
||||
@@ -578,22 +579,22 @@ class UpdateManager extends EventEmitter {
|
||||
|
||||
// Accept 2xx, 3xx, 4xx as "accessible" (server is responding)
|
||||
if (response.status >= 200 && response.status < 500) {
|
||||
console.log(`[UpdateManager] Port ${primaryPort.hostPort} is accessible (HTTP ${response.status})`);
|
||||
log.info('update', 'Port accessible', { hostPort: primaryPort.hostPort, httpStatus: response.status });
|
||||
|
||||
// Wait a bit more to ensure stability
|
||||
if (attempt >= 2) {
|
||||
console.log(`[UpdateManager] Container verified successfully`);
|
||||
log.info('update', 'Container verified successfully');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (fetchError) {
|
||||
lastError = `Port ${primaryPort.hostPort} not accessible: ${fetchError.message}`;
|
||||
console.log(`[UpdateManager] ${lastError} (attempt ${attempt + 1}/${maxAttempts})`);
|
||||
log.info('update', lastError, { attempt: attempt + 1, maxAttempts });
|
||||
}
|
||||
} else {
|
||||
// No ports exposed - just verify it's running for a few cycles
|
||||
if (attempt >= 5) {
|
||||
console.log(`[UpdateManager] Container running without exposed ports (verified)`);
|
||||
log.info('update', 'Container running without exposed ports (verified)');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -605,7 +606,7 @@ class UpdateManager extends EventEmitter {
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error.message;
|
||||
console.log(`[UpdateManager] Verification attempt ${attempt + 1} failed: ${lastError}`);
|
||||
log.info('update', 'Verification attempt failed', { attempt: attempt + 1, error: lastError });
|
||||
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
@@ -649,8 +650,8 @@ class UpdateManager extends EventEmitter {
|
||||
* Rollback to previous version
|
||||
*/
|
||||
async rollbackUpdate(containerId) {
|
||||
console.log(`[UpdateManager] Rolling back container ${containerId}`);
|
||||
|
||||
log.info('update', 'Rolling back container', { containerId });
|
||||
|
||||
// Find last successful update in history
|
||||
const lastUpdate = this.history
|
||||
.filter(h => h.containerId === containerId && h.status === 'success' && h.backup)
|
||||
@@ -682,12 +683,12 @@ class UpdateManager extends EventEmitter {
|
||||
|
||||
await newContainer.start();
|
||||
|
||||
console.log(`[UpdateManager] Rollback completed for ${backup.containerName}`);
|
||||
log.info('update', 'Rollback completed', { containerName: backup.containerName });
|
||||
this.emit('rollback-complete', { containerId, containerName: backup.containerName });
|
||||
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[UpdateManager] Rollback failed:`, error.message);
|
||||
log.error('update', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -697,18 +698,18 @@ class UpdateManager extends EventEmitter {
|
||||
*/
|
||||
scheduleUpdate(containerId, scheduledTime) {
|
||||
const delay = new Date(scheduledTime).getTime() - Date.now();
|
||||
|
||||
|
||||
if (delay < 0) {
|
||||
throw new Error('Scheduled time must be in the future');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.updateContainer(containerId).catch(error => {
|
||||
console.error(`[UpdateManager] Scheduled update failed:`, error.message);
|
||||
log.error('update', error);
|
||||
});
|
||||
}, delay);
|
||||
|
||||
console.log(`[UpdateManager] Update scheduled for ${containerId} at ${scheduledTime}`);
|
||||
log.info('update', 'Update scheduled', { containerId, scheduledTime });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -784,7 +785,7 @@ class UpdateManager extends EventEmitter {
|
||||
changelog: this.formatChangelog(repoInfo, tags, imageTag)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[UpdateManager] Error fetching changelog for ${imageName}:`, error.message);
|
||||
log.error('update', error, null, { imageName });
|
||||
|
||||
// Return basic info even on error
|
||||
const [fullRepo] = imageName.split(':');
|
||||
@@ -940,7 +941,7 @@ class UpdateManager extends EventEmitter {
|
||||
|
||||
const count = Object.values(this.config.autoUpdate || {}).filter(c => c.enabled).length;
|
||||
if (count > 0) {
|
||||
console.log(`[UpdateManager] Auto-update scheduler started (${count} container(s) configured)`);
|
||||
log.info('update', 'Auto-update scheduler started', { containerCount: count });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,17 +990,17 @@ class UpdateManager extends EventEmitter {
|
||||
const update = this.availableUpdates.get(containerId);
|
||||
if (!update) continue;
|
||||
|
||||
console.log(`[UpdateManager] Auto-updating ${update.containerName} (schedule: ${cfg.schedule})`);
|
||||
log.info('update', 'Auto-updating container', { containerName: update.containerName, schedule: cfg.schedule });
|
||||
this.emit('auto-update-start', { containerId, containerName: update.containerName, schedule: cfg.schedule });
|
||||
|
||||
try {
|
||||
const result = await this.updateContainer(containerId, { autoRollback: cfg.autoRollback !== false });
|
||||
cfg.lastAutoUpdate = now.toISOString();
|
||||
this.saveConfig();
|
||||
console.log(`[UpdateManager] Auto-update completed for ${update.containerName}`);
|
||||
log.info('update', 'Auto-update completed', { containerName: update.containerName });
|
||||
this.emit('auto-update-complete', { containerId, containerName: update.containerName, result });
|
||||
} catch (error) {
|
||||
console.error(`[UpdateManager] Auto-update failed for ${update.containerName}:`, error.message);
|
||||
log.error('update', error, null, { containerName: update.containerName });
|
||||
cfg.lastAutoUpdate = now.toISOString(); // Don't retry same day
|
||||
this.saveConfig();
|
||||
this.emit('auto-update-failed', { containerId, containerName: update.containerName, error: error.message });
|
||||
@@ -1056,7 +1057,7 @@ class UpdateManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(UPDATE_CONFIG_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UpdateManager] Error loading config:', error.message);
|
||||
log.error('update', error);
|
||||
}
|
||||
return { autoUpdate: {} };
|
||||
}
|
||||
@@ -1068,7 +1069,7 @@ class UpdateManager extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(UPDATE_CONFIG_FILE, JSON.stringify(this.config, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[UpdateManager] Error saving config:', error.message);
|
||||
log.error('update', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,7 +1082,7 @@ class UpdateManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(UPDATE_HISTORY_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UpdateManager] Error loading history:', error.message);
|
||||
log.error('update', error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1093,7 +1094,7 @@ class UpdateManager extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(UPDATE_HISTORY_FILE, JSON.stringify(this.history, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[UpdateManager] Error saving history:', error.message);
|
||||
log.error('update', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Disk Space Monitor
|
||||
*
|
||||
* Tracks Docker + system disk usage against a user-configured budget.
|
||||
* When usage exceeds thresholds, triggers automatic cleanup and notifications.
|
||||
*
|
||||
* Key concepts:
|
||||
* - diskBudgetGB: How much disk the user is willing to give DashCaddy (default 10)
|
||||
* - The monitor calculates Docker's footprint (images, volumes, containers, build cache)
|
||||
* - Breakdown shows where space goes so users can make informed decisions
|
||||
* - Auto-cleanup triggers at 80% (warning), 90% (aggressive), 95% (critical)
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_BUDGET_GB = 10;
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: true,
|
||||
diskBudgetGB: DEFAULT_BUDGET_GB,
|
||||
warningThresholdPct: 80,
|
||||
criticalThresholdPct: 90,
|
||||
autoCleanup: true,
|
||||
cleanupAggressivePct: 95,
|
||||
};
|
||||
|
||||
class DiskSpaceMonitor extends EventEmitter {
|
||||
constructor({ log, config }) {
|
||||
super();
|
||||
this.log = log;
|
||||
this.config = config;
|
||||
this.lastSnapshot = null;
|
||||
this.lastCleanup = null;
|
||||
this.intervalHandle = null;
|
||||
this.diskConfig = { ...DEFAULT_CONFIG };
|
||||
this._loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load disk budget config from the site config file
|
||||
* Stored under `diskSpace` key in config.json
|
||||
*/
|
||||
_loadConfig() {
|
||||
try {
|
||||
const raw = this.config?.diskSpace;
|
||||
if (raw) {
|
||||
this.diskConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...raw,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update disk space settings
|
||||
*/
|
||||
configure(updates) {
|
||||
const prev = { ...this.diskConfig };
|
||||
this.diskConfig = { ...this.diskConfig, ...updates };
|
||||
this._persistConfig();
|
||||
this.emit('config-changed', { prev, current: this.diskConfig });
|
||||
return this.diskConfig;
|
||||
}
|
||||
|
||||
_persistConfig() {
|
||||
// The config is persisted by the caller (settings route) which merges
|
||||
// into config.json. We just expose the current state.
|
||||
if (this.config) {
|
||||
this.config.diskSpace = this.diskConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a disk usage snapshot using `df` and `docker system df -v`
|
||||
*/
|
||||
async getSnapshot() {
|
||||
const [diskInfo, dockerInfo] = await Promise.all([
|
||||
this._getDiskInfo(),
|
||||
this._getDockerInfo(),
|
||||
]);
|
||||
|
||||
const snapshot = {
|
||||
timestamp: new Date().toISOString(),
|
||||
system: diskInfo,
|
||||
docker: dockerInfo,
|
||||
budget: {
|
||||
configuredGB: this.diskConfig.diskBudgetGB,
|
||||
dockerUsageGB: dockerInfo.totalGB,
|
||||
remainingBudgetGB: Math.max(0, this.diskConfig.diskBudgetGB - dockerInfo.totalGB),
|
||||
budgetUsedPct: Math.min(100, Math.round((dockerInfo.totalGB / this.diskConfig.diskBudgetGB) * 100)),
|
||||
status: this._getBudgetStatus(dockerInfo.totalGB),
|
||||
},
|
||||
config: { ...this.diskConfig },
|
||||
lastCleanup: this.lastCleanup,
|
||||
};
|
||||
|
||||
this.lastSnapshot = snapshot;
|
||||
|
||||
// Check thresholds and emit events
|
||||
this._checkThresholds(snapshot);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
_getBudgetStatus(dockerUsageGB) {
|
||||
const pct = (dockerUsageGB / this.diskConfig.diskBudgetGB) * 100;
|
||||
if (pct >= this.diskConfig.cleanupAggressivePct) return 'critical';
|
||||
if (pct >= this.diskConfig.criticalThresholdPct) return 'aggressive';
|
||||
if (pct >= this.diskConfig.warningThresholdPct) return 'warning';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
_checkThresholds(snapshot) {
|
||||
const { status, budgetUsedPct } = snapshot.budget;
|
||||
if (status === 'critical' || status === 'aggressive') {
|
||||
this.emit('budget-exceeded', snapshot);
|
||||
if (this.diskConfig.autoCleanup) {
|
||||
this.performCleanup(status === 'critical' ? 'aggressive' : 'standard').catch(() => {});
|
||||
}
|
||||
} else if (status === 'warning') {
|
||||
this.emit('budget-warning', snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async _getDiskInfo() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('df', ['-B1', '/']);
|
||||
const lines = stdout.trim().split('\n');
|
||||
const parts = lines[1].split(/\s+/);
|
||||
return {
|
||||
totalBytes: parseInt(parts[1], 10),
|
||||
usedBytes: parseInt(parts[2], 10),
|
||||
availableBytes: parseInt(parts[3], 10),
|
||||
usedPct: parseInt(parts[4], 10),
|
||||
mount: parts[5],
|
||||
totalGB: Math.round(parseInt(parts[1], 10) / 1073741824 * 10) / 10,
|
||||
usedGB: Math.round(parseInt(parts[2], 10) / 1073741824 * 10) / 10,
|
||||
availableGB: Math.round(parseInt(parts[3], 10) / 1073741824 * 10) / 10,
|
||||
};
|
||||
} catch {
|
||||
return { totalBytes: 0, usedBytes: 0, availableBytes: 0, usedPct: 0, totalGB: 0, usedGB: 0, availableGB: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async _getDockerInfo() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['system', 'df', '--format', '{{json .}}']);
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
let images = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
let containers = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
let volumes = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
let buildCache = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const d = JSON.parse(line);
|
||||
const type = d.Type?.toLowerCase() || '';
|
||||
const sizeGB = this._parseSizeToGB(d.Size);
|
||||
const reclaimGB = this._parseSizeToGB(d.Reclaimable);
|
||||
|
||||
if (type === 'images') images = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
else if (type === 'containers') containers = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
else if (type === 'local volumes') volumes = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
else if (type === 'build cache') buildCache = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
} catch { /* skip unparseable lines */ }
|
||||
}
|
||||
|
||||
const totalGB = Math.round((images.totalGB + containers.totalGB + volumes.totalGB + buildCache.totalGB) * 100) / 100;
|
||||
const reclaimableGB = Math.round((images.reclaimableGB + containers.reclaimableGB + volumes.reclaimableGB + buildCache.reclaimableGB) * 100) / 100;
|
||||
|
||||
return {
|
||||
images,
|
||||
containers,
|
||||
volumes,
|
||||
buildCache,
|
||||
totalGB,
|
||||
reclaimableGB,
|
||||
};
|
||||
} catch {
|
||||
return { images: {}, containers: {}, volumes: {}, buildCache: {}, totalGB: 0, reclaimableGB: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Docker's human-readable size strings (e.g., "2.519GB", "8.108MB", "0B")
|
||||
*/
|
||||
_parseSizeToGB(str) {
|
||||
if (!str || str === '0B') return 0;
|
||||
const match = str.match(/^([\d.]+)(B|KB|MB|GB|TB)$/i);
|
||||
if (!match) return 0;
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const multipliers = { B: 1e-9, KB: 1e-6, MB: 1e-3, GB: 1, TB: 1e3 };
|
||||
return Math.round(value * (multipliers[unit] || 0) * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-container log file sizes (the hidden disk hog)
|
||||
*/
|
||||
async _getContainerLogs() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('sh', ['-c', 'for f in /var/lib/docker/containers/*/*-json.log; do [ -f "$f" ] && stat -c "%s %n" "$f"; done 2>/dev/null | sort -rn | head -10']);
|
||||
const entries = [];
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
const [sizeStr, ...fileParts] = line.split(' ');
|
||||
const sizeBytes = parseInt(sizeStr, 10);
|
||||
entries.push({
|
||||
sizeBytes,
|
||||
sizeMB: Math.round(sizeBytes / 1048576 * 10) / 10,
|
||||
file: fileParts.join(' '),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cleanup
|
||||
* @param {string} level - 'standard' | 'aggressive' | 'logs-only'
|
||||
* @returns {Object} cleanup result with bytes reclaimed
|
||||
*/
|
||||
async performCleanup(level = 'standard') {
|
||||
const startTime = Date.now();
|
||||
const result = {
|
||||
level,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
actions: [],
|
||||
bytesReclaimed: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Always: truncate oversized container logs
|
||||
const logsBefore = await this._getContainerLogs();
|
||||
let logBytesFreed = 0;
|
||||
for (const log of logsBefore) {
|
||||
if (log.sizeBytes > 100 * 1048576) { // > 100MB
|
||||
try {
|
||||
await execFileAsync('truncate', ['-s', '0', log.file]);
|
||||
logBytesFreed += log.sizeBytes;
|
||||
result.actions.push({ action: 'truncate-log', file: log.file, freedBytes: log.sizeBytes });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
result.bytesReclaimed += logBytesFreed;
|
||||
|
||||
// Always: vacuum journald to 200MB
|
||||
try {
|
||||
const { stdout } = await execFileAsync('journalctl', ['--vacuum-size=200M']);
|
||||
const freedMatch = stdout.match(/freed ([\d.]+[KMGT]?B)/i);
|
||||
if (freedMatch) {
|
||||
const freedBytes = this._humanToBytes(freedMatch[1]);
|
||||
result.bytesReclaimed += freedBytes;
|
||||
result.actions.push({ action: 'vacuum-journal', freedBytes, freedHuman: freedMatch[1] });
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
if (level === 'standard' || level === 'aggressive') {
|
||||
// Prune dangling images
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['image', 'prune', '-f', '--filter', 'dangling=true']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-dangling-images', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Prune unused volumes
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['volume', 'prune', '-f']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-unused-volumes', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Prune build cache (keep last 500MB)
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['builder', 'prune', '-f', '--keep-storage', '500m']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-build-cache', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
if (level === 'aggressive') {
|
||||
// Remove ALL images not used by running containers
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['image', 'prune', '-a', '-f']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-all-unused-images', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Prune stopped containers older than 24h
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['container', 'prune', '-f', '--filter', 'until=24h']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-old-containers', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
result.completedAt = new Date().toISOString();
|
||||
result.durationMs = Date.now() - startTime;
|
||||
result.bytesReclaimedGB = Math.round(result.bytesReclaimed / 1073741824 * 100) / 100;
|
||||
|
||||
this.lastCleanup = result;
|
||||
this.emit('cleanup-complete', result);
|
||||
|
||||
if (this.log) {
|
||||
this.log.info('disk', 'Disk cleanup completed', {
|
||||
level,
|
||||
bytesReclaimed: result.bytesReclaimed,
|
||||
GBReclaimed: result.bytesReclaimedGB,
|
||||
durationMs: result.durationMs,
|
||||
actions: result.actions.length,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
result.error = err.message;
|
||||
result.completedAt = new Date().toISOString();
|
||||
if (this.log) {
|
||||
this.log.error('disk', 'Disk cleanup failed', { error: err.message, level });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
_humanToBytes(str) {
|
||||
const match = str.match(/^([\d.]+)(B|KB|MB|GB|TB)$/i);
|
||||
if (!match) return 0;
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const multipliers = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 };
|
||||
return Math.round(value * (multipliers[unit] || 0));
|
||||
}
|
||||
|
||||
_extractDockerReclaimed(stdout) {
|
||||
const match = stdout.match(/reclaimed\s+([\d.]+[KMGT]?B)/i) || stdout.match(/Total reclaimed space:\s*([\d.]+[KMGT]?B)/i);
|
||||
if (match) return this._humanToBytes(match[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic monitoring
|
||||
* @param {number} intervalMs - check interval (default 10 minutes)
|
||||
*/
|
||||
start(intervalMs = 600000) {
|
||||
if (this.intervalHandle) return;
|
||||
this.log?.info?.('disk', 'Disk space monitor started', { intervalMs });
|
||||
// Initial check
|
||||
this.getSnapshot().catch(() => {});
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.getSnapshot().catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return { ...this.diskConfig };
|
||||
}
|
||||
|
||||
async getDetailedBreakdown() {
|
||||
const [snapshot, containerLogs] = await Promise.all([
|
||||
this.getSnapshot(),
|
||||
this._getContainerLogs(),
|
||||
]);
|
||||
return {
|
||||
...snapshot,
|
||||
containerLogs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DiskSpaceMonitor, DEFAULT_DISK_CONFIG: DEFAULT_CONFIG };
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -349,7 +350,7 @@ class HealthChecker extends EventEmitter {
|
||||
|
||||
// Create new incident
|
||||
const incident = {
|
||||
id: `incident-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
id: `incident-${crypto.randomUUID()}`,
|
||||
serviceId,
|
||||
type,
|
||||
message,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { log } = require('../utils/logging');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const WORKFLOWS_FILE = process.env.WORKFLOWS_FILE || path.join(platformPaths.dataDir, 'workflows-config.json');
|
||||
@@ -45,7 +46,12 @@ const BUNDLED_WORKFLOWS = {
|
||||
interval: 15 * 60 * 1000, // 15 minutes
|
||||
actions: [
|
||||
{ type: 'health-check', target: '{{serviceId}}' },
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{serviceId}}' }
|
||||
// failingServices is set by healthCheckService when it throws (any
|
||||
// service failed). It's a comma-joined string of failing service IDs.
|
||||
// Previously this used {{serviceId}} which never resolved because
|
||||
// no per-service ID is in scope at the workflow level — that's the
|
||||
// DC-044 root-cause bug fix.
|
||||
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' }
|
||||
]
|
||||
},
|
||||
'disk-space-alert': {
|
||||
@@ -97,7 +103,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
this.enabled = new Map(Object.entries(data.enabled || {}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WorkflowEngine] Error loading config:', error.message);
|
||||
log.error('workflow', error, { operation: 'loadConfig' });
|
||||
}
|
||||
|
||||
// Default all workflows to enabled if not explicitly set
|
||||
@@ -118,7 +124,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
};
|
||||
fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[WorkflowEngine] Error saving config:', error.message);
|
||||
log.error('workflow', error, { operation: 'saveConfig' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +137,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
this.history = JSON.parse(fs.readFileSync(WORKFLOW_HISTORY_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WorkflowEngine] Error loading history:', error.message);
|
||||
log.error('workflow', error, { operation: 'loadHistory' });
|
||||
this.history = [];
|
||||
}
|
||||
}
|
||||
@@ -143,7 +149,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(WORKFLOW_HISTORY_FILE, JSON.stringify(this.history, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[WorkflowEngine] Error saving history:', error.message);
|
||||
log.error('workflow', error, { operation: 'saveHistory' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +175,11 @@ class WorkflowEngine extends EventEmitter {
|
||||
|
||||
const job = setInterval(() => {
|
||||
this.executeWorkflow(workflowId, { trigger: 'scheduled', timestamp: new Date().toISOString() })
|
||||
.catch(err => console.error(`[WorkflowEngine] Scheduled workflow ${workflowId} failed:`, err.message));
|
||||
.catch(err => log.error('workflow', err, { workflowId, phase: 'scheduled' }));
|
||||
}, workflow.interval);
|
||||
|
||||
this.scheduledJobs.set(workflowId, job);
|
||||
console.log(`[WorkflowEngine] Scheduled workflow '${workflowId}' every ${workflow.interval}ms`);
|
||||
log.info('workflow', 'Scheduled workflow', { workflowId, intervalMs: workflow.interval });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,34 +200,23 @@ class WorkflowEngine extends EventEmitter {
|
||||
if (!workflow) {
|
||||
throw new Error(`Unknown workflow: ${workflowId}`);
|
||||
}
|
||||
|
||||
|
||||
if (!this.enabled.get(workflowId)) {
|
||||
console.log(`[WorkflowEngine] Workflow ${workflowId} is disabled, skipping`);
|
||||
log.info('workflow', 'Workflow disabled, skipping', { workflowId });
|
||||
return { skipped: true, reason: 'disabled' };
|
||||
}
|
||||
|
||||
|
||||
const executionId = `${workflowId}-${Date.now()}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`[WorkflowEngine] Executing workflow: ${workflowId}`);
|
||||
|
||||
log.info('workflow', 'Executing workflow', { workflowId });
|
||||
this.emit('workflow-start', { workflowId, executionId, triggerData });
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const action of workflow.actions) {
|
||||
try {
|
||||
const result = await this.executeAction(action, triggerData);
|
||||
results.push({ action: action.type, success: true, result });
|
||||
} catch (error) {
|
||||
console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message);
|
||||
results.push({ action: action.type, success: false, error: error.message });
|
||||
// Continue with other actions but log failure
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const results = await this._runActions(workflow.actions, triggerData);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const allSucceeded = results.every(r => r.success);
|
||||
|
||||
|
||||
const historyEntry = {
|
||||
executionId,
|
||||
workflowId,
|
||||
@@ -232,22 +227,63 @@ class WorkflowEngine extends EventEmitter {
|
||||
success: allSucceeded,
|
||||
results
|
||||
};
|
||||
|
||||
|
||||
this.history.push(historyEntry);
|
||||
|
||||
|
||||
// Keep history to last 500 entries
|
||||
if (this.history.length > 500) {
|
||||
this.history = this.history.slice(-500);
|
||||
}
|
||||
|
||||
|
||||
this.saveHistory();
|
||||
|
||||
|
||||
this.emit('workflow-complete', historyEntry);
|
||||
console.log(`[WorkflowEngine] Workflow ${workflowId} completed in ${duration}ms, success: ${allSucceeded}`);
|
||||
|
||||
log.info('workflow', 'Workflow completed', { workflowId, durationMs: duration, success: allSucceeded });
|
||||
|
||||
return historyEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a sequence of actions and collect their results. Extracted from
|
||||
* executeWorkflow so the per-action result threading (notify-on-failure
|
||||
* gating) and the failingServices context surface can be unit-tested
|
||||
* directly. executeWorkflow() is the production entry point; _runActions
|
||||
* is an internal helper that callers shouldn't reach for.
|
||||
*/
|
||||
async _runActions(actions, triggerData = {}) {
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
const action = actions[i];
|
||||
const previousResult = i > 0 ? results[i - 1] : null;
|
||||
// notify-on-failure needs to see the previous action's outcome to decide
|
||||
// whether to fire. Passing the full results array in the trigger data lets
|
||||
// executeAction do that lookup without changing the action shape.
|
||||
// Also surface failingServices (set by healthCheckService on throw) so
|
||||
// template variables like {{failingServices}} can interpolate.
|
||||
const actionContext = {
|
||||
...triggerData,
|
||||
previousResult,
|
||||
failingServices: previousResult && previousResult.failingServices ? previousResult.failingServices : undefined,
|
||||
};
|
||||
try {
|
||||
const result = await this.executeAction(action, actionContext);
|
||||
results.push({ action: action.type, success: true, result });
|
||||
} catch (error) {
|
||||
log.error('workflow', error, { actionType: action.type });
|
||||
results.push({
|
||||
action: action.type,
|
||||
success: false,
|
||||
error: error.message,
|
||||
failingServices: error.failingServices,
|
||||
});
|
||||
// Continue with other actions but log failure
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single action
|
||||
*/
|
||||
@@ -269,7 +305,12 @@ class WorkflowEngine extends EventEmitter {
|
||||
);
|
||||
|
||||
case 'notify-on-failure':
|
||||
// Only send if previous action failed
|
||||
// Only send if previous action failed (success: false). The
|
||||
// previousResult is injected by executeWorkflow's loop. If there
|
||||
// was no previous action, this is a no-op (returns skipped).
|
||||
if (!context.previousResult || context.previousResult.success !== false) {
|
||||
return { skipped: true, reason: 'no previous failure' };
|
||||
}
|
||||
return this.notify(
|
||||
this.interpolate(action.message, context),
|
||||
action.channel
|
||||
@@ -282,7 +323,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
return this.collectMetrics(context.containerId, action.period);
|
||||
|
||||
default:
|
||||
console.warn(`[WorkflowEngine] Unknown action type: ${action.type}`);
|
||||
log.warn('workflow', 'Unknown action type', { actionType: action.type });
|
||||
return { skipped: true, reason: `Unknown action type: ${action.type}` };
|
||||
}
|
||||
}
|
||||
@@ -323,11 +364,31 @@ class WorkflowEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { checked: results.length, healthy: results.filter(r => r.healthy).length, results };
|
||||
// Surface failing service IDs so downstream notify-on-failure actions
|
||||
// can interpolate `{{failingServices}}` into the alert message. Without
|
||||
// this, templates like `Health check failed for {{serviceId}}` stay
|
||||
// literal because there's no serviceId in scope.
|
||||
const failing = results.filter(r => !r.healthy).map(r => r.service);
|
||||
const healthy = results.filter(r => r.healthy).length;
|
||||
const result = { checked: results.length, healthy, results, failing };
|
||||
if (failing.length > 0) {
|
||||
// Throw so the action's success:false path is taken and notify-on-failure fires.
|
||||
const err = new Error(`Health check failed for ${failing.length} service(s): ${failing.join(', ')}`);
|
||||
err.failingServices = failing;
|
||||
err.workflowResult = result;
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// Single service check
|
||||
const healthy = await this.checkContainerHealth(serviceId);
|
||||
if (!healthy) {
|
||||
const err = new Error(`Health check failed for ${serviceId}`);
|
||||
err.failingServices = [serviceId];
|
||||
err.workflowResult = { serviceId, healthy };
|
||||
throw err;
|
||||
}
|
||||
return { serviceId, healthy };
|
||||
}
|
||||
|
||||
@@ -338,10 +399,18 @@ class WorkflowEngine extends EventEmitter {
|
||||
try {
|
||||
const docker = this.ctx.docker?.client;
|
||||
if (!docker) return false;
|
||||
|
||||
|
||||
const container = docker.getContainer(containerId);
|
||||
const info = await container.inspect();
|
||||
return info.State && info.State.Running && info.State.Health !== 'unhealthy';
|
||||
// A container is healthy if it's running AND (it has no explicit
|
||||
// health check OR its health check reports healthy/starting).
|
||||
// info.State.Health is undefined when no HEALTHCHECK is declared.
|
||||
// info.State.Health.Status is 'starting' | 'healthy' | 'unhealthy'
|
||||
// when the health check IS declared.
|
||||
if (!info.State || !info.State.Running) return false;
|
||||
if (!info.State.Health) return true; // no health check defined → running = healthy
|
||||
const status = info.State.Health.Status;
|
||||
return status === 'healthy' || status === 'starting';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
@@ -360,7 +429,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
throw new Error('Container ID not provided');
|
||||
}
|
||||
|
||||
console.log(`[WorkflowEngine] Restarting container: ${containerId}`);
|
||||
log.info('workflow', 'Restarting container', { containerId });
|
||||
const container = docker.getContainer(containerId);
|
||||
await container.restart();
|
||||
|
||||
@@ -380,7 +449,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
throw new Error('App ID not provided');
|
||||
}
|
||||
|
||||
console.log(`[WorkflowEngine] Creating backup for: ${appId}`);
|
||||
log.info('workflow', 'Creating backup', { appId });
|
||||
|
||||
// Use backup manager's executeBackup if available
|
||||
const backupName = `${appId}-${label}`;
|
||||
@@ -409,11 +478,11 @@ class WorkflowEngine extends EventEmitter {
|
||||
async notify(message, channel) {
|
||||
const notification = this.ctx.notification;
|
||||
if (!notification) {
|
||||
console.warn('[WorkflowEngine] Notification manager not available');
|
||||
log.warn('workflow', 'Notification manager not available');
|
||||
return { notified: false, reason: 'no notification manager' };
|
||||
}
|
||||
|
||||
console.log(`[WorkflowEngine] Sending notification: ${message}`);
|
||||
log.info('workflow', 'Sending notification', { message });
|
||||
notification.send('workflow', 'Workflow Notification', message, 'info');
|
||||
|
||||
return { notified: true, message };
|
||||
@@ -480,7 +549,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[WorkflowEngine] Workflow ${workflowId} ${enabled ? 'enabled' : 'disabled'}`);
|
||||
log.info('workflow', 'Workflow toggled', { workflowId, enabled });
|
||||
return { workflowId, enabled };
|
||||
}
|
||||
|
||||
@@ -513,7 +582,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
const conditionMet = this.evaluateCondition(workflow.condition, eventData);
|
||||
return conditionMet;
|
||||
} catch (e) {
|
||||
console.warn(`[WorkflowEngine] Condition evaluation failed for ${id}:`, e.message);
|
||||
log.warn('workflow', 'Condition evaluation failed', { workflowId: id, error: e.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -573,7 +642,7 @@ class WorkflowEngine extends EventEmitter {
|
||||
for (const [workflowId] of this.scheduledJobs) {
|
||||
this.stopScheduledWorkflow(workflowId);
|
||||
}
|
||||
console.log('[WorkflowEngine] All scheduled workflows stopped');
|
||||
log.info('workflow', 'All scheduled workflows stopped');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
// Encryption settings
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
@@ -65,7 +66,7 @@ function loadOrCreateKey() {
|
||||
// Check for key in environment variable first
|
||||
if (process.env.DASHCADDY_ENCRYPTION_KEY) {
|
||||
encryptionKey = Buffer.from(process.env.DASHCADDY_ENCRYPTION_KEY, 'hex');
|
||||
console.log('[Crypto] Using encryption key from environment variable');
|
||||
log.info('crypto', 'Using encryption key from environment variable');
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
@@ -75,16 +76,16 @@ function loadOrCreateKey() {
|
||||
const keyData = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (keyData.length >= 64) {
|
||||
encryptionKey = Buffer.from(keyData, 'hex');
|
||||
console.log('[Crypto] Loaded encryption key from file');
|
||||
log.info('crypto', 'Loaded encryption key from file');
|
||||
// First-run bootstrap: if .bak doesn't exist yet, write the current
|
||||
// key to it. This ensures the silent recovery path is available from
|
||||
// the very next restart without requiring an explicit rotateKey().
|
||||
if (!fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 });
|
||||
console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`);
|
||||
log.info('crypto', 'Seeded .bak key file for future fallback');
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not seed .bak key file:', e.message);
|
||||
log.warn('crypto', 'Could not seed .bak key file', { error: e.message });
|
||||
}
|
||||
}
|
||||
// Try fallback to .bak key if primary can't decrypt existing credentials.
|
||||
@@ -98,14 +99,14 @@ function loadOrCreateKey() {
|
||||
encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not check backup key:', e.message);
|
||||
log.warn('crypto', 'Could not check backup key', { error: e.message });
|
||||
}
|
||||
}
|
||||
return encryptionKey;
|
||||
}
|
||||
// File exists but key is invalid/empty - will generate new one below
|
||||
} catch (error) {
|
||||
console.error('[Crypto] Error loading key file:', error.message);
|
||||
log.error('crypto', error, { operation: 'loadKey' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +116,10 @@ function loadOrCreateKey() {
|
||||
try {
|
||||
// Save key to file with restricted permissions
|
||||
fs.writeFileSync(KEY_FILE, encryptionKey.toString('hex'), { mode: 0o600 });
|
||||
console.log('[Crypto] Generated and saved new encryption key');
|
||||
log.info('crypto', 'Generated and saved new encryption key');
|
||||
} catch (error) {
|
||||
console.warn('[Crypto] Could not save key to file:', error.message);
|
||||
console.warn('[Crypto] Key will be regenerated on restart - credentials will need to be re-entered');
|
||||
log.warn('crypto', 'Could not save key to file', { error: error.message });
|
||||
log.warn('crypto', 'Key will be regenerated on restart - credentials will need to be re-entered');
|
||||
}
|
||||
|
||||
return encryptionKey;
|
||||
@@ -171,12 +172,7 @@ function tryFallbackToBackupKey(primaryKey, backupKey) {
|
||||
|
||||
if (tryDecrypt(primaryKey)) return primaryKey;
|
||||
if (tryDecrypt(backupKey)) {
|
||||
console.warn(
|
||||
'[Crypto] Primary encryption key failed to decrypt credentials; ' +
|
||||
'fell back to .encryption-key.bak. The current primary key was set ' +
|
||||
'without preserving the original. Consider rotating the key explicitly ' +
|
||||
'via the credential-manager API to avoid this warning next restart.'
|
||||
);
|
||||
log.warn('crypto', 'Primary encryption key failed to decrypt credentials; fell back to .encryption-key.bak. Consider rotating the key explicitly via the credential-manager API.');
|
||||
return backupKey;
|
||||
}
|
||||
return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
|
||||
@@ -291,7 +287,7 @@ function decryptFields(obj, fields = null) {
|
||||
try {
|
||||
result[field] = decrypt(result[field]);
|
||||
} catch (error) {
|
||||
console.error(`[Crypto] Failed to decrypt field '${field}':`, error.message);
|
||||
log.error('crypto', error, { field, operation: 'decryptField' });
|
||||
// Leave the field as-is if decryption fails
|
||||
}
|
||||
}
|
||||
@@ -315,7 +311,7 @@ function migrateToEncrypted(credentials, sensitiveFields) {
|
||||
return credentials; // Already encrypted
|
||||
}
|
||||
|
||||
console.log('[Crypto] Migrating plaintext credentials to encrypted format');
|
||||
log.info('crypto', 'Migrating plaintext credentials to encrypted format');
|
||||
return encryptFields(credentials, sensitiveFields);
|
||||
}
|
||||
|
||||
@@ -340,10 +336,10 @@ function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'ap
|
||||
}
|
||||
|
||||
// Plain text data - migrate it
|
||||
console.log(`[Crypto] Found plaintext data in ${filePath}, will encrypt on next save`);
|
||||
log.info('crypto', 'Found plaintext data', { filePath });
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error(`[Crypto] Error reading ${filePath}:`, error.message);
|
||||
log.error('crypto', error, { filePath, operation: 'readFile' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -357,7 +353,7 @@ function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'ap
|
||||
function writeEncryptedFile(filePath, credentials, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) {
|
||||
const encrypted = encryptFields(credentials, sensitiveFields);
|
||||
fs.writeFileSync(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
|
||||
console.log(`[Crypto] Saved encrypted credentials to ${filePath}`);
|
||||
log.info('crypto', 'Saved encrypted credentials', { filePath });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,7 +373,7 @@ function rotateKey() {
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message);
|
||||
log.warn('crypto', 'Could not save backup key', { error: error.message });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -162,6 +162,20 @@ function csrfValidationMiddleware(req, res, next) {
|
||||
// is bounded by the token's TTL + scope. Same model as invite accept.
|
||||
'/api/v1/share/:token/subscribe',
|
||||
'/api/v1/share/:token/redeem-tailscale',
|
||||
// DC-055: Stripe Checkout session creation. Browsers hit this from the
|
||||
// public pricing page (cross-origin from any *.sami subdomain that
|
||||
// serves it); no session cookie exists yet, so a CSRF token can't be
|
||||
// anchored. SameSite=Lax on the session cookie doesn't apply (none
|
||||
// exists). Threat model: an attacker who can trigger checkout sessions
|
||||
// can only force a customer to land on Stripe's hosted page — they
|
||||
// can't extract money. Stripe's session id is single-use and tied to a
|
||||
// chosen price; reusing it requires Stripe's webhook secret.
|
||||
'/api/v1/billing/checkout',
|
||||
// DC-057: success page polls this from the customer's browser after
|
||||
// Stripe redirects them back. Same CSRF argument as above (no session
|
||||
// cookie exists yet) — and the response is the customer's own license
|
||||
// code, not anything an attacker can exploit by triggering the lookup.
|
||||
'/api/v1/billing/lookup/:sessionId',
|
||||
'/health',
|
||||
'/health/live',
|
||||
'/health/ready',
|
||||
|
||||
@@ -9,6 +9,7 @@ const path = require('path');
|
||||
const https = require('https');
|
||||
const Docker = require('dockerode');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
@@ -19,7 +20,7 @@ class DockerSecurity {
|
||||
constructor() {
|
||||
this.config = this.loadConfig();
|
||||
this.mode = VERIFICATION_MODE;
|
||||
console.log(`[DockerSecurity] Initialized in ${this.mode} mode`);
|
||||
log.info('security', 'Docker security initialized', { mode: this.mode });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +33,7 @@ class DockerSecurity {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[DockerSecurity] Failed to load config: ${error.message}`);
|
||||
log.warn('security', 'Failed to load config', { error: error.message });
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
@@ -51,7 +52,7 @@ class DockerSecurity {
|
||||
try {
|
||||
fs.writeFileSync(SECURITY_CONFIG_FILE, JSON.stringify(this.config, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`[DockerSecurity] Failed to save config: ${error.message}`);
|
||||
log.error('security', error, { operation: 'saveConfig' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +111,7 @@ class DockerSecurity {
|
||||
repository = repository.split(':')[0];
|
||||
}
|
||||
|
||||
console.log(`[DockerSecurity] Fetching manifest for ${registry}/${repository}:${tag}`);
|
||||
log.info('security', 'Fetching manifest', { registry, repository, tag });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const isDockerHub = registry === 'registry-1.docker.io';
|
||||
@@ -216,7 +217,7 @@ class DockerSecurity {
|
||||
if (this.config.updateTrustedOnPull) {
|
||||
this.config.trustedDigests[imageName] = actualDigest;
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Added trusted digest for ${imageName}`);
|
||||
log.info('security', 'Added trusted digest', { imageName });
|
||||
}
|
||||
}
|
||||
} else if (actualDigest === trustedDigest) {
|
||||
@@ -250,26 +251,26 @@ class DockerSecurity {
|
||||
* @returns {Promise<object>} Verification result
|
||||
*/
|
||||
async verifyPulledImage(imageName) {
|
||||
console.log(`[DockerSecurity] Verifying image: ${imageName}`);
|
||||
log.info('security', 'Verifying image', { imageName });
|
||||
|
||||
try {
|
||||
const actualDigest = await this.getImageDigest(imageName);
|
||||
const result = await this.verifyImageDigest(imageName, actualDigest);
|
||||
|
||||
if (result.action === 'reject') {
|
||||
console.error(`[DockerSecurity] REJECTED: ${result.reason}`);
|
||||
log.error('security', 'Image REJECTED', { imageName, reason: result.reason });
|
||||
throw new Error(`Image verification failed: ${result.reason}`);
|
||||
} else if (result.action === 'warn') {
|
||||
console.warn(`[DockerSecurity] WARNING: ${result.reason}`);
|
||||
console.warn(`[DockerSecurity] Expected: ${result.trustedDigest}`);
|
||||
console.warn(`[DockerSecurity] Actual: ${result.actualDigest}`);
|
||||
log.warn('security', 'Image WARNING', { imageName, reason: result.reason });
|
||||
log.warn('security', 'Expected digest', { imageName, digest: result.trustedDigest });
|
||||
log.warn('security', 'Actual digest', { imageName, digest: result.actualDigest });
|
||||
} else {
|
||||
console.log(`[DockerSecurity] ACCEPTED: ${result.reason}`);
|
||||
log.info('security', 'Image ACCEPTED', { imageName, reason: result.reason });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[DockerSecurity] Verification error: ${error.message}`);
|
||||
log.error('security', error, { imageName, operation: 'verify' });
|
||||
|
||||
if (this.mode === 'strict') {
|
||||
throw error;
|
||||
@@ -294,7 +295,7 @@ class DockerSecurity {
|
||||
setTrustedDigest(imageName, digest) {
|
||||
this.config.trustedDigests[imageName] = digest;
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Updated trusted digest for ${imageName}`);
|
||||
log.info('security', 'Updated trusted digest', { imageName });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,7 +305,7 @@ class DockerSecurity {
|
||||
removeTrustedDigest(imageName) {
|
||||
delete this.config.trustedDigests[imageName];
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Removed trusted digest for ${imageName}`);
|
||||
log.info('security', 'Removed trusted digest', { imageName });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,7 +326,7 @@ class DockerSecurity {
|
||||
this.mode = mode;
|
||||
this.config.verificationMode = mode;
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Verification mode set to: ${mode}`);
|
||||
log.info('security', 'Verification mode set', { mode });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const { log } = require('../utils/logging');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
@@ -95,7 +96,7 @@ function createTail({ filePath, stateFile, onLine, label = 'tail', pollMs = 1000
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try { onLine(line); } catch (e) {
|
||||
console.error(`[${label}] onLine threw:`, e.message);
|
||||
log.error('events', e, { worker: label, phase: 'onLine' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +107,7 @@ function createTail({ filePath, stateFile, onLine, label = 'tail', pollMs = 1000
|
||||
setTimeout(tick, pollMs);
|
||||
});
|
||||
stream.on('error', (e) => {
|
||||
console.error(`[${label}] read error:`, e.message);
|
||||
log.error('events', e, { worker: label, phase: 'read' });
|
||||
setTimeout(tick, pollMs * 5);
|
||||
});
|
||||
});
|
||||
@@ -267,11 +268,11 @@ function startFail2banWorker({ log } = {}) {
|
||||
function startAll({ log } = {}) {
|
||||
const workers = [];
|
||||
try { workers.push(startCaddyWorker({ log })); }
|
||||
catch (e) { console.error('[workers] caddy worker failed to start:', e.message); }
|
||||
catch (e) { log.error('events', e, { worker: 'caddy', phase: 'start' }); }
|
||||
try { workers.push(startSharedBansWorker({ log })); }
|
||||
catch (e) { console.error('[workers] shared_bans worker failed to start:', e.message); }
|
||||
catch (e) { log.error('events', e, { worker: 'shared_bans', phase: 'start' }); }
|
||||
try { workers.push(startFail2banWorker({ log })); }
|
||||
catch (e) { console.error('[workers] fail2ban worker failed to start:', e.message); }
|
||||
catch (e) { log.error('events', e, { worker: 'fail2ban', phase: 'start' }); }
|
||||
return {
|
||||
stop() { workers.forEach(w => { try { w.stop(); } catch {} }); },
|
||||
workers,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const { execSync, execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
const SERVICE_NAME = 'DashCaddy';
|
||||
const ACCOUNT_PREFIX = 'dashcaddy';
|
||||
@@ -44,7 +45,7 @@ class KeychainManager {
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
console.warn('[Keychain] OS keychain not available, will use encrypted file storage');
|
||||
log.warn('keychain', 'OS keychain not available, will use encrypted file storage');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +73,7 @@ class KeychainManager {
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[Keychain] Failed to store ${key}:`, error.message);
|
||||
log.error('keychain', error, { key, operation: 'store' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +100,7 @@ class KeychainManager {
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[Keychain] Failed to retrieve ${key}:`, error.message);
|
||||
log.error('keychain', error, { key, operation: 'retrieve' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -126,7 +127,7 @@ class KeychainManager {
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[Keychain] Failed to delete ${key}:`, error.message);
|
||||
log.error('keychain', error, { key, operation: 'delete' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { DOCKER } = require('../utilities/constants');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
@@ -63,7 +64,7 @@ class LogDigest extends EventEmitter {
|
||||
// Collect logs every hour
|
||||
this.collectInterval = setInterval(() => {
|
||||
this._collectHourlyLogs().catch(e =>
|
||||
console.error('[LogDigest] Hourly collection failed:', e.message)
|
||||
log.error('logdigest', e, { phase: 'hourlyCollect' })
|
||||
);
|
||||
}, DOCKER.DIGEST.COLLECT_INTERVAL);
|
||||
|
||||
@@ -71,7 +72,7 @@ class LogDigest extends EventEmitter {
|
||||
this._scheduleDailyDigest();
|
||||
|
||||
// Run initial collection after 2 minutes
|
||||
setTimeout(() => {
|
||||
this._initialTimeout = setTimeout(() => {
|
||||
if (this.running) {
|
||||
this._collectHourlyLogs().catch(() => {});
|
||||
}
|
||||
@@ -89,6 +90,10 @@ class LogDigest extends EventEmitter {
|
||||
clearTimeout(this.digestTimeout);
|
||||
this.digestTimeout = null;
|
||||
}
|
||||
if (this._initialTimeout) {
|
||||
clearTimeout(this._initialTimeout);
|
||||
this._initialTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,7 +200,7 @@ class LogDigest extends EventEmitter {
|
||||
hourSummary.services[appId] = serviceSummary;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LogDigest] Container enumeration failed:', e.message);
|
||||
log.error('logdigest', e, { phase: 'enumerateContainers' });
|
||||
}
|
||||
|
||||
// Add to ring buffer
|
||||
@@ -258,7 +263,7 @@ class LogDigest extends EventEmitter {
|
||||
const delay = next.getTime() - now.getTime();
|
||||
this.digestTimeout = setTimeout(() => {
|
||||
this.generateDailyDigest().catch(e =>
|
||||
console.error('[LogDigest] Daily digest generation failed:', e.message)
|
||||
log.error('logdigest', e, { phase: 'dailyDigest' })
|
||||
);
|
||||
// Reschedule for tomorrow
|
||||
if (this.running) this._scheduleDailyDigest();
|
||||
|
||||
@@ -9,6 +9,7 @@ const { execSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
// Format bytes to human readable string
|
||||
function formatBytes(bytes) {
|
||||
@@ -38,7 +39,7 @@ class BackupManager extends EventEmitter {
|
||||
start() {
|
||||
if (this.running) return;
|
||||
|
||||
console.log('[BackupManager] Starting backup scheduler');
|
||||
log.info('backup', 'Starting backup scheduler');
|
||||
this.running = true;
|
||||
|
||||
// Schedule all configured backups
|
||||
@@ -55,7 +56,7 @@ class BackupManager extends EventEmitter {
|
||||
stop() {
|
||||
if (!this.running) return;
|
||||
|
||||
console.log('[BackupManager] Stopping backup scheduler');
|
||||
log.info('backup', 'Stopping backup scheduler');
|
||||
this.running = false;
|
||||
|
||||
// Clear all scheduled jobs
|
||||
@@ -91,7 +92,7 @@ class BackupManager extends EventEmitter {
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
intervalMs = minutes * 60 * 1000;
|
||||
} else {
|
||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||
log.warn('backup', 'Invalid schedule', { name, schedule: backup.schedule });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -100,17 +101,17 @@ class BackupManager extends EventEmitter {
|
||||
// Schedule the job
|
||||
const job = setInterval(() => {
|
||||
this.executeBackup(name, backup).catch(error => {
|
||||
console.error(`[BackupManager] Scheduled backup ${name} failed:`, error.message);
|
||||
log.error('backup', error, { name });
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
this.scheduledJobs.set(name, job);
|
||||
console.log(`[BackupManager] Scheduled backup '${name}' every ${backup.schedule}`);
|
||||
log.info('backup', 'Scheduled backup', { name, schedule: backup.schedule });
|
||||
|
||||
// Run immediately if configured
|
||||
if (backup.runImmediately) {
|
||||
this.executeBackup(name, backup).catch(error => {
|
||||
console.error(`[BackupManager] Initial backup ${name} failed:`, error.message);
|
||||
log.error('backup', error, { name, phase: 'initial' });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -122,7 +123,7 @@ class BackupManager extends EventEmitter {
|
||||
const startTime = Date.now();
|
||||
const backupId = `${name}-${Date.now()}`;
|
||||
|
||||
console.log(`[BackupManager] Starting backup: ${name}`);
|
||||
log.info('backup', 'Starting backup', { name });
|
||||
|
||||
this.emit('backup-start', { name, backupId, timestamp: new Date().toISOString() });
|
||||
|
||||
@@ -151,7 +152,7 @@ class BackupManager extends EventEmitter {
|
||||
const location = await this.saveToDestination(finalData, dest, backupId);
|
||||
savedLocations.push(location);
|
||||
} catch (error) {
|
||||
console.error(`[BackupManager] Failed to save to ${dest.type}:`, error.message);
|
||||
log.error('backup', error, { destType: dest.type });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +193,7 @@ class BackupManager extends EventEmitter {
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
||||
log.info('backup', 'Backup completed', { name, durationMs: duration });
|
||||
|
||||
return historyEntry;
|
||||
} catch (error) {
|
||||
@@ -263,7 +264,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(servicesFile, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up services:', error.message);
|
||||
log.error('backup', error, { source: 'services' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -278,7 +279,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up config:', error.message);
|
||||
log.error('backup', error, { source: 'config' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -291,7 +292,7 @@ class BackupManager extends EventEmitter {
|
||||
const credentialManager = require('../managers/credential-manager');
|
||||
return credentialManager.exportBackup();
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up credentials:', error.message);
|
||||
log.error('backup', error, { source: 'credentials' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -304,7 +305,7 @@ class BackupManager extends EventEmitter {
|
||||
const resourceMonitor = require('../managers/resource-monitor');
|
||||
return resourceMonitor.exportStats();
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up stats:', error.message);
|
||||
log.error('backup', error, { source: 'stats' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -374,7 +375,7 @@ class BackupManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
} catch (volumeError) {
|
||||
console.error(`[BackupManager] Error backing up volume ${volume.Name}:`, volumeError.message);
|
||||
log.error('backup', volumeError, { volume: volume.Name });
|
||||
backupResults.push({
|
||||
name: volume.Name,
|
||||
status: 'failed',
|
||||
@@ -390,7 +391,7 @@ class BackupManager extends EventEmitter {
|
||||
volumes: backupResults
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up volumes:', error.message);
|
||||
log.error('backup', error, { source: 'volumes' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -461,9 +462,9 @@ class BackupManager extends EventEmitter {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`[BackupManager] Volume ${volumeName} restored successfully`);
|
||||
log.info('backup', 'Volume restored', { volume: volumeName });
|
||||
} catch (restoreError) {
|
||||
console.error(`[BackupManager] Error restoring volume ${volBackup.name}:`, restoreError.message);
|
||||
log.error('backup', restoreError, { volume: volBackup.name });
|
||||
restoreResults.push({
|
||||
name: volBackup.name,
|
||||
status: 'failed',
|
||||
@@ -849,7 +850,7 @@ class BackupManager extends EventEmitter {
|
||||
throw new Error('Backup verification failed: checksum mismatch');
|
||||
}
|
||||
|
||||
console.log('[BackupManager] Backup verified successfully');
|
||||
log.info('backup', 'Backup verified successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -860,7 +861,7 @@ class BackupManager extends EventEmitter {
|
||||
* Restore from backup
|
||||
*/
|
||||
async restoreBackup(backupId, options = {}) {
|
||||
console.log(`[BackupManager] Starting restore from backup: ${backupId}`);
|
||||
log.info('backup', 'Starting restore', { backupId });
|
||||
|
||||
this.emit('restore-start', { backupId, timestamp: new Date().toISOString() });
|
||||
|
||||
@@ -922,7 +923,7 @@ class BackupManager extends EventEmitter {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log('[BackupManager] Restore completed successfully');
|
||||
log.info('backup', 'Restore completed successfully');
|
||||
return { success: true, restored };
|
||||
} catch (error) {
|
||||
this.emit('restore-failed', {
|
||||
@@ -940,7 +941,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreServices(services) {
|
||||
const servicesFile = platformPaths.servicesFile;
|
||||
fs.writeFileSync(servicesFile, JSON.stringify(services, null, 2));
|
||||
console.log('[BackupManager] Services restored');
|
||||
log.info('backup', 'Services restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -949,7 +950,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreConfig(config) {
|
||||
const configFile = platformPaths.configFile;
|
||||
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
|
||||
console.log('[BackupManager] Config restored');
|
||||
log.info('backup', 'Config restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -958,7 +959,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreCredentials(credentials) {
|
||||
const credentialManager = require('../managers/credential-manager');
|
||||
credentialManager.importBackup(credentials);
|
||||
console.log('[BackupManager] Credentials restored');
|
||||
log.info('backup', 'Credentials restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -967,7 +968,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreStats(stats) {
|
||||
const resourceMonitor = require('../managers/resource-monitor');
|
||||
resourceMonitor.importStats(stats);
|
||||
console.log('[BackupManager] Stats restored');
|
||||
log.info('backup', 'Stats restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -975,7 +976,7 @@ class BackupManager extends EventEmitter {
|
||||
*/
|
||||
async enforceStorageLimit(name, maxBytes) {
|
||||
const maxStr = formatBytes(maxBytes);
|
||||
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
|
||||
log.info('backup', 'Enforcing storage limit', { name, limit: maxStr });
|
||||
|
||||
const backups = this.history
|
||||
.filter(b => b.name === name && b.status === 'success')
|
||||
@@ -994,10 +995,10 @@ class BackupManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
|
||||
log.info('backup', 'Current storage usage', { totalSize: formatBytes(totalSize), limit: maxStr });
|
||||
|
||||
if (totalSize <= maxBytes) {
|
||||
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
|
||||
log.info('backup', 'Storage limit OK', { totalSize: formatBytes(totalSize), limit: maxStr });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1013,10 +1014,10 @@ class BackupManager extends EventEmitter {
|
||||
const sz = backup.size || 0;
|
||||
totalSize -= sz;
|
||||
freed += sz;
|
||||
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
|
||||
log.info('backup', 'Deleted old backup file', { size: formatBytes(sz), path });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
|
||||
log.error('backup', error, { path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,7 +1025,7 @@ class BackupManager extends EventEmitter {
|
||||
}
|
||||
|
||||
this.saveHistory();
|
||||
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
|
||||
log.info('backup', 'Storage limit enforced', { freed: formatBytes(freed), totalSize: formatBytes(totalSize) });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1051,9 +1052,9 @@ class BackupManager extends EventEmitter {
|
||||
// Remove from history
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
|
||||
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
|
||||
log.info('backup', 'Deleted old backup', { backupId: backup.id });
|
||||
} catch (error) {
|
||||
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
|
||||
log.error('backup', error, { backupId: backup.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1109,7 +1110,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(BACKUP_CONFIG_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error loading config:', error.message);
|
||||
log.error('backup', error, { operation: 'loadConfig' });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1125,7 +1126,7 @@ class BackupManager extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(BACKUP_CONFIG_FILE, JSON.stringify(this.config, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error saving config:', error.message);
|
||||
log.error('backup', error, { operation: 'saveConfig' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1138,7 +1139,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(BACKUP_HISTORY_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error loading history:', error.message);
|
||||
log.error('backup', error, { operation: 'loadHistory' });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1150,7 +1151,7 @@ class BackupManager extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(BACKUP_HISTORY_FILE, JSON.stringify(this.history, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error saving history:', error.message);
|
||||
log.error('backup', error, { operation: 'saveHistory' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,161 @@
|
||||
* Validates config.json structure to catch typos and invalid values early.
|
||||
*/
|
||||
|
||||
const VALID_TIMEZONES_SAMPLE = [
|
||||
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai',
|
||||
'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland'
|
||||
const VALID_THEMES = ['dark', 'light', 'blue'];
|
||||
const VALID_ROUTING_MODES = ['subdomain', 'subdirectory'];
|
||||
const VALID_DNS_PROVIDERS = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
||||
|
||||
const KNOWN_KEYS = [
|
||||
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
|
||||
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
|
||||
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
|
||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||
'customLogoDark', 'customLogoLight'
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string[]} arr
|
||||
* @param {string} val
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isInArray(arr, val) {
|
||||
return arr.includes(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateTld(ctx, config) {
|
||||
if (config.tld === undefined) return;
|
||||
if (typeof config.tld !== 'string') {
|
||||
ctx.errors.push('tld must be a string');
|
||||
return;
|
||||
}
|
||||
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
|
||||
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
|
||||
ctx.errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
|
||||
}
|
||||
if (tld.length > 20) {
|
||||
ctx.warnings.push(`tld "${config.tld}" is unusually long`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateDns(ctx, config) {
|
||||
if (config.dns === undefined) return;
|
||||
if (typeof config.dns !== 'object' || config.dns === null) {
|
||||
ctx.errors.push('dns must be an object');
|
||||
return;
|
||||
}
|
||||
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
|
||||
ctx.errors.push('dns.ip must be a string');
|
||||
}
|
||||
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
|
||||
ctx.errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
|
||||
}
|
||||
if (config.dns.port !== undefined) {
|
||||
const port = parseInt(config.dns.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
|
||||
}
|
||||
}
|
||||
if (config.dns.servers !== undefined) {
|
||||
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
|
||||
ctx.errors.push('dns.servers must be an object');
|
||||
}
|
||||
}
|
||||
if (config.dns.provider !== undefined) {
|
||||
if (typeof config.dns.provider !== 'string') {
|
||||
ctx.errors.push('dns.provider must be a string');
|
||||
} else if (!isInArray(VALID_DNS_PROVIDERS, config.dns.provider)) {
|
||||
ctx.warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${VALID_DNS_PROVIDERS.join(', ')}. It may still work if a custom adapter is installed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateDashboardHost(ctx, config) {
|
||||
if (config.dashboardHost === undefined) return;
|
||||
if (typeof config.dashboardHost !== 'string') {
|
||||
ctx.errors.push('dashboardHost must be a string');
|
||||
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
|
||||
ctx.errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateTimezone(ctx, config) {
|
||||
if (config.timezone === undefined) return;
|
||||
if (typeof config.timezone !== 'string') {
|
||||
ctx.errors.push('timezone must be a string');
|
||||
} else if (config.timezone) {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
|
||||
} catch {
|
||||
ctx.errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateTheme(ctx, config) {
|
||||
if (config.theme === undefined) return;
|
||||
if (!isInArray(VALID_THEMES, config.theme)) {
|
||||
ctx.warnings.push(`theme "${config.theme}" is not one of: ${VALID_THEMES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateRoutingMode(ctx, config) {
|
||||
if (config.routingMode === undefined) return;
|
||||
if (!isInArray(VALID_ROUTING_MODES, config.routingMode)) {
|
||||
ctx.errors.push(`routingMode "${config.routingMode}" is not one of: ${VALID_ROUTING_MODES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateDomain(ctx, config) {
|
||||
if (config.domain === undefined) return;
|
||||
if (typeof config.domain !== 'string') {
|
||||
ctx.errors.push('domain must be a string');
|
||||
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
|
||||
ctx.warnings.push(`domain "${config.domain}" may not be a valid domain name`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateKnownKeys(ctx, config) {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!isInArray(KNOWN_KEYS, key)) {
|
||||
ctx.warnings.push(`Unknown config key "${key}" — possible typo?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a config object and return errors/warnings.
|
||||
* @param {object} config - The config object to validate
|
||||
@@ -17,123 +166,20 @@ const VALID_TIMEZONES_SAMPLE = [
|
||||
function validateConfig(config) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const ctx = { errors, warnings };
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { valid: false, errors: ['Config must be a non-null object'], warnings };
|
||||
}
|
||||
|
||||
// TLD validation
|
||||
if (config.tld !== undefined) {
|
||||
if (typeof config.tld !== 'string') {
|
||||
errors.push('tld must be a string');
|
||||
} else {
|
||||
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
|
||||
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
|
||||
errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
|
||||
}
|
||||
if (tld.length > 20) {
|
||||
warnings.push(`tld "${config.tld}" is unusually long`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DNS config validation
|
||||
if (config.dns !== undefined) {
|
||||
if (typeof config.dns !== 'object' || config.dns === null) {
|
||||
errors.push('dns must be an object');
|
||||
} else {
|
||||
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
|
||||
errors.push('dns.ip must be a string');
|
||||
}
|
||||
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
|
||||
errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
|
||||
}
|
||||
if (config.dns.port !== undefined) {
|
||||
const port = parseInt(config.dns.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
|
||||
}
|
||||
}
|
||||
if (config.dns.servers !== undefined) {
|
||||
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
|
||||
errors.push('dns.servers must be an object');
|
||||
}
|
||||
}
|
||||
// DNS provider validation
|
||||
if (config.dns.provider !== undefined) {
|
||||
const validProviders = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
||||
if (typeof config.dns.provider !== 'string') {
|
||||
errors.push('dns.provider must be a string');
|
||||
} else if (!validProviders.includes(config.dns.provider)) {
|
||||
warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${validProviders.join(', ')}. It may still work if a custom adapter is installed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard host validation
|
||||
if (config.dashboardHost !== undefined) {
|
||||
if (typeof config.dashboardHost !== 'string') {
|
||||
errors.push('dashboardHost must be a string');
|
||||
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
|
||||
errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Timezone validation
|
||||
if (config.timezone !== undefined) {
|
||||
if (typeof config.timezone !== 'string') {
|
||||
errors.push('timezone must be a string');
|
||||
} else if (config.timezone) {
|
||||
// Basic format check — full validation would require Intl API
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
|
||||
} catch {
|
||||
errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Theme validation
|
||||
if (config.theme !== undefined) {
|
||||
const validThemes = ['dark', 'light', 'blue'];
|
||||
if (!validThemes.includes(config.theme)) {
|
||||
warnings.push(`theme "${config.theme}" is not one of: ${validThemes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Routing mode validation
|
||||
if (config.routingMode !== undefined) {
|
||||
const validModes = ['subdomain', 'subdirectory'];
|
||||
if (!validModes.includes(config.routingMode)) {
|
||||
errors.push(`routingMode "${config.routingMode}" is not one of: ${validModes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Domain validation
|
||||
if (config.domain !== undefined) {
|
||||
if (typeof config.domain !== 'string') {
|
||||
errors.push('domain must be a string');
|
||||
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
|
||||
warnings.push(`domain "${config.domain}" may not be a valid domain name`);
|
||||
}
|
||||
}
|
||||
|
||||
// Warn on unknown top-level keys
|
||||
const knownKeys = [
|
||||
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
|
||||
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
|
||||
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
|
||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||
'customLogoDark', 'customLogoLight'
|
||||
];
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!knownKeys.includes(key)) {
|
||||
warnings.push(`Unknown config key "${key}" — possible typo?`);
|
||||
}
|
||||
}
|
||||
validateTld(ctx, config);
|
||||
validateDns(ctx, config);
|
||||
validateDashboardHost(ctx, config);
|
||||
validateTimezone(ctx, config);
|
||||
validateTheme(ctx, config);
|
||||
validateRoutingMode(ctx, config);
|
||||
validateDomain(ctx, config);
|
||||
validateKnownKeys(ctx, config);
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
@@ -113,6 +113,41 @@ module.exports = function configureMiddleware(app, {
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Tailscale authentication helpers ──
|
||||
|
||||
const PROBE_PATHS_TAILSCALE = new Set([
|
||||
'/health', '/health/live', '/health/ready', '/healthz', '/readyz',
|
||||
]);
|
||||
|
||||
function isTailScaleProbePath(reqPath) {
|
||||
return PROBE_PATHS_TAILSCALE.has(reqPath) || reqPath.startsWith('/probe/');
|
||||
}
|
||||
|
||||
function extractTailscaleIPs(req) {
|
||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const realIP = req.headers['x-real-ip'];
|
||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||
const fromTailscale = ipsToCheck.some(ip =>
|
||||
isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||
const clientTailscaleIP = ipsToCheck
|
||||
.map(ip => ip.toString().split(',')[0].trim())
|
||||
.find(ip => isTailscaleIP(ip));
|
||||
return { clientIP, ipsToCheck, fromTailscale, clientTailscaleIP };
|
||||
}
|
||||
|
||||
async function isIPInTailnet(clientTailscaleIP) {
|
||||
const status = await getTailscaleStatus();
|
||||
if (!status) return true; // no status = can't verify = allow
|
||||
|
||||
const knownIPs = new Set();
|
||||
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
for (const peer of Object.values(status.Peer || {})) {
|
||||
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
}
|
||||
return knownIPs.has(clientTailscaleIP);
|
||||
}
|
||||
|
||||
// ── Tailscale authentication middleware (optional) ──
|
||||
const tailscaleAuthMiddleware = async (req, res, next) => {
|
||||
if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) {
|
||||
@@ -121,25 +156,11 @@ module.exports = function configureMiddleware(app, {
|
||||
|
||||
// Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks
|
||||
// don't carry a Tailscale identity header.
|
||||
if (req.path === '/health'
|
||||
|| req.path === '/health/live'
|
||||
|| req.path === '/health/ready'
|
||||
|| req.path === '/healthz'
|
||||
|| req.path === '/readyz'
|
||||
|| req.path.startsWith('/probe/')) {
|
||||
if (isTailScaleProbePath(req.path) || req.path.startsWith('/api/v1/tailscale/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/v1/tailscale/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const realIP = req.headers['x-real-ip'];
|
||||
|
||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||
const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||
const { clientIP, fromTailscale, clientTailscaleIP } = extractTailscaleIPs(req);
|
||||
|
||||
if (!fromTailscale) {
|
||||
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
|
||||
@@ -148,27 +169,14 @@ module.exports = function configureMiddleware(app, {
|
||||
});
|
||||
}
|
||||
|
||||
if (tailscaleConfig.allowedTailnet) {
|
||||
if (tailscaleConfig.allowedTailnet && clientTailscaleIP) {
|
||||
try {
|
||||
const status = await getTailscaleStatus();
|
||||
if (status) {
|
||||
const clientTailscaleIP = ipsToCheck
|
||||
.map(ip => ip.toString().split(',')[0].trim())
|
||||
.find(ip => isTailscaleIP(ip));
|
||||
|
||||
if (clientTailscaleIP) {
|
||||
const knownIPs = new Set();
|
||||
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
for (const peer of Object.values(status.Peer || {})) {
|
||||
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
}
|
||||
if (!knownIPs.has(clientTailscaleIP)) {
|
||||
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
|
||||
requiresTailscale: true,
|
||||
clientIP
|
||||
});
|
||||
}
|
||||
}
|
||||
const inTailnet = await isIPInTailnet(clientTailscaleIP);
|
||||
if (!inTailnet) {
|
||||
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
|
||||
requiresTailscale: true,
|
||||
clientIP
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });
|
||||
@@ -227,6 +235,10 @@ module.exports = function configureMiddleware(app, {
|
||||
ipSessions.delete(getClientIP(req));
|
||||
}
|
||||
|
||||
// Session cookies are intentionally host-only. Browsers reject Domain=.sami
|
||||
// because .sami is an unregistered custom TLD and therefore treated as a
|
||||
// public suffix. Cross-subdomain login is handled by the one-time SSO
|
||||
// handoff below, which mints a separate host-only cookie on each service.
|
||||
function setSessionCookie(res, durationKey) {
|
||||
const durationMs = SESSION_DURATIONS[durationKey];
|
||||
if (!durationMs) return;
|
||||
@@ -235,9 +247,8 @@ module.exports = function configureMiddleware(app, {
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||
res.setHeader('Set-Cookie',
|
||||
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,16 +279,28 @@ module.exports = function configureMiddleware(app, {
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||
res.setHeader('Set-Cookie',
|
||||
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
|
||||
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||
);
|
||||
}
|
||||
|
||||
// COOKIE-ONLY session validation. The previous IP-keyed cache (verifyIPSession
|
||||
// + the write-back in this function) caused cross-subdomain SSO breakage when
|
||||
// Caddy on --network host forwards auth to the container: req.ip arrives as
|
||||
// 100.121.150.22 (DNS2's tailnet IP) instead of the user's real IP, so the
|
||||
// IP cache misses even when the cookie is valid. The host-only cookie is
|
||||
// signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain
|
||||
// authentication uses the one-time SSO handoff because browsers reject
|
||||
// Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger
|
||||
// credential than the IP cache. Ref: skill auth-and-monitoring-pitfalls.md
|
||||
// "TOTP session validation IP-key issue" (FIXED 2026-07-21).
|
||||
function isSessionValid(req) {
|
||||
if (verifyIPSession(req)) return true;
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
|
||||
// Re-warm the IP cache as a no-op-only fast path (kept for backwards
|
||||
// compat with code that reads ctx.session.ipSessions.size for telemetry,
|
||||
// but it is NOT consulted for auth decisions). The next line intentionally
|
||||
// does NOT gate the return on verifyIPSession anymore.
|
||||
const ip = getClientIP(req);
|
||||
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
|
||||
ipSessions.set(ip, { exp: Date.now() + SESSION_DURATIONS[totpConfig.sessionDuration] });
|
||||
@@ -287,6 +310,43 @@ module.exports = function configureMiddleware(app, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Cross-subdomain SSO token handoff ──
|
||||
// Domain=.sami cookies are silently rejected by real browsers: .sami is an
|
||||
// unregistered custom TLD, so browsers treat "sami" itself as the effective
|
||||
// public suffix and refuse to set a cookie scoped to it (the same rule that
|
||||
// stops a site from setting a supercookie for all of .com). That means the
|
||||
// session cookie set on status.sami never reaches plex.sami/jellyfin.sami/
|
||||
// etc, and cross-subdomain SSO can never work via a shared cookie no matter
|
||||
// how the cookie itself is constructed.
|
||||
//
|
||||
// Fix: after TOTP verify, mint a short-lived single-use opaque token and
|
||||
// pass it in the redirect URL back to the target service. That service's
|
||||
// origin exchanges the token (via /auth/sso-exchange) for its OWN host-only
|
||||
// cookie (no Domain attribute — always accepted, since it's scoped to the
|
||||
// exact host that set it). isSessionValid/verifySessionCookie don't care
|
||||
// about the cookie's Domain at all, only its HMAC signature, so a host-only
|
||||
// cookie validates identically to the cross-domain one — no changes needed
|
||||
// to any existing session-check code path.
|
||||
const ssoHandoffTokens = new Map();
|
||||
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
||||
|
||||
function createHandoffToken() {
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
|
||||
return token;
|
||||
}
|
||||
|
||||
function redeemHandoffToken(token) {
|
||||
if (!token) return false;
|
||||
const entry = ssoHandoffTokens.get(token);
|
||||
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
||||
return !!entry && entry.exp > Date.now();
|
||||
}
|
||||
|
||||
function setHostOnlySessionCookie(res, durationKey) {
|
||||
setSessionCookie(res, durationKey);
|
||||
}
|
||||
|
||||
// ── Public routes (bypass TOTP and JWT auth) ──
|
||||
// Routes here are accessible without authentication. By default the
|
||||
// monitoring/health-check endpoints are public so the dashboard can
|
||||
@@ -327,6 +387,11 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/auth/gate/', prefix: true },
|
||||
{ path: '/api/v1/auth/app-token/', prefix: true },
|
||||
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
|
||||
// Must be public: a fresh cross-subdomain visitor has no session yet by
|
||||
// definition — that's exactly the gap /auth/sso-exchange closes. The
|
||||
// endpoint itself only accepts a valid single-use handoff token minted
|
||||
// moments earlier by a successful TOTP verify, so this isn't an open door.
|
||||
{ path: '/api/v1/auth/sso-exchange', exact: true, method: 'GET' },
|
||||
// DC-046 pluggable auth endpoints — public by design (they ARE login).
|
||||
// Use :provider placeholder; today's only provider is TOTP, but the
|
||||
// route is parameterized so DC-047's email provider just works.
|
||||
@@ -345,9 +410,17 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/share/:token/preview', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/share/:token/subscribe', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/share/:token/redeem-tailscale', exact: true, method: 'POST' },
|
||||
// /me and /admin/* require authentication — NOT public. Listed here
|
||||
// only to document them; absence from PUBLIC_ROUTES means they go
|
||||
// through the normal auth gate. CSRF applies to writes as usual.
|
||||
// /api/v1/billing/* (DC-055 + DC-057): public checkout session creation
|
||||
// + license lookup for the success page. No DashCaddy account exists
|
||||
// yet at checkout time. The lookup endpoint serves the persisted
|
||||
// license in both `delivered` and `pending_email` states (the SMTP
|
||||
// failure-recovery path); the bearer-style secret is the Stripe
|
||||
// Checkout sessionId (single-use, 24h TTL — see routes/billing.js).
|
||||
{ path: '/api/v1/billing/checkout', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/billing/lookup/:sessionId', exact: true, method: 'GET' },
|
||||
// /api/v1/services + status: read-only service metadata that the public
|
||||
// dashboard needs before login (services list widget, status pill).
|
||||
// Writes go through the normal auth gate. CSRF applies to writes as usual.
|
||||
{ path: '/api/v1/services', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
||||
@@ -573,6 +646,9 @@ module.exports = function configureMiddleware(app, {
|
||||
clearSessionCookie,
|
||||
isSessionValid,
|
||||
ipSessions,
|
||||
renewCSRFToken
|
||||
renewCSRFToken,
|
||||
createHandoffToken,
|
||||
redeemHandoffToken,
|
||||
setHostOnlySessionCookie
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Graceful shutdown coordinator — DashCaddy
|
||||
*
|
||||
* Extracts the SIGTERM/SIGINT handler from server.js into a testable,
|
||||
* reusable module that:
|
||||
* 1. Calls server.close() to drain in-flight HTTP connections
|
||||
* 2. Stops each manager in a deterministic order
|
||||
* 3. Emits a 'shutdown' event so additional listeners can do cleanup
|
||||
* 4. Force-exits after a configurable drain timeout if connections don't drain
|
||||
* 5. Is idempotent — a second SIGTERM during shutdown does not re-run handlers
|
||||
*
|
||||
* Spec: DC-067 (production-grade backlog). Docker sends SIGTERM on stop;
|
||||
* without this coordinator, in-flight API calls drop.
|
||||
*
|
||||
* Exports:
|
||||
* - createShutdownCoordinator({ server, log, drainTimeoutMs, managers })
|
||||
* Returns an EventEmitter with: { shutdown, isShuttingDown, on, emit, ... }
|
||||
* - installSignalHandlers(coordinator, signals = ['SIGTERM', 'SIGINT'])
|
||||
* Registers the OS-level handlers. Idempotent.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
|
||||
|
||||
class ShutdownCoordinator extends EventEmitter {
|
||||
constructor({ server, log, drainTimeoutMs, managers }) {
|
||||
super();
|
||||
if (!server) throw new Error('createShutdownCoordinator: server is required');
|
||||
if (!log || typeof log.info !== 'function' || typeof log.warn !== 'function'
|
||||
|| typeof log.error !== 'function') {
|
||||
throw new Error('createShutdownCoordinator: log must have info(), warn(), and error() methods');
|
||||
}
|
||||
this.server = server;
|
||||
this.log = log;
|
||||
this.drainTimeoutMs = Number.isFinite(drainTimeoutMs) && drainTimeoutMs > 0
|
||||
? drainTimeoutMs
|
||||
: DEFAULT_DRAIN_TIMEOUT_MS;
|
||||
this.managers = Array.isArray(managers) ? managers : [];
|
||||
this._shuttingDown = false;
|
||||
this._forceTimer = null;
|
||||
}
|
||||
|
||||
isShuttingDown() {
|
||||
return this._shuttingDown;
|
||||
}
|
||||
|
||||
async _stopManager(m) {
|
||||
try {
|
||||
await m.stop();
|
||||
this.log.info('shutdown', `manager stopped: ${m.name}`);
|
||||
} catch (err) {
|
||||
this.log.warn('shutdown', `manager stop failed: ${m.name}`, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop each manager sequentially in declaration order. Each manager's
|
||||
* stop() is awaited so that a downstream manager is not stopped until
|
||||
* its upstream dependency has finished draining.
|
||||
*
|
||||
* IMPORTANT: this runs AFTER server.close() returns (see shutdown()).
|
||||
* We must wait for in-flight HTTP requests to complete before tearing
|
||||
* down the services that serve them — otherwise those requests fail
|
||||
* mid-drain with "service not found" / "monitor not running" errors.
|
||||
*/
|
||||
async _stopManagersInOrder() {
|
||||
for (const m of this.managers) {
|
||||
await this._stopManager(m);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event but swallow listener exceptions so one bad listener
|
||||
* can't abort the shutdown sequence. Logs each failure with the
|
||||
* listener's name (set via `listener.name`) if available.
|
||||
*/
|
||||
_safeEmit(event, ...args) {
|
||||
const listeners = this.listeners(event);
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener.apply(this, args);
|
||||
} catch (err) {
|
||||
const name = listener.name || '<anonymous>';
|
||||
this.log.error('shutdown', `event listener for '${event}' threw`,
|
||||
{ listener: name, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(signal) {
|
||||
if (this._shuttingDown) {
|
||||
this.log.info('shutdown', `${signal || 'shutdown'} received — already shutting down, ignoring`);
|
||||
return;
|
||||
}
|
||||
this._shuttingDown = true;
|
||||
this.log.info('shutdown', `${signal || 'shutdown'} received, draining (timeout=${this.drainTimeoutMs}ms)...`);
|
||||
|
||||
// Emit 'shutdown' event first so any listeners can observe the signal
|
||||
// before the drain begins. NOTE: listeners should NOT tear down their
|
||||
// state here — that happens in the 'closed' event after server.close.
|
||||
// _safeEmit swallows listener exceptions so a buggy listener can't
|
||||
// abort the entire shutdown sequence.
|
||||
this._safeEmit('shutdown', signal);
|
||||
|
||||
// Close the HTTP server FIRST. Stops accepting new connections, waits
|
||||
// for in-flight requests to complete naturally. Only AFTER close fires
|
||||
// do we tear down managers — otherwise in-flight requests could fail
|
||||
// when the services they call have already been stopped.
|
||||
let serverClosed = false;
|
||||
let managersStopped = false;
|
||||
try {
|
||||
this.server.close(async () => {
|
||||
serverClosed = true;
|
||||
this.log.info('shutdown', 'HTTP server closed cleanly');
|
||||
// Now that in-flight requests are done, stop managers in order.
|
||||
// We do NOT clear the force-exit timer yet — if a manager's stop()
|
||||
// hangs, the timer is the safety net that prevents the process
|
||||
// from living forever in a half-shut-down state.
|
||||
try {
|
||||
await this._stopManagersInOrder();
|
||||
} catch (err) {
|
||||
// _stopManager already logs per-manager failures, but a top-level
|
||||
// throw (e.g. from the for-loop itself) is still possible.
|
||||
this.log.error('shutdown', 'manager shutdown loop threw', { error: err.message });
|
||||
}
|
||||
managersStopped = true;
|
||||
// Manager drain complete — NOW we can clear the safety timer.
|
||||
if (this._forceTimer) {
|
||||
clearTimeout(this._forceTimer);
|
||||
this._forceTimer = null;
|
||||
}
|
||||
this._safeEmit('closed', signal);
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.error('shutdown', 'server.close threw', { error: err.message });
|
||||
}
|
||||
|
||||
// Force-exit safety net. Fires when EITHER:
|
||||
// (a) server.close never fires (HTTP server stuck draining), or
|
||||
// (b) server.close fired but managers hung during stop()
|
||||
// We only suppress when managersStopped === true (full drain complete).
|
||||
// serverClosed alone is NOT enough — managers could still be running.
|
||||
this._forceTimer = setTimeout(() => {
|
||||
if (managersStopped) return; // full shutdown complete
|
||||
if (!serverClosed) {
|
||||
this.log.warn('shutdown', `drain timeout (${this.drainTimeoutMs}ms) reached before HTTP server closed, force-exiting`);
|
||||
} else {
|
||||
this.log.warn('shutdown', `drain timeout (${this.drainTimeoutMs}ms) reached after HTTP close (manager hung), force-exiting`);
|
||||
}
|
||||
process.exit(0);
|
||||
}, this.drainTimeoutMs);
|
||||
if (this._forceTimer && typeof this._forceTimer.unref === 'function') {
|
||||
this._forceTimer.unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createShutdownCoordinator(opts) {
|
||||
return new ShutdownCoordinator(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install OS-level signal handlers. Idempotent: second call for the same
|
||||
* signal does NOT register a duplicate listener. Tracks registered signals
|
||||
* on the coordinator itself so a future caller can introspect.
|
||||
*
|
||||
* @param {ShutdownCoordinator} coordinator
|
||||
* @param {string[]} signals - signals to handle (default: SIGTERM, SIGINT)
|
||||
*/
|
||||
function installSignalHandlers(coordinator, signals) {
|
||||
if (!coordinator || typeof coordinator.shutdown !== 'function') {
|
||||
throw new Error('installSignalHandlers: coordinator required');
|
||||
}
|
||||
if (!Array.isArray(coordinator._installedSignals)) {
|
||||
coordinator._installedSignals = [];
|
||||
}
|
||||
const sigs = Array.isArray(signals) && signals.length > 0
|
||||
? signals
|
||||
: ['SIGTERM', 'SIGINT'];
|
||||
for (const sig of sigs) {
|
||||
if (coordinator._installedSignals.includes(sig)) continue;
|
||||
process.on(sig, () => coordinator.shutdown(sig));
|
||||
coordinator._installedSignals.push(sig);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createShutdownCoordinator,
|
||||
installSignalHandlers,
|
||||
DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
ShutdownCoordinator, // exported for tests
|
||||
};
|
||||
@@ -9,10 +9,11 @@
|
||||
*
|
||||
* Priority:
|
||||
* 1. internet → https://www.google.com
|
||||
* 2. isExternal + externalUrl → use as-is
|
||||
* 3. service.url → prepend https:// if no protocol
|
||||
* 4. dnsServers config → http://{ip}:{port}
|
||||
* 5. fallback → buildServiceUrl(id)
|
||||
* 2. healthCheckUrl → use as-is (bypass SSO/Caddy for direct container health checks)
|
||||
* 3. isExternal + externalUrl → use as-is
|
||||
* 4. service.url → prepend https:// if no protocol
|
||||
* 5. dnsServers config → http://{ip}:{port}
|
||||
* 6. fallback → buildServiceUrl(id)
|
||||
*
|
||||
* @param {string} id - service identifier
|
||||
* @param {Object|null} service - service object from services.json (may be null for top-card services)
|
||||
@@ -22,6 +23,7 @@
|
||||
*/
|
||||
function resolveServiceUrl(id, service, siteConfig, buildServiceUrl) {
|
||||
if (id === 'internet') return 'https://www.google.com';
|
||||
if (service?.healthCheckUrl) return service.healthCheckUrl;
|
||||
if (service?.isExternal && service.externalUrl) return service.externalUrl;
|
||||
if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`;
|
||||
const dnsServer = siteConfig?.dnsServers?.[id];
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Joi-based request body validation middleware.
|
||||
*
|
||||
* Usage:
|
||||
* const { validateBody, schemas } = require('../utilities/validate');
|
||||
*
|
||||
* router.post('/schedule', validateBody(schemas.backupScheduleCreate), handler);
|
||||
*
|
||||
* The middleware validates req.body against the provided Joi schema.
|
||||
* On success it replaces req.body with the validated/stripped value.
|
||||
* On failure it throws a ValidationError (caught by asyncHandler → 400).
|
||||
*
|
||||
* Schemas for destructive routes live in the `schemas` export so they
|
||||
* can be unit-tested without spinning up Express.
|
||||
*/
|
||||
|
||||
const Joi = require('joi');
|
||||
const { ValidationError } = require('./errors');
|
||||
|
||||
/**
|
||||
* Factory: returns an Express middleware that validates req.body.
|
||||
* @param {Joi.Schema} schema
|
||||
* @param {{ stripUnknown?: boolean, abortEarly?: boolean }} [opts]
|
||||
*/
|
||||
function validateBody(schema, opts = {}) {
|
||||
return (req, _res, next) => {
|
||||
const { error, value } = schema.validate(req.body, {
|
||||
stripUnknown: opts.stripUnknown ?? true,
|
||||
abortEarly: opts.abortEarly ?? false,
|
||||
convert: true,
|
||||
});
|
||||
if (error) {
|
||||
const msg = error.details.map(d => d.message).join('; ');
|
||||
throw new ValidationError(msg);
|
||||
}
|
||||
req.body = value;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reusable schema fragments ───────────────────────────────
|
||||
|
||||
const scheduleSchema = Joi.alternatives().try(
|
||||
Joi.string().valid('hourly', 'daily', 'weekly', 'monthly'),
|
||||
Joi.string().pattern(/^\d+[mh]?$/, 'custom-interval (e.g. "30m", "6h")'),
|
||||
).optional();
|
||||
|
||||
const ipOrCidr = Joi.alternatives().try(
|
||||
// Authoritative single-IP validation (Joi's built-in strict IPv4/IPv6 check)
|
||||
Joi.string().ip({ version: ['ipv4', 'ipv6'] }),
|
||||
// Authoritative CIDR validation (Joi's built-in strict CIDR check rejects malformed
|
||||
// addresses like "::::/64" that a permissive hex/colon regex would otherwise accept)
|
||||
Joi.string().ip({ version: ['ipv4', 'ipv6'], cidr: 'required' }),
|
||||
);
|
||||
|
||||
// ─── Schemas for destructive routes ───────────────────────────
|
||||
|
||||
const schemas = {
|
||||
/** POST /backups/config — update backup configuration */
|
||||
backupConfigUpdate: Joi.object({
|
||||
backups: Joi.object().pattern(
|
||||
Joi.string().max(100), // appId key
|
||||
Joi.object({ // per-app backup config
|
||||
enabled: Joi.boolean().optional(),
|
||||
schedule: scheduleSchema,
|
||||
retention: Joi.object({
|
||||
keep: Joi.number().integer().min(1).max(365).optional(),
|
||||
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
||||
}).optional(),
|
||||
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
||||
destinationPath: Joi.string().max(512).optional(),
|
||||
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
|
||||
runImmediately: Joi.boolean().optional(),
|
||||
include: Joi.array().items(Joi.string().max(50)).optional(),
|
||||
destinations: Joi.array().items(Joi.object({
|
||||
type: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
||||
path: Joi.string().max(512).optional(),
|
||||
}).unknown(false)).optional(),
|
||||
}).unknown(false)
|
||||
).optional(),
|
||||
defaultRetention: Joi.object({
|
||||
keep: Joi.number().integer().min(1).max(365).optional(),
|
||||
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
||||
}).optional(),
|
||||
}),
|
||||
|
||||
/** POST /backups/schedule — create or update a scheduled backup */
|
||||
backupScheduleCreate: Joi.object({
|
||||
appId: Joi.string().min(1).max(100).required(),
|
||||
enabled: Joi.boolean().optional(),
|
||||
schedule: scheduleSchema,
|
||||
retention: Joi.object({
|
||||
keep: Joi.number().integer().min(1).max(365).optional(),
|
||||
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
||||
}).optional(),
|
||||
runImmediately: Joi.boolean().optional(),
|
||||
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
||||
destinationPath: Joi.string().max(512).optional(),
|
||||
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
|
||||
// Legacy schedule route fields
|
||||
name: Joi.string().max(100).optional(),
|
||||
}),
|
||||
|
||||
/** POST /backups/restore/:backupId — passes through to backupManager.restoreBackup */
|
||||
backupRestore: Joi.object({
|
||||
encryptionKey: Joi.string().max(512).optional(),
|
||||
restartContainers: Joi.boolean().optional(),
|
||||
// restoreBackup reads options from body; allow known control flags only
|
||||
services: Joi.boolean().optional(),
|
||||
config: Joi.boolean().optional(),
|
||||
credentials: Joi.boolean().optional(),
|
||||
volumes: Joi.boolean().optional(),
|
||||
}),
|
||||
|
||||
/** POST /backups/restore-file/:filename */
|
||||
backupRestoreFile: Joi.object({
|
||||
encryptionKey: Joi.string().max(512).optional(),
|
||||
restartContainers: Joi.boolean().optional(),
|
||||
}),
|
||||
|
||||
/** POST /apps/deploy */
|
||||
appDeploy: Joi.object({
|
||||
appId: Joi.string().min(1).max(100).required(),
|
||||
config: Joi.object({
|
||||
subdomain: Joi.string().min(1).max(63).required(),
|
||||
port: Joi.number().integer().min(1).max(65535).optional(),
|
||||
ip: Joi.string().max(45).optional(),
|
||||
useExisting: Joi.boolean().optional(),
|
||||
existingContainerId: Joi.string().max(200).optional(),
|
||||
existingPort: Joi.number().integer().min(1).max(65535).optional(),
|
||||
createDns: Joi.boolean().optional(),
|
||||
tailscaleOnly: Joi.boolean().optional(),
|
||||
allowedIPs: Joi.array().items(ipOrCidr).optional(),
|
||||
customVolumes: Joi.array().items(Joi.object({
|
||||
hostPath: Joi.string().max(500).required(),
|
||||
containerPath: Joi.string().max(500).required(),
|
||||
}).unknown(false)).optional(),
|
||||
mediaPath: Joi.string().max(500).optional(),
|
||||
// Template-specific config fields preserved from the live frontend
|
||||
sslType: Joi.string().valid('self-signed', 'tailscale', 'letsencrypt', 'none').optional(),
|
||||
dnsType: Joi.string().valid('private', 'public', 'none').optional(),
|
||||
plexClaimToken: Joi.string().max(500).optional(),
|
||||
resources: Joi.object({
|
||||
memory: Joi.number().min(32).max(65536).optional(),
|
||||
cpus: Joi.number().min(0.1).max(64).optional(),
|
||||
}).optional(),
|
||||
}).unknown(true), // Forward-compat: templates may accept additional fields
|
||||
}),
|
||||
|
||||
/** POST /apps/:appId/restore — empty body, reject any input fields */
|
||||
appRestore: Joi.any().custom((value, helpers) => {
|
||||
if (value !== undefined && value !== null && (typeof value !== 'object' || Object.keys(value).length > 0)) {
|
||||
return helpers.error('object.empty');
|
||||
}
|
||||
return {};
|
||||
}, 'empty-body-guard').messages({
|
||||
'object.empty': '"body" must be empty (this endpoint accepts no input)',
|
||||
}),
|
||||
|
||||
/** POST /apps/:appId/revert/:filename */
|
||||
appRevert: Joi.object({
|
||||
encryptionKey: Joi.string().max(512).optional(),
|
||||
restartContainers: Joi.boolean().optional(),
|
||||
}),
|
||||
|
||||
/** POST /assets/upload */
|
||||
assetUpload: Joi.object({
|
||||
filename: Joi.string().min(1).max(255).required(),
|
||||
data: Joi.string().min(1).max(10 * 1024 * 1024).required(), // 10MB base64 cap
|
||||
}),
|
||||
|
||||
/** POST /assets/logo */
|
||||
logoUpload: Joi.object({
|
||||
data: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
|
||||
dataDark: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
|
||||
dataLight: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
|
||||
position: Joi.string().valid('left', 'center', 'right').optional(),
|
||||
dashboardTitle: Joi.string().max(50).allow('').optional(),
|
||||
}).min(1),
|
||||
};
|
||||
|
||||
module.exports = { validateBody, schemas };
|
||||
@@ -1,335 +0,0 @@
|
||||
// ========== MONITORING WIDGETS ==========
|
||||
// Embeds a compact system-resource + health summary panel directly on the
|
||||
// main dashboard. Replaces the need for a separate monitoring-dashboard.html
|
||||
// page — quick at-a-glance stats where you already are.
|
||||
(function () {
|
||||
|
||||
// ----- Style injection (scoped to .dc-monitor so it doesn't leak) -----
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.dc-monitor-card {
|
||||
padding: 10px 12px;
|
||||
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.dc-monitor-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dc-monitor-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.dc-monitor-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.dc-monitor-bar {
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: color-mix(in srgb, var(--muted) 20%, transparent);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dc-monitor-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--ok-fg, #27ae60);
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
.dc-monitor-bar-fill.warn { background: #f39c12; }
|
||||
.dc-monitor-bar-fill.bad { background: #e74c3c; }
|
||||
.dc-monitor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dc-monitor-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dc-monitor-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dc-monitor-pill.ok { background: color-mix(in srgb, #27ae60 20%, transparent); color: #27ae60; }
|
||||
.dc-monitor-pill.warn { background: color-mix(in srgb, #f39c12 20%, transparent); color: #f39c12; }
|
||||
.dc-monitor-pill.bad { background: color-mix(in srgb, #e74c3c 20%, transparent); color: #e74c3c; }
|
||||
.dc-monitor-refresh {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
// ----- Container element (inserted above service-filter-bar) -----
|
||||
const filterBar = document.getElementById('service-filter-bar');
|
||||
if (!filterBar) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'dc-monitor';
|
||||
panel.id = 'dc-monitor-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="dc-monitor-header" style="grid-column: 1 / -1;">
|
||||
<div class="dc-monitor-title">📊 System Overview</div>
|
||||
<span class="dc-monitor-refresh" id="dc-monitor-refresh-stamp">—</span>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Services</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-services">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-services-sub">loading…</div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Containers Up</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-containers">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-containers-sub">loading…</div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Avg CPU</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-cpu">—</div>
|
||||
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-cpu-bar"></div></div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Avg Memory</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-mem">—</div>
|
||||
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-mem-bar"></div></div>
|
||||
</div>
|
||||
<div class="dc-monitor-card">
|
||||
<div class="dc-monitor-label">Health</div>
|
||||
<div class="dc-monitor-value" id="dc-monitor-health">—</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-health-sub">—</div>
|
||||
</div>
|
||||
`;
|
||||
// Insert ABOVE the filter bar
|
||||
filterBar.parentNode.insertBefore(panel, filterBar);
|
||||
|
||||
// ----- Helpers -----
|
||||
function setBar(id, pct) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const p = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
el.style.width = p + '%';
|
||||
el.classList.remove('warn', 'bad');
|
||||
if (p >= 85) el.classList.add('bad');
|
||||
else if (p >= 65) el.classList.add('warn');
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
if (v == null || isNaN(v)) return '—';
|
||||
return (Math.round(v * 10) / 10) + '%';
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (b == null || isNaN(b)) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||||
return b.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// ----- Robust services count -----
|
||||
// Read from multiple sources so we always have a number:
|
||||
// 1. window.APPS (populated by grid.js after loadServices)
|
||||
// 2. #cards .card elements (post-buildGrid)
|
||||
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
|
||||
async function fetchServicesCount() {
|
||||
// Source 1+2: window.APPS / DOM cards
|
||||
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: window.APPS.length, up, source: 'APPS' };
|
||||
}
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
if (cards.length > 0) {
|
||||
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
|
||||
return { total: cards.length, up, source: 'DOM' };
|
||||
}
|
||||
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
|
||||
try {
|
||||
const r = await fetch('/api/v1/services', { cache: 'no-store' });
|
||||
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
|
||||
const body = await r.json();
|
||||
const list = (body && Array.isArray(body.services)) ? body.services
|
||||
: (Array.isArray(body)) ? body
|
||||
: [];
|
||||
// Persist for the grid so this fallback only fires once
|
||||
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: list.length, up, source: 'fetch' };
|
||||
} catch (_) {
|
||||
return { total: 0, up: 0, source: 'fetch-error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function setServicesCard() {
|
||||
const { total, up } = await fetchServicesCount();
|
||||
const el = document.getElementById('dc-monitor-services');
|
||||
const sub = document.getElementById('dc-monitor-services-sub');
|
||||
if (el) el.textContent = `${up} / ${total}`;
|
||||
if (sub) sub.textContent = total === 0
|
||||
? 'no services yet'
|
||||
: `${up} online · ${total - up} offline`;
|
||||
}
|
||||
|
||||
function applyHealthSummary(data) {
|
||||
const el = document.getElementById('dc-monitor-health');
|
||||
const sub = document.getElementById('dc-monitor-health-sub');
|
||||
if (!el) return;
|
||||
if (!data || data.summary == null) {
|
||||
el.textContent = '—';
|
||||
if (sub) sub.textContent = 'no data';
|
||||
return;
|
||||
}
|
||||
const s = data.summary;
|
||||
const healthy = s.healthy ?? s.up ?? 0;
|
||||
const unhealthy = s.unhealthy ?? s.down ?? 0;
|
||||
const total = s.total ?? (healthy + unhealthy);
|
||||
el.textContent = `${healthy}/${total}`;
|
||||
if (sub) {
|
||||
if (unhealthy === 0) {
|
||||
sub.innerHTML = '<span class="dc-monitor-pill ok">● all healthy</span>';
|
||||
} else if (unhealthy <= 2) {
|
||||
sub.innerHTML = `<span class="dc-monitor-pill warn">● ${unhealthy} degraded</span>`;
|
||||
} else {
|
||||
sub.innerHTML = `<span class="dc-monitor-pill bad">● ${unhealthy} down</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Data fetches -----
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/monitoring/stats', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
return (data && data.stats) ? data.stats : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/health-checks/status', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStats(stats) {
|
||||
const containers = document.getElementById('dc-monitor-containers');
|
||||
const containersSub = document.getElementById('dc-monitor-containers-sub');
|
||||
const cpuEl = document.getElementById('dc-monitor-cpu');
|
||||
const memEl = document.getElementById('dc-monitor-mem');
|
||||
|
||||
if (!stats) {
|
||||
if (containers) containers.textContent = '—';
|
||||
if (cpuEl) cpuEl.textContent = '—';
|
||||
if (memEl) memEl.textContent = '—';
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.values(stats);
|
||||
if (entries.length === 0) {
|
||||
if (containers) containers.textContent = '0';
|
||||
if (containersSub) containersSub.textContent = 'no containers reporting';
|
||||
if (cpuEl) cpuEl.textContent = '0%';
|
||||
if (memEl) memEl.textContent = '0%';
|
||||
setBar('dc-monitor-cpu-bar', 0);
|
||||
setBar('dc-monitor-mem-bar', 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cpuSum = 0, memSum = 0, memBytes = 0, cpuCount = 0, memCount = 0;
|
||||
entries.forEach(s => {
|
||||
// CPU may be percentage (0-100) or fraction (0-1) — handle both
|
||||
if (s.cpu != null) {
|
||||
const cpu = Number(s.cpu);
|
||||
if (!isNaN(cpu)) {
|
||||
cpuSum += cpu > 1 ? cpu : cpu * 100;
|
||||
cpuCount++;
|
||||
}
|
||||
}
|
||||
if (s.memory != null) {
|
||||
const mem = Number(s.memory);
|
||||
if (!isNaN(mem)) {
|
||||
memSum += mem;
|
||||
memBytes += Number(s.memoryUsage || 0);
|
||||
memCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const avgCpu = cpuCount ? cpuSum / cpuCount : 0;
|
||||
const avgMem = memCount ? memSum / memCount : 0;
|
||||
|
||||
if (containers) containers.textContent = String(entries.length);
|
||||
if (containersSub) {
|
||||
const memTxt = memBytes ? ` · ${fmtBytes(memBytes)} RAM` : '';
|
||||
containersSub.textContent = `running${memTxt}`;
|
||||
}
|
||||
if (cpuEl) cpuEl.textContent = fmtPct(avgCpu);
|
||||
if (memEl) memEl.textContent = fmtPct(avgMem);
|
||||
setBar('dc-monitor-cpu-bar', avgCpu);
|
||||
setBar('dc-monitor-mem-bar', avgMem);
|
||||
}
|
||||
|
||||
// ----- Public refresh function -----
|
||||
let inFlight = false;
|
||||
async function refresh() {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
setServicesCard();
|
||||
const [stats, health] = await Promise.all([fetchStats(), fetchHealth()]);
|
||||
applyStats(stats);
|
||||
applyHealthSummary(health);
|
||||
const stamp = document.getElementById('dc-monitor-refresh-stamp');
|
||||
if (stamp) {
|
||||
const now = new Date();
|
||||
stamp.textContent = `updated ${now.toLocaleTimeString()}`;
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for init.js to call once and re-call after each refreshAll cycle
|
||||
window.refreshMonitoringWidgets = refresh;
|
||||
|
||||
// Auto-refresh on the STATS interval (separate from full DASHBOARD refresh)
|
||||
setInterval(refresh, (typeof DC !== 'undefined' && DC.POLL && DC.POLL.STATS) || 5000);
|
||||
|
||||
// Refresh once on first script load (init.js also calls this; double-call is harmless)
|
||||
setTimeout(refresh, 200);
|
||||
|
||||
})();
|
||||
@@ -835,6 +835,22 @@ start_caddy() {
|
||||
fi
|
||||
}
|
||||
|
||||
# DC-037: Make API source reachable from both the install path
|
||||
# (${SITES_DIR}/dashcaddy-api, where this installer writes files) and the
|
||||
# /opt/dashcaddy/dashcaddy-api path that the auto-updater and several runtime
|
||||
# helpers default to. Without this, a first auto-update lands on a fresh host
|
||||
# that wrote its API files to ${SITES_DIR}/dashcaddy-api but tried to read
|
||||
# from /opt/dashcaddy/dashcaddy-api and crashes with
|
||||
# `cp: cannot create directory '/etc/dashcaddy/sites/dashcaddy-api/routes':
|
||||
# No such file or directory` because the trailing parent path is missing.
|
||||
# `ln -sfn` is idempotent (safe on re-runs; does not fail if the link already
|
||||
# points to the same target) and replaces any stale link.
|
||||
install_api_symlink() {
|
||||
mkdir -p /opt/dashcaddy
|
||||
ln -sfn "${API_DIR}" /opt/dashcaddy/dashcaddy-api
|
||||
ok "API symlink: /opt/dashcaddy/dashcaddy-api -> ${API_DIR}"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Firewall
|
||||
# ============================================================================
|
||||
@@ -1091,6 +1107,7 @@ main() {
|
||||
# ---- Step 7: Start Caddy ----
|
||||
step "Starting web server"
|
||||
start_caddy
|
||||
install_api_symlink
|
||||
|
||||
print_success "$(elapsed "$start_time")"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# DashCaddy Docker Space Management
|
||||
# Runs via cron to keep Docker disk usage under control
|
||||
# Prevents the overlay2 + dangling volumes + stale images that fill the disk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAX_DISK_PCT=85 # Alert if disk usage exceeds this
|
||||
LOG_PREFIX="[dc-disk]"
|
||||
|
||||
# 1. Remove dangling (untagged) images
|
||||
echo "$LOG_PREFIX Pruning dangling images..."
|
||||
docker image prune -f --filter "dangling=true" 2>/dev/null || true
|
||||
|
||||
# 2. Remove unused volumes (volumes not attached to any container)
|
||||
echo "$LOG_PREFIX Pruning unused volumes..."
|
||||
docker volume prune -f 2>/dev/null || true
|
||||
|
||||
# 3. Remove old build cache
|
||||
echo "$LOG_PREFIX Pruning build cache..."
|
||||
docker builder prune -f --keep-storage 500m 2>/dev/null || true
|
||||
|
||||
# 4. Remove stopped containers older than 7 days
|
||||
echo "$LOG_PREFIX Pruning old stopped containers..."
|
||||
docker container prune -f --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
# 5. Remove images not used by any container (keep only running images)
|
||||
# Only remove images older than 7 days to avoid breaking recent updates
|
||||
echo "$LOG_PREFIX Pruning unused images (>7 days old)..."
|
||||
docker image prune -a -f --filter "until=168h" --filter "dangling=false" 2>/dev/null || true
|
||||
|
||||
# 6. Truncate container log files that are bigger than 100MB
|
||||
echo "$LOG_PREFIX Checking container logs..."
|
||||
for logfile in /var/lib/docker/containers/*/*-json.log; do
|
||||
if [ -f "$logfile" ]; then
|
||||
size=$(stat -c%s "$logfile" 2>/dev/null || echo 0)
|
||||
if [ "$size" -gt 104857600 ]; then # 100MB
|
||||
echo "$LOG_PREFIX Truncating $(basename $logfile) ($(( size / 1048576 ))MB)"
|
||||
truncate -s 0 "$logfile"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. Vacuum journald logs to 200MB
|
||||
echo "$LOG_PREFIX Vacuuming journal logs..."
|
||||
journalctl --vacuum-size=200M 2>/dev/null || true
|
||||
|
||||
# 8. Clear pip/npm caches that grow over time
|
||||
echo "$LOG_PREFIX Clearing stale caches..."
|
||||
rm -rf /root/.cache/pip/cache/html 2>/dev/null || true
|
||||
rm -rf /root/.cache/npm/_cacache 2>/dev/null || true
|
||||
|
||||
# 9. Report disk usage
|
||||
USAGE=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
|
||||
FREE_GB=$(df -h / | tail -1 | awk '{print $4}')
|
||||
echo "$LOG_PREFIX Disk usage: ${USAGE}% (${FREE_GB} free)"
|
||||
|
||||
if [ "$USAGE" -gt "$MAX_DISK_PCT" ]; then
|
||||
echo "$LOG_PREFIX WARNING: Disk usage above ${MAX_DISK_PCT}%!"
|
||||
# More aggressive: remove ALL images not used by running containers
|
||||
echo "$LOG_PREFIX Aggressive prune: removing all unused images..."
|
||||
docker image prune -a -f 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "$LOG_PREFIX Done."
|
||||
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DashCaddy — Your Pro License</title>
|
||||
<link rel="canonical" href="/billing/success">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --pro:#7cf2c0; --danger:#ff9090; --warn:#ffd07f; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
||||
main { width: min(720px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,48px); }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
||||
h1 { margin: 8px 0 0; font-size: clamp(1.8rem,5vw,2.5rem); }
|
||||
.lede { color: var(--muted); }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-top: 24px; }
|
||||
.card.ready { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
|
||||
.card.warn { border-color: var(--warn); }
|
||||
.card.error { border-color: var(--danger); }
|
||||
.key { font: 600 1.2rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #06101e; padding: 16px; border-radius: 10px; border: 1px dashed var(--border); word-break: break-all; user-select: all; }
|
||||
.row { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
|
||||
button { cursor: pointer; border: 0; padding: 10px 16px; border-radius: 10px; font: inherit; font-weight: 600; background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
||||
button.primary { background: var(--pro); color: #052016; border-color: var(--pro); }
|
||||
button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.meta { color: var(--muted); font-size: .9rem; margin-top: 8px; }
|
||||
.next-steps { margin-top: 24px; }
|
||||
.next-steps ol { padding-left: 20px; }
|
||||
.next-steps li { margin: 6px 0; color: var(--muted); }
|
||||
.next-steps li strong { color: var(--text); }
|
||||
a { color: var(--accent); }
|
||||
.copied { color: var(--pro); font-size: .9rem; margin-left: 8px; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--muted); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 600px) { .card { padding: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">DashCaddy</div>
|
||||
<h1>Thanks for your purchase!</h1>
|
||||
<p class="lede">Your Pro license code is shown below. We've also sent it to your email as a backup — keep it safe.</p>
|
||||
|
||||
<div id="card" class="card">
|
||||
<div id="loading"><span class="spinner"></span>Generating your license…</div>
|
||||
|
||||
<div id="ready" hidden>
|
||||
<div class="meta" id="meta"></div>
|
||||
<div class="key" id="key" aria-live="polite"></div>
|
||||
<div class="row">
|
||||
<button id="copy" class="primary" type="button">Copy license key</button>
|
||||
<span id="copied" class="copied" hidden>Copied!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="processing" hidden>
|
||||
Your payment was received. The license is being generated — this page will update automatically. You can also check your email.
|
||||
</div>
|
||||
|
||||
<div id="warn" hidden>
|
||||
<strong>Email delivery didn't complete.</strong> Your license code is below — save it now. We're retrying email delivery on our side.
|
||||
<div class="meta" id="warn-meta" style="margin-top:12px"></div>
|
||||
<div class="key" id="key-warn" aria-live="polite"></div>
|
||||
<div class="row">
|
||||
<button id="copy-warn" type="button">Copy license key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" hidden>
|
||||
<strong>We can't find a record for this session yet.</strong> This page refreshes every 1.5 seconds. If you closed Stripe before being redirected back, your license has been emailed to you.
|
||||
<div class="meta" id="error-meta" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h2 style="font-size:1.25rem;margin-bottom:8px">How to install your key</h2>
|
||||
<ol>
|
||||
<li>Open your DashCaddy host: <strong>https://<your-host></strong></li>
|
||||
<li>Sign in (TOTP or email magic link)</li>
|
||||
<li>Go to <strong>Settings → License</strong> (path: <code>/admin/license</code>)</li>
|
||||
<li>Paste the key and click <strong>Activate license</strong></li>
|
||||
<li>Pro features (unlimited users, public share links, Tailscale-mediated share) unlock immediately</li>
|
||||
</ol>
|
||||
<p class="meta">Need help? Reply to the receipt email or open an issue at <a href="https://github.com/sami7777/dashcaddy/issues" rel="noopener">github.com/sami7777/dashcaddy</a>. 14-day pro-rated refunds per the <a href="/legal/terms">Terms of Service</a>.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var sessionId = new URLSearchParams(window.location.search).get('session_id');
|
||||
if (!sessionId) {
|
||||
// No session_id — Stripe didn't redirect here from a checkout. Show a friendly fallback.
|
||||
document.getElementById('card').classList.add('error');
|
||||
document.getElementById('loading').hidden = true;
|
||||
document.getElementById('error').hidden = false;
|
||||
document.getElementById('error-meta').textContent = 'Missing session_id in URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
var card = document.getElementById('card');
|
||||
var loadingEl = document.getElementById('loading');
|
||||
var readyEl = document.getElementById('ready');
|
||||
var processingEl = document.getElementById('processing');
|
||||
var warnEl = document.getElementById('warn');
|
||||
var errorEl = document.getElementById('error');
|
||||
var metaEl = document.getElementById('meta');
|
||||
var warnMetaEl = document.getElementById('warn-meta');
|
||||
var keyEl = document.getElementById('key');
|
||||
var keyWarnEl = document.getElementById('key-warn');
|
||||
var copyBtn = document.getElementById('copy');
|
||||
var copyWarnBtn = document.getElementById('copy-warn');
|
||||
var copiedEl = document.getElementById('copied');
|
||||
|
||||
var POLL_INTERVAL_MS = 1500;
|
||||
var POLL_TIMEOUT_MS = 60 * 1000;
|
||||
var startTime = Date.now();
|
||||
|
||||
function showReady(code, durationDays, productId, deliveredVia) {
|
||||
loadingEl.hidden = true;
|
||||
readyEl.hidden = false;
|
||||
card.classList.add('ready');
|
||||
keyEl.textContent = code;
|
||||
metaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
|
||||
(deliveredVia ? ' · delivered via ' + deliveredVia : '') +
|
||||
' · also emailed to you';
|
||||
}
|
||||
|
||||
function showProcessing() {
|
||||
loadingEl.hidden = true;
|
||||
processingEl.hidden = false;
|
||||
}
|
||||
|
||||
function showWarn(code, durationDays, productId, lastError, deliveredVia) {
|
||||
loadingEl.hidden = true;
|
||||
warnEl.hidden = false;
|
||||
card.classList.add('warn');
|
||||
keyWarnEl.textContent = code;
|
||||
warnMetaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
|
||||
(lastError ? ' · last email error: ' + lastError : '') +
|
||||
(deliveredVia ? ' · attempted via ' + deliveredVia : '');
|
||||
}
|
||||
|
||||
function showError(reason) {
|
||||
loadingEl.hidden = true;
|
||||
errorEl.hidden = false;
|
||||
card.classList.add('error');
|
||||
if (reason) document.getElementById('error-meta').textContent = reason;
|
||||
}
|
||||
|
||||
function copyFromTextarea(text, cb) {
|
||||
// Use the modern Clipboard API; fall back to a hidden textarea + execCommand.
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(cb).catch(function () {
|
||||
fallbackCopy(text, cb);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text, cb);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, cb) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'absolute';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); cb(); } catch (_) { /* swallow */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
copyBtn.addEventListener('click', function () {
|
||||
copyFromTextarea(keyEl.textContent, function () {
|
||||
copiedEl.hidden = false;
|
||||
setTimeout(function () { copiedEl.hidden = true; }, 2000);
|
||||
});
|
||||
});
|
||||
copyWarnBtn.addEventListener('click', function () {
|
||||
copyFromTextarea(keyWarnEl.textContent, function () { /* no-op */ });
|
||||
});
|
||||
|
||||
function tick() {
|
||||
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
|
||||
showError('Timed out waiting for the bridge to generate the license. Your license has been emailed to you — please check your inbox.');
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/billing/lookup/' + encodeURIComponent(sessionId), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
|
||||
.then(function (resp) {
|
||||
var data = resp.body && resp.body.data ? resp.body.data : null;
|
||||
if (resp.status === 200 && data) {
|
||||
if (data.status === 'delivered') {
|
||||
showReady(data.code, data.durationDays, data.productId, data.deliveredVia);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'pending_email' && data.code) {
|
||||
showWarn(data.code, data.durationDays, data.productId, data.lastError, data.deliveredVia);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'processing') {
|
||||
showProcessing();
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (resp.status === 404 && resp.body && /expired/i.test(resp.body.error || '')) {
|
||||
showError('License lookup window has expired. Your key was emailed to you.');
|
||||
return;
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
// Payment still processing — keep polling.
|
||||
showProcessing();
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
showError('Unexpected response: ' + (resp.body && resp.body.error ? resp.body.error : 'HTTP ' + resp.status));
|
||||
})
|
||||
.catch(function () {
|
||||
// Transient network error — keep trying.
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
tick();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -72,6 +72,11 @@ const bundles = {
|
||||
JS('card-badges.js'),
|
||||
JS('theme-builder.js'),
|
||||
JS('license.js'),
|
||||
// DC-058: Share modal — opened from the share button on each service card.
|
||||
// Must come after license.js because it uses window.openShareModal and
|
||||
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
||||
// defined in globals.js (already in core.js).
|
||||
JS('share-modal.js'),
|
||||
],
|
||||
'onboarding.js': [
|
||||
JS('driver.min.js'),
|
||||
|
||||
@@ -3852,6 +3852,7 @@ button:focus-visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
padding: 40px 0 20px;
|
||||
margin-top: 48px;
|
||||
@@ -3873,3 +3874,7 @@ button:focus-visible {
|
||||
height: 140px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.footer-legal { display: flex; gap: 14px; font-size: 0.8rem; }
|
||||
.footer-legal a { color: var(--muted); text-decoration: none; }
|
||||
.footer-legal a:hover, .footer-legal a:focus-visible { color: var(--accent); text-decoration: underline; }
|
||||
|
||||
Vendored
+87
-87
File diff suppressed because one or more lines are too long
Vendored
+252
-171
File diff suppressed because one or more lines are too long
@@ -939,6 +939,10 @@
|
||||
<footer class="dashcaddy-footer">
|
||||
<span class="footer-copy">© <span id="footer-year"></span></span>
|
||||
<img src="/assets/sami7777-logo.png" alt="samiahmed7777" class="footer-logo">
|
||||
<nav class="footer-legal" aria-label="Legal">
|
||||
<a href="/legal/terms">Terms of Service</a>
|
||||
<a href="/legal/privacy">Privacy Policy</a>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
<!-- xterm.js for container exec/shell -->
|
||||
|
||||
+22
-11
@@ -249,20 +249,31 @@
|
||||
// back to window._showTotpOverlay() in `show()` below.
|
||||
window.__dc_049_handled = true;
|
||||
|
||||
function isAllowedReturnUrl(returnUrl) {
|
||||
try {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
if (parsed.origin === window.location.origin) return true;
|
||||
if (parsed.protocol !== 'https:') return false;
|
||||
|
||||
// globals.js is concatenated before this module in core.js, so SITE is
|
||||
// available here. Permit exact hosts and subdomains under the configured
|
||||
// private TLD (for example plex.sami), while rejecting lookalikes such as
|
||||
// plex.sami.evil.example.
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
return parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('auth') === 'required') {
|
||||
// Save returnUrl the same way totp-auth.js does, so both paths share state.
|
||||
// We don't have access to the SITE constant here (it lives in globals.js's
|
||||
// module scope), so we use a conservative origin-only check. Caddy's
|
||||
// forward_auth already validates the request origin upstream.
|
||||
// Preserve the gated service destination so submitTotpCode() can append
|
||||
// the one-time SSO handoff token and return the browser to that host.
|
||||
const returnUrl = urlParams.get('return');
|
||||
if (returnUrl) {
|
||||
try {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) {
|
||||
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (returnUrl && isAllowedReturnUrl(returnUrl)) {
|
||||
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
|
||||
}
|
||||
// Clean URL — happens after we've captured the redirect
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
@@ -270,6 +270,25 @@
|
||||
btnRow.appendChild(optBtn);
|
||||
}
|
||||
|
||||
// Add share button for all services except 'internet' (DC-058).
|
||||
// Calls into the share-modal module registered on window. We don't
|
||||
// hard-require the module — if share-modal.js was excluded from the
|
||||
// bundle, the button still renders but clicking it surfaces a clear
|
||||
// error toast instead of a silent no-op.
|
||||
if (s.id !== 'internet') {
|
||||
const shareBtn = el('button', 'share-btn', '🔗');
|
||||
shareBtn.title = 'Share this service (Pro)';
|
||||
shareBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (typeof window.openShareModal === 'function') {
|
||||
window.openShareModal(s);
|
||||
} else if (typeof window.showNotification === 'function') {
|
||||
window.showNotification('Share modal not loaded. Refresh the page.', 'error');
|
||||
}
|
||||
};
|
||||
btnRow.appendChild(shareBtn);
|
||||
}
|
||||
|
||||
// Add delete button for all services except Internet
|
||||
if (s.id !== 'internet') {
|
||||
const delBtn = el('button', 'delete-btn', '🗑️');
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
// Share Modal — DC-058
|
||||
//
|
||||
// Admin UI for DC-053 share routes. Opened from the "Share" button on each
|
||||
// service card (added in core/grid.js next to the existing options/delete
|
||||
// buttons). Two tabs:
|
||||
// - Public link: pick a TTL (1h/24h/7d), POST /api/v1/share, render the
|
||||
// returned urlPath with a copy button + revoke control.
|
||||
// - Tailscale invite: enter an email, POST /api/v1/share/tailscale, render
|
||||
// delivered status (inbox vs dev-console fallback vs URL fallback).
|
||||
//
|
||||
// Modal also lists outstanding shares for the selected service (GET /api/v1/share)
|
||||
// with revoke buttons. The list refreshes after every issue/revoke.
|
||||
//
|
||||
// Both issue endpoints are Pro-gated on the server — the modal surfaces a 402
|
||||
// as an upgrade prompt ("Share is a Pro feature. Activate a license to unlock.").
|
||||
// Tailscale invites additionally require tailscaleCoord configured on the host;
|
||||
// the server returns 400 with a clear message when missing.
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if (window.__dc_058_share_modal_loaded) return;
|
||||
window.__dc_058_share_modal_loaded = true;
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
{ ms: 60 * 60 * 1000, label: '1 hour' },
|
||||
{ ms: 24 * 60 * 60 * 1000, label: '24 hours' },
|
||||
{ ms: 7 * 24 * 60 * 60 * 1000, label: '7 days' },
|
||||
];
|
||||
|
||||
injectModal('share-modal', `
|
||||
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
|
||||
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
|
||||
<h3 id="share-modal-title">Share <span id="share-modal-service-name">…</span></h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
Create a public link or Tailscale invite so someone outside your network can access this service.
|
||||
</p>
|
||||
|
||||
<div class="share-tabs" style="display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border);">
|
||||
<button class="share-tab active" data-tab="public" type="button"
|
||||
style="background: transparent; border: 0; border-bottom: 2px solid var(--accent); padding: 8px 14px; color: var(--fg); font-weight: 600; cursor: pointer;">
|
||||
Public link
|
||||
</button>
|
||||
<button class="share-tab" data-tab="tailscale" type="button"
|
||||
style="background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; color: var(--muted); cursor: pointer;">
|
||||
Tailscale invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="share-tab-panel" data-panel="public">
|
||||
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
|
||||
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
|
||||
${TTL_OPTIONS.map(o => `<option value="${o.ms}">${o.label}</option>`).join('')}
|
||||
</select>
|
||||
<button id="share-public-create" class="btn-accent" type="button"
|
||||
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
|
||||
Create share link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="share-tab-panel" data-panel="tailscale" style="display: none;">
|
||||
<label class="form-label-bold" for="share-ts-email">Recipient email:</label>
|
||||
<input id="share-ts-email" type="email" placeholder="alice@example.com" autocomplete="off"
|
||||
style="width: 100%; padding: 10px 12px; margin: 6px 0 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem;" />
|
||||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
||||
A single-use Tailscale pre-auth key is generated and emailed. The device joins your tailnet and is routed to this service via Caddy.
|
||||
</p>
|
||||
<button id="share-ts-create" class="btn-accent" type="button"
|
||||
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
|
||||
Create Tailscale invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="share-issued" style="display: none; margin-top: 16px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 6px;">Share link:</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center;">
|
||||
<input id="share-issued-url" type="text" readonly
|
||||
style="flex: 1; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; font-family: monospace; font-size: 0.85rem;" />
|
||||
<button id="share-issued-copy" type="button"
|
||||
style="padding: 8px 14px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div id="share-issued-meta" style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="share-error" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(231,76,60,0.15); color: var(--bad-fg); font-size: 0.85rem;"></div>
|
||||
<div id="share-success" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(46,204,113,0.15); color: var(--ok-fg); font-size: 0.85rem;"></div>
|
||||
|
||||
<div id="share-outstanding" style="margin-top: 16px;">
|
||||
<label class="form-label-bold">Outstanding shares for this service</label>
|
||||
<div id="share-outstanding-list" style="margin-top: 6px; font-size: 0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 18px;">
|
||||
<button id="share-cancel" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const modal = document.getElementById('share-modal');
|
||||
const serviceNameEl = document.getElementById('share-modal-service-name');
|
||||
const issuedEl = document.getElementById('share-issued');
|
||||
const issuedUrlInput = document.getElementById('share-issued-url');
|
||||
const issuedCopyBtn = document.getElementById('share-issued-copy');
|
||||
const issuedMetaEl = document.getElementById('share-issued-meta');
|
||||
const errorEl = document.getElementById('share-error');
|
||||
const successEl = document.getElementById('share-success');
|
||||
const outstandingListEl = document.getElementById('share-outstanding-list');
|
||||
const cancelBtn = document.getElementById('share-cancel');
|
||||
const publicCreateBtn = document.getElementById('share-public-create');
|
||||
const tsCreateBtn = document.getElementById('share-ts-create');
|
||||
const tsEmailInput = document.getElementById('share-ts-email');
|
||||
const publicTtlSelect = document.getElementById('share-public-ttl');
|
||||
|
||||
let currentService = null; // { id, name }
|
||||
let activeTab = 'public';
|
||||
|
||||
function hideMessages() {
|
||||
errorEl.style.display = 'none';
|
||||
successEl.style.display = 'none';
|
||||
issuedEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
successEl.style.display = 'none';
|
||||
issuedEl.style.display = 'none';
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function showSuccess(msg) {
|
||||
errorEl.style.display = 'none';
|
||||
successEl.textContent = msg;
|
||||
successEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function _originFromPage() {
|
||||
// Build the absolute share URL from the page's current origin so the
|
||||
// link is correct regardless of whether the user is on http://localhost
|
||||
// (dev) or https://status.sami (prod). The urlPath returned by the API
|
||||
// is a path-only string starting with /share/.
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function _absUrl(urlPath) {
|
||||
if (urlPath.startsWith('http')) return urlPath;
|
||||
return _originFromPage() + urlPath;
|
||||
}
|
||||
|
||||
function _formatExpiry(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function _formatRemaining(ms) {
|
||||
if (ms <= 0) return 'expired';
|
||||
const h = Math.floor(ms / 3600000);
|
||||
if (h >= 24) return `${Math.floor(h / 24)}d ${h % 24}h left`;
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
return `${h}h ${m}m left`;
|
||||
}
|
||||
|
||||
function setActiveTab(name) {
|
||||
activeTab = name;
|
||||
hideMessages();
|
||||
modal.querySelectorAll('.share-tab').forEach(btn => {
|
||||
const isActive = btn.dataset.tab === name;
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.style.borderBottomColor = isActive ? 'var(--accent)' : 'transparent';
|
||||
btn.style.color = isActive ? 'var(--fg)' : 'var(--muted)';
|
||||
btn.style.fontWeight = isActive ? '600' : '400';
|
||||
});
|
||||
modal.querySelectorAll('.share-tab-panel').forEach(p => {
|
||||
p.style.display = p.dataset.panel === name ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOutstanding() {
|
||||
outstandingListEl.innerHTML = '<span style="color: var(--muted);">Loading…</span>';
|
||||
try {
|
||||
const resp = await fetch('/api/v1/share', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
outstandingListEl.innerHTML = '<span style="color: var(--muted);">No shares listed.</span>';
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
const list = (body && body.data) || [];
|
||||
const filtered = list.filter(s => s.serviceId === currentService.id);
|
||||
if (filtered.length === 0) {
|
||||
outstandingListEl.innerHTML = '<span style="color: var(--muted);">No outstanding shares for this service.</span>';
|
||||
return;
|
||||
}
|
||||
outstandingListEl.innerHTML = filtered.map(s => {
|
||||
const remaining = s.expiresAt ? _formatRemaining(new Date(s.expiresAt).getTime() - Date.now()) : '';
|
||||
return `
|
||||
<div class="share-row" data-share-id="${escapeHtml(s.id)}"
|
||||
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
|
||||
<span style="flex: 1;">
|
||||
<strong>${s.kind === 'tailscale' ? 'Tailscale' : 'Public'}</strong>
|
||||
<span style="color: var(--muted);"> · expires ${escapeHtml(_formatExpiry(s.expiresAt))} (${remaining})</span>
|
||||
</span>
|
||||
<button class="share-revoke" data-share-id="${escapeHtml(s.id)}" type="button"
|
||||
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
outstandingListEl.querySelectorAll('.share-revoke').forEach(btn => {
|
||||
btn.addEventListener('click', () => revokeShare(btn.dataset.shareId));
|
||||
});
|
||||
} catch (e) {
|
||||
outstandingListEl.innerHTML = '<span style="color: var(--muted);">Could not load outstanding shares.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeShare(id) {
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/share/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
showError(body && body.error || `Revoke failed (HTTP ${resp.status}).`);
|
||||
return;
|
||||
}
|
||||
showSuccess('Share revoked.');
|
||||
loadOutstanding();
|
||||
} catch (e) {
|
||||
showError('Network error while revoking.');
|
||||
}
|
||||
}
|
||||
|
||||
async function issuePublic() {
|
||||
if (!currentService) return;
|
||||
hideMessages();
|
||||
publicCreateBtn.disabled = true;
|
||||
const original = publicCreateBtn.textContent;
|
||||
publicCreateBtn.textContent = 'Creating…';
|
||||
try {
|
||||
const ttlMs = parseInt(publicTtlSelect.value, 10);
|
||||
const resp = await fetch('/api/v1/share', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serviceId: currentService.id, ttlMs }),
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !body.success) {
|
||||
_renderApiError(resp.status, body);
|
||||
return;
|
||||
}
|
||||
const urlPath = body.data.urlPath;
|
||||
issuedUrlInput.value = _absUrl(urlPath);
|
||||
issuedMetaEl.textContent = `Public link · expires ${_formatExpiry(body.data.expiresAt)}`;
|
||||
issuedEl.style.display = 'block';
|
||||
showSuccess('Share link created.');
|
||||
loadOutstanding();
|
||||
} catch (e) {
|
||||
showError('Network error while creating share.');
|
||||
} finally {
|
||||
publicCreateBtn.disabled = false;
|
||||
publicCreateBtn.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
async function issueTailscale() {
|
||||
if (!currentService) return;
|
||||
const email = (tsEmailInput.value || '').trim();
|
||||
if (!email || !email.includes('@')) {
|
||||
showError('A valid recipient email is required.');
|
||||
return;
|
||||
}
|
||||
hideMessages();
|
||||
tsCreateBtn.disabled = true;
|
||||
const original = tsCreateBtn.textContent;
|
||||
tsCreateBtn.textContent = 'Creating…';
|
||||
try {
|
||||
const resp = await fetch('/api/v1/share/tailscale', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serviceId: currentService.id, email }),
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !body.success) {
|
||||
_renderApiError(resp.status, body);
|
||||
return;
|
||||
}
|
||||
// Tailscale shares show the raw urlPath (which is the share URL) if
|
||||
// email delivery failed. The share itself is one-shot so the admin
|
||||
// can manually paste the link into a chat if SMTP failed.
|
||||
const fallback = body.data && body.data.urlPath;
|
||||
if (fallback) {
|
||||
issuedUrlInput.value = _absUrl(fallback);
|
||||
issuedMetaEl.textContent = `Tailscale invite · emailed to ${email} (or use this URL manually if delivery failed).`;
|
||||
} else {
|
||||
issuedUrlInput.value = '';
|
||||
issuedMetaEl.textContent = `Tailscale invite sent to ${email}.`;
|
||||
}
|
||||
issuedEl.style.display = 'block';
|
||||
showSuccess('Tailscale invite created.');
|
||||
tsEmailInput.value = '';
|
||||
loadOutstanding();
|
||||
} catch (e) {
|
||||
showError('Network error while creating Tailscale invite.');
|
||||
} finally {
|
||||
tsCreateBtn.disabled = false;
|
||||
tsCreateBtn.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderApiError(status, body) {
|
||||
const err = (body && body.error) || '';
|
||||
if (status === 402) {
|
||||
showError('Share is a Pro feature. Activate a license to unlock.');
|
||||
return;
|
||||
}
|
||||
if (status === 400 && /tailscale/i.test(err)) {
|
||||
showError('Tailscale is not configured on this host. Set up Tailscale in the dashboard first.');
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
showError('You do not have permission to share services.');
|
||||
return;
|
||||
}
|
||||
if (status === 404) {
|
||||
showError('Service not found. It may have been deleted.');
|
||||
return;
|
||||
}
|
||||
showError(err || `Request failed (HTTP ${status}).`);
|
||||
}
|
||||
|
||||
function openShareModal(service) {
|
||||
if (!service || !service.id) return;
|
||||
currentService = { id: service.id, name: service.name || service.id };
|
||||
serviceNameEl.textContent = currentService.name;
|
||||
hideMessages();
|
||||
setActiveTab('public');
|
||||
tsEmailInput.value = '';
|
||||
publicTtlSelect.value = String(TTL_OPTIONS[1].ms); // 24h default
|
||||
modal.classList.add('show');
|
||||
loadOutstanding();
|
||||
}
|
||||
|
||||
// Wire events
|
||||
modal.querySelectorAll('.share-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => setActiveTab(btn.dataset.tab));
|
||||
});
|
||||
publicCreateBtn.addEventListener('click', issuePublic);
|
||||
tsCreateBtn.addEventListener('click', issueTailscale);
|
||||
cancelBtn.addEventListener('click', () => modal.classList.remove('show'));
|
||||
wireModal(modal, cancelBtn);
|
||||
|
||||
// Copy-to-clipboard for the issued share URL
|
||||
issuedCopyBtn.addEventListener('click', async () => {
|
||||
const url = issuedUrlInput.value;
|
||||
if (!url) return;
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} else {
|
||||
// Fallback for older browsers / unsafe contexts
|
||||
issuedUrlInput.select();
|
||||
document.execCommand('copy');
|
||||
}
|
||||
const original = issuedCopyBtn.textContent;
|
||||
issuedCopyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { issuedCopyBtn.textContent = original; }, 1200);
|
||||
} catch (e) {
|
||||
showError('Could not copy to clipboard. Select the URL manually.');
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for other modules to open
|
||||
window.openShareModal = openShareModal;
|
||||
})();
|
||||
+28
-1
@@ -35,6 +35,24 @@
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
}
|
||||
|
||||
function buildSsoHandoffTarget(redirect, token) {
|
||||
const parsed = new URL(redirect, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) return parsed.toString();
|
||||
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
|
||||
if (parsed.protocol !== 'https:' || !isPrivateHost) return null;
|
||||
if (!token) return parsed.toString();
|
||||
|
||||
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
parsed.pathname = '/dashcaddy-sso';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.set('token', token);
|
||||
parsed.searchParams.set('return', returnPath);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
// Setup digit input UX
|
||||
const container = document.getElementById('totp-digits');
|
||||
if (container) {
|
||||
@@ -91,7 +109,16 @@
|
||||
const redirect = safeSessionGet('totp_redirect');
|
||||
if (redirect) {
|
||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||
window.location.href = redirect;
|
||||
// .sami is an unregistered TLD, so browsers silently drop the
|
||||
// Domain=.sami session cookie on any OTHER *.sami subdomain (they
|
||||
// treat "sami" as the effective public suffix, same protection
|
||||
// that blocks a Domain=.com supercookie). The target service can't
|
||||
// see our session cookie no matter how it's built, so instead we
|
||||
// hand it a one-time token in the URL; its login page exchanges
|
||||
// that for its own host-only cookie via /auth/sso-exchange.
|
||||
const target = buildSsoHandoffTarget(redirect, data.ssoToken);
|
||||
if (!target) return;
|
||||
window.location.href = target;
|
||||
return;
|
||||
}
|
||||
// Initialize dashboard
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Privacy Policy | DashCaddy</title><style>:root{color-scheme:dark;--card:#111c2e;--text:#e8edf5;--muted:#aab7ca;--accent:#68a4ff;--border:#263750}*{box-sizing:border-box}body{margin:0;background:linear-gradient(145deg,#07101d,#101b31);color:var(--text);font:16px/1.7 system-ui,sans-serif}main{width:min(900px,calc(100% - 32px));margin:48px auto;padding:clamp(24px,5vw,56px);background:var(--card);border:1px solid var(--border);border-radius:18px}h1{margin:0;font-size:clamp(2rem,5vw,3rem)}h2{margin-top:2rem;color:#fff}h3,strong{color:var(--text)}p,li{color:var(--muted)}a{color:var(--accent)}.eyebrow{color:var(--accent);font-weight:700;text-transform:uppercase}.notice{border-left:3px solid var(--accent);background:#0b1628;padding:12px 16px}footer{margin-top:42px;padding-top:22px;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:20px}</style></head><body><main><div class="eyebrow">DashCaddy Legal</div><h1>Privacy Policy</h1><p><strong>Effective and last updated:</strong> July 31, 2026</p><p class="notice">This GDPR-aware policy describes DashCaddy v1.0. It is not legal advice and may be refined following professional review.</p>
|
||||
<h2>1. Controller and contact</h2><p>Sami Ahmed, operator of DashCaddy, controls personal data collected for subscriptions, licensing, and operation. Contact <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a>. DashCaddy has no separate Data Protection Officer; this is the privacy contact.</p>
|
||||
<h2>2. Data collected</h2><h3>Account, login, and billing</h3><ul><li>Email address for login, license delivery, support, billing, and essential notices.</li><li>Subscription status, Stripe customer/session IDs, product, payment status, dates, and refunds. <strong>We do not receive or store full card numbers or security codes.</strong></li></ul><h3>License and server metadata</h3><ul><li>License key, tier, activation/expiry dates, and machine/host metadata embedded in or associated with the license.</li><li>Connection metadata needed to validate and secure licenses, such as IP address, timestamp, host/machine identifier, version, and request outcome.</li><li>The key containing machine metadata is stored locally in <code>data/credentials.json</code> and on the operator’s license server.</li></ul><h3>Optional Tailscale data</h3><p>Only if enabled, DashCaddy sends coordination API requests and may process Tailscale device IDs, tailnet/user IDs, names/status, and minted device or pre-auth keys. Keys are stored only as needed for the configured integration or share flow. Tailscale independently processes data under its terms.</p><h3>Support</h3><p>We collect messages and diagnostics you voluntarily provide. Do not send passwords, private keys, or unrelated personal data.</p>
|
||||
<h2>3. Data not intentionally collected</h2><p>The hosted licensing service does not intentionally collect proxied content, DNS query history, injected credentials, or card details. Credentials and local configuration remain customer-controlled unless deliberately provided for support. v1.0 makes no automated decisions with legal or similarly significant effects.</p>
|
||||
<h2>4. Purposes and GDPR lawful bases</h2><ul><li><strong>Contract:</strong> licenses, authentication, optional features, billing/refunds, and support.</li><li><strong>Legitimate interests:</strong> per-host enforcement, fraud/abuse prevention, security, troubleshooting, and proportionate product improvement.</li><li><strong>Legal obligation:</strong> required transaction/tax records and valid legal requests.</li><li><strong>Consent:</strong> optional marketing and integrations where consent is appropriate. Consent may be withdrawn without affecting earlier lawful processing.</li></ul>
|
||||
<h2>5. Sharing and processors</h2><p>We do not sell personal data. Necessary disclosures are to:</p><ul><li><strong>Stripe</strong> for Checkout, billing, fraud prevention, receipts, and refunds. Card data goes directly to Stripe.</li><li><strong>Tailscale</strong> only when you configure/use the integration, for coordination and device/key operations.</li><li><strong>Our email delivery provider</strong> for login, license, billing, security, and support email; it receives the address and message content.</li></ul><p>We may disclose data when legally required, to protect rights/safety, or in a business transfer with safeguards. We do not otherwise share personal data except as described in this policy.</p>
|
||||
<h2>6. International transfers</h2><p>Processors may handle data outside your country. Where GDPR applies, we will use a legally recognized transfer mechanism where one is required, such as an adequacy decision or Standard Contractual Clauses. Contact us for information about safeguards applicable to your data.</p>
|
||||
<h2>7. Retention</h2><ul><li><strong>License keys and host metadata:</strong> life of subscription plus 30 days after cancellation, then deleted or irreversibly anonymized unless law requires longer.</li><li><strong>Billing records:</strong> as required for tax, accounting, chargebacks, and fraud prevention.</li><li><strong>Connection/security logs:</strong> normally no more than 30 days unless an incident requires preservation.</li><li><strong>Support records:</strong> while active and normally up to 12 months afterward.</li><li><strong>Optional Tailscale keys:</strong> until expired, used/revoked, share removal, or integration disablement, subject to Tailscale retention.</li></ul><p>Backups may retain deleted data for a limited rotation and are restored only for disaster recovery.</p>
|
||||
<h2>8. Security</h2><p>We use reasonable safeguards and data minimization, but no system is completely secure. DashCaddy does not claim SOC 2, HIPAA, PCI-DSS, or another audited certification. Stripe Checkout processes cards; card data never touches DashCaddy servers.</p>
|
||||
<h2>9. GDPR and other privacy rights</h2><p>Depending on location, you may request access, correction, deletion, restriction, objection, withdrawal of consent, and data portability in a structured machine-readable format, and complain to your supervisory authority. Email <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a> with “Privacy Request.” We may verify identity. We aim to respond within 30 days (one month), explain lawful extensions/refusals, and normally charge no fee. Without a central account, we search using identifiers you provide.</p>
|
||||
<h2>10. Children, cookies, and marketing</h2><p>DashCaddy is not directed to children under 16. Checkout/login may use strictly necessary cookies. We request consent before non-essential analytics/marketing cookies where required. Marketing email is optional and includes unsubscribe.</p>
|
||||
<h2>11. Changes and contact</h2><p>Revisions will show a new date, with reasonable notice for material changes. Questions and rights requests: <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a>.</p><footer><a href="/legal/terms">Terms of Service</a><a href="mailto:privacy@sami-ahmed.net">Contact</a><a href="/">Back to DashCaddy</a></footer></main></body></html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Terms of Service | DashCaddy</title><style>:root{color-scheme:dark;--bg:#09111f;--card:#111c2e;--text:#e8edf5;--muted:#aab7ca;--accent:#68a4ff;--border:#263750}*{box-sizing:border-box}body{margin:0;background:linear-gradient(145deg,#07101d,#101b31);color:var(--text);font:16px/1.7 system-ui,sans-serif}main{width:min(900px,calc(100% - 32px));margin:48px auto;padding:clamp(24px,5vw,56px);background:var(--card);border:1px solid var(--border);border-radius:18px}h1{margin:0;font-size:clamp(2rem,5vw,3rem)}h2{margin-top:2rem;color:#fff}p,li{color:var(--muted)}strong{color:var(--text)}a{color:var(--accent)}.eyebrow{color:var(--accent);font-weight:700;text-transform:uppercase}.notice{border-left:3px solid var(--accent);background:#0b1628;padding:12px 16px}footer{margin-top:42px;padding-top:22px;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:20px}</style></head><body><main><div class="eyebrow">DashCaddy Legal</div><h1>Terms of Service</h1><p><strong>Effective and last updated:</strong> July 31, 2026</p><p class="notice">These Terms are a general launch document and are not legal advice. The operator may revise them following professional legal review.</p>
|
||||
<h2>1. Agreement and operator</h2><p>These Terms govern your purchase, installation, and use of DashCaddy software and related hosted licensing services (the “Service”), operated by Sami Ahmed (“DashCaddy,” “we,” “us,” or “our”). By purchasing, activating, or using DashCaddy, you agree to these Terms and the <a href="/legal/privacy">Privacy Policy</a>. If acting for an organization, you represent that you can bind it.</p>
|
||||
<h2>2. License grant</h2><p>Subject to payment and these Terms, we grant a limited, revocable, non-exclusive, non-sublicensable, non-transferable license to install and use DashCaddy on <strong>one host per license</strong> for the subscription term. A license may be moved to a replacement host with approval, but not shared, resold, rented, or used concurrently on multiple hosts. DashCaddy retains all ownership and intellectual-property rights.</p><p>The license key embeds or is associated with machine metadata. A copy is stored on the licensed host in <code>data/credentials.json</code> and on our license server for validation and enforcement.</p>
|
||||
<h2>3. Acceptable use</h2><p>You must use DashCaddy lawfully and are responsible for connected systems. You must not:</p><ul><li>use proxy, DNS, credential-injection, sharing, or Tailscale features for unauthorized access, traffic interception, evasion, malware, spam, phishing, or attacks;</li><li>overload, bypass, or interfere with the Service, licensing, authentication, or security;</li><li>reverse engineer or modify DashCaddy except where law expressly permits, or remove notices;</li><li>violate privacy, intellectual-property, sanctions, export-control, or other applicable law; or</li><li>provide data or credentials you lack authority to process.</li></ul><p>We may investigate abuse and suspend access when reasonably necessary to protect users, third parties, or the Service.</p>
|
||||
<h2>4. Availability and changes</h2><p>DashCaddy v1.0 is provided on a <strong>best-effort basis with no service-level agreement (SLA)</strong>, uptime guarantee, or guaranteed response time. Maintenance, failures, third-party outages, security events, and product changes may interrupt availability. Features may change or be discontinued with reasonable notice where practical.</p>
|
||||
<h2>5. Billing, renewal, and Refund policy</h2><p>Prices, billing periods, taxes, and renewal terms appear at checkout. Stripe processes payments; card details go directly to Stripe and never touch DashCaddy servers. Unless checkout states otherwise, subscriptions renew automatically until cancelled.</p><p><strong>Refund policy:</strong> request a pro-rated refund within 14 calendar days after initial purchase. It covers the unused portion of that initial period from the request date. After 14 days, and for renewals, payments are non-refundable except where law requires. Cancellation prevents renewal but does not itself create a refund.</p>
|
||||
<h2>6. Your systems and data</h2><p>You are responsible for backups, configuration, access control, and host security. DashCaddy manages sensitive proxy, DNS, and credential-injection settings; review changes. Data handling is described in the <a href="/legal/privacy">Privacy Policy</a>.</p>
|
||||
<h2>7. Suspension and Termination</h2><p>You may stop using DashCaddy and cancel renewal anytime. We may suspend or terminate for material breach, non-payment, unlawful or abusive use, or security risk, with notice and opportunity to cure where reasonably possible. On termination the license ends. Ownership, disclaimers, liability, and governing-law provisions survive.</p>
|
||||
<h2>8. Disclaimers</h2><p>To the maximum extent permitted by law, the Service is “as is” and “as available.” We disclaim implied warranties of merchantability, fitness, non-infringement, and uninterrupted or error-free operation. DashCaddy is not represented as certified for regulated workloads and makes no SOC 2, HIPAA, or similar compliance claim. Mandatory rights remain unaffected.</p>
|
||||
<h2>9. Limitation of liability</h2><p>To the maximum extent permitted by law, DashCaddy and its operator are not liable for indirect, incidental, special, consequential, exemplary, or punitive damages, or lost profits, revenue, data, goodwill, or business interruption. Aggregate liability will not exceed amounts paid for DashCaddy in the 12 months before the claim. Limits do not apply where prohibited or to liability that cannot lawfully be limited.</p>
|
||||
<h2>10. Indemnity</h2><p>Where permitted, you will indemnify us against third-party claims from your unlawful use, connected services or data, or breach, except to the extent caused by our unlawful conduct.</p>
|
||||
<h2>11. Governing law and disputes</h2><p>These Terms are governed by laws applicable in the operator’s principal place of business, without conflict-of-law rules. Courts there have jurisdiction, except consumers retain mandatory rights and forum protections in their country. Before filing, parties will attempt resolution by email for 30 days.</p>
|
||||
<h2>12. Changes and contact</h2><p>Material changes will be posted with a new effective date and reasonable advance notice where practical. Questions, cancellation, or refunds: <a href="mailto:privacy@sami-ahmed.net">privacy@sami-ahmed.net</a>.</p><footer><a href="/legal/privacy">Privacy Policy</a><a href="mailto:privacy@sami-ahmed.net">Contact</a><a href="/">Back to DashCaddy</a></footer></main></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=/legal/terms"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="canonical" href="/legal/terms"><title>Terms of Service | DashCaddy</title></head><body><p>DashCaddy Legal: Continue to the <a href="/legal/terms">Terms of Service</a>.</p></body></html>
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"test": "node --test tests/*.test.js",
|
||||
"watch": "node build.js --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DashCaddy Pricing — Free & Pro</title>
|
||||
<link rel="canonical" href="/pricing">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --pro:#7cf2c0; --danger:#ff9090; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
||||
main { width: min(1100px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,56px); }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
||||
h1 { margin: 8px 0 0; font-size: clamp(2rem,5vw,3rem); }
|
||||
.lede { color: var(--muted); max-width: 720px; margin-top: 12px; }
|
||||
.tiers { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 18px; margin-top: 36px; }
|
||||
.tier { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 28px; display: flex; flex-direction: column; }
|
||||
.tier.pro { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
|
||||
.tier h2 { margin: 0 0 4px; font-size: 1.25rem; }
|
||||
.tier .price { font-size: 2rem; font-weight: 700; margin: 14px 0 0; }
|
||||
.tier .price small { font-size: 1rem; color: var(--muted); font-weight: 400; }
|
||||
.tier .duration { color: var(--muted); margin-top: 4px; font-size: .9rem; }
|
||||
.tier ul { margin: 14px 0; padding-left: 18px; color: var(--muted); font-size: .9rem; }
|
||||
.tier li { margin: 4px 0; }
|
||||
.tier button { cursor: pointer; border: 0; padding: 12px 16px; border-radius: 10px; font: inherit; font-weight: 600; margin-top: auto; }
|
||||
.tier.free { grid-column: 1 / -1; }
|
||||
.tier.free button { background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
||||
.tier.pro button { background: var(--pro); color: #052016; }
|
||||
.tier button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.footnote { color: var(--muted); margin-top: 32px; font-size: .9rem; }
|
||||
.footnote a { color: var(--accent); }
|
||||
.error { color: var(--danger); margin-top: 12px; min-height: 1.4em; }
|
||||
@media (max-width: 600px) { .tier { padding: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">DashCaddy</div>
|
||||
<h1>Simple pricing. Self-hosted either way.</h1>
|
||||
<p class="lede">DashCaddy runs on your hardware. Free is enough for most homelabs. Pro unlocks multi-host fleets, public sharing, and email support.</p>
|
||||
|
||||
<div class="tiers">
|
||||
<div class="tier free">
|
||||
<h2>Free</h2>
|
||||
<div class="price">$0<small>/forever</small></div>
|
||||
<div class="duration">Unlimited duration</div>
|
||||
<ul>
|
||||
<li>Single host</li>
|
||||
<li>Up to <strong>3 users</strong></li>
|
||||
<li>TOTP login (single-user)</li>
|
||||
<li>Docker / Caddy / DNS management</li>
|
||||
<li>Community support (GitHub issues)</li>
|
||||
</ul>
|
||||
<button type="button" onclick="window.location.href='/download'">Download Free</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-30d">
|
||||
<h2>1 month</h2>
|
||||
<div class="price">$20</div>
|
||||
<div class="duration">30-day Pro license</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-30d">Buy 1 month</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-90d">
|
||||
<h2>3 months</h2>
|
||||
<div class="price">$50</div>
|
||||
<div class="duration">90-day Pro license (17% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-90d">Buy 3 months</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-180d">
|
||||
<h2>6 months</h2>
|
||||
<div class="price">$70</div>
|
||||
<div class="duration">180-day Pro license (42% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-180d">Buy 6 months</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-365d">
|
||||
<h2>12 months</h2>
|
||||
<div class="price">$99</div>
|
||||
<div class="duration">365-day Pro license (59% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-365d">Buy 12 months</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
<p class="footnote">Payments are processed by <a href="https://stripe.com" rel="noopener">Stripe</a>. Your card details never touch DashCaddy servers. After payment you receive a Pro license code on the success page AND by email — keep it safe; you'll paste it into <code>/admin/license</code> on your host. 14-day pro-rated refunds. By purchasing you agree to the <a href="/legal/terms">Terms of Service</a> and <a href="/legal/privacy">Privacy Policy</a>.</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var errEl = document.getElementById('error');
|
||||
|
||||
function setError(msg) {
|
||||
errEl.textContent = msg || '';
|
||||
}
|
||||
|
||||
function buy(productId, btn) {
|
||||
setError('');
|
||||
btn.disabled = true;
|
||||
var originalText = btn.textContent;
|
||||
btn.textContent = 'Opening Stripe…';
|
||||
|
||||
var email = null; // could prefill from a logged-in user; left null for the public pricing page
|
||||
|
||||
fetch('/api/v1/billing/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId: productId, customerEmail: email })
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (body) { return { status: r.status, body: body }; });
|
||||
}).then(function (resp) {
|
||||
if (resp.status === 200 && resp.body.success && resp.body.data && resp.body.data.url) {
|
||||
window.location.href = resp.body.data.url;
|
||||
return;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
setError((resp.body && resp.body.error) || ('Checkout failed (HTTP ' + resp.status + ').'));
|
||||
}).catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
setError('Network error. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
var buttons = document.querySelectorAll('.buy-btn');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var productId = btn.getAttribute('data-product-id');
|
||||
buy(productId, btn);
|
||||
});
|
||||
})(buttons[i]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,253 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DashCaddy Share</title>
|
||||
<link rel="canonical" href="/share">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --ok:#7cf2c0; --danger:#ff9090; --warn:#ffd07f; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
||||
main { width: min(640px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,48px); }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
||||
h1 { margin: 8px 0 0; font-size: clamp(1.8rem,5vw,2.5rem); }
|
||||
.lede { color: var(--muted); }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-top: 24px; }
|
||||
.card.error { border-color: var(--danger); }
|
||||
.meta { color: var(--muted); font-size: .9rem; margin-top: 12px; }
|
||||
.field { margin-top: 16px; }
|
||||
.field label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 6px; }
|
||||
.field input { width: 100%; padding: 10px 12px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; font: inherit; font-size: 0.95rem; }
|
||||
.button { display: inline-block; cursor: pointer; border: 0; padding: 12px 22px; border-radius: 10px; font: inherit; font-weight: 600; background: var(--accent); color: #04141f; margin-top: 12px; text-decoration: none; }
|
||||
.button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.button.secondary { background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
||||
.status-ok { color: var(--ok); }
|
||||
.status-err { color: var(--danger); }
|
||||
.status-warn { color: var(--warn); }
|
||||
.health { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
||||
.health.up { background: rgba(124,242,192,0.12); color: var(--ok); }
|
||||
.health.down { background: rgba(255,144,144,0.12); color: var(--danger); }
|
||||
.health.unknown { background: rgba(255,208,127,0.12); color: var(--warn); }
|
||||
.service-meta { display: grid; gap: 4px; font-size: 0.9rem; color: var(--muted); margin-top: 12px; }
|
||||
.service-meta strong { color: var(--text); }
|
||||
.footer { color: var(--muted); font-size: 0.85rem; margin-top: 24px; text-align: center; }
|
||||
.footer a { color: var(--accent); }
|
||||
@media (max-width: 600px) { .card { padding: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">DashCaddy</div>
|
||||
<h1 id="title">Loading share…</h1>
|
||||
|
||||
<div id="card" class="card">
|
||||
<div id="loading">Loading service details…</div>
|
||||
|
||||
<div id="ready" hidden>
|
||||
<div id="health-badge" class="health unknown">Checking status…</div>
|
||||
<h2 id="service-name" style="margin: 12px 0 0; font-size: 1.5rem;"></h2>
|
||||
<p id="service-description" class="lede" style="margin-top: 6px;"></p>
|
||||
<div id="service-meta" class="service-meta"></div>
|
||||
|
||||
<div id="cta-public" style="margin-top: 24px;">
|
||||
<a id="open-service" class="button" href="#" target="_blank" rel="noopener">Open service</a>
|
||||
</div>
|
||||
|
||||
<div id="cta-tailscale" style="display: none; margin-top: 24px;">
|
||||
<p class="lede">To join this service, install <a href="https://tailscale.com/download" target="_blank" rel="noopener" style="color: var(--accent);">Tailscale</a> on your device, then visit the service URL. Your device will be authorized automatically via the share token carried in this link.</p>
|
||||
<a id="tailscale-open" class="button" href="#" target="_blank" rel="noopener">Open service</a>
|
||||
<p class="meta" style="margin-top: 12px;">This link is single-use. Only one device can join. The host's operator issued it specifically for you.</p>
|
||||
</div>
|
||||
|
||||
<div id="subscribe-section" style="margin-top: 28px;">
|
||||
<div class="eyebrow" style="font-size: 0.75rem;">Get notified</div>
|
||||
<p class="lede" style="margin-top: 4px; font-size: 0.9rem;">Enter your email to be notified when this service goes down or recovers.</p>
|
||||
<div class="field">
|
||||
<label for="subscribe-email">Email address</label>
|
||||
<input id="subscribe-email" type="email" placeholder="you@example.com" autocomplete="email" />
|
||||
</div>
|
||||
<button id="subscribe-btn" class="button secondary" type="button">Subscribe</button>
|
||||
<div id="subscribe-status" class="meta"></div>
|
||||
</div>
|
||||
|
||||
<div id="expires" class="meta" style="margin-top: 24px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="error" hidden>
|
||||
<h2 style="margin: 0 0 8px; color: var(--danger);">Share link unavailable</h2>
|
||||
<p id="error-message" class="lede">This share link is invalid, expired, or has been revoked.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footer">
|
||||
<a href="/legal/privacy">Privacy</a> · <a href="/legal/terms">Terms</a>
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// The token is the last path segment of /share/<token>. Falls back to
|
||||
// empty string if the URL pattern is wrong (handled by the "invalid link"
|
||||
// error path below).
|
||||
function _tokenFromPath() {
|
||||
var parts = window.location.pathname.split('/').filter(Boolean);
|
||||
// parts[0] === 'share', parts[1] === token
|
||||
return parts.length >= 2 ? parts[parts.length - 1] : '';
|
||||
}
|
||||
|
||||
var token = _tokenFromPath();
|
||||
var card = document.getElementById('card');
|
||||
var loadingEl = document.getElementById('loading');
|
||||
var readyEl = document.getElementById('ready');
|
||||
var errorEl = document.getElementById('error');
|
||||
var errorMsgEl = document.getElementById('error-message');
|
||||
var titleEl = document.getElementById('title');
|
||||
var serviceNameEl = document.getElementById('service-name');
|
||||
var serviceDescEl = document.getElementById('service-description');
|
||||
var serviceMetaEl = document.getElementById('service-meta');
|
||||
var healthBadgeEl = document.getElementById('health-badge');
|
||||
var openLinkEl = document.getElementById('open-service');
|
||||
var tailscaleOpenEl = document.getElementById('tailscale-open');
|
||||
var ctaPublicEl = document.getElementById('cta-public');
|
||||
var ctaTailscaleEl = document.getElementById('cta-tailscale');
|
||||
var subscribeBtn = document.getElementById('subscribe-btn');
|
||||
var subscribeEmailEl = document.getElementById('subscribe-email');
|
||||
var subscribeStatusEl = document.getElementById('subscribe-status');
|
||||
var expiresEl = document.getElementById('expires');
|
||||
|
||||
function showError(message) {
|
||||
loadingEl.hidden = true;
|
||||
readyEl.hidden = true;
|
||||
errorEl.hidden = false;
|
||||
card.classList.add('error');
|
||||
titleEl.textContent = 'Share link unavailable';
|
||||
if (message) errorMsgEl.textContent = message;
|
||||
}
|
||||
|
||||
function showReady(data) {
|
||||
loadingEl.hidden = true;
|
||||
errorEl.hidden = true;
|
||||
readyEl.hidden = false;
|
||||
|
||||
var service = data.service || {};
|
||||
serviceNameEl.textContent = service.name || data.serviceId || 'Unknown service';
|
||||
titleEl.textContent = service.name || data.serviceId || 'Shared service';
|
||||
serviceDescEl.textContent = service.description || '';
|
||||
if (!service.description) serviceDescEl.style.display = 'none';
|
||||
|
||||
// Service metadata: tags, category
|
||||
var meta = [];
|
||||
if (service.category) meta.push('<strong>Category:</strong> ' + escapeHtml(service.category));
|
||||
if (Array.isArray(service.tags) && service.tags.length) {
|
||||
meta.push('<strong>Tags:</strong> ' + service.tags.map(escapeHtml).join(', '));
|
||||
}
|
||||
serviceMetaEl.innerHTML = meta.join(' · ');
|
||||
|
||||
// Health badge
|
||||
var health = (service.health || 'unknown').toLowerCase();
|
||||
healthBadgeEl.className = 'health ' + (health === 'up' ? 'up' : health === 'down' ? 'down' : 'unknown');
|
||||
healthBadgeEl.textContent = health === 'up' ? 'Online' : health === 'down' ? 'Offline' : 'Status unknown';
|
||||
|
||||
// Kind-specific CTA. Both kinds show an "Open service" link — the
|
||||
// service URL is the same destination in both cases. For Tailscale
|
||||
// shares, the share token is also the auth credential that Caddy
|
||||
// forward_auth checks against the store; the user just needs to
|
||||
// open the URL after installing Tailscale and the host has already
|
||||
// authorized the share via the email they were sent. The redemption
|
||||
// flow lives on the SERVER side (Caddy forward_auth checks the share
|
||||
// store on each request) — never on the client.
|
||||
if (service.url) {
|
||||
openLinkEl.href = service.url;
|
||||
openLinkEl.textContent = 'Open ' + (service.name || 'service');
|
||||
tailscaleOpenEl.href = service.url;
|
||||
} else {
|
||||
openLinkEl.style.display = 'none';
|
||||
tailscaleOpenEl.style.display = 'none';
|
||||
}
|
||||
if (data.kind === 'tailscale') {
|
||||
ctaPublicEl.style.display = 'none';
|
||||
ctaTailscaleEl.style.display = '';
|
||||
} else {
|
||||
ctaPublicEl.style.display = '';
|
||||
ctaTailscaleEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Expiry footer
|
||||
if (data.expiresAt) {
|
||||
var exp = new Date(data.expiresAt);
|
||||
if (!isNaN(exp.getTime())) {
|
||||
expiresEl.textContent = 'This share expires ' + exp.toLocaleString() + '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function fetchPreview() {
|
||||
if (!token) {
|
||||
showError('Invalid share link.');
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/share/' + encodeURIComponent(token) + '/preview', { cache: 'no-store' })
|
||||
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
|
||||
.then(function (resp) {
|
||||
if (resp.status === 200 && resp.body && resp.body.success && resp.body.data) {
|
||||
showReady(resp.body.data);
|
||||
return;
|
||||
}
|
||||
showError((resp.body && resp.body.error) ? resp.body.error.replace(/^\[DC-\d+\]\s*/, '') : 'This share link is invalid, expired, or has been revoked.');
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe handler (public — no auth required). Marks the share record
|
||||
// as having a subscriber; the host's check-event workflow can then notify
|
||||
// on status changes. This is the only POST the public preview page
|
||||
// makes against the share API — the Tailscale redemption path is
|
||||
// server-side (Caddy forward_auth on each request to the shared service).
|
||||
subscribeBtn.addEventListener('click', function () {
|
||||
var email = (subscribeEmailEl.value || '').trim();
|
||||
if (!email || !email.includes('@')) {
|
||||
subscribeStatusEl.textContent = 'Please enter a valid email.';
|
||||
subscribeStatusEl.className = 'meta status-err';
|
||||
return;
|
||||
}
|
||||
subscribeBtn.disabled = true;
|
||||
subscribeStatusEl.textContent = 'Subscribing…';
|
||||
subscribeStatusEl.className = 'meta';
|
||||
fetch('/api/v1/share/' + encodeURIComponent(token) + '/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
|
||||
.then(function (resp) {
|
||||
if (resp.status === 200 && resp.body && resp.body.success) {
|
||||
subscribeStatusEl.textContent = 'You will be notified when this service changes status.';
|
||||
subscribeStatusEl.className = 'meta status-ok';
|
||||
subscribeEmailEl.value = '';
|
||||
} else {
|
||||
subscribeStatusEl.textContent = (resp.body && resp.body.error) ? resp.body.error.replace(/^\[DC-\d+\]\s*/, '') : 'Subscription failed. Please try again.';
|
||||
subscribeStatusEl.className = 'meta status-err';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
subscribeStatusEl.textContent = 'Network error. Please try again.';
|
||||
subscribeStatusEl.className = 'meta status-err';
|
||||
})
|
||||
.finally(function () { subscribeBtn.disabled = false; });
|
||||
});
|
||||
|
||||
fetchPreview();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-1b7c08184e';
|
||||
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'auth-gate.js'), 'utf8');
|
||||
const totpSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'totp-auth.js'), 'utf8');
|
||||
|
||||
function buildHandoffTarget(returnUrl, token, tld = '.sami') {
|
||||
const start = totpSource.indexOf(' function buildSsoHandoffTarget');
|
||||
const end = totpSource.indexOf('\n\n // Setup digit input UX', start);
|
||||
assert.notEqual(start, -1, 'handoff builder must exist');
|
||||
assert.notEqual(end, -1, 'handoff builder boundary must exist');
|
||||
const functionSource = totpSource.slice(start, end);
|
||||
const context = {
|
||||
URL,
|
||||
SITE: { tld },
|
||||
window: { location: { origin: 'https://status.sami' } },
|
||||
};
|
||||
const sandbox = { ...context, input: returnUrl, token, result: undefined };
|
||||
vm.runInNewContext(`${functionSource}\nresult = buildSsoHandoffTarget(input, token);`, sandbox);
|
||||
return sandbox.result;
|
||||
}
|
||||
|
||||
function capturedRedirect(returnUrl, tld = '.sami') {
|
||||
const stored = new Map();
|
||||
const query = new URLSearchParams({ auth: 'required', return: returnUrl });
|
||||
const location = {
|
||||
origin: 'https://status.sami',
|
||||
pathname: '/',
|
||||
search: `?${query.toString()}`,
|
||||
reload() {},
|
||||
};
|
||||
const context = {
|
||||
URL,
|
||||
URLSearchParams,
|
||||
SITE: { tld },
|
||||
sessionStorage: {
|
||||
setItem(key, value) { stored.set(key, value); },
|
||||
},
|
||||
document: { getElementById() { return null; } },
|
||||
setTimeout() {},
|
||||
console,
|
||||
window: {
|
||||
location,
|
||||
history: { replaceState() {} },
|
||||
},
|
||||
};
|
||||
context.window.window = context.window;
|
||||
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
|
||||
return stored.get('totp_redirect');
|
||||
}
|
||||
|
||||
test('preserves a return URL on another host under the configured private TLD', () => {
|
||||
assert.equal(capturedRedirect('https://plex.sami/web/'), 'https://plex.sami/web/');
|
||||
});
|
||||
|
||||
test('preserves a same-origin return URL', () => {
|
||||
assert.equal(capturedRedirect('https://status.sami/settings'), 'https://status.sami/settings');
|
||||
});
|
||||
|
||||
test('rejects lookalike domains, plaintext cross-host URLs, and non-web schemes', () => {
|
||||
assert.equal(capturedRedirect('https://plex.sami.evil.example/'), undefined);
|
||||
assert.equal(capturedRedirect('http://plex.sami/'), undefined);
|
||||
assert.equal(capturedRedirect('javascript:alert(1)'), undefined);
|
||||
});
|
||||
|
||||
test('accepts relative same-origin paths and protocol-relative HTTPS private hosts', () => {
|
||||
assert.equal(capturedRedirect('/settings'), '/settings');
|
||||
assert.equal(capturedRedirect('//plex.sami/web/'), '//plex.sami/web/');
|
||||
});
|
||||
|
||||
test('normalizes a configured TLD without a leading dot', () => {
|
||||
assert.equal(capturedRedirect('https://plex.sami/web/', 'sami'), 'https://plex.sami/web/');
|
||||
});
|
||||
|
||||
test('builds the generic cross-host SSO landing URL and preserves the final path', () => {
|
||||
assert.equal(
|
||||
buildHandoffTarget('https://router.sami/config?tab=network#dns', 'one-time'),
|
||||
'https://router.sami/dashcaddy-sso?token=one-time&return=%2Fconfig%3Ftab%3Dnetwork%23dns',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not create cross-host handoffs for plaintext or lookalike destinations', () => {
|
||||
assert.equal(buildHandoffTarget('http://router.sami/', 'one-time'), null);
|
||||
assert.equal(buildHandoffTarget('https://router.sami.evil.example/', 'one-time'), null);
|
||||
});
|
||||
|
||||
test('same-origin and tokenless destinations keep their direct URL', () => {
|
||||
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
||||
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user