Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1328cfda6b | ||
|
|
df55677bd1 | ||
|
|
ddbea0a040 | ||
|
|
1d1cd5c95e | ||
|
|
88f1d4a414 | ||
|
|
dd1110ef52 | ||
|
|
6732a1e1df | ||
|
|
ea96abe95a | ||
|
|
3ccf66754a | ||
|
|
c71b794ccc | ||
|
|
f2285a2550 | ||
|
|
54a1df5ac4 | ||
|
|
eb546bf468 | ||
|
|
84e051d975 | ||
|
|
628bbe32f6 | ||
|
|
f8b99f9b5a | ||
|
|
eab2b00b13 | ||
|
|
84edb035e3 | ||
|
|
d313b1e872 | ||
|
|
be021588c7 | ||
|
|
d8459a4a87 | ||
|
|
499fcc2742 | ||
|
|
93d6c44e45 | ||
|
|
4c4ffc35ca | ||
|
|
7e68955e66 | ||
|
|
089f5d2902 | ||
|
|
0e7bb97129 | ||
|
|
98737995a9 | ||
|
|
99ec6ebc53 | ||
|
|
a7260436d1 | ||
|
|
a4e4b24732 | ||
|
|
0086de97da |
+25
@@ -400,3 +400,28 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
|
|||||||
- **prerequisite:** None.
|
- **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).
|
- **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).
|
||||||
|
|
||||||
|
|
||||||
|
### DC-086: Service-status flicker fix — asymmetric hysteresis on the badge
|
||||||
|
- **status:** in-progress
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** Dashboard service badges perpetually flip between green and red for "a few seconds at a time, never stable" (Sami's report, 2026-08-20). Root cause: `src/monitoring/health-checker.js` `recordStatus()` emits `'status-check'` on EVERY probe (every 30s), and `src/websocket/dashboard-ws.js` forwards every probe as `'status-change'` to the browser with no diff. The frontend `live-events.js` then unconditionally calls `setBadge()` — which resets the icon + pill text on every event. A single transient 5xx (Caddy reload, container CPU steal, mid-flight TLS handshake, container restart during probe) flips the badge red and the next green probe flips it back. Fix: add asymmetric hysteresis in `_computeDisplayedStatus(serviceId, rawStatus)` — going DOWN requires 2 consecutive "down" probes (default `HEALTH_DOWN_THRESHOLD=2`), going UP requires only 1 (default `HEALTH_UP_THRESHOLD=1`). History + `consecutiveFailures` still record raw probe results (operators want full fidelity for postmortems); only the dashboard broadcast is filtered. `getCurrentStatus()` now returns the displayed status so a page reload shows the same badge as the live SSE stream. Both thresholds are env-var configurable so operators can tune. New tests in `__tests__/health-checker-hysteresis.test.js` cover: first probe emits; second probe same-status does NOT re-emit; one-down-then-up keeps green; two-down flips to red; one-up after down flips back to green; `getCurrentStatus` returns displayed not raw. Effort: ~30 min. Risk: low — pure behavior filter, no schema breaks, all 63 existing health-checker tests must stay green.
|
||||||
|
- **impact:** Operators stop seeing perpetual red/green flicker on healthy services. Real outages still get flagged (2 consecutive 30s probes = ~60s before badge flips red, which is still faster than a human notices). Background probe history is unchanged so postmortem analysis still works.
|
||||||
|
- **prerequisite:** None.
|
||||||
|
- **result:** _pending — ship + codex round_
|
||||||
|
|
||||||
|
### DC-085: Link-first invite — Discord-style "share it however you want"
|
||||||
|
- **status:** in-progress
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** Today `POST /api/v1/auth/admin/invites` defaults to sending the invite link via SMTP; if SMTP is not configured it spams the server console with `[DC-048-DEV-INVITE-LINK]` log lines. Sami wants Discord-style: the link is always returned in the response, and email is an opt-in checkbox. Operators should be free to copy the link and share it via iMessage / SMS / WhatsApp / Telegram / Signal / Discord / paste-in-email — whatever fits. (1) Flip default `sendEmail !== false` to `sendEmail === true` in `routes/auth/admin.js` so omitting the field means "no email, just hand me the link." (2) Stop logging the raw invite URL to error.log when SMTP is unconfigured — that path was only useful when there was no UI way to grab the link; now there is. (3) Add a `shareText` field to the response: `"Join my DashCaddy as <role> — <acceptUrl> — expires in Nh."` for one-tap paste into any messenger. (4) Frontend: `status/js/admin.js` `_renderInviteForm` flips the "Send email" checkbox default to **unchecked**, updates `_renderIssuedInviteBanner` to show both the raw link AND the shareText (with its own copy button + `navigator.share()` native share-sheet button where available). (5) New tests in `__tests__/admin-invites.test.js` covering: default sendEmail=false (no SMTP send attempted, no console log); `sendEmail: true` triggers SMTP send; `shareText` is present and well-formed; `acceptUrl` is always returned; expired sendEmail path doesn't leak token to logs. Effort: ~1 hr. Risk: low — pure behavior flip + UI additive change.
|
||||||
|
- **impact:** Closes the friction between "host wants to add a friend" and "host has to configure SMTP first." Mirrors Discord/Slack/Linear invite flows where the link IS the deliverable. No new tier changes, no schema breaks.
|
||||||
|
- **prerequisite:** DC-048 (invite store + admin route), DC-052 (Pro gate stays).
|
||||||
|
- **result:** _pending — ship + codex round_
|
||||||
|
|
||||||
|
### DC-084: Remove redundant active Caddy health check from `arch.sami` site — eliminate 6 syslog spam lines/min
|
||||||
|
- **status:** done
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** `/etc/caddy/sites/arch.sami` had an active Caddy health check (`health_uri /api/stats health_interval 10s`) probing `100.120.159.34:5000` every 10 seconds. The upstream Arch Linux server `100.120.159.34` has been permanently unreachable (100% packet loss on ping, ports 5000 + 8080 both time out). Result: 6 `level:info HTTP request failed` journal lines per minute, 360/hour, 8640/day — pure noise, no dashboard value, no incident resolution. The `src/monitoring/caddy-upstream-watcher.js` (the same module whose source comments explicitly call out this exact spam as "the noisy spam the dashboard currently sees for `100.120.159.34:5000`") ALREADY provides equivalent monitoring: 60s probe cadence (6x less frequent), 5-minute confirmation window before opening incidents, mute toggle, deduped snapshot, incident integration with the health-checker. The active Caddy check is redundant. Fix: edit `/etc/caddy/sites/arch.sami` to remove the `health_uri / health_interval` block, leaving only `reverse_proxy 100.120.159.34:5000`. Apply via `caddy-apply` (validates+reloads+commits atomically). Backup `.bak-DC-084-pre` created pre-edit; deleted after `caddy-apply` succeeded because the `.bak` file was being picked up by Caddy's `import sites/*` and causing an "ambiguous site definition" validation error.
|
||||||
|
- **impact:** Eliminates 100% of recurring caddy journal spam from the dead Arch upstream. The dashboard's `caddy-upstream-watcher.js` continues to monitor the dead upstream correctly (now at `consecutiveFailures: 1905+`, `lastSuccessAt: null`, `status: down`, `dead: true`) — operators see the dead upstream in the dashboard, just without the journal noise. Future Caddyfile authors who add an active health check to a `*.sami` site will be unaware that they should not (since the dashboard handles monitoring), so a follow-up could add a CLAUDE.md note or a Caddyfile lint warning. Out of scope for this tick.
|
||||||
|
- **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring.
|
||||||
|
- **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes` — `health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`.
|
||||||
|
- **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change).
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-085: link-first invite (Discord-style "share it however you want").
|
||||||
|
*
|
||||||
|
* - default sendEmail omission = no email sent, link returned, no token in logs
|
||||||
|
* - sendEmail:true triggers SMTP send when configured
|
||||||
|
* - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
|
||||||
|
* - shareText field present and well-formed in every response
|
||||||
|
* - acceptUrl always present (regardless of sendEmail)
|
||||||
|
* - role + ttl validation unchanged from DC-048
|
||||||
|
*
|
||||||
|
* Strategy: drive the route handler directly with mock req/res, mount the admin
|
||||||
|
* router against an isolated userStore + inviteStore + email-sender stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
function _tmpDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
|
||||||
|
}
|
||||||
|
function _cleanup(dir) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub email-sender so we can assert "was it called?" without an SMTP server.
|
||||||
|
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
|
||||||
|
// call is allowed to reference it (Babel guard against out-of-scope access).
|
||||||
|
const mockEmailSender = {
|
||||||
|
isConfigured: jest.fn(() => false),
|
||||||
|
sendEmail: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);
|
||||||
|
|
||||||
|
describe('DC-085: link-first admin invites', () => {
|
||||||
|
let dir, app, request;
|
||||||
|
let logCalls; // captured { level, msg, meta } from our fake log
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
dir = _tmpDir();
|
||||||
|
logCalls = [];
|
||||||
|
|
||||||
|
// Set up email auth enable flag so userStore mounts.
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
const { createUserStore } = require('../src/security/user-store');
|
||||||
|
const userStore = createUserStore({ dataDir: dir });
|
||||||
|
|
||||||
|
// Bootstrap the admin so we have a session-attributable user.
|
||||||
|
await userStore.login({ email: 'admin@sami-host.me' });
|
||||||
|
|
||||||
|
// Build a tiny Express app with the admin router mounted, but skip the
|
||||||
|
// global auth gate (we inject req.user directly).
|
||||||
|
const adminRouter = require('../routes/auth/admin')({
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||||
|
log: {
|
||||||
|
info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
|
||||||
|
warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
|
||||||
|
error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
|
||||||
|
},
|
||||||
|
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||||
|
dataDir: dir,
|
||||||
|
});
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// Inject req.user = admin so /admin/* passes the role gate.
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||||
|
req.app.locals = req.app.locals || {};
|
||||||
|
req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
|
||||||
|
req.app.locals.emailConfig = null; // SMTP not configured by default
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/v1/auth', adminRouter);
|
||||||
|
// Error handler — last in chain.
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
const code = (err && err.statusCode) || 500;
|
||||||
|
res.status(code).json({
|
||||||
|
success: false,
|
||||||
|
error: err && err.message,
|
||||||
|
code: err && err.code,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request = require('supertest');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('default sendEmail (omitted) returns link and does NOT send email', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
|
||||||
|
expect(res.body.deliveredVia).toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default sendEmail does NOT log raw token to server log', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator' });
|
||||||
|
|
||||||
|
const acceptUrl = res.body.acceptUrl;
|
||||||
|
// Extract the token from the URL and verify it does NOT appear in any log call.
|
||||||
|
const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
const tokenLeaked = logCalls.some(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes(token)
|
||||||
|
);
|
||||||
|
expect(tokenLeaked).toBe(false);
|
||||||
|
|
||||||
|
// Also assert no log entry mentions the URL verbatim (the old
|
||||||
|
// `[DC-048-DEV-INVITE-LINK] url=...` spam).
|
||||||
|
const oldSpam = logCalls.find(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
|
||||||
|
);
|
||||||
|
expect(oldSpam).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shareText is present and well-formed in every response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });
|
||||||
|
|
||||||
|
expect(res.body.shareText).toBeDefined();
|
||||||
|
expect(res.body.shareText).toContain('Join my DashCaddy');
|
||||||
|
expect(res.body.shareText).toContain('operator');
|
||||||
|
expect(res.body.shareText).toContain(res.body.acceptUrl);
|
||||||
|
expect(res.body.shareText).toContain('expires in 24h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('acceptUrl is always returned regardless of sendEmail', async () => {
|
||||||
|
const r1 = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', sendEmail: false });
|
||||||
|
const r2 = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'b@x.com' });
|
||||||
|
expect(r1.body.acceptUrl).toBeTruthy();
|
||||||
|
expect(r2.body.acceptUrl).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail: true triggers SMTP send when configured', async () => {
|
||||||
|
// Build a SECOND app instance where emailConfig is a real-looking object,
|
||||||
|
// so isConfigured() returns true. The first app uses emailConfig=null.
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(true);
|
||||||
|
mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
|
||||||
|
const app2 = express();
|
||||||
|
app2.use(express.json());
|
||||||
|
app2.use((req, _res, next) => {
|
||||||
|
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||||
|
req.app.locals = req.app.locals || {};
|
||||||
|
req.app.locals.siteConfig = {};
|
||||||
|
req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const { createUserStore } = require('../src/security/user-store');
|
||||||
|
const userStore2 = createUserStore({ dataDir: dir });
|
||||||
|
await userStore2.login({ email: 'admin@sami-host.me' });
|
||||||
|
const router2 = require('../routes/auth/admin')({
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||||
|
log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
|
||||||
|
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||||
|
dataDir: dir,
|
||||||
|
});
|
||||||
|
app2.use('/api/v1/auth', router2);
|
||||||
|
|
||||||
|
const res = await request(app2)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
|
||||||
|
expect(to).toBe('friend@example.com');
|
||||||
|
expect(subject).toMatch(/invited/i);
|
||||||
|
expect(text).toContain(res.body.acceptUrl);
|
||||||
|
expect(html).toContain(res.body.acceptUrl);
|
||||||
|
expect(res.body.deliveredVia).toBe('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
// acceptUrl + shareText still present so the operator can share manually.
|
||||||
|
expect(res.body.acceptUrl).toBeTruthy();
|
||||||
|
expect(res.body.shareText).toBeTruthy();
|
||||||
|
// Token does NOT appear in any log call.
|
||||||
|
const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
const tokenLeaked = logCalls.some(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes(token)
|
||||||
|
);
|
||||||
|
expect(tokenLeaked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
|
||||||
|
// DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
|
||||||
|
// silently substitutes default rather than throwing. This test pins that
|
||||||
|
// behavior so a future "strict role validation" change is a deliberate
|
||||||
|
// decision, not a silent regression.
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', role: 'superuser' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.role).toBe('operator');
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email validation: missing email still rejected', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ role: 'operator' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', ttlHours: 1 });
|
||||||
|
expect(res.body.shareText).toContain('expires in 1h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => {
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
const warn = logCalls.find(c =>
|
||||||
|
c.level === 'warn' && c.topic === 'auth-invite-send'
|
||||||
|
);
|
||||||
|
expect(warn).toBeDefined();
|
||||||
|
// The raw address must not appear; the masked form must.
|
||||||
|
expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com');
|
||||||
|
expect(warn.meta.email).toBe('fr****@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => {
|
||||||
|
// Pre-authorize the email (POST /admin/users) so userStore.login doesn't
|
||||||
|
// reject with not_authorized — bootstrap already happened in beforeEach.
|
||||||
|
const preauth = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/users')
|
||||||
|
.send({ email: 'newfriend@example.com' });
|
||||||
|
expect(preauth.status).toBe(200);
|
||||||
|
|
||||||
|
const issue = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'newfriend@example.com', role: 'viewer' });
|
||||||
|
expect(issue.status).toBe(200);
|
||||||
|
const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/v1/auth/invites/${token}/accept`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const info = logCalls.find(c =>
|
||||||
|
c.level === 'info' && c.msg === 'invite accepted, user created'
|
||||||
|
);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com');
|
||||||
|
expect(info.meta.email).toBe('ne****@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for config-schema.js KNOWN_KEYS — DC-091.
|
||||||
|
*
|
||||||
|
* Bug: license-manager.js persists config.licenseBackup (activation
|
||||||
|
* restore-on-restart) and src/config/migrations.js stamps config._version,
|
||||||
|
* but neither key was in KNOWN_KEYS — so every startup logged
|
||||||
|
* `Unknown config key "licenseBackup" / "_version" — possible typo?`
|
||||||
|
* false positives (verified in live dashcaddy-api container logs,
|
||||||
|
* 2026-08-22T23:53:54Z restart).
|
||||||
|
*
|
||||||
|
* These tests pin: (1) the live production config key set validates with
|
||||||
|
* zero unknown-key warnings, (2) genuine typos still warn, (3) the schema
|
||||||
|
* stays in sync with the first-party writer keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { validateConfig } = require('../src/utilities/config-schema');
|
||||||
|
|
||||||
|
describe('config-schema KNOWN_KEYS vs first-party writers (DC-091)', () => {
|
||||||
|
// Exact key set of the live production config.json (DNS2, verified
|
||||||
|
// 2026-08-23). If a new key appears here, teach KNOWN_KEYS about it —
|
||||||
|
// or fix the writer if it's a typo.
|
||||||
|
const LIVE_CONFIG_KEYS = [
|
||||||
|
'_version', 'configurationType', 'customFavicon', 'customLogo',
|
||||||
|
'dashboardHost', 'dashboardTitle', 'dns', 'dnsServers', 'language',
|
||||||
|
'license', 'licenseBackup', 'logoPosition', 'pylon', 'setupComplete',
|
||||||
|
'timestamp', 'tld', 'updatedAt'
|
||||||
|
];
|
||||||
|
|
||||||
|
test('live production config key set produces zero unknown-key warnings', () => {
|
||||||
|
const config = {};
|
||||||
|
for (const key of LIVE_CONFIG_KEYS) {
|
||||||
|
// Minimal valid-ish values; validateConfig only cares about shape
|
||||||
|
// for these keys, and unknown-key detection is the target here.
|
||||||
|
config[key] = key === '_version' ? 2 : (key === 'dnsServers' ? {} : 'x');
|
||||||
|
}
|
||||||
|
const result = validateConfig(config);
|
||||||
|
const unknownWarnings = result.warnings.filter((w) => w.includes('Unknown config key'));
|
||||||
|
expect(unknownWarnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('licenseBackup and _version (first-party writer keys) do not warn', () => {
|
||||||
|
const result = validateConfig({ licenseBackup: { code: 'DC-...' }, _version: 2 });
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genuine typos still warn (guard against over-allowing)', () => {
|
||||||
|
const result = validateConfig({ dashboadTitle: 'typo' });
|
||||||
|
expect(result.warnings).toEqual([
|
||||||
|
'Unknown config key "dashboadTitle" — possible typo?'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KNOWN_KEYS stays in sync with license-manager writer keys', () => {
|
||||||
|
// license-manager writes config.licenseBackup and config.license — both
|
||||||
|
// must be recognized. We assert via validateConfig (public surface)
|
||||||
|
// rather than importing the private KNOWN_KEYS array.
|
||||||
|
const result = validateConfig({ license: { code: 'DC-...' }, licenseBackup: { code: 'DC-...' } });
|
||||||
|
expect(result.warnings.filter((w) => w.includes('Unknown config key'))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config-schema sync guard: migrations writer', () => {
|
||||||
|
test('_version is recognized at every migration version value', () => {
|
||||||
|
// migrations.js bumps _version 0→1→2; the key itself must never warn.
|
||||||
|
for (const v of [0, 1, 2, 99]) {
|
||||||
|
const result = validateConfig({ _version: v });
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-086: asymmetric hysteresis on the dashboard service badge.
|
||||||
|
*
|
||||||
|
* - First probe always emits (no prior state).
|
||||||
|
* - Same-status probe does NOT re-emit (dedup against repeated green).
|
||||||
|
* - One "down" then back to "up" keeps the badge green (no flicker).
|
||||||
|
* - Two consecutive "down" probes flip the badge to red.
|
||||||
|
* - One "up" after a down streak flips back to green (fast recovery).
|
||||||
|
* - History retains every raw probe even when no emit happens.
|
||||||
|
* - getCurrentStatus returns displayed status, not raw.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Use an isolated data dir so test history doesn't pollute the real one.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-hyst-'));
|
||||||
|
process.env.HEALTH_DATA_DIR = tmpDir;
|
||||||
|
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
||||||
|
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
||||||
|
|
||||||
|
// Module exports a singleton instance, not a class — see module.exports in
|
||||||
|
// src/monitoring/health-checker.js. The test creates fresh state by replacing
|
||||||
|
// the relevant maps on the singleton in beforeEach.
|
||||||
|
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
||||||
|
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
||||||
|
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
||||||
|
|
||||||
|
function restoreEnv(name, value) {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUp(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'up',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 200,
|
||||||
|
message: 'Service is healthy',
|
||||||
|
details: { headers: {}, bodyLength: 12 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDown(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'down',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'fail',
|
||||||
|
details: { headers: {}, bodyLength: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-086: hysteresis on the dashboard badge', () => {
|
||||||
|
let hc;
|
||||||
|
let emitSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the singleton's per-test state so each case starts clean.
|
||||||
|
healthCheckerSingleton.displayedStatus = new Map();
|
||||||
|
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
||||||
|
healthCheckerSingleton.currentStatus = new Map();
|
||||||
|
healthCheckerSingleton.history = {};
|
||||||
|
healthCheckerSingleton.removeAllListeners('status-check');
|
||||||
|
emitSpy = jest.fn();
|
||||||
|
healthCheckerSingleton.on('status-check', emitSpy);
|
||||||
|
hc = healthCheckerSingleton;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
||||||
|
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first probe (no prior state) emits', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(emitSpy.mock.calls[0][0].status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('second probe with same status does NOT re-emit', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one "down" then "up" keeps the badge green (the flicker bug)', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp()); // baseline: green, emit 1
|
||||||
|
hc.recordStatus('svc1', makeDown()); // one blip — keep green, no emit
|
||||||
|
hc.recordStatus('svc1', makeUp()); // recovered — still green, no emit
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('up, down, up, down, down resets the first streak before flipping', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two consecutive "down" probes flip the badge to red', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp()); // baseline: green
|
||||||
|
hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1)
|
||||||
|
hc.recordStatus('svc1', makeDown()); // blip #2 — flip red (counter=2 >= DOWN_THRESHOLD)
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(emitSpy.mock.calls[1][0].status).toBe('down');
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one "up" after a down streak flips back to green (fast recovery)', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
hc.recordStatus('svc1', makeDown()); // now red
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
|
||||||
|
hc.recordStatus('svc1', makeUp()); // first green — flip back
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(emitSpy.mock.calls[2][0].status).toBe('up');
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history retains every raw probe even when no emit happens', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown()); // blip, no emit
|
||||||
|
hc.recordStatus('svc1', makeUp()); // recovery, no emit
|
||||||
|
expect(hc.history['svc1'].length).toBe(3);
|
||||||
|
expect(hc.history['svc1'][0].status).toBe('up');
|
||||||
|
expect(hc.history['svc1'][1].status).toBe('down');
|
||||||
|
expect(hc.history['svc1'][2].status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCurrentStatus returns the displayed status, not the raw probe', () => {
|
||||||
|
const displayedUp = makeUp();
|
||||||
|
displayedUp.timestamp = '2026-08-22T09:59:00.000Z';
|
||||||
|
displayedUp.statusCode = 200;
|
||||||
|
displayedUp.message = 'healthy';
|
||||||
|
displayedUp.details = { source: 'accepted-up' };
|
||||||
|
hc.recordStatus('svc1', displayedUp);
|
||||||
|
const latestRaw = makeDown();
|
||||||
|
latestRaw.timestamp = '2026-08-22T10:00:00.000Z';
|
||||||
|
latestRaw.responseTime = 987;
|
||||||
|
latestRaw.statusCode = 500;
|
||||||
|
latestRaw.message = 'failed probe';
|
||||||
|
latestRaw.error = 'upstream failure';
|
||||||
|
latestRaw.details = { source: 'suppressed-down' };
|
||||||
|
hc.recordStatus('svc1', latestRaw); // raw=down, displayed=up
|
||||||
|
const out = hc.getCurrentStatus();
|
||||||
|
expect(out['svc1'].status).toBe('up'); // shown to API consumers
|
||||||
|
expect(out['svc1'].timestamp).toBe(displayedUp.timestamp);
|
||||||
|
expect(out['svc1'].statusCode).toBe(200);
|
||||||
|
expect(out['svc1'].message).toBe('healthy');
|
||||||
|
expect(out['svc1'].error).toBeUndefined();
|
||||||
|
expect(out['svc1'].details).toEqual({ source: 'accepted-up' });
|
||||||
|
expect(hc.currentStatus.get('svc1')).toBe(latestRaw);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
|
||||||
|
for (let i = 0; i < 50; i++) hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long steady-green-then-steady-red transition: 1 emit (up), 1 emit (red)', () => {
|
||||||
|
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
hc.recordStatus('svc1', makeDown()); // flips to red
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||||
|
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2); // no further broadcasts
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOWN_THRESHOLD env var is honored', () => {
|
||||||
|
process.env.HEALTH_DOWN_THRESHOLD = '3';
|
||||||
|
jest.resetModules();
|
||||||
|
const HC2Module = require('../src/monitoring/health-checker');
|
||||||
|
// Module is a singleton with DOWN_THRESHOLD captured at module load —
|
||||||
|
// resetModules gives us a fresh module-level instance with the new env.
|
||||||
|
const hc2 = HC2Module;
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
hc2.recordStatus('svc1', makeDown()); // 1
|
||||||
|
hc2.recordStatus('svc1', makeDown()); // 2 — still green (need 3)
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
hc2.recordStatus('svc1', makeDown()); // 3 — flip
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => {
|
||||||
|
process.env.HEALTH_DOWN_THRESHOLD = value;
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UP_THRESHOLD env var greater than 1 is honored', () => {
|
||||||
|
process.env.HEALTH_UP_THRESHOLD = '2';
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => {
|
||||||
|
process.env.HEALTH_UP_THRESHOLD = value;
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeService clears hysteresis state before the same ID is re-added', () => {
|
||||||
|
hc.config.services.svc1 = { name: 'Service 1' };
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(hc.displayedStatus.has('svc1')).toBe(true);
|
||||||
|
expect(hc.consecutiveSinceChange.get('svc1')).toBe(1);
|
||||||
|
hc.consecutiveFailures.set('svc1', 3);
|
||||||
|
const timer = setTimeout(() => {}, 60_000);
|
||||||
|
hc.serviceTimers.set('svc1', timer);
|
||||||
|
|
||||||
|
hc.saveConfig = jest.fn();
|
||||||
|
hc.removeService('svc1');
|
||||||
|
|
||||||
|
expect(hc.displayedStatus.has('svc1')).toBe(false);
|
||||||
|
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
||||||
|
expect(hc.currentStatus.has('svc1')).toBe(false);
|
||||||
|
expect(hc.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
expect(hc.serviceTimers.has('svc1')).toBe(false);
|
||||||
|
|
||||||
|
hc.config.services.svc1 = { name: 'Service 1 re-added' };
|
||||||
|
const emitSpyAfterReAdd = jest.fn();
|
||||||
|
hc.on('status-check', emitSpyAfterReAdd);
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
|
||||||
|
expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-090: outage incidents follow the DISPLAYED (post-hysteresis)
|
||||||
|
* status — the same signal that flips the dashboard badge.
|
||||||
|
*
|
||||||
|
* - A single raw "down" blip that hysteresis suppresses opens NO outage
|
||||||
|
* incident (the DC-089-noted raw-transition bug).
|
||||||
|
* - A suppressed blip does not resolve a real open outage (UP_THRESHOLD=2).
|
||||||
|
* - DOWN_THRESHOLD consecutive downs open exactly ONE outage incident.
|
||||||
|
* - The incident payload carries the displayed snapshot, not the raw probe.
|
||||||
|
* - Direct callers without hysteresis state keep legacy raw semantics.
|
||||||
|
*
|
||||||
|
* The probe() helper replicates checkService's exact call order: capture the
|
||||||
|
* pre-probe raw + displayed state, recordStatus (updates both maps), then
|
||||||
|
* checkForIncidents with both previous states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Use an isolated data dir so test history doesn't pollute the real one.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-incpar-'));
|
||||||
|
process.env.HEALTH_DATA_DIR = tmpDir;
|
||||||
|
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
||||||
|
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
||||||
|
|
||||||
|
// Module exports a singleton instance, not a class. Reset per-test state by
|
||||||
|
// replacing the relevant maps on the singleton in beforeEach.
|
||||||
|
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
||||||
|
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
||||||
|
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
||||||
|
|
||||||
|
function restoreEnv(name, value) {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUp(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'up',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 200,
|
||||||
|
message: 'Service is healthy',
|
||||||
|
details: { headers: {}, bodyLength: 12 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDown(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'down',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'fail',
|
||||||
|
details: { headers: {}, bodyLength: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-090: outage incidents follow the displayed (hysteresis) status', () => {
|
||||||
|
let hc;
|
||||||
|
let incidentCreatedSpy;
|
||||||
|
let incidentResolvedSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
healthCheckerSingleton.displayedStatus = new Map();
|
||||||
|
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
||||||
|
healthCheckerSingleton.currentStatus = new Map();
|
||||||
|
healthCheckerSingleton.history = {};
|
||||||
|
healthCheckerSingleton.incidents = [];
|
||||||
|
healthCheckerSingleton.removeAllListeners('incident-created');
|
||||||
|
healthCheckerSingleton.removeAllListeners('incident-resolved');
|
||||||
|
incidentCreatedSpy = jest.fn();
|
||||||
|
incidentResolvedSpy = jest.fn();
|
||||||
|
healthCheckerSingleton.on('incident-created', incidentCreatedSpy);
|
||||||
|
healthCheckerSingleton.on('incident-resolved', incidentResolvedSpy);
|
||||||
|
hc = healthCheckerSingleton;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
||||||
|
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replicates checkService's record+incident sequence for one raw probe.
|
||||||
|
function probe(status, config = {}) {
|
||||||
|
const previousStatus = hc.currentStatus.get(status.serviceId);
|
||||||
|
const previousDisplayed = hc.displayedStatus.get(status.serviceId) || null;
|
||||||
|
hc.recordStatus(status.serviceId, status);
|
||||||
|
hc.checkForIncidents(status.serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a single down blip between two ups opens NO outage incident', () => {
|
||||||
|
probe(makeUp()); // baseline: displayed up
|
||||||
|
probe(makeDown()); // blip — hysteresis keeps displayed up
|
||||||
|
probe(makeUp()); // recovered
|
||||||
|
expect(hc.incidents).toHaveLength(0);
|
||||||
|
expect(incidentCreatedSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOWN_THRESHOLD consecutive downs open exactly one outage incident (critical)', () => {
|
||||||
|
probe(makeUp());
|
||||||
|
probe(makeDown()); // counter=1, displayed still up
|
||||||
|
probe(makeDown()); // counter=2 → displayed flips down → incident
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
const incident = hc.incidents[0];
|
||||||
|
expect(incident.type).toBe('outage');
|
||||||
|
expect(incident.severity).toBe('critical');
|
||||||
|
expect(incident.status).toBe('open');
|
||||||
|
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
probe(makeDown()); // still down — no new transition, no second incident
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
expect(incident.occurrences).toBe(1); // occurrences count displayed flips, not raw probes
|
||||||
|
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the outage incident payload carries the displayed snapshot, not the raw blip', () => {
|
||||||
|
probe(makeUp());
|
||||||
|
const blip = makeDown();
|
||||||
|
blip.statusCode = 599;
|
||||||
|
probe(blip); // suppressed blip — must not appear in any incident
|
||||||
|
probe(makeDown()); // flip
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
// The incident's details snapshot is the probe that FLIPPED the displayed
|
||||||
|
// state (the second down), not the earlier suppressed blip.
|
||||||
|
expect(hc.incidents[0].details.statusCode).not.toBe(599);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a suppressed up blip does not resolve a real open outage (UP_THRESHOLD=2)', () => {
|
||||||
|
process.env.HEALTH_UP_THRESHOLD = '2';
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.incidents = [];
|
||||||
|
hc2.removeAllListeners('incident-created');
|
||||||
|
hc2.removeAllListeners('incident-resolved');
|
||||||
|
|
||||||
|
const p2 = (status) => {
|
||||||
|
const prevRaw = hc2.currentStatus.get(status.serviceId);
|
||||||
|
const prevDisp = hc2.displayedStatus.get(status.serviceId) || null;
|
||||||
|
hc2.recordStatus(status.serviceId, status);
|
||||||
|
hc2.checkForIncidents(status.serviceId, status, {}, prevRaw, prevDisp);
|
||||||
|
};
|
||||||
|
|
||||||
|
p2(makeUp());
|
||||||
|
p2(makeDown());
|
||||||
|
p2(makeDown()); // displayed down → outage opens
|
||||||
|
expect(hc2.incidents).toHaveLength(1);
|
||||||
|
expect(hc2.incidents[0].status).toBe('open');
|
||||||
|
|
||||||
|
p2(makeUp()); // counter=1 < UP_THRESHOLD=2 → displayed still down
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
expect(hc2.incidents[0].status).toBe('open'); // NOT resolved by the blip
|
||||||
|
|
||||||
|
p2(makeUp()); // counter=2 → displayed up → incident resolves
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
expect(hc2.incidents[0].status).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy direct callers (no displayed state) keep raw transition semantics', () => {
|
||||||
|
hc.currentStatus.set('svc1', { status: 'up' });
|
||||||
|
const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 };
|
||||||
|
hc.checkForIncidents('svc1', status, {}); // 4-arg call, no previousDisplayed
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
expect(hc.incidents[0].type).toBe('outage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slow-response detection still fires per-probe regardless of hysteresis', () => {
|
||||||
|
const slowUp = makeUp();
|
||||||
|
slowUp.responseTime = 6000;
|
||||||
|
probe(slowUp, { slowResponseThreshold: 5000 });
|
||||||
|
expect(hc.incidents.some(i => i.type === 'slow-response')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -203,6 +203,55 @@ describe('HealthChecker', () => {
|
|||||||
expect(result.error).toBe('ECONNREFUSED');
|
expect(result.error).toBe('ECONNREFUSED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens and resolves an outage incident across real checkService transitions', async () => {
|
||||||
|
// DC-090: incidents follow the DISPLAYED (post-hysteresis) status.
|
||||||
|
// DOWN_THRESHOLD defaults to 2, so it takes two consecutive failed
|
||||||
|
// probes to flip displayed down and open the outage; one up probe
|
||||||
|
// (UP_THRESHOLD=1) resolves it.
|
||||||
|
healthChecker._doRequest = jest.fn()
|
||||||
|
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
|
||||||
|
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
||||||
|
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
||||||
|
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
|
||||||
|
|
||||||
|
const config = { url: 'http://test.local' };
|
||||||
|
await healthChecker.checkService('svc1', config);
|
||||||
|
await healthChecker.checkService('svc1', config);
|
||||||
|
expect(healthChecker.incidents).toHaveLength(0); // one down alone: suppressed blip
|
||||||
|
|
||||||
|
await healthChecker.checkService('svc1', config); // second down flips displayed → open
|
||||||
|
expect(healthChecker.incidents).toHaveLength(1);
|
||||||
|
expect(healthChecker.incidents[0]).toMatchObject({
|
||||||
|
serviceId: 'svc1',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open'
|
||||||
|
});
|
||||||
|
|
||||||
|
await healthChecker.checkService('svc1', config); // up resolves
|
||||||
|
expect(healthChecker.incidents[0].status).toBe('resolved');
|
||||||
|
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resurrect state when an in-flight probe resolves after removal', async () => {
|
||||||
|
let resolveProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
||||||
|
resolveProbe = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.displayedStatus.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.history.svc1).toBeUndefined();
|
||||||
|
expect(healthChecker.incidents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('increments consecutive failures on error', async () => {
|
it('increments consecutive failures on error', async () => {
|
||||||
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
||||||
|
|
||||||
@@ -549,6 +598,113 @@ describe('HealthChecker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DC-088: removeService generation tombstones + incident closure', () => {
|
||||||
|
it('does not leak a serviceGenerations entry and records a tombstone', () => {
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
||||||
|
expect(healthChecker.serviceGenerations.has('svc1')).toBe(true);
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
|
||||||
|
expect(healthChecker.serviceGenerations.has('svc1')).toBe(false);
|
||||||
|
const tomb = healthChecker.removedGenerations.get('svc1');
|
||||||
|
expect(tomb).toBeDefined();
|
||||||
|
expect(tomb.generation).toBeGreaterThan(0);
|
||||||
|
expect(tomb.removedAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-added service gets a strictly higher generation (no ABA)', () => {
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
||||||
|
const gen1 = healthChecker.serviceGenerations.get('svc1');
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local/v2' });
|
||||||
|
|
||||||
|
const gen2 = healthChecker.serviceGenerations.get('svc1');
|
||||||
|
expect(gen2).toBeGreaterThan(gen1);
|
||||||
|
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes open incidents for the removed service as resolved', () => {
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.incidents.push({
|
||||||
|
id: 'incident-test-1',
|
||||||
|
serviceId: 'svc1',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
healthChecker.incidents.push({
|
||||||
|
id: 'incident-other',
|
||||||
|
serviceId: 'svc2',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
const resolvedSpy = jest.fn();
|
||||||
|
healthChecker.on('incident-resolved', resolvedSpy);
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
|
||||||
|
const closed = healthChecker.incidents.find(i => i.id === 'incident-test-1');
|
||||||
|
expect(closed.status).toBe('resolved');
|
||||||
|
expect(closed.resolvedBy).toBe('service-removed');
|
||||||
|
expect(closed.resolvedAt).toBeDefined();
|
||||||
|
expect(closed.duration).toBeGreaterThan(0);
|
||||||
|
expect(healthChecker.incidents.find(i => i.id === 'incident-other').status).toBe('open');
|
||||||
|
expect(resolvedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in-flight probe captured before removal is discarded via tombstone', async () => {
|
||||||
|
let resolveProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
||||||
|
resolveProbe = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rejected in-flight probe after removal does not re-create failure state', async () => {
|
||||||
|
let rejectProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise((resolve, reject) => {
|
||||||
|
rejectProbe = reject;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
rejectProbe(new Error('late failure'));
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps expired tombstones in cleanupHistory', () => {
|
||||||
|
healthChecker.removedGenerations.set('svc1', {
|
||||||
|
generation: 1,
|
||||||
|
removedAt: Date.now() - 60 * 60 * 1000 // 1h ago, TTL default 10m
|
||||||
|
});
|
||||||
|
healthChecker.removedGenerations.set('svc2', {
|
||||||
|
generation: 2,
|
||||||
|
removedAt: Date.now() // fresh
|
||||||
|
});
|
||||||
|
|
||||||
|
healthChecker.cleanupHistory();
|
||||||
|
|
||||||
|
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.removedGenerations.has('svc2')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cleanupHistory', () => {
|
describe('cleanupHistory', () => {
|
||||||
it('removes entries older than retention period', () => {
|
it('removes entries older than retention period', () => {
|
||||||
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ jest.mock('dockerode', () => {
|
|||||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||||
|
|
||||||
|
// DC-087 — mirror src/app.js faithfully: the caddy check goes through
|
||||||
|
// fetchT (which injects the Origin header Caddy's enforce_origin allowlist
|
||||||
|
// requires), and is MOCKED so the suite is hermetic — no live request to a
|
||||||
|
// real Caddy admin on :2019. The previous raw-`fetch` mirror sent an
|
||||||
|
// Origin-less probe to the LIVE admin whenever the full suite ran on the
|
||||||
|
// prod host (adversarial cron every 30 min): 12 journal 403 lines per run,
|
||||||
|
// ~700/day of `client is not allowed to access from origin ''` noise,
|
||||||
|
// plus a false checks.caddy.ok=false in the mirrored readiness payload.
|
||||||
|
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||||
|
.mockImplementation(async () => (caddyOk
|
||||||
|
? { ok: true, status: 200 }
|
||||||
|
: { ok: false, status: 403 }));
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const config = {
|
const config = {
|
||||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||||
@@ -103,9 +116,13 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk
|
|||||||
allOk = false;
|
allOk = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-087 — mirror src/app.js exactly (fetchT, not raw fetch). fetchT is
|
||||||
|
// mocked at buildApp() scope, so this stays hermetic: no live probe to a
|
||||||
|
// real Caddy admin (the old raw-fetch mirror 403-spammed the prod journal
|
||||||
|
// every time the adversarial cron ran the full suite on this host).
|
||||||
try {
|
try {
|
||||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||||
checks.caddy = { ok: response.ok, status: response.status };
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
if (!response.ok) allOk = false;
|
if (!response.ok) allOk = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -33,9 +33,18 @@ jest.mock('dockerode', () => {
|
|||||||
|
|
||||||
// Mirror the canonical handler block from src/app.js — if this drifts from
|
// Mirror the canonical handler block from src/app.js — if this drifts from
|
||||||
// the real handler, these tests will start failing and force a sync.
|
// the real handler, these tests will start failing and force a sync.
|
||||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) {
|
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||||
|
|
||||||
|
// DC-087 — mirror src/app.js: caddy check via fetchT (Origin-injecting),
|
||||||
|
// mocked here so the suite is hermetic. The old raw-fetch mirror probed the
|
||||||
|
// LIVE Caddy admin on :2019 whenever the full suite ran on the prod host
|
||||||
|
// (adversarial cron): Origin-less → 403 → 12 journal error lines per run.
|
||||||
|
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||||
|
.mockImplementation(async () => (caddyOk
|
||||||
|
? { ok: true, status: 200 }
|
||||||
|
: { ok: false, status: 403 }));
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const config = {
|
const config = {
|
||||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||||
@@ -108,8 +117,10 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {})
|
|||||||
allOk = false;
|
allOk = false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// DC-087 — mirror src/app.js exactly: fetchT (mocked above), not raw
|
||||||
|
// fetch. Hermetic: no live request to a real Caddy admin.
|
||||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||||
checks.caddy = { ok: response.ok, status: response.status };
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
if (!response.ok) allOk = false;
|
if (!response.ok) allOk = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -214,4 +214,99 @@ describe('NotificationManager', () => {
|
|||||||
nm.stopHealthDaemon();
|
nm.stopHealthDaemon();
|
||||||
expect(nm.healthDaemonInterval).toBeNull();
|
expect(nm.healthDaemonInterval).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── DC-092: event alias folding + legacy config canonicalization ──────────
|
||||||
|
|
||||||
|
test('DC-092: send() folds camelCase aliases onto canonical kebab keys', async () => {
|
||||||
|
// deploymentSuccess (emitted by routes/apps/deploy.js) previously hit a
|
||||||
|
// gate miss (no such key in events) and the notification was dropped.
|
||||||
|
const result = await nm.send('deploymentSuccess', { text: 'deployed' });
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: send() accepts the canonical kebab spelling too', async () => {
|
||||||
|
const result = await nm.send('deploy-success', { text: 'deployed' });
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DC-092: send('test') bypasses the events gate (Test button works)", async () => {
|
||||||
|
const result = await nm.send('test', { text: 'Test Notification' });
|
||||||
|
// No providers are enabled in the default config, so results is empty —
|
||||||
|
// but the gate must NOT return 'Event test not enabled' like it used to.
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(nm.getHistory()[0].event).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: send() still gates unknown and disabled events', async () => {
|
||||||
|
const unknown = await nm.send('some-unknown-event', { text: 'x' });
|
||||||
|
expect(unknown.success).toBe(false);
|
||||||
|
expect(unknown.error).toMatch(/not enabled/i);
|
||||||
|
|
||||||
|
nm.config.events['container-down'] = false;
|
||||||
|
const disabled = await nm.send('container-down', { text: 'x' });
|
||||||
|
expect(disabled.success).toBe(false);
|
||||||
|
expect(disabled.error).toMatch(/not enabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: DEFAULT_CONFIG includes deploy/auto-restart events', () => {
|
||||||
|
// Regression pin: these were absent entirely, so deploy notifications
|
||||||
|
// were dropped for every install regardless of UI toggles.
|
||||||
|
expect(nm.config.events['deploy-success']).toBe(true);
|
||||||
|
expect(nm.config.events['deploy-failed']).toBe(true);
|
||||||
|
expect(nm.config.events['auto-restart']).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: legacy config with user/pass and camelCase events canonicalizes on load', () => {
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
providers: {
|
||||||
|
email: {
|
||||||
|
enabled: true,
|
||||||
|
host: 'smtp.test',
|
||||||
|
port: 465,
|
||||||
|
secure: 'false', // legacy string — must normalize to boolean false
|
||||||
|
to: 'me@test',
|
||||||
|
from: 'from@test',
|
||||||
|
user: 'legacy-user',
|
||||||
|
pass: 'legacy-pass',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
containerDown: false,
|
||||||
|
deploymentSuccess: false,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const loaded = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
const email = loaded.getConfig().providers.email;
|
||||||
|
expect(email.username).toBe('legacy-user');
|
||||||
|
expect(email.password).toBe('legacy-pass');
|
||||||
|
expect(email.user).toBeUndefined();
|
||||||
|
expect(email.pass).toBeUndefined();
|
||||||
|
expect(email.secure).toBe(false);
|
||||||
|
const events = loaded.getConfig().events;
|
||||||
|
expect(events['container-down']).toBe(false);
|
||||||
|
expect(events['deploy-success']).toBe(false);
|
||||||
|
expect(events.containerDown).toBeUndefined();
|
||||||
|
expect(events.deploymentSuccess).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: canonical keys win when both spellings exist in a legacy file', () => {
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||||
|
providers: { email: { user: 'legacy', username: 'canonical' } },
|
||||||
|
events: { containerDown: false, 'container-down': true },
|
||||||
|
}));
|
||||||
|
const loaded = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
expect(loaded.getConfig().providers.email.username).toBe('canonical');
|
||||||
|
expect(loaded.getConfig().events['container-down']).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ function createApp(depsOverride = {}) {
|
|||||||
errorResponse: jest.fn(),
|
errorResponse: jest.fn(),
|
||||||
log,
|
log,
|
||||||
renewCSRFToken,
|
renewCSRFToken,
|
||||||
|
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
|
||||||
...depsOverride,
|
...depsOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -299,7 +300,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
||||||
const secret = await setupTOTP();
|
const secret = await setupTOTP();
|
||||||
const token = authenticator.generate(secret);
|
const token = authenticator.generate(secret);
|
||||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||||
@@ -308,8 +309,29 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
expect(deps.session.create).toHaveBeenCalled();
|
expect(deps.session.create).toHaveBeenCalled();
|
||||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not issue an unbound handoff token for a dashboard-only login', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ssoToken).toBeNull();
|
||||||
|
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid handoff service ID before issuing a token', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Invalid service ID/);
|
||||||
|
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
@@ -450,7 +472,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
|
|
||||||
// 4. Re-login via /totp/verify (the "login" path)
|
// 4. Re-login via /totp/verify (the "login" path)
|
||||||
const loginCode = authenticator.generate(secret);
|
const loginCode = authenticator.generate(secret);
|
||||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
|
||||||
expect(loginRes.status).toBe(200);
|
expect(loginRes.status).toBe(200);
|
||||||
expect(loginRes.body.csrfToken).toBeDefined();
|
expect(loginRes.body.csrfToken).toBeDefined();
|
||||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ function createDiscoverApp(docker, servicesStateManager) {
|
|||||||
|
|
||||||
function createDisasterApp(platformPaths, log) {
|
function createDisasterApp(platformPaths, log) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
// Match the production body-parser limit (1 MiB) so the in-handler
|
||||||
|
// DC-079 cap (512 KiB) is actually reachable from tests. The default
|
||||||
|
// express.json() limit is 100 KiB, which would short-circuit the test
|
||||||
|
// with a 413 before the route's defense-in-depth check runs.
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
const routes = require('../../routes/disaster-recovery');
|
const routes = require('../../routes/disaster-recovery');
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||||
@@ -135,4 +139,267 @@ describe('DC-107: Disaster Recovery', () => {
|
|||||||
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
||||||
expect(svc[0].id).toBe('restored-svc');
|
expect(svc[0].id).toBe('restored-svc');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DC-079: Caddyfile restore hardening — the live Caddyfile path must
|
||||||
|
// NEVER be written from the disaster-recovery endpoint. The endpoint
|
||||||
|
// stages the candidate file under dataDir/disaster-staged/Caddyfile.candidate
|
||||||
|
// and surfaces a warning that `caddy-apply` is required to apply it.
|
||||||
|
it('DC-079: POST /disaster/restore with caddyfile STAGES instead of writing the live Caddyfile', async () => {
|
||||||
|
// The env var CADDYFILE_PATH is read by the route. Use a sentinel
|
||||||
|
// path that we can prove was NOT written. The route must instead
|
||||||
|
// create <dataDir>/disaster-staged/Caddyfile.candidate.
|
||||||
|
const liveSentinel = path.join(tmpDir, 'LIVE_CADDYFILE_SENTINEL.txt');
|
||||||
|
fs.writeFileSync(liveSentinel, 'do-not-overwrite');
|
||||||
|
|
||||||
|
const candidateCaddyfile =
|
||||||
|
'# staged candidate\n' +
|
||||||
|
'example.com {\n' +
|
||||||
|
' respond "ok"\n' +
|
||||||
|
'}\n';
|
||||||
|
|
||||||
|
const app = createDisasterApp({
|
||||||
|
dataDir: tmpDir,
|
||||||
|
caddyfilePath: liveSentinel, // route reads env or fallback; this is just for the response
|
||||||
|
});
|
||||||
|
// Override process.env.CADDYFILE_PATH so the route picks up our sentinel
|
||||||
|
const prev = process.env.CADDYFILE_PATH;
|
||||||
|
process.env.CADDYFILE_PATH = liveSentinel;
|
||||||
|
try {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: candidateCaddyfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('success');
|
||||||
|
expect(res.body.caddyfileStaged).toBeTruthy();
|
||||||
|
expect(res.body.caddyfileStaged).toHaveLength(1);
|
||||||
|
expect(res.body.caddyfileStaged[0].file).toBe('Caddyfile');
|
||||||
|
expect(res.body.caddyfileStaged[0].action).toBe('awaiting caddy-apply');
|
||||||
|
expect(res.body.caddyfileStaged[0].stagedPath).toBe(
|
||||||
|
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate')
|
||||||
|
);
|
||||||
|
expect(res.body.caddyfileStaged[0].livePath).toBe(liveSentinel);
|
||||||
|
expect(res.body.warning).toMatch(/DC-079/);
|
||||||
|
|
||||||
|
// The live sentinel file is UNTOUCHED — still has its original content.
|
||||||
|
const liveContents = fs.readFileSync(liveSentinel, 'utf8');
|
||||||
|
expect(liveContents).toBe('do-not-overwrite');
|
||||||
|
|
||||||
|
// The candidate file IS staged at the staging path.
|
||||||
|
const stagedContents = fs.readFileSync(
|
||||||
|
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
expect(stagedContents).toBe(candidateCaddyfile);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.CADDYFILE_PATH;
|
||||||
|
else process.env.CADDYFILE_PATH = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects non-string caddyfile content', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: { evil: 'object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Caddyfile content must be a string/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects explicit empty caddyfile string', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: '', // explicit empty payload — rejected
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Caddyfile content is empty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects oversized caddyfile content', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
// 512 KiB + 1 byte — over the in-handler cap, under the 1 MB body limit
|
||||||
|
const huge = 'a'.repeat(512 * 1024 + 1);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: huge,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/exceeds 524288 bytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects forbidden `import` directive (absolute path)', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const evil =
|
||||||
|
'# malicious snapshot\n' +
|
||||||
|
'import /etc/caddy/external.caddy\n' +
|
||||||
|
'example.com { respond "ok" }\n';
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: evil,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||||
|
|
||||||
|
// No staging file should have been created — fail closed.
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects forbidden `import` with relative-path escape', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const evil =
|
||||||
|
'# malicious snapshot\n' +
|
||||||
|
'import ../../../etc/passwd\n' +
|
||||||
|
'example.com { respond "ok" }\n';
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: evil,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects URL-encoded import payload', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const evil =
|
||||||
|
'import %2fetc%2fcaddy%2fevil.caddy\n' +
|
||||||
|
'example.com { respond "ok" }\n';
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: evil,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore without caddyfile field succeeds and stages nothing', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
files: {
|
||||||
|
services: [{ id: 'no-caddy' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.caddyfileStaged).toBeUndefined();
|
||||||
|
expect(res.body.warning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// DC-079 follow-up (GLM round-2 BLOCKING): assets/themes path traversal.
|
||||||
|
// Without the assertSafeAssetKey / assertSafeThemeName + path.resolve
|
||||||
|
// checks, an attacker can POST `{assets: {"../../etc/caddy/Caddyfile":
|
||||||
|
// "<base64-evil>"}}` and overwrite the live Caddyfile via the dataDir
|
||||||
|
// bind-mount. These tests prove the fix.
|
||||||
|
it('DC-079: POST /disaster/restore rejects assets with path-traversal key', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
assets: {
|
||||||
|
'../../etc/caddy/Caddyfile': Buffer.from('EVIL_BASE64_PAYLOAD').toString('base64'),
|
||||||
|
'custom-logo.png': Buffer.from('legit-logo').toString('base64'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The traversal key is rejected (added to errors), the legit key
|
||||||
|
// still works. Status is success-or-partial, never 500.
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial'); // one error
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../etc/caddy/Caddyfile'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
expect(erroredFile.error).toMatch(/forbidden characters or path segments/);
|
||||||
|
|
||||||
|
// The legit logo DID get written.
|
||||||
|
const legitPath = path.join(tmpDir, 'assets', 'custom-logo.png');
|
||||||
|
expect(fs.existsSync(legitPath)).toBe(true);
|
||||||
|
|
||||||
|
// The traversal target was NEVER written.
|
||||||
|
const escapePath = path.join(tmpDir, 'assets', '../../etc/caddy/Caddyfile');
|
||||||
|
// Resolve to absolute path — should be outside tmpDir/assets.
|
||||||
|
const resolvedEsc = path.resolve(escapePath);
|
||||||
|
expect(fs.existsSync(resolvedEsc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects assets with absolute path key', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
assets: {
|
||||||
|
'/etc/passwd': Buffer.from('evil').toString('base64'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial');
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('/etc/passwd'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects themes with path-traversal name', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
themes: {
|
||||||
|
'../../../etc/caddy/evil.json': { evil: true },
|
||||||
|
'legit-theme.json': { ok: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial');
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../../etc/caddy/evil.json'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
expect(erroredFile.error).toMatch(/must match/);
|
||||||
|
|
||||||
|
// The legit theme DID get written.
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'themes', 'legit-theme.json'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects themes without .json extension', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
themes: {
|
||||||
|
'no-extension': { ok: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial');
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('no-extension'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
expect(erroredFile.error).toMatch(/must match/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* DC-081: log-insights dispose path + keepDays input validation hardening.
|
||||||
|
*
|
||||||
|
* Two coupled bugs surfaced in the 2026-08-19 sweep:
|
||||||
|
*
|
||||||
|
* 1. log-insights.js hardcoded the audit-log.json + security-events.jsonl
|
||||||
|
* paths to `/opt/dashcaddy/dashcaddy-api/data/...`, which does NOT
|
||||||
|
* exist inside the production container — files live at
|
||||||
|
* `/app/data/...` (mounted via the existing data bind). The dispose
|
||||||
|
* endpoint silently no-op'd: `fs.readFile('/opt/.../audit-log.json')`
|
||||||
|
* hit the `.catch` arm → `auditData = []` → wrote an empty file back.
|
||||||
|
*
|
||||||
|
* 2. `parseInt(req.body.keepDays) || 30` accepted negative numbers. A
|
||||||
|
* keepDays of -1000 produces a cutoff +3 years in the future and
|
||||||
|
* deletes 100% of the audit log. Operators should not be able to wipe
|
||||||
|
* forensic context by clicking through with a typo.
|
||||||
|
*
|
||||||
|
* DC-081 fix:
|
||||||
|
* - `_resolvePaths()` returns `{ auditPath, secPath }` from
|
||||||
|
* `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`
|
||||||
|
* — same canonical resolution as the audit-logger module.
|
||||||
|
* - `_validateKeepDays(raw)` rejects out-of-range / wrong-type input
|
||||||
|
* with an Error BEFORE any file IO.
|
||||||
|
* - POST /log-insights/dispose now requires `{ keepDays: integer 1..3650, confirm: true }`.
|
||||||
|
* The pre-confirm preview is read-only.
|
||||||
|
*
|
||||||
|
* Verified live on DNS2 2026-08-19: `/app/data/audit-log.json` (318 KB)
|
||||||
|
* and `/app/data/security-events.jsonl` (15 MB) both exist; the old
|
||||||
|
* `/opt/dashcaddy/dashcaddy-api/data/...` paths are ENOENT in the container.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const fsp = require('fs').promises;
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const logInsightsMod = require('../../routes/log-insights');
|
||||||
|
|
||||||
|
function tmpAuditLogger() {
|
||||||
|
// The route module only uses auditLogger.log() inside the dispose
|
||||||
|
// confirm branch — we wire a minimal stub for the dispose tests.
|
||||||
|
return {
|
||||||
|
query: async () => [],
|
||||||
|
log: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmpSecurityEventStore() {
|
||||||
|
return {
|
||||||
|
query: () => ({ events: [], total: 0 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouter(opts = {}) {
|
||||||
|
const mod = logInsightsMod;
|
||||||
|
return mod({
|
||||||
|
asyncHandler: (fn) => async (req, res, next) => {
|
||||||
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||||
|
},
|
||||||
|
ok: (res, data) => res.json({ success: true, ...data }),
|
||||||
|
auditLogger: opts.auditLogger || tmpAuditLogger(),
|
||||||
|
securityEventStore: opts.securityEventStore || tmpSecurityEventStore(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(router) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(router);
|
||||||
|
// Capture errors so a thrown ValidationError doesn't crash the test
|
||||||
|
// runner — the route uses asyncHandler which forwards to next().
|
||||||
|
app.use((err, req, res, next) => res.status(err.statusCode || 500).json({ success: false, error: err.message, code: err.code }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive requests through http directly so we exercise the FULL Express
|
||||||
|
// middleware stack (body parser, error handler).
|
||||||
|
function start(app) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(server) {
|
||||||
|
return new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpJson(server, httpMethod, urlPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const port = server.address().port;
|
||||||
|
const data = httpMethod === 'GET' ? '' : JSON.stringify({});
|
||||||
|
const req = require('http').request({
|
||||||
|
hostname: '127.0.0.1', port, path: urlPath, method: httpMethod,
|
||||||
|
headers: httpMethod === 'GET'
|
||||||
|
? {}
|
||||||
|
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||||
|
}, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString('utf8');
|
||||||
|
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||||
|
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (httpMethod !== 'GET') req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routes/log-insights [DC-081]', () => {
|
||||||
|
describe('_validateKeepDays', () => {
|
||||||
|
const { _validateKeepDays } = logInsightsMod.__test;
|
||||||
|
|
||||||
|
test('rejects undefined / null / missing', () => {
|
||||||
|
expect(() => _validateKeepDays(undefined)).toThrow(/required/i);
|
||||||
|
expect(() => _validateKeepDays(null)).toThrow(/required/i);
|
||||||
|
expect(() => _validateKeepDays()).toThrow(/required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-finite numbers (NaN, Infinity, -Infinity)', () => {
|
||||||
|
expect(() => _validateKeepDays(NaN)).toThrow(/finite/i);
|
||||||
|
expect(() => _validateKeepDays(Infinity)).toThrow(/finite/i);
|
||||||
|
expect(() => _validateKeepDays(-Infinity)).toThrow(/finite/i);
|
||||||
|
expect(() => _validateKeepDays('not-a-number')).toThrow(/finite/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-integers (floats, strings of floats)', () => {
|
||||||
|
expect(() => _validateKeepDays(1.5)).toThrow(/integer/i);
|
||||||
|
expect(() => _validateKeepDays(30.7)).toThrow(/integer/i);
|
||||||
|
expect(() => _validateKeepDays('30.5')).toThrow(/integer/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects out-of-range values — the DC-081 core fix', () => {
|
||||||
|
// The pre-fix bug: parseInt(-1000, 10) === -1000, accepted as keepDays.
|
||||||
|
// cutoff = Date.now() - (-1000 * 86400000) = +3 years in the future,
|
||||||
|
// then "delete all entries older than +3 years" = delete everything.
|
||||||
|
expect(() => _validateKeepDays(-1)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(-1000)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(0)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(3651)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(1000000)).toThrow(/between 1 and 3650/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts integers in [1, 3650]', () => {
|
||||||
|
expect(_validateKeepDays(1)).toBe(1);
|
||||||
|
expect(_validateKeepDays(30)).toBe(30);
|
||||||
|
expect(_validateKeepDays(90)).toBe(90);
|
||||||
|
expect(_validateKeepDays(365)).toBe(365);
|
||||||
|
expect(_validateKeepDays(3650)).toBe(3650);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coerces numeric strings', () => {
|
||||||
|
expect(_validateKeepDays('30')).toBe(30);
|
||||||
|
expect(_validateKeepDays('3650')).toBe(3650);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_resolvePaths', () => {
|
||||||
|
const { _resolvePaths } = logInsightsMod.__test;
|
||||||
|
|
||||||
|
test('falls back to platformPaths.dataDir when env unset', () => {
|
||||||
|
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||||
|
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
delete process.env.AUDIT_LOG_FILE;
|
||||||
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
try {
|
||||||
|
const { auditPath, secPath } = _resolvePaths();
|
||||||
|
// platformPaths.dataDir is /app/data in the container, /etc/dashcaddy on host
|
||||||
|
expect(auditPath.endsWith('audit-log.json')).toBe(true);
|
||||||
|
expect(secPath.endsWith('security-events.jsonl')).toBe(true);
|
||||||
|
// Audit + security should land in the same data dir
|
||||||
|
expect(path.dirname(auditPath)).toBe(path.dirname(secPath));
|
||||||
|
} finally {
|
||||||
|
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||||
|
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honours AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE env overrides', () => {
|
||||||
|
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||||
|
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
process.env.AUDIT_LOG_FILE = '/tmp/dc-081-audit.json';
|
||||||
|
process.env.SECURITY_EVENT_LOG_FILE = '/tmp/dc-081-sec.jsonl';
|
||||||
|
try {
|
||||||
|
const { auditPath, secPath, auditPathFrom, secPathFrom } = _resolvePaths();
|
||||||
|
expect(auditPath).toBe('/tmp/dc-081-audit.json');
|
||||||
|
expect(secPath).toBe('/tmp/dc-081-sec.jsonl');
|
||||||
|
expect(auditPathFrom).toBe('env');
|
||||||
|
expect(secPathFrom).toBe('env');
|
||||||
|
} finally {
|
||||||
|
if (prevAudit === undefined) delete process.env.AUDIT_LOG_FILE;
|
||||||
|
else process.env.AUDIT_LOG_FILE = prevAudit;
|
||||||
|
if (prevSec === undefined) delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
else process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches the canonical paths used by audit-logger + event-store', async () => {
|
||||||
|
// Sanity: load both modules' resolved paths and assert they match
|
||||||
|
// what _resolvePaths returns. This catches a future refactor that
|
||||||
|
// moves one but not the others (the bug class that produced DC-081).
|
||||||
|
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||||
|
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
delete process.env.AUDIT_LOG_FILE;
|
||||||
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
try {
|
||||||
|
const auditLoggerMod = require('../../src/security/audit-logger');
|
||||||
|
const eventStoreMod = require('../../src/security/event-store');
|
||||||
|
// Trigger event-store module-load (it captures ENV at require time)
|
||||||
|
eventStoreMod.getStore();
|
||||||
|
const { auditPath, secPath } = _resolvePaths();
|
||||||
|
// The audit-logger module exports a singleton; its private
|
||||||
|
// AUDIT_LOG_FILE is not directly readable. Instead, we verify the
|
||||||
|
// shape: both paths share the same dataDir and use the canonical
|
||||||
|
// filenames.
|
||||||
|
expect(path.basename(auditPath)).toBe('audit-log.json');
|
||||||
|
expect(path.basename(secPath)).toBe('security-events.jsonl');
|
||||||
|
// And the dirname matches platformPaths.dataDir
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
|
expect(path.dirname(auditPath)).toBe(platformPaths.dataDir);
|
||||||
|
expect(path.dirname(secPath)).toBe(platformPaths.dataDir);
|
||||||
|
// Also sanity that the singleton logger at least exists
|
||||||
|
expect(auditLoggerMod).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||||
|
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /log-insights/dispose (TOTP-gated in production; here we hit the handler directly)', () => {
|
||||||
|
let server;
|
||||||
|
let app;
|
||||||
|
let tmpDir;
|
||||||
|
let auditFile;
|
||||||
|
let secFile;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-081-'));
|
||||||
|
auditFile = path.join(tmpDir, 'audit-log.json');
|
||||||
|
secFile = path.join(tmpDir, 'security-events.jsonl');
|
||||||
|
// Stage files so the route resolves them via env override.
|
||||||
|
process.env.AUDIT_LOG_FILE = auditFile;
|
||||||
|
process.env.SECURITY_EVENT_LOG_FILE = secFile;
|
||||||
|
const router = buildRouter();
|
||||||
|
app = makeApp(router);
|
||||||
|
server = await start(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stop(server);
|
||||||
|
delete process.env.AUDIT_LOG_FILE;
|
||||||
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function postKeepDays(body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const port = server.address().port;
|
||||||
|
const data = JSON.stringify(body);
|
||||||
|
const req = require('http').request({
|
||||||
|
hostname: '127.0.0.1', port, path: '/log-insights/dispose',
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||||
|
}, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString('utf8');
|
||||||
|
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||||
|
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects negative keepDays with 400 + DC-081_INVALID_KEEP_DAYS', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: -1000 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
expect(r.body.error).toMatch(/between 1 and 3650/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects 0 keepDays (no-op-but-lies)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 0 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects keepDays=Infinity (NaN-via-parseInt fallback)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: Infinity });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-integer keepDays', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 30.5 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing keepDays', async () => {
|
||||||
|
const r = await postKeepDays({});
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects keepDays > 3650 (10-year cap)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 10000 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview pass: returns wouldDelete count without writing', async () => {
|
||||||
|
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString(); // 100 days ago
|
||||||
|
const newTs = new Date(Date.now() - 5 * 86400000).toISOString(); // 5 days ago
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify([
|
||||||
|
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||||
|
{ id: 'a2', timestamp: oldTs, action: 'service.delete' },
|
||||||
|
{ id: 'a3', timestamp: newTs, action: 'auth.totp-verify' },
|
||||||
|
]));
|
||||||
|
await fsp.writeFile(secFile, [
|
||||||
|
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||||
|
JSON.stringify({ id: 's2', timestamp: oldTs, severity: 'info' }),
|
||||||
|
JSON.stringify({ id: 's3', timestamp: newTs, severity: 'info' }),
|
||||||
|
].join('\n') + '\n');
|
||||||
|
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.preview).toBe(true);
|
||||||
|
expect(r.body.wouldDelete.auditEntries).toBe(2);
|
||||||
|
expect(r.body.wouldDelete.securityEvents).toBe(2);
|
||||||
|
// Files untouched
|
||||||
|
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||||
|
expect(afterAudit.length).toBe(3);
|
||||||
|
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean);
|
||||||
|
expect(afterSec.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm pass: actually deletes old entries, keeps new ones', async () => {
|
||||||
|
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString();
|
||||||
|
const newTs = new Date(Date.now() - 5 * 86400000).toISOString();
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify([
|
||||||
|
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||||
|
{ id: 'a2', timestamp: newTs, action: 'auth.totp-verify' },
|
||||||
|
]));
|
||||||
|
await fsp.writeFile(secFile, [
|
||||||
|
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||||
|
JSON.stringify({ id: 's2', timestamp: newTs, severity: 'info' }),
|
||||||
|
].join('\n') + '\n');
|
||||||
|
|
||||||
|
const r = await postKeepDays({ keepDays: 30, confirm: true });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.disposed).toBe(true);
|
||||||
|
expect(r.body.deleted.auditEntries).toBe(1);
|
||||||
|
expect(r.body.deleted.securityEvents).toBe(1);
|
||||||
|
expect(r.body.remaining.auditEntries).toBe(1);
|
||||||
|
expect(r.body.remaining.securityEvents).toBe(1);
|
||||||
|
|
||||||
|
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||||
|
expect(afterAudit.map(e => e.id)).toEqual(['a2']);
|
||||||
|
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean).map(JSON.parse);
|
||||||
|
expect(afterSec.map(e => e.id)).toEqual(['s2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm=false treated as preview (not confirm)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 30, confirm: false });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.preview).toBe(true);
|
||||||
|
// confirm was false, so no dispose
|
||||||
|
expect(r.body.disposed).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview response includes resolved paths so operator knows what files will be touched', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.paths.auditPath).toBe(auditFile);
|
||||||
|
expect(r.body.paths.secPath).toBe(secFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing audit-log file gracefully on preview', async () => {
|
||||||
|
await fsp.unlink(auditFile).catch(() => {});
|
||||||
|
// fs.readFile().catch returns '[]', so preview reports 0 deletions
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.wouldDelete.auditEntries).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 500 DC-081_AUDIT_PARSE_FAILED on corrupt audit-log file', async () => {
|
||||||
|
await fsp.writeFile(auditFile, 'this-is-not-json{');
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(500);
|
||||||
|
expect(r.body.code).toBe('DC-081_AUDIT_PARSE_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 500 DC-081_AUDIT_SHAPE_INVALID if audit-log is a JSON object, not array', async () => {
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify({ not: 'an array' }));
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(500);
|
||||||
|
expect(r.body.code).toBe('DC-081_AUDIT_SHAPE_INVALID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-081 CORE: pre-fix keptDays=-1000 no longer wipes everything', async () => {
|
||||||
|
// Sanity-test the actual fix: a negative keepDays would, pre-fix,
|
||||||
|
// compute a cutoff in the FUTURE and then delete everything. After
|
||||||
|
// DC-081 it's a 400 with a clear error before any file read.
|
||||||
|
const r = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.success).toBe(false);
|
||||||
|
// No file IO occurred — confirm that an unrelated existing audit
|
||||||
|
// log file would survive. Since we already wiped tmpDir's auditFile
|
||||||
|
// is empty, write a sentinel and confirm it's still there after.
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify([{ id: 'sentinel', timestamp: new Date().toISOString() }]));
|
||||||
|
const r2 = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||||
|
expect(r2.status).toBe(400);
|
||||||
|
const after = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||||
|
expect(after.length).toBe(1);
|
||||||
|
expect(after[0].id).toBe('sentinel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* DC-092: notifications config contract tests (route level).
|
||||||
|
*
|
||||||
|
* The settings UI and the backend drifted apart in three ways, all of which
|
||||||
|
* made user-facing features silently dead:
|
||||||
|
* 1. UI sent email.user/email.pass; backend read username/password →
|
||||||
|
* SMTP auth never applied for UI-saved configs.
|
||||||
|
* 2. UI sent camelCase event keys (containerDown); the send() gate read
|
||||||
|
* kebab-case keys (container-down) → event toggles were cosmetic.
|
||||||
|
* 3. deploy-success/deploy-failed/auto-restart were missing from DEFAULT
|
||||||
|
* events → deploy + auto-restart notifications always dropped, and
|
||||||
|
* 'test' was gated too → the Test button was a no-op.
|
||||||
|
* 4. Non-boolean enabled/secure values (string "false") persisted as-is and
|
||||||
|
* coerced truthy (!!secure) — silently forcing TLS.
|
||||||
|
* 5. UI password field roundtrip: GET /config omitted port/secure/to/
|
||||||
|
* username, and an empty password on save clobbered the stored one.
|
||||||
|
*
|
||||||
|
* These tests pin the FIXED contract: alias normalization, strict booleans,
|
||||||
|
* event-key folding, non-destructive credential merge, redacted GET fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
// Stub notification manager: in-memory config object, real merge semantics
|
||||||
|
// are exercised through the route; manager-level canonicalization has its
|
||||||
|
// own tests in notification-manager.test.js.
|
||||||
|
function makeStubNotification(initial) {
|
||||||
|
const nm = {
|
||||||
|
config: initial,
|
||||||
|
getConfig() { return this.config; },
|
||||||
|
async saveConfig() { this.saved = JSON.parse(JSON.stringify(this.config)); return true; },
|
||||||
|
startHealthDaemon: jest.fn(),
|
||||||
|
stopHealthDaemon: jest.fn(),
|
||||||
|
};
|
||||||
|
return nm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(notification) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const notificationRoutes = require('../../routes/notifications');
|
||||||
|
app.use('/api/v1/notifications', notificationRoutes({
|
||||||
|
notification,
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next),
|
||||||
|
ok: (res, data) => res.json({ success: true, ...data }),
|
||||||
|
}));
|
||||||
|
// Inline error handler (same pattern as sites-dc074.routes.test.js): maps
|
||||||
|
// AppError.statusCode to the HTTP status and surfaces err.message.
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.message || 'Internal Server Error',
|
||||||
|
code: err.code || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
enabled: true,
|
||||||
|
providers: {
|
||||||
|
discord: { enabled: false, webhookUrl: '' },
|
||||||
|
telegram: { enabled: false, botToken: '', chatId: '' },
|
||||||
|
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
|
||||||
|
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' },
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
'container-down': true,
|
||||||
|
'container-up': false,
|
||||||
|
'alert': true,
|
||||||
|
'backup-complete': true,
|
||||||
|
'backup-failed': true,
|
||||||
|
'update-available': true,
|
||||||
|
'deploy-success': true,
|
||||||
|
'deploy-failed': true,
|
||||||
|
'auto-restart': true,
|
||||||
|
},
|
||||||
|
healthCheck: { enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
function freshConfig() {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-092: POST /config field aliases and typing', () => {
|
||||||
|
test('UI spelling email.user/email.pass normalizes onto username/password', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { user: 'svc@example.com', pass: 'app-secret' } } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.providers.email.username).toBe('svc@example.com');
|
||||||
|
expect(nm.config.providers.email.password).toBe('app-secret');
|
||||||
|
expect(nm.config.providers.email.user).toBeUndefined();
|
||||||
|
expect(nm.config.providers.email.pass).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit username/password wins over user/pass aliases', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { user: 'legacy@x.com', pass: 'old', username: 'modern@x.com', password: 'new' } } });
|
||||||
|
expect(nm.config.providers.email.username).toBe('modern@x.com');
|
||||||
|
expect(nm.config.providers.email.password).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('string "false" for secure is rejected, not coerced truthy', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { secure: 'false' } } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(nm.config.providers.email.secure).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('string enabled for any provider is rejected', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
for (const prov of ['discord', 'telegram', 'ntfy', 'email']) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { [prov]: { enabled: 'true' } } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
}
|
||||||
|
const top = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ enabled: 'true' });
|
||||||
|
expect(top.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real booleans pass and persist', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ enabled: false, providers: { email: { secure: true } } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.enabled).toBe(false);
|
||||||
|
expect(nm.config.providers.email.secure).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SMTP port bounds enforced (0, 65536, non-integer rejected)', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
for (const bad of [0, 65536, 58.5, 'abc']) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { port: bad } } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
}
|
||||||
|
const good = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { port: 465 } } });
|
||||||
|
expect(good.status).toBe(200);
|
||||||
|
expect(nm.config.providers.email.port).toBe(465);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-092: POST /config event-key folding', () => {
|
||||||
|
test('camelCase event keys fold onto canonical kebab keys', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ events: { containerDown: false, deploymentSuccess: false, resourceAlert: false } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.events['container-down']).toBe(false);
|
||||||
|
expect(nm.config.events['deploy-success']).toBe(false);
|
||||||
|
expect(nm.config.events['alert']).toBe(false);
|
||||||
|
// legacy camelCase keys must NOT be stored
|
||||||
|
expect(nm.config.events.containerDown).toBeUndefined();
|
||||||
|
expect(nm.config.events.deploymentSuccess).toBeUndefined();
|
||||||
|
expect(nm.config.events.resourceAlert).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical kebab keys accepted directly', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ events: { 'container-down': false, 'auto-restart': false } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.events['container-down']).toBe(false);
|
||||||
|
expect(nm.config.events['auto-restart']).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-boolean event values rejected', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ events: { 'container-down': 'yes' } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-092: POST /config non-destructive credential merge', () => {
|
||||||
|
test('empty password does not clobber stored password', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email.username = 'svc@example.com';
|
||||||
|
cfg.providers.email.password = 'stored-secret';
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { host: 'smtp.example.com', password: '' } } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.providers.email.password).toBe('stored-secret');
|
||||||
|
expect(nm.config.providers.email.host).toBe('smtp.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty username does not clobber stored username', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email.username = 'svc@example.com';
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { username: '' } } });
|
||||||
|
expect(nm.config.providers.email.username).toBe('svc@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-empty password overwrites', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email.password = 'old';
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { password: 'rotated' } } });
|
||||||
|
expect(nm.config.providers.email.password).toBe('rotated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-092: GET /config redaction and roundtrip fields', () => {
|
||||||
|
test('returns port/secure/to/username/hasPassword but never the password', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email = {
|
||||||
|
enabled: true,
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
to: 'admin@example.com',
|
||||||
|
from: 'DashCaddy <noreply@example.com>',
|
||||||
|
username: 'svc@example.com',
|
||||||
|
password: 'super-secret',
|
||||||
|
};
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app).get('/api/v1/notifications/config');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const email = res.body.config.providers.email;
|
||||||
|
expect(email.port).toBe(465);
|
||||||
|
expect(email.secure).toBe(true);
|
||||||
|
expect(email.to).toBe('admin@example.com');
|
||||||
|
expect(email.username).toBe('svc@example.com');
|
||||||
|
expect(email.hasPassword).toBe(true);
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('super-secret');
|
||||||
|
expect(res.body.config.providers.email.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -288,6 +288,21 @@ describe('Services Routes', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.hasApiKey).toBe(true);
|
expect(res.body.hasApiKey).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires both username and password before reporting Basic Auth ready', async () => {
|
||||||
|
const credentialManager = {
|
||||||
|
store: jest.fn(),
|
||||||
|
retrieve: jest.fn().mockImplementation((key) => {
|
||||||
|
if (key === 'service.radarr.username') return Promise.resolve('admin');
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
const { app } = createApp({ credentialManager });
|
||||||
|
const res = await request(app).get('/api/services/radarr/credentials');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.hasBasicAuth).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||||
|
|||||||
@@ -131,6 +131,20 @@ describe('routes/tailscale-admin: PUT /settings', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('400 on apiToken exceeding 256-char length cap (DC-080)', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const oversized = 'tskey-api-' + 'x'.repeat(300); // > 256 chars
|
||||||
|
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: oversized });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error || res.body.message).toMatch(/exceeds maximum length/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('400 on non-string apiToken (DC-080)', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 12345 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
test('200 + saves token + writes metadata on valid token', async () => {
|
test('200 + saves token + writes metadata on valid token', async () => {
|
||||||
const fakeClient = makeFakeClient({
|
const fakeClient = makeFakeClient({
|
||||||
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
||||||
@@ -293,6 +307,76 @@ describe('routes/tailscale-admin: POST /settings/test', () => {
|
|||||||
expect(res.body.valid).toBe(true);
|
expect(res.body.valid).toBe(true);
|
||||||
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('400 on body.apiToken not starting with tskey-api- (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: false }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/tailscale/settings/test')
|
||||||
|
.send({ apiToken: 'arbitrary-junk' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('400 on body.apiToken exceeding length cap (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: false }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const oversized = 'tskey-api-' + 'x'.repeat(300);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/tailscale/settings/test')
|
||||||
|
.send({ apiToken: oversized });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitting apiToken is allowed (uses stored token path) (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/tailscale/settings/test')
|
||||||
|
.send({}); // no apiToken in body
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.valid).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
||||||
@@ -511,6 +595,99 @@ describe('routes/tailscale-admin: pre-auth keys', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys rejects null/123/object tags entries (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
// Mixed: null, number, object — all must be rejected
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['tag:guest', null, 123, { x: 1 }] });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys rejects uppercase / whitespace / CRLF in tags (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['TAG:guest', 'tag:foo bar', 'tag:x\r\ninjection'] });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys rejects description exceeding 120 chars (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const longDesc = 'a'.repeat(200); // > 120 chars
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ description: longDesc });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys accepts canonical lowercase tag: form (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient({
|
||||||
|
createAuthKey: jest.fn(async (opts) => ({ id: 'k2', key: 'tskey-secret-2', ...opts })),
|
||||||
|
});
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
|
||||||
|
tags: ['tag:guest-plex', 'tag:server'],
|
||||||
|
expirySeconds: 86400,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
tags: ['tag:guest-plex', 'tag:server'],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
||||||
const fakeClient = makeFakeClient();
|
const fakeClient = makeFakeClient();
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -572,4 +749,110 @@ describe('routes/tailscale-admin: security boundary', () => {
|
|||||||
await request(app).delete('/api/v1/tailscale/settings');
|
await request(app).delete('/api/v1/tailscale/settings');
|
||||||
expect(stored.token).toBeNull();
|
expect(stored.token).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// DC-080 direct validator unit tests (no supertest, no Express)
|
||||||
|
describe('routes/tailscale-admin: DC-080 validators (direct)', () => {
|
||||||
|
const { _validators } = require('../../routes/tailscale-admin');
|
||||||
|
const {
|
||||||
|
validateApiToken,
|
||||||
|
validateTags,
|
||||||
|
validateDescription,
|
||||||
|
TAILSCALE_TOKEN_PREFIX,
|
||||||
|
TAILSCALE_TOKEN_MAX_LEN,
|
||||||
|
DESCRIPTION_MAX_LEN,
|
||||||
|
} = _validators;
|
||||||
|
|
||||||
|
describe('validateApiToken', () => {
|
||||||
|
test('accepts canonical tskey-api-...', () => {
|
||||||
|
expect(validateApiToken('tskey-api-abc123')).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects empty', () => {
|
||||||
|
expect(validateApiToken('')).toMatch(/required/);
|
||||||
|
});
|
||||||
|
test('rejects undefined / null', () => {
|
||||||
|
expect(validateApiToken(undefined)).toMatch(/required/);
|
||||||
|
expect(validateApiToken(null)).toMatch(/required/);
|
||||||
|
});
|
||||||
|
test('rejects non-string (number, object, array)', () => {
|
||||||
|
expect(validateApiToken(123)).toMatch(/must be a string/);
|
||||||
|
expect(validateApiToken({})).toMatch(/must be a string/);
|
||||||
|
expect(validateApiToken(['x'])).toMatch(/must be a string/);
|
||||||
|
});
|
||||||
|
test('rejects wrong prefix', () => {
|
||||||
|
expect(validateApiToken('not-a-token')).toMatch(/must start with/);
|
||||||
|
});
|
||||||
|
test('accepts exactly at length cap', () => {
|
||||||
|
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length);
|
||||||
|
expect(validateApiToken(token)).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects 1 over length cap', () => {
|
||||||
|
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length + 1);
|
||||||
|
expect(validateApiToken(token)).toMatch(/exceeds maximum length/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateTags', () => {
|
||||||
|
test('accepts undefined / null (optional)', () => {
|
||||||
|
expect(validateTags(undefined)).toBeNull();
|
||||||
|
expect(validateTags(null)).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects non-array', () => {
|
||||||
|
expect(validateTags('tag:foo')).toMatch(/must be an array/);
|
||||||
|
expect(validateTags({})).toMatch(/must be an array/);
|
||||||
|
});
|
||||||
|
test('rejects entries that are not strings', () => {
|
||||||
|
expect(validateTags(['tag:a', null])).toMatch(/tags\[1\]/);
|
||||||
|
expect(validateTags(['tag:a', 123])).toMatch(/tags\[1\]/);
|
||||||
|
expect(validateTags(['tag:a', {}])).toMatch(/tags\[1\]/);
|
||||||
|
});
|
||||||
|
test('rejects uppercase / whitespace / CRLF', () => {
|
||||||
|
expect(validateTags(['TAG:foo'])).toMatch(/tags\[0\]/);
|
||||||
|
expect(validateTags(['tag:foo bar'])).toMatch(/tags\[0\]/);
|
||||||
|
expect(validateTags(['tag:foo\r\nbar'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
test('rejects entries starting with non-alnum (no leading colon)', () => {
|
||||||
|
expect(validateTags([':foo'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects bare "tag:" with empty name (Tailscale spec violation) (DC-080 round-2)', () => {
|
||||||
|
expect(validateTags(['tag:'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects colon-only chars after tag: prefix (DC-080 round-2)', () => {
|
||||||
|
expect(validateTags(['tag:::'])).toMatch(/tags\[0\]/);
|
||||||
|
expect(validateTags(['tag:---'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
test('accepts canonical tag:server form', () => {
|
||||||
|
expect(validateTags(['tag:server'])).toBeNull();
|
||||||
|
expect(validateTags(['tag:guest-plex', 'tag:server'])).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects empty array entry', () => {
|
||||||
|
expect(validateTags(['tag:a', ''])).toMatch(/tags\[1\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateDescription', () => {
|
||||||
|
test('accepts undefined / null', () => {
|
||||||
|
expect(validateDescription(undefined)).toBeNull();
|
||||||
|
expect(validateDescription(null)).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects non-string', () => {
|
||||||
|
expect(validateDescription(123)).toMatch(/must be a string/);
|
||||||
|
});
|
||||||
|
test('rejects over 120 chars', () => {
|
||||||
|
const long = 'a'.repeat(DESCRIPTION_MAX_LEN + 1);
|
||||||
|
expect(validateDescription(long)).toMatch(/exceeds maximum length/);
|
||||||
|
});
|
||||||
|
test('accepts at the cap', () => {
|
||||||
|
const exact = 'a'.repeat(DESCRIPTION_MAX_LEN);
|
||||||
|
expect(validateDescription(exact)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports surface stays in sync with constants used inside validators', () => {
|
||||||
|
// Guard against drift: if a future refactor renames a constant, this fails
|
||||||
|
expect(TAILSCALE_TOKEN_PREFIX).toBe('tskey-api-');
|
||||||
|
expect(typeof TAILSCALE_TOKEN_MAX_LEN).toBe('number');
|
||||||
|
expect(typeof DESCRIPTION_MAX_LEN).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => {
|
|||||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('host-bound SSO token can only be redeemed on its intended service host', () => {
|
||||||
|
const session = buildSession();
|
||||||
|
const wrongHostToken = session.createHandoffToken('plex.sami');
|
||||||
|
expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false);
|
||||||
|
expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false);
|
||||||
|
|
||||||
|
const correctHostToken = session.createHandoffToken('plex.sami');
|
||||||
|
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true);
|
||||||
|
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('logout clears the host-only secure cookie', () => {
|
test('logout clears the host-only secure cookie', () => {
|
||||||
const session = buildSession();
|
const session = buildSession();
|
||||||
const headers = {};
|
const headers = {};
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* DC-083 -- Public share endpoint input hardening.
|
||||||
|
*
|
||||||
|
* The two CSRF-exempt public endpoints (POST /share/:token/subscribe +
|
||||||
|
* POST /share/:token/redeem-tailscale) accept untrusted body fields. The
|
||||||
|
* pre-fix code had three coupled bugs:
|
||||||
|
*
|
||||||
|
* 1. `email.includes('@')` accepted `@`, `a@`, `<script>@x.c`, and 10MB
|
||||||
|
* strings as "valid email" -- and the field was never even used after
|
||||||
|
* validation (the subscribe endpoint discarded it).
|
||||||
|
* 2. `typeof deviceId === 'string'` accepted arbitrary strings of any
|
||||||
|
* length, including CR/LF/NUL -- which fed straight into the Tailscale
|
||||||
|
* auth-key description string and the on-disk shares.json.
|
||||||
|
* 3. No rate-limit; the general limiter (1000/15min) was too generous for
|
||||||
|
* unauthenticated state-mutating endpoints.
|
||||||
|
*
|
||||||
|
* Fix: charset/length/control-char-bounded validators at the route layer
|
||||||
|
* AND at the store layer (defense-in-depth), plus a dedicated
|
||||||
|
* SHARE_PUBLIC rate-limit (30/15min) on the public endpoints.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - subscribe email: rejects bare @, missing TLD, oversized, CR/LF, shell
|
||||||
|
* metachars, control chars; accepts normal addresses; accepts OMITTED
|
||||||
|
* email (backwards-compatible with the original behavior).
|
||||||
|
* - subscribe email propagates to share-store subscriberEmails (capped 8).
|
||||||
|
* - redeem-tailscale deviceId: rejects CR/LF/NUL, oversized, empty,
|
||||||
|
* spaces, brackets, quotes; accepts Tailscale-style base64url+hphens;
|
||||||
|
* accepts OMITTED deviceId (treated as 'unknown').
|
||||||
|
* - Sanitized usedBy is what flows into the on-disk shares.json.
|
||||||
|
* - Rate-limit fires after the configured budget per IP.
|
||||||
|
* - Store-level defense: bypassing the route (direct store call) still
|
||||||
|
* rejects invalid inputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { createShareStore } = require('../src/security/share-store');
|
||||||
|
|
||||||
|
function _tmpDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-dc083-'));
|
||||||
|
}
|
||||||
|
function _cleanup(dir) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildApp({ shareStore } = {}) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// No req.user injection -- the public endpoints must work without auth.
|
||||||
|
const shareRoutes = require('../routes/share');
|
||||||
|
app.use(shareRoutes({
|
||||||
|
shareStore,
|
||||||
|
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
||||||
|
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
||||||
|
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
||||||
|
servicesStateManager: { get: async () => null, read: async () => [] },
|
||||||
|
servicesFile: null,
|
||||||
|
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
log: { info() {}, warn() {}, error() {} },
|
||||||
|
}));
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
if (err && err.statusCode) {
|
||||||
|
return res.status(err.statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
code: err.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(500).json({ success: false, error: err && err.message });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- Subscribe endpoint -- email validation ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-083: subscribe email validation', () => {
|
||||||
|
let dir, shareStore;
|
||||||
|
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('accepts omitted email (backwards-compatible)', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a well-formed email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'subscriber@example.com' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lowercases the email on capture', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'Subscriber@Example.COM' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].subscriberEmails).toEqual(['subscriber@example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects bare @', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: '@' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing local-part', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: '@example.com' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing TLD', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'user@localhost' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects single-char TLD', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'user@example.c' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects CR/LF in email (CRLF-injection defense)', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'a@b.com\r\nX-Injected: yes' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NUL in email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'a@b.com\x00hack' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects oversized email (>254 chars)', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const longLocal = 'a'.repeat(250) + '@example.com';
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: longLocal });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects XSS-shape email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: '<script>@x.com' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 42 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps subscriberEmails capped to 8 entries', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: `user${i}@example.com` });
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].subscriberEmails).toHaveLength(8);
|
||||||
|
// FIFO cap -- the first 4 got dropped, latest 8 remain.
|
||||||
|
expect(raw.shares[id].subscriberEmails[0]).toBe('user4@example.com');
|
||||||
|
expect(raw.shares[id].subscriberEmails[7]).toBe('user11@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitted email does not write subscriberEmails', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].subscriberEmails).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------- Redeem-tailscale endpoint -- deviceId validation ---------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-083: redeem-tailscale deviceId validation', () => {
|
||||||
|
let dir, shareStore;
|
||||||
|
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('accepts Tailscale-style base64url ID', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey-abc123-def456' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.redeemed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts OMITTED deviceId (treated as "unknown")', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].usedBy).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects CR/LF in deviceId (CRLF-injection defense)', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey\r\nX-Injected: yes' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NUL in deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey\x00hack' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects oversized deviceId (>128 chars)', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const long = 'a'.repeat(200);
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: long });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty string deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: '' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects whitespace in deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node key 1' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects shell metachars in deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey; rm -rf /' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: { evil: true } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitized usedBy flows into the on-disk shares.json', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node-abc.def-123' });
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].usedBy).toBe('node-abc.def-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejection does NOT mark the share used', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const bad = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node with spaces' });
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
// A FOLLOW-UP valid redeem should still succeed.
|
||||||
|
const ok = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node-clean' });
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------- Store-layer defense-in-depth (bypass the route, hit the store) ------------
|
||||||
|
|
||||||
|
describe('DC-083: store-layer defense-in-depth', () => {
|
||||||
|
let dir, store;
|
||||||
|
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('recordPublicSubscribe rejects CRLF in email', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token, { email: 'a@b.com\r\nX: 1' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPublicSubscribe rejects oversized email', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token, { email: 'a'.repeat(300) + '@x.com' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse rejects CRLF in deviceId', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'node\r\nhack' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_device_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse rejects oversized deviceId', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'a'.repeat(200) });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_device_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse accepts null deviceId (defaults to "unknown")', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, { deviceId: null });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.share.usedBy).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse accepts omitted deviceId (defaults to "unknown")', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, {});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.share.usedBy).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPublicSubscribe accepts omitted email (backwards-compatible)', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPublicSubscribe accepts null email (backwards-compatible)', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token, { email: null });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------- Rate-limit guard ------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-083: SHARE_PUBLIC rate-limit', () => {
|
||||||
|
// We can't easily trigger the rate-limit in a unit test because the
|
||||||
|
// default 30/15min is high. Instead, verify the constant is wired and
|
||||||
|
// that the limiter is mounted on the public endpoints (the test env
|
||||||
|
// skips the limiter, so we just confirm the constants).
|
||||||
|
test('RATE_LIMITS.SHARE_PUBLIC is bounded tighter than GENERAL', () => {
|
||||||
|
const { RATE_LIMITS } = require('../src/utilities/constants');
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC).toBeDefined();
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThan(RATE_LIMITS.GENERAL.max);
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThanOrEqual(30);
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC.windowMs).toBe(15 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route module loads without throwing when express-rate-limit is wired', () => {
|
||||||
|
// Smoke test: the route factory must succeed with the limiter attached.
|
||||||
|
const dir = _tmpDir();
|
||||||
|
try {
|
||||||
|
const shareStore = createShareStore({ dataDir: dir });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
// _buildApp would have thrown if the route factory threw.
|
||||||
|
expect(typeof app).toBe('function');
|
||||||
|
} finally {
|
||||||
|
_cleanup(dir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sharePublicLimiter is mounted on /preview (route stack contains limiter)', () => {
|
||||||
|
// Verify the limiter middleware is actually wired into /preview's route
|
||||||
|
// stack. The route uses express.Router().use(path, ...mw, handler) so we
|
||||||
|
// can inspect the stack via the router's internal `stack` array.
|
||||||
|
const dir = _tmpDir();
|
||||||
|
try {
|
||||||
|
const shareStore = createShareStore({ dataDir: dir });
|
||||||
|
const router = require('../routes/share')({
|
||||||
|
shareStore,
|
||||||
|
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
||||||
|
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
||||||
|
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
||||||
|
servicesStateManager: { get: async () => null, read: async () => [] },
|
||||||
|
servicesFile: null,
|
||||||
|
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
log: { info() {}, warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
const previewStack = router.stack.find(
|
||||||
|
(layer) => layer.route && layer.route.path === '/share/:token/preview'
|
||||||
|
);
|
||||||
|
expect(previewStack).toBeDefined();
|
||||||
|
// The route handler should be preceded by at least one middleware
|
||||||
|
// layer (the limiter). route.stack contains the per-route middleware.
|
||||||
|
// In express, .route.stack has the route-local middleware + handler.
|
||||||
|
// The limiter is mounted at the router level (router.use pattern), so
|
||||||
|
// it's actually a separate layer in router.stack. Look for any layer
|
||||||
|
// that has a regex/path matching /share/:token.
|
||||||
|
const limiterLayer = router.stack.find(
|
||||||
|
(layer) => layer.regexp && layer.regexp.test && layer.regexp.test('/share/abc/preview')
|
||||||
|
);
|
||||||
|
expect(limiterLayer).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
_cleanup(dir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-083: positive smoke tests (legitimate inputs)', () => {
|
||||||
|
test('validates user+tag@sub.domain.io (RFC 5322 plus addressing)', () => {
|
||||||
|
const { validatePublicEmail } = require('../src/security/share-store');
|
||||||
|
const v = validatePublicEmail('user+tag@sub.domain.io');
|
||||||
|
expect(v).toEqual({ ok: true, email: 'user+tag@sub.domain.io' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates a typical Tailscale node ID as deviceId', () => {
|
||||||
|
const { validatePublicDeviceId } = require('../src/security/share-store');
|
||||||
|
// Tailscale node IDs look like "nodekey:abcdef0123456789" or just hex
|
||||||
|
const v = validatePublicDeviceId('nodekey:abcdef0123456789');
|
||||||
|
expect(v).toEqual({ ok: true, deviceId: 'nodekey:abcdef0123456789' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -378,12 +378,35 @@ describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
|
|||||||
expect(r2.body.error).toMatch(/already_used/);
|
expect(r2.body.error).toMatch(/already_used/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects missing deviceId', async () => {
|
test('rejects missing deviceId — DC-083 accepts omitted, treats as "unknown"', async () => {
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
const app = _buildApp({ shareStore, noAdmin: true });
|
const app = _buildApp({ shareStore, noAdmin: true });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
.send({});
|
.send({});
|
||||||
|
// DC-083: omitted deviceId is now accepted; the store defaults usedBy
|
||||||
|
// to 'unknown'. The pre-fix route layer required deviceId be present;
|
||||||
|
// the new behavior matches the store's defensive default and is
|
||||||
|
// safer for partially-malformed forward_auth calls from Caddy.
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.redeemed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid deviceId (control chars / oversized)', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore, noAdmin: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node\r\nhack' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore, noAdmin: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: '' });
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||||
|
|
||||||
function createApp({ redeem = true } = {}) {
|
function loadCredentialVaultHandoff() {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const window = { location: { origin: 'https://status.sami' } };
|
||||||
|
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
|
||||||
|
return window.DCCredentialVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
|
||||||
const app = express();
|
const app = express();
|
||||||
const session = {
|
const session = {
|
||||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||||
setCookieHostOnly: jest.fn((res) => {
|
setCookieHostOnly: jest.fn((res) => {
|
||||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||||
}),
|
}),
|
||||||
isValid: jest.fn().mockReturnValue(true),
|
isValid: jest.fn().mockReturnValue(valid),
|
||||||
|
createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
|
||||||
};
|
};
|
||||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
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 errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
||||||
@@ -21,14 +35,15 @@ function createApp({ redeem = true } = {}) {
|
|||||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||||
getAppSession: jest.fn(),
|
getAppSession: jest.fn(),
|
||||||
appSessionCache: new Map(),
|
appSessionCache: new Map(),
|
||||||
credentialManager: { retrieve: jest.fn() },
|
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
|
||||||
fetchT: jest.fn(),
|
fetchT: jest.fn(),
|
||||||
getServiceById: jest.fn(),
|
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
|
||||||
licenseManager: {
|
licenseManager: {
|
||||||
hasFeature: jest.fn().mockReturnValue(true),
|
hasFeature: jest.fn().mockReturnValue(true),
|
||||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||||
},
|
},
|
||||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||||
|
siteConfig: { dashboardHost },
|
||||||
});
|
});
|
||||||
app.use('/api/v1', router);
|
app.use('/api/v1', router);
|
||||||
return { app, session };
|
return { app, session };
|
||||||
@@ -44,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(res.status).toBe(303);
|
expect(res.status).toBe(303);
|
||||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||||
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -82,3 +97,105 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('existing-session SSO handoff', () => {
|
||||||
|
test('mints a handoff token without asking for TOTP again', async () => {
|
||||||
|
const { app, session } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
|
||||||
|
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to mint a handoff token without a valid session', async () => {
|
||||||
|
const { app, session } = createApp({ valid: false });
|
||||||
|
const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
|
||||||
|
const issued = new Set(['fresh-sso-handoff-token']);
|
||||||
|
const redeemOnce = (token) => issued.delete(token);
|
||||||
|
const { app } = createApp({ redeem: redeemOnce });
|
||||||
|
|
||||||
|
const mint = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
const exchange = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||||
|
|
||||||
|
expect(exchange.status).toBe(303);
|
||||||
|
expect(exchange.headers.location).toBe('/web/');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
|
|
||||||
|
const replay = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||||
|
expect(replay.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encrypted-vault credential onboarding', () => {
|
||||||
|
test('app-token identifies missing credentials as a form requirement', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/app-token/plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
|
expect(res.status).toBe(428);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId: 'plex',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('service login page sends missing credentials to the encrypted vault form', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
|
||||||
|
expect(res.text).toContain("dashboardOrigin+'?credentials='");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('service login page derives the vault origin from trusted dashboard config', async () => {
|
||||||
|
const { app } = createApp({ dashboardHost: 'dashboard.home' });
|
||||||
|
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
|
||||||
|
const issued = new Set(['fresh-sso-handoff-token']);
|
||||||
|
const { app } = createApp({ redeem: (token) => issued.delete(token) });
|
||||||
|
const mint = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
|
const vault = loadCredentialVaultHandoff();
|
||||||
|
const target = new URL(vault.buildHandoffTarget(
|
||||||
|
'https://plex.sami/web/?direct=1#home',
|
||||||
|
mint.body.ssoToken,
|
||||||
|
'plex',
|
||||||
|
));
|
||||||
|
// The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
|
||||||
|
// route while preserving the token and relative return query.
|
||||||
|
const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);
|
||||||
|
|
||||||
|
expect(target.pathname).toBe('/dashcaddy-sso');
|
||||||
|
expect(exchange.status).toBe(303);
|
||||||
|
expect(exchange.headers.location).toBe('/web/?direct=1#home');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* DC-082: Update-manager image-name parsing for docker-compose prefixed names.
|
||||||
|
*
|
||||||
|
* The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to
|
||||||
|
* `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub.
|
||||||
|
* Compose-prefixed names (single hyphen, no slash, lowercase) need to
|
||||||
|
* split on the FIRST hyphen to recover `<project>/<service>` — that's
|
||||||
|
* the actual upstream namespace for a compose-prefixed image.
|
||||||
|
*
|
||||||
|
* The fix also adds a "no upstream registry image, skip cleanly" path
|
||||||
|
* for when the authed GET 401s against a compose-prefixed name (the
|
||||||
|
* compose-prefixed image is built locally and not published to Docker
|
||||||
|
* Hub). That should log as info, not error.
|
||||||
|
*/
|
||||||
|
const updateManager = require('../src/managers/update-manager');
|
||||||
|
|
||||||
|
describe('DC-082 update-manager / compose-prefixed image names', () => {
|
||||||
|
let um = updateManager; // module exports the singleton instance
|
||||||
|
|
||||||
|
describe('_composeProjectToRepo', () => {
|
||||||
|
test('splits dashcaddy-dashcaddy-api on the first hyphen', () => {
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits myproject-myservice on the first hyphen', () => {
|
||||||
|
expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits multi-hyphen names on the FIRST hyphen only', () => {
|
||||||
|
// "myproj-grandchild-service" -> "myproj/grandchild-service"
|
||||||
|
// (first hyphen is the project/service boundary; later hyphens are
|
||||||
|
// part of the service name like docker-compose's `web-cache`).
|
||||||
|
expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for slash-namespaced names (handled by other path)', () => {
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('library/nginx')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for Docker Official Image names (no hyphen)', () => {
|
||||||
|
expect(um._composeProjectToRepo('nginx')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('alpine')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('node')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for empty / malformed input', () => {
|
||||||
|
expect(um._composeProjectToRepo('')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(null)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(undefined)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(123)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen
|
||||||
|
expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen
|
||||||
|
// The regex tolerates mixed-case via the /i flag for defensiveness
|
||||||
|
// even though Docker Compose names are typically lowercase — the
|
||||||
|
// important shape constraints are the letter/digit/underscore/hyphen
|
||||||
|
// charset and the non-empty two-part split.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts names with underscores and digits (compose allows)', () => {
|
||||||
|
expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service');
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects names with chars compose never produces', () => {
|
||||||
|
// dot/colon/slash should never pass — they're either already-namespaced
|
||||||
|
// or invalid in a Docker Compose service name.
|
||||||
|
expect(um._composeProjectToRepo('foo:bar')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('foo.bar')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('foo/bar')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_isNotPublishedError', () => {
|
||||||
|
test('returns true for HTTP 401 + compose-prefixed remainder', () => {
|
||||||
|
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for HTTP 401 with non-compose-prefixed remainder', () => {
|
||||||
|
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
expect(um._isNotPublishedError(err, 'nginx')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for non-401 errors', () => {
|
||||||
|
const err = new Error('network timeout after 10s');
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for malformed error or remainder', () => {
|
||||||
|
expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLatestImageDigest (mocked fetchWithReliability)', () => {
|
||||||
|
let originalFetch;
|
||||||
|
let originalFetchAuth;
|
||||||
|
let originalFetchRetry;
|
||||||
|
beforeEach(() => {
|
||||||
|
originalFetch = um.fetchWithReliability.bind(um);
|
||||||
|
originalFetchAuth = um.fetchAuthToken.bind(um);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
// Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token
|
||||||
|
// (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub).
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: {
|
||||||
|
'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"',
|
||||||
|
},
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' };
|
||||||
|
};
|
||||||
|
um.fetchAuthToken = async () => 'fake-token';
|
||||||
|
const { log } = require('../src/utils/logging');
|
||||||
|
const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {});
|
||||||
|
const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest');
|
||||||
|
expect(result).toBe(null);
|
||||||
|
|
||||||
|
// First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest
|
||||||
|
// NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest
|
||||||
|
const firstPath = calls[0].path;
|
||||||
|
expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest');
|
||||||
|
expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api');
|
||||||
|
|
||||||
|
// The 401 after auth should produce an INFO log about "no upstream"
|
||||||
|
// NOT an error log.
|
||||||
|
const infoMsgs = infoSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(infoMsgs).toContain('No upstream registry image — skipping update check');
|
||||||
|
const errorMsgs = errorSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
|
||||||
|
infoSpy.mockRestore();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official image (nginx) still probes library/nginx', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' };
|
||||||
|
}
|
||||||
|
return { statusCode: 200, headers: {}, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('nginx:latest');
|
||||||
|
expect(result).toBe('sha256:abc123');
|
||||||
|
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('library/nginx (explicit) probes library/nginx', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' };
|
||||||
|
}
|
||||||
|
return { statusCode: 200, headers: {}, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('library/nginx:latest');
|
||||||
|
expect(result).toBe('sha256:abc');
|
||||||
|
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest');
|
||||||
|
expect(result).toBe('sha256:ghcr');
|
||||||
|
expect(calls[0].hostname).toBe('ghcr.io');
|
||||||
|
expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
callCount += 1;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' },
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' };
|
||||||
|
};
|
||||||
|
um.fetchAuthToken = async () => 'fake-token';
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('myproj-myservice:latest');
|
||||||
|
expect(result).toBe(null);
|
||||||
|
// Probe targets the correct namespace (myproj/myservice), not library/.
|
||||||
|
const firstCall = await (async () => {
|
||||||
|
let p;
|
||||||
|
um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; };
|
||||||
|
await um.getLatestImageDigest('myproj-myservice:latest');
|
||||||
|
return p;
|
||||||
|
})();
|
||||||
|
expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
um.fetchWithReliability = originalFetch;
|
||||||
|
um.fetchAuthToken = originalFetchAuth;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -125,6 +125,239 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── DC-078: registry digest probe reliability hardening ──────────────────
|
||||||
|
// Verifies that getLatestImageDigest / getDockerHubDigest / getGhcrDigest /
|
||||||
|
// fetchWithReliability all apply the IPv4-only + timeout + transient-retry
|
||||||
|
// policy. Without these guards, the per-hour checkForUpdates() loop on DNS2
|
||||||
|
// surfaces AggregateError [ETIMEDOUT] in error.log because the container's
|
||||||
|
// /etc/resolv.conf returns AAAA records from Technitium whose IPv6 path to
|
||||||
|
// public registries (Docker Hub, ghcr.io) is intermittently unreachable.
|
||||||
|
describe('DC-078 registry reliability', () => {
|
||||||
|
// Use real timers — fetchWithReliability's retry uses setTimeout for
|
||||||
|
// backoff, which jest's fake timers would block indefinitely.
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_httpsRequestOnce sets family: 4 and timeout on the request options', async () => {
|
||||||
|
let capturedOptions = null;
|
||||||
|
const req = {
|
||||||
|
on: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
// Return a 200 immediately so the promise resolves cleanly.
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateManager._httpsRequestOnce({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' },
|
||||||
|
maxBodyBytes: 65536,
|
||||||
|
});
|
||||||
|
expect(capturedOptions).not.toBeNull();
|
||||||
|
expect(capturedOptions.family).toBe(4);
|
||||||
|
expect(capturedOptions.timeout).toBeGreaterThan(0);
|
||||||
|
expect(capturedOptions.method).toBe('GET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchWithReliability retries on transient ETIMEDOUT and eventually succeeds', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
// First attempt: emit ETIMEDOUT via the request 'error' event
|
||||||
|
const reqErr = new Error('request timeout');
|
||||||
|
reqErr.code = 'ETIMEDOUT';
|
||||||
|
const req = {
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||||
|
}),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
// Second attempt: 200 OK with a digest header
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:abc123def456' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateManager.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
});
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
expect(result.headers['docker-content-digest']).toBe('sha256:abc123def456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchWithReliability does NOT retry on non-transient HTTP errors', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
attempts += 1;
|
||||||
|
const res = {
|
||||||
|
statusCode: 500,
|
||||||
|
headers: {},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const result = await updateManager.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
});
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
expect(result.statusCode).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchWithReliability retries up to REGISTRY_MAX_RETRIES then throws', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
https.request.mockImplementation(() => {
|
||||||
|
attempts += 1;
|
||||||
|
const reqErr = new Error('connect ETIMEDOUT');
|
||||||
|
reqErr.code = 'ETIMEDOUT';
|
||||||
|
const req = {
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||||
|
}),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
await expect(updateManager.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
})).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
||||||
|
// 1 initial attempt + REGISTRY_MAX_RETRIES retries
|
||||||
|
expect(attempts).toBe(1 + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDockerHubDigest returns digest on 200', async () => {
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:hubdigest9999' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
||||||
|
expect(digest).toBe('sha256:hubdigest9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDockerHubDigest acquires bearer token on 401 then returns digest', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {
|
||||||
|
// First call to registry-1.docker.io returns 401 with WWW-Authenticate
|
||||||
|
const res = {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: {
|
||||||
|
'www-authenticate': 'Bearer realm="https://auth.example.com/token",service="registry.docker.io",scope="repository:library/nginx:pull"',
|
||||||
|
},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
} else if (calls === 2) {
|
||||||
|
// Second call: auth.example.com returns the token JSON
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'data') handler(Buffer.from(JSON.stringify({ token: 'jwt-token-xyz' })));
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
} else {
|
||||||
|
// Third call: registry-1.docker.io with Bearer header returns the digest
|
||||||
|
expect(options.headers['Authorization']).toBe('Bearer jwt-token-xyz');
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:autheddigest7777' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
}
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
||||||
|
expect(digest).toBe('sha256:autheddigest7777');
|
||||||
|
expect(calls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getGhcrDigest returns digest on 200', async () => {
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
expect(options.hostname).toBe('ghcr.io');
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:ghcrdigest1234' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getGhcrDigest('ghcr.io/some/repo', 'latest');
|
||||||
|
expect(digest).toBe('sha256:ghcrdigest1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLatestImageDigest returns null on transient errors after retries (registry unavailable)', async () => {
|
||||||
|
// Simulate a totally-down registry: every attempt fails with ETIMEDOUT.
|
||||||
|
// After REGISTRY_MAX_RETRIES the error propagates to getLatestImageDigest's
|
||||||
|
// catch arm, which logs and returns null (matches old behavior).
|
||||||
|
https.request.mockImplementation(() => {
|
||||||
|
const reqErr = new Error('connect ETIMEDOUT');
|
||||||
|
reqErr.code = 'ETIMEDOUT';
|
||||||
|
const req = {
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||||
|
}),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getLatestImageDigest('nginx:latest');
|
||||||
|
expect(digest).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseAuthHeader', () => {
|
describe('parseAuthHeader', () => {
|
||||||
it('parses Docker Hub Bearer auth header', () => {
|
it('parses Docker Hub Bearer auth header', () => {
|
||||||
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
|
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
|
||||||
@@ -481,7 +714,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { 'docker-content-digest': 'sha256:fromregistry' },
|
headers: { 'docker-content-digest': 'sha256:fromregistry' },
|
||||||
on: jest.fn()
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
@@ -495,7 +730,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
headers: {},
|
headers: {},
|
||||||
on: jest.fn()
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
@@ -504,6 +741,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects on https request error', async () => {
|
it('rejects on https request error', async () => {
|
||||||
|
// ECONNREFUSED is in REGISTRY_TRANSIENT_ERROR_CODES, so this would retry.
|
||||||
|
// Use a non-transient code (or no code) for the test to propagate.
|
||||||
|
jest.useRealTimers();
|
||||||
https.request.mockImplementation(() => {
|
https.request.mockImplementation(() => {
|
||||||
const req = { on: jest.fn(), end: jest.fn() };
|
const req = { on: jest.fn(), end: jest.fn() };
|
||||||
// Trigger error event asynchronously
|
// Trigger error event asynchronously
|
||||||
@@ -516,6 +756,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
|
|
||||||
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
|
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
|
||||||
.rejects.toThrow('connection refused');
|
.rejects.toThrow('connection refused');
|
||||||
|
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes library/ prefix for official images', async () => {
|
it('normalizes library/ prefix for official images', async () => {
|
||||||
@@ -525,7 +766,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { 'docker-content-digest': 'sha256:digest' },
|
headers: { 'docker-content-digest': 'sha256:digest' },
|
||||||
on: jest.fn()
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,67 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
|||||||
expect(offenders).toEqual([]);
|
expect(offenders).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => {
|
||||||
|
// DC-087 — the same rule, extended into __tests__. The api-code walk above
|
||||||
|
// skips __tests__, which let two mirrored health-handler test files keep a
|
||||||
|
// raw await-fetch caddy probe long after src/app.js moved to fetchT. On a
|
||||||
|
// host where the suite runs alongside a live Caddy admin (the prod box
|
||||||
|
// runs the full jest suite every 30 min via a cron adversarial check),
|
||||||
|
// that Origin-less raw fetch 403-spammed the Caddy journal (~700
|
||||||
|
// client-not-allowed error lines per day) while the tests still passed —
|
||||||
|
// checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors
|
||||||
|
// MUST call fetchT (mocked at buildApp scope for hermeticity). A raw
|
||||||
|
// await-fetch at a Caddy-admin-URL call site in a test is an offender.
|
||||||
|
// NOTE: keep this comment free of backticks — stripComments pairs
|
||||||
|
// backtick spans across lines, and a stray pair shields real code from
|
||||||
|
// the comment stripper (this test self-flagged its first draft).
|
||||||
|
//
|
||||||
|
// Detection is deliberately FILE-LEVEL, not call-window: the historical
|
||||||
|
// drift kept the fetch call itself token-free (the URL came from a
|
||||||
|
// caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL),
|
||||||
|
// so a call-window regex never fired. Any raw await-fetch in a file
|
||||||
|
// that also references the Caddy admin anywhere is an offender.
|
||||||
|
// Escape hatch for future tests that intentionally assert Origin-less
|
||||||
|
// 403 behavior against their own local listener: put the marker
|
||||||
|
// DC-087-ALLOW-RAW-FETCH in the file and it is skipped.
|
||||||
|
const testsRoot = path.join(__dirname);
|
||||||
|
const offenders = [];
|
||||||
|
const skipped = [];
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === 'node_modules') continue;
|
||||||
|
const p = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(p);
|
||||||
|
else if (entry.name.endsWith('.js')) {
|
||||||
|
const rawText = fs.readFileSync(p, 'utf8');
|
||||||
|
// Escape hatch (checked on RAW text so a comment marker works —
|
||||||
|
// comments are stripped below): a file carrying the
|
||||||
|
// DC-087-ALLOW-RAW-FETCH marker declares it intentionally
|
||||||
|
// raw-fetches the Caddy admin (e.g. asserting Origin-less 403
|
||||||
|
// against its own local listener). The guard file itself is
|
||||||
|
// always scanned (never skipped) so the hatch can't be used to
|
||||||
|
// blind this very test.
|
||||||
|
if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) {
|
||||||
|
skipped.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = stripComments(rawText);
|
||||||
|
const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text);
|
||||||
|
const hasRawAwaitFetch = /await\s+fetch\(/.test(text);
|
||||||
|
if (hasAdminToken && hasRawAwaitFetch) {
|
||||||
|
offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(testsRoot);
|
||||||
|
if (skipped.length) {
|
||||||
|
// Visibility for hatch use — shows up in jest output for reviewers.
|
||||||
|
console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', '));
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
|
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
|
||||||
const raw = fs.readFileSync(
|
const raw = fs.readFileSync(
|
||||||
path.join(__dirname, '../src/app.js'),
|
path.join(__dirname, '../src/app.js'),
|
||||||
@@ -127,9 +188,9 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
|||||||
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
|
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
|
||||||
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
|
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
|
||||||
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
|
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
|
||||||
// line / call site — the call must be `fetchT(...)`, not `await fetch(...)`.
|
// line / call site — the call must be fetchT(...), never a raw await of
|
||||||
// (We look backward because the URL sits inside the call's argument list,
|
// the global fetch. (We look backward because the URL sits inside the
|
||||||
// so the call site comes before the URL token.)
|
// call's argument list, so the call site comes before the URL token.)
|
||||||
const idx = raw.indexOf('srv0/listen');
|
const idx = raw.indexOf('srv0/listen');
|
||||||
const around = raw.substr(Math.max(0, idx - 400), 800);
|
const around = raw.substr(Math.max(0, idx - 400), 800);
|
||||||
expect(around).toMatch(/fetchT\(/);
|
expect(around).toMatch(/fetchT\(/);
|
||||||
|
|||||||
@@ -29,26 +29,10 @@ const platformPaths = require('../../platform-paths');
|
|||||||
const { createUserStore } = require('../../src/security/user-store');
|
const { createUserStore } = require('../../src/security/user-store');
|
||||||
const { createInviteStore } = require('../../src/security/invite-store');
|
const { createInviteStore } = require('../../src/security/invite-store');
|
||||||
const emailSender = require('../../src/auth/providers/email-sender');
|
const emailSender = require('../../src/auth/providers/email-sender');
|
||||||
|
const AuthProvider = require('../../src/auth/providers/base');
|
||||||
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
||||||
const { ok, successMessage } = require('../../src/utils/responses');
|
const { ok, successMessage } = require('../../src/utils/responses');
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's
|
|
||||||
* _resolvePublicUrl logic — kept duplicated (not extracted) because the two
|
|
||||||
* callers have slightly different link paths and the duplication is smaller
|
|
||||||
* than the abstraction would be.
|
|
||||||
*/
|
|
||||||
function _buildInviteUrl(req, siteConfig, token) {
|
|
||||||
if (siteConfig && siteConfig.publicBaseUrl) {
|
|
||||||
return siteConfig.publicBaseUrl.replace(/\/+$/, '') +
|
|
||||||
'/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept';
|
|
||||||
}
|
|
||||||
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
|
|
||||||
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|
|
||||||
|| (siteConfig && siteConfig.dashboardHost) || 'localhost:3001';
|
|
||||||
return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _requireAdmin(req, _res, next) {
|
function _requireAdmin(req, _res, next) {
|
||||||
if (!req.user || req.user.role !== 'admin') {
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
return next(new ForbiddenError('Admin role required'));
|
return next(new ForbiddenError('Admin role required'));
|
||||||
@@ -240,11 +224,21 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
});
|
});
|
||||||
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
||||||
|
|
||||||
let deliveredVia = 'none';
|
// Build the accept URL once — used both for the response and for email delivery.
|
||||||
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
const baseUrl = (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
||||||
if (sendEmail !== false) {
|
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
||||||
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
|
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
||||||
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
|
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001')));
|
||||||
|
const acceptUrl = baseUrl + '/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept';
|
||||||
|
|
||||||
|
// DC-085: link-first delivery. Default = no email, just hand the link back.
|
||||||
|
// Operators opt INTO email by sending { sendEmail: true } (or the admin UI
|
||||||
|
// checks the "Send email" checkbox). When SMTP is unconfigured AND the
|
||||||
|
// operator did opt in, we surface the failure as `deliveredVia: 'failed'`
|
||||||
|
// but NEVER leak the raw token into the server log — the link is already
|
||||||
|
// in the response, so the operator has a UI-side fallback.
|
||||||
|
let deliveredVia = 'manual';
|
||||||
|
if (sendEmail === true) {
|
||||||
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
||||||
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||||
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||||
@@ -254,33 +248,37 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
||||||
deliveredVia = 'email';
|
deliveredVia = 'email';
|
||||||
} else {
|
} else {
|
||||||
// Dev fallback — log the raw link so operators can grab it.
|
// Operator asked for email but SMTP isn't configured. Surface the
|
||||||
log.warn && log.warn('auth-invite-dev',
|
// failure cleanly; the link is still in the response so the
|
||||||
'[DC-048-DEV-INVITE-LINK] email=' + issued.email +
|
// operator can share it manually. Do NOT log the raw URL — it
|
||||||
' role=' + issued.role + ' url=' + acceptUrl);
|
// would duplicate what's already in the response and pollute the
|
||||||
deliveredVia = 'dev-console';
|
// server log on every unconfigured-install invite.
|
||||||
|
log.warn && log.warn('auth-invite-send',
|
||||||
|
'invite send skipped: SMTP not configured (operator opted in)',
|
||||||
|
{ inviteId: issued.id, email: AuthProvider.maskEmail(issued.email) || '[unmaskable-email]' });
|
||||||
|
deliveredVia = 'failed';
|
||||||
}
|
}
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
log.warn && log.warn('auth-invite-send',
|
log.warn && log.warn('auth-invite-send',
|
||||||
'invite send failed: ' + (sendErr.message || String(sendErr)));
|
'invite send failed: ' + (sendErr.message || String(sendErr)),
|
||||||
|
{ inviteId: issued.id });
|
||||||
deliveredVia = 'failed';
|
deliveredVia = 'failed';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
deliveredVia = 'manual';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
||||||
|
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
||||||
|
const shareText =
|
||||||
|
'Join my DashCaddy as ' + issued.role + ' — ' + acceptUrl +
|
||||||
|
' — expires in ' + ttlHoursOut + 'h.';
|
||||||
|
|
||||||
return ok(res, {
|
return ok(res, {
|
||||||
id: issued.id,
|
id: issued.id,
|
||||||
email: issued.email,
|
email: issued.email,
|
||||||
role: issued.role,
|
role: issued.role,
|
||||||
expiresAt: issued.expiresAt,
|
expiresAt: issued.expiresAt,
|
||||||
// The raw token is returned ONCE so the admin UI can show/copy the
|
acceptUrl,
|
||||||
// link. It is also embedded in the email when sendEmail !== false.
|
shareText,
|
||||||
acceptUrl: (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
|
||||||
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
|
||||||
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
|
||||||
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))) +
|
|
||||||
'/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept',
|
|
||||||
deliveredVia,
|
deliveredVia,
|
||||||
maskedEmail,
|
maskedEmail,
|
||||||
});
|
});
|
||||||
@@ -372,7 +370,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
|
|
||||||
log.info && log.info('auth', 'invite accepted, user created', {
|
log.info && log.info('auth', 'invite accepted, user created', {
|
||||||
userId: userResult.user.id,
|
userId: userResult.user.id,
|
||||||
email: userResult.user.email,
|
email: AuthProvider.maskEmail(userResult.user.email) || '[unmaskable-email]',
|
||||||
role: userResult.user.role,
|
role: userResult.user.role,
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module.exports = function(deps) {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Extract dependencies
|
// Extract dependencies
|
||||||
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps;
|
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;
|
||||||
|
|
||||||
// Create ctx-like object for compatibility
|
// Create ctx-like object for compatibility
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -126,7 +126,12 @@ module.exports = function(deps) {
|
|||||||
try {
|
try {
|
||||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
if (!username || !password) {
|
||||||
|
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
const service = await ctx.getServiceById(serviceId);
|
const service = await ctx.getServiceById(serviceId);
|
||||||
const baseUrl = service?.url;
|
const baseUrl = service?.url;
|
||||||
if (!baseUrl) throw new NotFoundError('No service URL');
|
if (!baseUrl) throw new NotFoundError('No service URL');
|
||||||
@@ -181,7 +186,12 @@ module.exports = function(deps) {
|
|||||||
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
if (!username || !password) {
|
||||||
|
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
||||||
if (appCookies) {
|
if (appCookies) {
|
||||||
@@ -203,8 +213,28 @@ module.exports = function(deps) {
|
|||||||
}
|
}
|
||||||
}, 'auth-app-token'));
|
}, 'auth-app-token'));
|
||||||
|
|
||||||
|
// A browser that already has a valid status.sami session must not be asked
|
||||||
|
// for TOTP again just because it opened another private-TLD service host.
|
||||||
|
// Mint a fresh one-time token that the target host can exchange for its own
|
||||||
|
// host-only cookie. This route is intentionally session-protected both by
|
||||||
|
// the global middleware and here (defence in depth).
|
||||||
|
router.get('/auth/sso-handoff', (req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (!session.isValid(req)) {
|
||||||
|
return errorResponse(res, 401, 'Session expired or invalid');
|
||||||
|
}
|
||||||
|
const serviceId = String(req.query.serviceId || '');
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) {
|
||||||
|
return errorResponse(res, 400, 'Valid serviceId is required');
|
||||||
|
}
|
||||||
|
const suffix = String(siteConfig?.tld || '.sami');
|
||||||
|
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||||
|
ok(res, { ssoToken: session.createHandoffToken(expectedHost) });
|
||||||
|
});
|
||||||
|
|
||||||
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
||||||
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
|
// (minted by /totp/verify or /auth/sso-handoff) for a HOST-ONLY session
|
||||||
|
// cookie on whichever
|
||||||
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
||||||
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
||||||
// browsers treat "sami" as the effective public suffix and refuse to set
|
// browsers treat "sami" as the effective public suffix and refuse to set
|
||||||
@@ -215,7 +245,9 @@ module.exports = function(deps) {
|
|||||||
router.get('/auth/sso-exchange', (req, res) => {
|
router.get('/auth/sso-exchange', (req, res) => {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
const token = req.query.token;
|
const token = req.query.token;
|
||||||
if (!session.redeemHandoffToken(token)) {
|
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
|
||||||
|
.split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
|
||||||
|
if (!session.redeemHandoffToken(token, forwardedHost)) {
|
||||||
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
||||||
}
|
}
|
||||||
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
||||||
@@ -237,7 +269,12 @@ module.exports = function(deps) {
|
|||||||
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
||||||
router.get('/auth/login-page', (req, res) => {
|
router.get('/auth/login-page', (req, res) => {
|
||||||
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
||||||
const html = buildLoginPage(service);
|
const configuredHost = siteConfig?.dashboardHost;
|
||||||
|
const dashboardOrigin = typeof configuredHost === 'string'
|
||||||
|
&& /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost)
|
||||||
|
? `https://${configuredHost}`
|
||||||
|
: 'https://status.sami';
|
||||||
|
const html = buildLoginPage(service, dashboardOrigin);
|
||||||
if (!html) return res.status(404).send('Unknown service');
|
if (!html) return res.status(404).send('Unknown service');
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -255,7 +292,7 @@ module.exports = function(deps) {
|
|||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildLoginPage(service) {
|
function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
|
||||||
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
||||||
// disabled or blocked. The cookie is sent automatically because we hit the
|
// disabled or blocked. The cookie is sent automatically because we hit the
|
||||||
// same origin (plex.sami); if the API returns 200 the user has a valid
|
// same origin (plex.sami); if the API returns 200 the user has a valid
|
||||||
@@ -266,7 +303,7 @@ function buildLoginPage(service) {
|
|||||||
<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>
|
<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>
|
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||||
<script>(function(){
|
<script>(function(){
|
||||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
|
||||||
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
// 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
|
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
||||||
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
||||||
@@ -274,13 +311,16 @@ function buildLoginPage(service) {
|
|||||||
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
||||||
// stale token exists in localStorage, instead of failing silently.
|
// stale token exists in localStorage, instead of failing silently.
|
||||||
function go(u){setTimeout(function(){location.replace(u)},300)}
|
function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||||
|
function authUrl(){return dashboardOrigin+'?auth=required&return='+encodeURIComponent(location.href)}
|
||||||
|
function authLink(label){return '<a href="'+authUrl()+'">'+label+'</a>'}
|
||||||
|
function vault(svc){go(dashboardOrigin+'?credentials='+encodeURIComponent(svc)+'&return='+encodeURIComponent(location.href))}
|
||||||
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
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 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 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()}]}))}
|
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
|
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
||||||
// within 15s, force-redirect to status.sami so the user can re-auth.
|
// 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);
|
var overallTimer=setTimeout(function(){go(authUrl())},15000);
|
||||||
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
||||||
// with this origin (Domain=.sami cookies are silently rejected by real
|
// with this origin (Domain=.sami cookies are silently rejected by real
|
||||||
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
||||||
@@ -305,18 +345,17 @@ function buildLoginPage(service) {
|
|||||||
preExchange.then(function(){
|
preExchange.then(function(){
|
||||||
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
|
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){
|
}).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}
|
if(!st||!st.success||!st.authenticated){go(authUrl());return}
|
||||||
${body}
|
${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'))})
|
}).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'Auth check error: '+(e&&e.message||'unknown'))})
|
||||||
})()</script></body></html>`;
|
})()</script></body></html>`;
|
||||||
|
|
||||||
const pages = {
|
const pages = {
|
||||||
chat: {
|
chat: {
|
||||||
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
||||||
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
body: `d.textContent='Fetching token from DashCaddy...';
|
||||||
d.textContent='Fetching token from DashCaddy...';
|
|
||||||
ft('chat').then(function(r){return r.text()}).then(function(t){
|
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}
|
try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}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
|
// 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))}
|
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(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
||||||
@@ -324,30 +363,29 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
|
|||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
||||||
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
body: `ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||||
ft('plex').then(function(r){return r.json()}).then(function(j){
|
if(j.credentialsRequired){vault('plex');return}if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
||||||
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:
|
// No token returned. Three fallbacks in priority order:
|
||||||
// 1. Stale token in localStorage — Plex may still accept it.
|
// 1. Stale token in localStorage — Plex may still accept it.
|
||||||
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||||
// 2. Manual link so the user is never trapped on this page.
|
// 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))
|
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'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'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
||||||
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
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/');return}
|
if(j.credentialsRequired){vault('jellyfin');return}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}
|
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))
|
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
emby: {
|
emby: {
|
||||||
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
||||||
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
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/');return}
|
if(j.credentialsRequired){vault('emby');return}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}
|
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))
|
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -357,5 +395,6 @@ ft('plex').then(function(r){return r.json()}).then(function(j){
|
|||||||
return SHELL(cfg.body)
|
return SHELL(cfg.body)
|
||||||
.replace(/__TITLE__/g, cfg.title)
|
.replace(/__TITLE__/g, cfg.title)
|
||||||
.replace('__BG__', cfg.bg)
|
.replace('__BG__', cfg.bg)
|
||||||
.replace('__ACCENT__', cfg.accent);
|
.replace('__ACCENT__', cfg.accent)
|
||||||
|
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
|
|||||||
* @param {Object} deps.log - Logger instance
|
* @param {Object} deps.log - Logger instance
|
||||||
* @returns {express.Router}
|
* @returns {express.Router}
|
||||||
*/
|
*/
|
||||||
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
|
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken, siteConfig }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Ctx shim for backward compatibility
|
// Ctx shim for backward compatibility
|
||||||
@@ -23,7 +23,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
credentialManager,
|
credentialManager,
|
||||||
totpConfig,
|
totpConfig,
|
||||||
saveTotpConfig,
|
saveTotpConfig,
|
||||||
session
|
session,
|
||||||
|
siteConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current TOTP config (public route)
|
// Get current TOTP config (public route)
|
||||||
@@ -193,11 +194,14 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
// Login: verify TOTP code and set session cookie
|
// Login: verify TOTP code and set session cookie
|
||||||
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
||||||
const { authenticator } = require('otplib');
|
const { authenticator } = require('otplib');
|
||||||
const { code } = req.body;
|
const { code, serviceId } = req.body;
|
||||||
|
|
||||||
if (!code || !/^\d{6}$/.test(code)) {
|
if (!code || !/^\d{6}$/.test(code)) {
|
||||||
throw new ValidationError('Invalid code format', 'code');
|
throw new ValidationError('Invalid code format', 'code');
|
||||||
}
|
}
|
||||||
|
if (serviceId != null && !/^[a-z0-9][a-z0-9-]*$/.test(String(serviceId))) {
|
||||||
|
throw new ValidationError('Invalid service ID', 'serviceId');
|
||||||
|
}
|
||||||
|
|
||||||
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
||||||
throw new ValidationError('TOTP is not enabled');
|
throw new ValidationError('TOTP is not enabled');
|
||||||
@@ -227,7 +231,12 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
// URL when bouncing the user back to a gated service. That service's
|
// 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
|
// login page exchanges it via /auth/sso-exchange for its own host-only
|
||||||
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
||||||
const ssoToken = ctx.session.createHandoffToken();
|
let ssoToken = null;
|
||||||
|
if (serviceId) {
|
||||||
|
const suffix = String(ctx.siteConfig?.tld || '.sami');
|
||||||
|
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||||
|
ssoToken = ctx.session.createHandoffToken(expectedHost);
|
||||||
|
}
|
||||||
|
|
||||||
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
||||||
|
|||||||
@@ -37,6 +37,81 @@ const BACKUP_FILES = [
|
|||||||
|
|
||||||
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
|
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
|
||||||
|
|
||||||
|
// DC-079: Restrict restored assets to the hardcoded ASSET_FILES allowlist.
|
||||||
|
// The asset KEYS in the snapshot are user-controlled JSON, so iterating
|
||||||
|
// `Object.entries(snapshot.assets)` and writing each name verbatim into
|
||||||
|
// `path.join(assetsDir, name)` lets an attacker POST `{assets: {"../../etc/caddy/Caddyfile":
|
||||||
|
// "<base64-evil>"}}` and overwrite the live Caddyfile via the bind-mount
|
||||||
|
// (path.join('/app/data/assets', '../../etc/caddy/Caddyfile') resolves
|
||||||
|
// to /etc/caddy/Caddyfile). This bypasses the caddyfile-staging gate
|
||||||
|
// above because the dataDir bind-mount can write to /etc/caddy on the host.
|
||||||
|
const ASSET_KEY_RE = /^[a-zA-Z0-9._-]+$/;
|
||||||
|
const ASSET_PATH_TRAVERSAL_RE = /(^|\/)\.\.($|\/)|^\//;
|
||||||
|
|
||||||
|
// DC-079: Caddyfile content safety limits for disaster-recovery restore.
|
||||||
|
// The live Caddyfile on DNS2 is ~17 KB and grows linearly with vhost count.
|
||||||
|
// Express's default JSON body parser limit (1 MB) is the outer gate; this
|
||||||
|
// in-handler cap is defense-in-depth against either a future body-limit
|
||||||
|
// raise or a custom body parser. Cap well below the body-parser ceiling.
|
||||||
|
const MAX_CADDYFILE_BYTES = 512 * 1024; // 512 KiB — 30x the live file, far below 1 MB body limit
|
||||||
|
|
||||||
|
// DC-079: theme filenames must match this pattern. No slashes (no path
|
||||||
|
// traversal), no `..`, must end in `.json`, and only filename-safe chars.
|
||||||
|
// Themes are written to <dataDir>/themes/<name>; we also defense-in-depth
|
||||||
|
// check the resolved path stays inside that dir.
|
||||||
|
const THEME_NAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
||||||
|
|
||||||
|
function assertSafeAssetKey(key) {
|
||||||
|
if (typeof key !== 'string' || key.length === 0 || key.length > 128) {
|
||||||
|
throw new Error(`asset key must be a non-empty string up to 128 chars`);
|
||||||
|
}
|
||||||
|
if (ASSET_PATH_TRAVERSAL_RE.test(key) || !ASSET_KEY_RE.test(key)) {
|
||||||
|
throw new Error(`asset key contains forbidden characters or path segments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeThemeName(name) {
|
||||||
|
if (typeof name !== 'string' || name.length === 0 || name.length > 128) {
|
||||||
|
throw new Error(`theme name must be a non-empty string up to 128 chars`);
|
||||||
|
}
|
||||||
|
if (!THEME_NAME_RE.test(name)) {
|
||||||
|
throw new Error(`theme name must match ${THEME_NAME_RE} (alphanum / dot / dash / underscore, ending in .json)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject Caddyfile content that smuggles in arbitrary `import` directives.
|
||||||
|
// caddy-apply expects the single top-level Caddyfile; any `import` to an
|
||||||
|
// absolute path means "load another file from disk at Caddy reload time" —
|
||||||
|
// that's a classic injection vector (an attacker can craft a snapshot whose
|
||||||
|
// `import /etc/caddy/external.caddy` reads any file Caddy can read).
|
||||||
|
// We allow the relative-style `import <snippet>` form ONLY if the snippet
|
||||||
|
// name matches a small allowlist of well-known Caddy snippet names (none
|
||||||
|
// today; add explicit names if a future snippet module is needed).
|
||||||
|
const FORBIDDEN_IMPORT_RE = /^\s*import\s+(["']|\/|\.\.|~\/|%[A-F0-9]{2})/im;
|
||||||
|
|
||||||
|
function validateCaddyfileContent(content) {
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
return { ok: false, error: 'Caddyfile content must be a string' };
|
||||||
|
}
|
||||||
|
if (content.length === 0) {
|
||||||
|
return { ok: false, error: 'Caddyfile content is empty' };
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(content, 'utf8') > MAX_CADDYFILE_BYTES) {
|
||||||
|
return { ok: false, error: `Caddyfile content exceeds ${MAX_CADDYFILE_BYTES} bytes` };
|
||||||
|
}
|
||||||
|
if (FORBIDDEN_IMPORT_RE.test(content)) {
|
||||||
|
// Allow the canonical single-quoted snippet import form ONLY if the
|
||||||
|
// snippet name is on the explicit allowlist (currently empty). This
|
||||||
|
// catches absolute paths, ../, ~/, and URL-encoded payloads while
|
||||||
|
// leaving room for future snippet additions without touching this gate.
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Caddyfile contains forbidden `import` directive (absolute path, encoded, or non-allowlisted snippet)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
|
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
|
||||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -44,6 +119,15 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
||||||
let lastRestoreStatus = { timestamp: null, status: null };
|
let lastRestoreStatus = { timestamp: null, status: null };
|
||||||
|
|
||||||
|
// DC-079: Staging dir for the candidate Caddyfile. The disaster-recovery
|
||||||
|
// restore endpoint stages here instead of writing directly to the live
|
||||||
|
// Caddyfile path. The operator must run `caddy-apply` (or its equivalent)
|
||||||
|
// to validate + reload + git-commit the staged file. This keeps the live
|
||||||
|
// Caddyfile under the same atomic-commit guard as every other edit.
|
||||||
|
function getStagedCaddyfileDir(dataDir) {
|
||||||
|
return path.join(dataDir, 'disaster-staged');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/disaster/backup
|
* POST /api/v1/disaster/backup
|
||||||
* Creates a complete system snapshot as a downloadable JSON file.
|
* Creates a complete system snapshot as a downloadable JSON file.
|
||||||
@@ -175,13 +259,64 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore Caddyfile
|
// DC-079: Stage the Caddyfile to a staging path inside dataDir
|
||||||
if (snapshot.caddyfile) {
|
// instead of writing directly to caddyfilePath (which is the LIVE
|
||||||
|
// /etc/caddy/Caddyfile bind-mounted into the container as /caddyfile).
|
||||||
|
//
|
||||||
|
// Threat model (defense-in-depth, mirrors DC-070 / DC-074 / DC-076):
|
||||||
|
// the endpoint is TOTP-gated, but a compromised operator / phished
|
||||||
|
// session / pivot path could POST a snapshot with `caddyfile: <evil>`
|
||||||
|
// and the pre-fix code would call `fsp.writeFile(caddyfilePath, ...)`
|
||||||
|
// which writes the attacker-controlled string straight to the live
|
||||||
|
// Caddyfile. Caddy then reads that file on the next reload (which can
|
||||||
|
// be triggered by ACME renewals, health probes, or any admin API
|
||||||
|
// touch), executing whatever directives the attacker embedded:
|
||||||
|
// - `admin off` + arbitrary config write
|
||||||
|
// - `import /etc/caddy/<anything-caddy-can-read>` for content theft
|
||||||
|
// - `reverse_proxy` to attacker-controlled upstreams
|
||||||
|
// - `acme_ca` override to attacker CA
|
||||||
|
// - `log` directives to attacker-writable paths
|
||||||
|
//
|
||||||
|
// The Caddyfile is managed by the `caddy-apply` wrapper (validates +
|
||||||
|
// reloads + git-commits atomically — see CLAUDE.md hard rule). This
|
||||||
|
// endpoint previously bypassed that wrapper. The fix stages the
|
||||||
|
// candidate file under dataDir/disaster-staged/Caddyfile.candidate and
|
||||||
|
// returns the path so the operator can apply it via the normal flow.
|
||||||
|
const caddyfileStaged = [];
|
||||||
|
// DC-079: handle three cases for the caddyfile field:
|
||||||
|
// - absent/null/undefined: back-compat — no Caddyfile in snapshot
|
||||||
|
// - empty string "": explicit empty payload is suspicious — reject
|
||||||
|
// - non-string (object/array/number): type confusion attempt — reject
|
||||||
|
// - valid string: stage to dataDir/disaster-staged/Caddyfile.candidate
|
||||||
|
if (snapshot.caddyfile !== undefined && snapshot.caddyfile !== null) {
|
||||||
|
const validation = validateCaddyfileContent(snapshot.caddyfile);
|
||||||
|
if (!validation.ok) {
|
||||||
|
return errorResponse(res, 400, `Invalid Caddyfile in snapshot: ${validation.error}`, {
|
||||||
|
code: ErrorCodes.BACKUP.INVALID_CONFIG,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedDir = getStagedCaddyfileDir(dataDir);
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(caddyfilePath, snapshot.caddyfile);
|
await fsp.mkdir(stagedDir, { recursive: true });
|
||||||
restored.push('Caddyfile');
|
const stagedPath = path.join(stagedDir, 'Caddyfile.candidate');
|
||||||
|
// Atomic write: write to .candidate.tmp then rename. The live
|
||||||
|
// Caddyfile is NEVER touched from this endpoint.
|
||||||
|
const tmpPath = stagedPath + '.tmp';
|
||||||
|
await fsp.writeFile(tmpPath, snapshot.caddyfile, { mode: 0o644 });
|
||||||
|
await fsp.rename(tmpPath, stagedPath);
|
||||||
|
caddyfileStaged.push({
|
||||||
|
file: 'Caddyfile',
|
||||||
|
stagedPath,
|
||||||
|
action: 'awaiting caddy-apply',
|
||||||
|
livePath: caddyfilePath,
|
||||||
|
});
|
||||||
|
if (log) log.info('disaster-recovery', 'Caddyfile staged (not applied)', {
|
||||||
|
stagedPath,
|
||||||
|
size: Buffer.byteLength(snapshot.caddyfile, 'utf8'),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ file: 'Caddyfile', error: err.message });
|
errors.push({ file: 'Caddyfile (staging)', error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,8 +324,20 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
||||||
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
||||||
try {
|
try {
|
||||||
|
// DC-079: assets directory is the first attack surface that
|
||||||
|
// bypasses the Caddyfile-staging gate. `name` is a user-supplied
|
||||||
|
// JSON key; without validation, `path.join(assetsDir, name)` lets
|
||||||
|
// an attacker escape to /etc/caddy via path traversal.
|
||||||
|
assertSafeAssetKey(name);
|
||||||
|
const resolved = path.resolve(assetsDir, name);
|
||||||
|
// Defense-in-depth: even after charset checks, the resolved path
|
||||||
|
// MUST stay inside assetsDir. If it doesn't, refuse the write.
|
||||||
|
if (!resolved.startsWith(path.resolve(assetsDir) + path.sep) &&
|
||||||
|
resolved !== path.resolve(assetsDir)) {
|
||||||
|
throw new Error(`asset path resolves outside assets directory`);
|
||||||
|
}
|
||||||
await fsp.mkdir(assetsDir, { recursive: true });
|
await fsp.mkdir(assetsDir, { recursive: true });
|
||||||
await fsp.writeFile(path.join(assetsDir, name), Buffer.from(base64, 'base64'));
|
await fsp.writeFile(resolved, Buffer.from(base64, 'base64'));
|
||||||
restored.push(`assets/${name}`);
|
restored.push(`assets/${name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ file: `assets/${name}`, error: err.message });
|
errors.push({ file: `assets/${name}`, error: err.message });
|
||||||
@@ -203,8 +350,21 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
try {
|
try {
|
||||||
await fsp.mkdir(themesDir, { recursive: true });
|
await fsp.mkdir(themesDir, { recursive: true });
|
||||||
for (const [name, content] of Object.entries(snapshot.themes)) {
|
for (const [name, content] of Object.entries(snapshot.themes)) {
|
||||||
await fsp.writeFile(path.join(themesDir, name), JSON.stringify(content, null, 2));
|
// DC-079: same path-traversal vector as assets — keys are
|
||||||
restored.push(`themes/${name}`);
|
// user-controlled JSON. Validate the name AND confirm the
|
||||||
|
// resolved path stays inside themesDir.
|
||||||
|
try {
|
||||||
|
assertSafeThemeName(name);
|
||||||
|
const resolved = path.resolve(themesDir, name);
|
||||||
|
if (!resolved.startsWith(path.resolve(themesDir) + path.sep) &&
|
||||||
|
resolved !== path.resolve(themesDir)) {
|
||||||
|
throw new Error(`theme path resolves outside themes directory`);
|
||||||
|
}
|
||||||
|
await fsp.writeFile(resolved, JSON.stringify(content, null, 2));
|
||||||
|
restored.push(`themes/${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ file: `themes/${name}`, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ file: 'themes', error: err.message });
|
errors.push({ file: 'themes', error: err.message });
|
||||||
@@ -215,19 +375,33 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: errors.length === 0 ? 'success' : 'partial',
|
status: errors.length === 0 ? 'success' : 'partial',
|
||||||
restored: restored.length,
|
restored: restored.length,
|
||||||
|
staged: caddyfileStaged.length,
|
||||||
errors: errors.length,
|
errors: errors.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
|
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
|
||||||
|
|
||||||
ok(res, {
|
// DC-079: Surface the staged-Caddyfile warning in the response body so
|
||||||
|
// the UI / operator can see that the Caddyfile is NOT yet live. The
|
||||||
|
// restore endpoint stages under dataDir/disaster-staged/Caddyfile.candidate
|
||||||
|
// and the operator must run `caddy-apply` (or its equivalent) to
|
||||||
|
// validate + reload + git-commit the staged file. The live Caddyfile
|
||||||
|
// is owned by the caddy-apply wrapper per CLAUDE.md hard rule.
|
||||||
|
const responseBody = {
|
||||||
status: errors.length === 0 ? 'success' : 'partial',
|
status: errors.length === 0 ? 'success' : 'partial',
|
||||||
restored,
|
restored,
|
||||||
errors,
|
errors,
|
||||||
message: errors.length === 0
|
message: errors.length === 0
|
||||||
? `Successfully restored ${restored.length} files. Restart DashCaddy to apply.`
|
? `Successfully restored ${restored.length} files${caddyfileStaged.length > 0 ? ` (Caddyfile staged — ${caddyfileStaged[0].stagedPath}; run caddy-apply to apply)` : ''}. Restart DashCaddy to apply.`
|
||||||
: `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
|
: `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (caddyfileStaged.length > 0) {
|
||||||
|
responseBody.caddyfileStaged = caddyfileStaged;
|
||||||
|
responseBody.warning = '[DC-079] Caddyfile is STAGED, not applied. Live /etc/caddy/Caddyfile was NOT modified by this restore. Run `caddy-apply <reason>` (or equivalent) to validate + reload + git-commit the staged candidate.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, responseBody);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* DC-081: Plain-English log insights + dispose endpoint
|
||||||
|
*
|
||||||
|
* GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||||
|
* POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||||
|
*
|
||||||
|
* DC-081 hardening (paired with the deploy path fix):
|
||||||
|
* - AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE were HARDCODED to
|
||||||
|
* `/opt/dashcaddy/dashcaddy-api/data/...` which DOES NOT EXIST in the
|
||||||
|
* production container — files live at `/app/data/...`. The dispose
|
||||||
|
* endpoint silently no-op'd (read empty arrays, wrote empty arrays
|
||||||
|
* back) and the GET endpoint dropped the storage-size block. Both
|
||||||
|
* paths now use the same canonical resolution as the audit-logger
|
||||||
|
* itself: `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`.
|
||||||
|
* - keepDays was unbounded — `parseInt(req.body.keepDays) || 30` accepted
|
||||||
|
* negative numbers (e.g. -1000 → cutoff = +3 years in the future,
|
||||||
|
* deleting 100% of forensic context) and non-integers (Infinity,
|
||||||
|
* floats). Now validated to an integer in [1, 3650] (1 day .. 10 years)
|
||||||
|
* before any file read.
|
||||||
|
* - confirm gate added: must send { confirm: true, keepDays: N } — the
|
||||||
|
* preview pass is read-only, the confirm pass writes. Matches the
|
||||||
|
* audit-logs/DELETE confirm=CLEAR pattern.
|
||||||
|
* - The dispose handler now uses a single shared `_resolvePaths()` helper
|
||||||
|
* to keep GET and POST in lockstep (and so a future path-config change
|
||||||
|
* touches one site, not four).
|
||||||
|
*
|
||||||
|
* Pre-DC-081 verification: from inside the running container, both
|
||||||
|
* `/app/data/audit-log.json` (318 KB) and `/app/data/security-events.jsonl`
|
||||||
|
* (15 MB) exist, but the old hardcoded `/opt/dashcaddy/dashcaddy-api/data/...`
|
||||||
|
* paths resolve to ENOENT. The dispose endpoint therefore did nothing;
|
||||||
|
* this fix wires it back to the actual files.
|
||||||
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the canonical paths for the audit log + security event log.
|
||||||
|
*
|
||||||
|
* Both store the file path in their own module-level constants, so any
|
||||||
|
* environment override (e.g. AUDIT_LOG_FILE=...) is honoured here too —
|
||||||
|
* exactly the same behaviour as src/security/audit-logger.js and
|
||||||
|
* src/security/event-store.js. Without this, a container with
|
||||||
|
* AUDIT_LOG_FILE set would see the dispose handler read from one file
|
||||||
|
* and the audit-logger write to a different one.
|
||||||
|
*
|
||||||
|
* @returns {{auditPath: string, secPath: string, auditPathFrom: string, secPathFrom: string}}
|
||||||
|
* paths + the source ("env" or "default") so tests can verify.
|
||||||
|
*/
|
||||||
|
function _resolvePaths() {
|
||||||
|
const auditPath = process.env.AUDIT_LOG_FILE
|
||||||
|
|| path.join(platformPaths.dataDir, 'audit-log.json');
|
||||||
|
const secPath = process.env.SECURITY_EVENT_LOG_FILE
|
||||||
|
|| path.join(platformPaths.dataDir, 'security-events.jsonl');
|
||||||
|
return {
|
||||||
|
auditPath,
|
||||||
|
secPath,
|
||||||
|
auditPathFrom: process.env.AUDIT_LOG_FILE ? 'env' : 'default',
|
||||||
|
secPathFrom: process.env.SECURITY_EVENT_LOG_FILE ? 'env' : 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the keepDays input. Coerces + bounds-checks BEFORE any file
|
||||||
|
* read so a malicious or mistyped client can't:
|
||||||
|
* - pass a negative number (cutoff = far future → wipe 100%)
|
||||||
|
* - pass Infinity (parseInt(Infinity, 10) === NaN, currently falls
|
||||||
|
* through `|| 30` — fixed to fail-fast instead)
|
||||||
|
* - pass a non-integer (e.g. 1.5 → cutoff mid-day, off-by-half-day)
|
||||||
|
* - pass 0 (no-op-but-lies) or 10000 (way past retention policy)
|
||||||
|
*
|
||||||
|
* @param {unknown} raw - value from req.body.keepDays
|
||||||
|
* @returns {number} validated integer in [1, 3650]
|
||||||
|
* @throws {Error} when out of range / wrong type
|
||||||
|
*/
|
||||||
|
function _validateKeepDays(raw) {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
throw new Error('keepDays is required (integer in [1, 3650])');
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
throw new Error(`keepDays must be a finite number (received ${JSON.stringify(raw)})`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(n)) {
|
||||||
|
throw new Error(`keepDays must be an integer (received ${raw})`);
|
||||||
|
}
|
||||||
|
if (n < 1 || n > 3650) {
|
||||||
|
throw new Error(`keepDays must be between 1 and 3650 (received ${n})`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
// Resolve once at module init so GET + POST both use the same files.
|
||||||
|
// If the env vars change at runtime (rare — start.sh wires them at
|
||||||
|
// container start), operators re-deploy rather than mutate env mid-flight.
|
||||||
|
const { auditPath, secPath } = _resolvePaths();
|
||||||
|
|
||||||
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||||
router.get('/log-insights', asyncHandler(async (req, res) => {
|
router.get('/log-insights', asyncHandler(async (req, res) => {
|
||||||
@@ -74,16 +169,18 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Storage info ---
|
// --- Storage info ---
|
||||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
// DC-081: read from the canonical resolved paths (NOT the hardcoded
|
||||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
// /opt/... paths that don't exist in the container). Empty-object
|
||||||
|
// fallback on ENOENT — the file may legitimately be absent on a
|
||||||
|
// fresh install where the audit-logger hasn't written yet.
|
||||||
let storage = {};
|
let storage = {};
|
||||||
try {
|
try {
|
||||||
const a = await fs.stat(auditPath);
|
const a = await fs.stat(auditPath);
|
||||||
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
|
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length, path: auditPath };
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const s = await fs.stat(secPath);
|
const s = await fs.stat(secPath);
|
||||||
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
|
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length, path: secPath };
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
ok(res, {
|
ok(res, {
|
||||||
@@ -108,16 +205,44 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||||
|
//
|
||||||
|
// Two-call pattern:
|
||||||
|
// 1. { keepDays: 30 } → preview, no writes
|
||||||
|
// 2. { keepDays: 30, confirm: true } → actually delete
|
||||||
|
//
|
||||||
|
// DC-081 hardening:
|
||||||
|
// - keepDays is validated to integer [1, 3650] BEFORE any file read.
|
||||||
|
// A negative keepDays (e.g. -1000) would previously compute a
|
||||||
|
// cutoff +3 years in the future, then delete every entry older
|
||||||
|
// than that — i.e. 100% of the audit log. Now rejected at the gate.
|
||||||
|
// - auditPath / secPath come from the canonical _resolvePaths() helper
|
||||||
|
// so the container's actual /app/data files are read (the pre-fix
|
||||||
|
// hardcoded /opt/dashcaddy/dashcaddy-api/data/... paths resolved to
|
||||||
|
// ENOENT inside the container, so the endpoint silently did nothing).
|
||||||
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
||||||
const keepDays = parseInt(req.body.keepDays) || 30;
|
// Validate keepDays first — fail-fast before any file IO so a bad
|
||||||
|
// client never touches disk.
|
||||||
|
let keepDays;
|
||||||
|
try {
|
||||||
|
keepDays = _validateKeepDays(req.body?.keepDays);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ success: false, error: e.message, code: 'DC-081_INVALID_KEEP_DAYS' });
|
||||||
|
}
|
||||||
const confirm = req.body.confirm === true;
|
const confirm = req.body.confirm === true;
|
||||||
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
||||||
|
|
||||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
// Read both files via the canonical resolved paths (NOT the hardcoded
|
||||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
// /opt/... paths from before — those don't exist in the container).
|
||||||
|
|
||||||
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
||||||
const auditData = JSON.parse(auditRaw);
|
let auditData;
|
||||||
|
try {
|
||||||
|
auditData = JSON.parse(auditRaw);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ success: false, error: `audit-log file is corrupt (${auditPath}): ${e.message}`, code: 'DC-081_AUDIT_PARSE_FAILED' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(auditData)) {
|
||||||
|
return res.status(500).json({ success: false, error: `audit-log file is not an array (${auditPath})`, code: 'DC-081_AUDIT_SHAPE_INVALID' });
|
||||||
|
}
|
||||||
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
||||||
|
|
||||||
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
||||||
@@ -127,16 +252,40 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
if (!confirm) {
|
if (!confirm) {
|
||||||
ok(res, {
|
ok(res, {
|
||||||
preview: true,
|
preview: true,
|
||||||
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
|
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true, keepDays: ' + keepDays + '} to proceed.',
|
||||||
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
cutoffDate: cutoff
|
cutoffDate: cutoff,
|
||||||
|
paths: { auditPath, secPath },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute cleanup
|
// Execute cleanup. Audit the wipe FIRST via the audit-logger so the
|
||||||
|
// fact that a delete happened is itself preserved (matches the
|
||||||
|
// audit-logs/DELETE + error-logs/DELETE pattern).
|
||||||
|
try {
|
||||||
|
if (auditLogger && typeof auditLogger.log === 'function') {
|
||||||
|
await auditLogger.log({
|
||||||
|
action: 'log-insights.dispose',
|
||||||
|
resource: 'audit-log,security-events',
|
||||||
|
outcome: 'success',
|
||||||
|
details: {
|
||||||
|
keepDays,
|
||||||
|
cutoff,
|
||||||
|
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* don't fail the dispose on audit-side errors */ }
|
||||||
|
|
||||||
|
// Rewrite audit-log.json atomically — write to tmp + rename so a
|
||||||
|
// crash mid-write can't leave the file half-empty (the file is read
|
||||||
|
// by state-manager on every container start; a corrupt file would
|
||||||
|
// block the whole API).
|
||||||
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
||||||
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
|
const tmpAudit = auditPath + '.tmp';
|
||||||
|
await fs.writeFile(tmpAudit, JSON.stringify(keptAudit, null, 2));
|
||||||
|
await fs.rename(tmpAudit, auditPath);
|
||||||
|
|
||||||
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
||||||
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
||||||
@@ -145,9 +294,16 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
disposed: true,
|
disposed: true,
|
||||||
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
||||||
cutoffDate: cutoff
|
cutoffDate: cutoff,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DC-081: export helpers for direct unit testing (the route handlers are
|
||||||
|
// otherwise unreachable from outside the factory closure).
|
||||||
|
module.exports.__test = {
|
||||||
|
_resolvePaths,
|
||||||
|
_validateKeepDays,
|
||||||
|
};
|
||||||
@@ -40,7 +40,15 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
enabled: notificationConfig.providers.email?.enabled || false,
|
enabled: notificationConfig.providers.email?.enabled || false,
|
||||||
configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to),
|
configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to),
|
||||||
host: notificationConfig.providers.email?.host || '',
|
host: notificationConfig.providers.email?.host || '',
|
||||||
from: notificationConfig.providers.email?.from || ''
|
from: notificationConfig.providers.email?.from || '',
|
||||||
|
// DC-092: the settings UI needs these to roundtrip the form.
|
||||||
|
// Password is NEVER returned; hasPassword lets the UI show a
|
||||||
|
// "leave blank to keep" hint instead of an empty-looking field.
|
||||||
|
port: notificationConfig.providers.email?.port || 587,
|
||||||
|
secure: notificationConfig.providers.email?.secure === true,
|
||||||
|
to: notificationConfig.providers.email?.to || '',
|
||||||
|
username: notificationConfig.providers.email?.username || '',
|
||||||
|
hasPassword: !!notificationConfig.providers.email?.password
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: notificationConfig.events,
|
events: notificationConfig.events,
|
||||||
@@ -54,6 +62,39 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
const { enabled, providers, events, healthCheck } = req.body;
|
const { enabled, providers, events, healthCheck } = req.body;
|
||||||
const notificationConfig = notification.getConfig();
|
const notificationConfig = notification.getConfig();
|
||||||
|
|
||||||
|
// DC-092: clients have historically sent at least three field spellings:
|
||||||
|
// the settings UI sends email.user/email.pass (its input ids are
|
||||||
|
// email-user/email-pass) while the manager/route read username/password.
|
||||||
|
// Normalize aliases onto the canonical keys BEFORE the merge so SMTP auth
|
||||||
|
// actually applies for UI-saved configs.
|
||||||
|
if (providers?.email) {
|
||||||
|
if (providers.email.user !== undefined && providers.email.username === undefined) {
|
||||||
|
providers.email.username = providers.email.user;
|
||||||
|
}
|
||||||
|
if (providers.email.pass !== undefined && providers.email.password === undefined) {
|
||||||
|
providers.email.password = providers.email.pass;
|
||||||
|
}
|
||||||
|
delete providers.email.user;
|
||||||
|
delete providers.email.pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DC-092 strict boolean contract: enabled/secure must be actual
|
||||||
|
// booleans. `"false"` (string) is truthy — !!"false" === true — and
|
||||||
|
// previously persisted as-is, silently forcing TLS on the next send.
|
||||||
|
// Reject instead of coercing.
|
||||||
|
const boolOrThrow = (val, label) => {
|
||||||
|
if (val === undefined) return;
|
||||||
|
if (typeof val !== 'boolean') {
|
||||||
|
throw new ValidationError(`${label} must be a boolean (got ${typeof val})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
boolOrThrow(enabled, 'enabled');
|
||||||
|
boolOrThrow(providers?.discord?.enabled, 'providers.discord.enabled');
|
||||||
|
boolOrThrow(providers?.telegram?.enabled, 'providers.telegram.enabled');
|
||||||
|
boolOrThrow(providers?.ntfy?.enabled, 'providers.ntfy.enabled');
|
||||||
|
boolOrThrow(providers?.email?.enabled, 'providers.email.enabled');
|
||||||
|
boolOrThrow(providers?.email?.secure, 'providers.email.secure');
|
||||||
|
|
||||||
// Validate provider webhook URLs and tokens
|
// Validate provider webhook URLs and tokens
|
||||||
if (providers) {
|
if (providers) {
|
||||||
if (providers.discord?.webhookUrl) {
|
if (providers.discord?.webhookUrl) {
|
||||||
@@ -96,6 +137,12 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
throw new ValidationError('Invalid SMTP host');
|
throw new ValidationError('Invalid SMTP host');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providers.email?.port !== undefined) {
|
||||||
|
const p = Number(providers.email.port);
|
||||||
|
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
||||||
|
throw new ValidationError('SMTP port must be an integer 1-65535');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update enabled state
|
// Update enabled state
|
||||||
@@ -124,16 +171,50 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (providers.email) {
|
if (providers.email) {
|
||||||
|
// Non-destructive merge: an empty-string username/password from the
|
||||||
|
// UI (password field is intentionally left blank to keep stored
|
||||||
|
// credentials) must NOT clobber the stored credential.
|
||||||
|
const stored = notificationConfig.providers.email;
|
||||||
|
const incoming = { ...providers.email };
|
||||||
|
if (incoming.password === '') delete incoming.password;
|
||||||
|
if (incoming.username === '') delete incoming.username;
|
||||||
notificationConfig.providers.email = {
|
notificationConfig.providers.email = {
|
||||||
...notificationConfig.providers.email,
|
...stored,
|
||||||
...providers.email
|
...incoming
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update events
|
// Update events. DC-092: the UI sends camelCase keys (containerDown);
|
||||||
|
// the canonical store/gate keys are kebab-case (container-down). Fold
|
||||||
|
// before merging so UI toggles actually reach the keys the send() gate
|
||||||
|
// reads. Values must be booleans; unknown keys pass through unchanged
|
||||||
|
// (canonicalized if known alias) and merge over defaults.
|
||||||
if (events) {
|
if (events) {
|
||||||
notificationConfig.events = { ...notificationConfig.events, ...events };
|
const EVENT_KEY_ALIASES = {
|
||||||
|
containerDown: 'container-down',
|
||||||
|
containerUp: 'container-up',
|
||||||
|
deploymentSuccess: 'deploy-success',
|
||||||
|
deploymentFailed: 'deploy-failed',
|
||||||
|
deploySuccess: 'deploy-success',
|
||||||
|
deployFailed: 'deploy-failed',
|
||||||
|
resourceAlert: 'alert',
|
||||||
|
updateAvailable: 'update-available',
|
||||||
|
backupComplete: 'backup-complete',
|
||||||
|
backupFailed: 'backup-failed',
|
||||||
|
autoRestart: 'auto-restart',
|
||||||
|
};
|
||||||
|
const folded = {};
|
||||||
|
for (const [k, v] of Object.entries(events)) {
|
||||||
|
const canonicalKey = EVENT_KEY_ALIASES[k] || k;
|
||||||
|
folded[canonicalKey] = v;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(folded)) {
|
||||||
|
if (typeof v !== 'boolean') {
|
||||||
|
throw new ValidationError(`events.${k} must be a boolean (got ${typeof v})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notificationConfig.events = { ...notificationConfig.events, ...folded };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update health check settings
|
// Update health check settings
|
||||||
|
|||||||
@@ -263,9 +263,10 @@ module.exports = function({
|
|||||||
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||||
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
|
const password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
success(res, {
|
success(res, {
|
||||||
hasApiKey: !!(arrKey || svcKey),
|
hasApiKey: !!(arrKey || svcKey),
|
||||||
hasBasicAuth: !!username,
|
hasBasicAuth: !!username && !!password,
|
||||||
username: username || null
|
username: username || null
|
||||||
});
|
});
|
||||||
}, 'service-creds'));
|
}, 'service-creds'));
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||||
const { PaymentRequiredError } = require('../src/utilities/errors');
|
const { PaymentRequiredError } = require('../src/utilities/errors');
|
||||||
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
|
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
|
||||||
|
// DC-083: route-layer validators for the public CSRF-exempt endpoints. These
|
||||||
|
// are imported from share-store so the route and store stay in lockstep
|
||||||
|
// (drift risk if one set is updated and the other is forgotten).
|
||||||
|
const { validatePublicEmail, validatePublicDeviceId } = require('../src/security/share-store');
|
||||||
|
|
||||||
const PUBLIC_TTL_OPTIONS = new Set([
|
const PUBLIC_TTL_OPTIONS = new Set([
|
||||||
60 * 60 * 1000,
|
60 * 60 * 1000,
|
||||||
@@ -293,7 +297,35 @@ module.exports = function shareRoutesFactory({
|
|||||||
|
|
||||||
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
|
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
|
||||||
|
|
||||||
router.get('/share/:token/preview', asyncHandler(async (req, res) => {
|
// DC-083: rate-limit the two CSRF-exempt public endpoints. The general
|
||||||
|
// limiter (1000/15min) is mounted globally in app.js and is too generous
|
||||||
|
// for unauthenticated state-mutating endpoints. 30/15min per IP is
|
||||||
|
// enough for a legitimate user clicking "subscribe" once or twice; anything
|
||||||
|
// beyond is abuse. Skipped in test envs via the standard isTest guard.
|
||||||
|
// Lazy-loaded so test environments without the dep installed don't blow up;
|
||||||
|
// a missing-dep in production logs a warning and falls back to no-op (still
|
||||||
|
// safe — the route+store validators are the primary defense).
|
||||||
|
const { RATE_LIMITS } = require('../src/utilities/constants');
|
||||||
|
const isTest = process.env.NODE_ENV === 'test';
|
||||||
|
let _sharePublicLimiter = (req, _res, next) => next(); // no-op default
|
||||||
|
try {
|
||||||
|
const rateLimit = require('express-rate-limit'); // eslint-disable-line global-require
|
||||||
|
_sharePublicLimiter = rateLimit({
|
||||||
|
...RATE_LIMITS.SHARE_PUBLIC,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: () => isTest,
|
||||||
|
message: { success: false, error: 'Too many share requests, please try again later' },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Don't crash on missing dep in a bare-bones env — but log so it's not
|
||||||
|
// invisible if production misconfigured.
|
||||||
|
if (log && typeof log.warn === 'function') {
|
||||||
|
log.warn({ ctx: 'share-routes', err: e.message }, 'express-rate-limit unavailable; share public endpoints have NO rate limit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/share/:token/preview', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||||
const meta = await shareStore.peek(req.params.token);
|
const meta = await shareStore.peek(req.params.token);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
|
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
|
||||||
@@ -310,12 +342,21 @@ module.exports = function shareRoutesFactory({
|
|||||||
});
|
});
|
||||||
}, 'share-preview'));
|
}, 'share-preview'));
|
||||||
|
|
||||||
router.post('/share/:token/subscribe', asyncHandler(async (req, res) => {
|
router.post('/share/:token/subscribe', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||||
|
// DC-083: replace the primitive `email.includes('@')` check with a
|
||||||
|
// charset/length/control-char-bounded validator. The pre-fix code
|
||||||
|
// accepted `@`, `a@`, `<script>@x.c`, and 10MB strings as "valid email".
|
||||||
|
// The subscribe body's `email` is now also captured to the share record
|
||||||
|
// (capped to last 8 entries, see share-store recordPublicSubscribe) so
|
||||||
|
// the operator can see who subscribed.
|
||||||
const { email } = req.body || {};
|
const { email } = req.body || {};
|
||||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
let normalizedEmail = null;
|
||||||
throw new ValidationError('valid email required', 'email');
|
if (email !== undefined && email !== null) {
|
||||||
|
const v = validatePublicEmail(email);
|
||||||
|
if (!v.ok) throw new ValidationError(v.reason, 'email');
|
||||||
|
normalizedEmail = v.email;
|
||||||
}
|
}
|
||||||
const result = await shareStore.recordPublicSubscribe(req.params.token);
|
const result = await shareStore.recordPublicSubscribe(req.params.token, { email: normalizedEmail });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||||
throw new ValidationError(result.reason, 'share');
|
throw new ValidationError(result.reason, 'share');
|
||||||
@@ -323,12 +364,23 @@ module.exports = function shareRoutesFactory({
|
|||||||
res.json({ success: true, data: { count: result.count, cap: result.cap } });
|
res.json({ success: true, data: { count: result.count, cap: result.cap } });
|
||||||
}, 'share-subscribe'));
|
}, 'share-subscribe'));
|
||||||
|
|
||||||
router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => {
|
router.post('/share/:token/redeem-tailscale', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||||
|
// DC-083: replace the bare `typeof deviceId === 'string'` check with a
|
||||||
|
// charset/length/control-char-bounded validator. The pre-fix code
|
||||||
|
// accepted arbitrary strings of any length — including CR/LF/NUL,
|
||||||
|
// which flow into the Tailscale auth-key description string in
|
||||||
|
// POST /share/tailscale (routes/share.js:213 in the issue path).
|
||||||
|
// The redeem-tailscale path receives the deviceId from Caddy's
|
||||||
|
// forward_auth (a Tailscale machine ID), which is base64url +
|
||||||
|
// hyphens — well within the validator's charset.
|
||||||
const { deviceId } = req.body || {};
|
const { deviceId } = req.body || {};
|
||||||
if (!deviceId || typeof deviceId !== 'string') {
|
let normalizedDeviceId = null;
|
||||||
throw new ValidationError('deviceId required', 'deviceId');
|
if (deviceId !== undefined && deviceId !== null) {
|
||||||
|
const v = validatePublicDeviceId(deviceId);
|
||||||
|
if (!v.ok) throw new ValidationError(v.reason, 'deviceId');
|
||||||
|
normalizedDeviceId = v.deviceId;
|
||||||
}
|
}
|
||||||
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId });
|
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId: normalizedDeviceId });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||||
throw new ValidationError(result.reason, 'share');
|
throw new ValidationError(result.reason, 'share');
|
||||||
|
|||||||
@@ -41,12 +41,124 @@
|
|||||||
*
|
*
|
||||||
* DELETE /api/v1/tailscale/admin/devices/:id
|
* DELETE /api/v1/tailscale/admin/devices/:id
|
||||||
* Revokes a device from the tailnet.
|
* Revokes a device from the tailnet.
|
||||||
|
*
|
||||||
|
* # DC-080 input validation
|
||||||
|
*
|
||||||
|
* Three coupled gaps in the route layer pre-fix:
|
||||||
|
*
|
||||||
|
* (a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but had
|
||||||
|
* no length cap — body-parser limit was the only ceiling. A 1 MB
|
||||||
|
* string starting with `tskey-api-` would be `.trim()`-ed, sent to
|
||||||
|
* Tailscale's /devices endpoint, and waste server-side CPU on a
|
||||||
|
* request that will always 401.
|
||||||
|
* (b) POST /settings/test accepted `apiToken` from the body with NO
|
||||||
|
* validation at all. The PUT route's prefix check is bypassed on
|
||||||
|
* the test path — an operator could submit any string and have the
|
||||||
|
* container ping Tailscale's API with it (low impact, but inconsistent
|
||||||
|
* with PUT and surfaces fingerprinting via the 401 timing).
|
||||||
|
* (c) POST /admin/keys validated `tags` as Array but NOT per-element
|
||||||
|
* type — `tags: ['tag:guest', null, 123, {injection: true}]` would be
|
||||||
|
* forwarded to Tailscale verbatim. Tailscale's API is JSON-strict
|
||||||
|
* and would 400 the request, but the bad shape reached the wire.
|
||||||
|
* Similarly `description` had no length cap (Tailscale caps at 120
|
||||||
|
* chars per their docs).
|
||||||
|
*
|
||||||
|
* All three are gated by TOTP — this is a logged-in-operator / phished-
|
||||||
|
* session threat surface, not anonymous-unauth. The fix is defense-in-
|
||||||
|
* depth: a bug in the auth path (TOTP bypass, session theft, future
|
||||||
|
* route handler trust-boundary drift) should not turn these endpoints
|
||||||
|
* into a "submit anything and forward to Tailscale" relay.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ok, errorResponse } = require('../src/utils/responses');
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
||||||
|
|
||||||
|
// DC-080: shared validation helpers for the Tailscale admin surface.
|
||||||
|
// Tailscale API tokens follow the form `tskey-<kind>-<opaque>` where
|
||||||
|
// `<kind>` is one of a small set of values (`api`, `auth`, `partner`,
|
||||||
|
// `cli`). Real tokens observed in the wild are 40..80 chars; we cap at
|
||||||
|
// 256 to leave headroom for future Tailscale key formats without giving
|
||||||
|
// an unbounded buffer to validate+forward.
|
||||||
|
const TAILSCALE_TOKEN_PREFIX = 'tskey-api-';
|
||||||
|
const TAILSCALE_TOKEN_MAX_LEN = 256;
|
||||||
|
const TAG_KEY_MAX_LEN = 64;
|
||||||
|
const TAGS_MAX_LEN = 32;
|
||||||
|
const DESCRIPTION_MAX_LEN = 120;
|
||||||
|
|
||||||
|
// Tailscale tags are lowercased identifiers with optional colons
|
||||||
|
// (e.g. `tag:server`, `tag:guest-plex`). Reject whitespace, CR/LF,
|
||||||
|
// control chars, JSON metacharacters, and any character that could
|
||||||
|
// enable header-injection through the Tailscale coord client.
|
||||||
|
//
|
||||||
|
// DC-080 round-2 polish: Tailscale's tag spec requires `tag:` followed by
|
||||||
|
// ≥1 identifier char — bare `tag:` (empty name) is rejected by their API.
|
||||||
|
// We split the pattern in two so the error message names which form failed
|
||||||
|
// instead of dumping a generic regex.
|
||||||
|
const TAG_KEY_RE = /^tag:[a-z0-9][a-z0-9_-]{0,62}$/;
|
||||||
|
|
||||||
|
function _validateApiToken(token, fieldName = 'apiToken') {
|
||||||
|
if (typeof token !== 'string' || !token) {
|
||||||
|
return `${fieldName} is required and must be a string`;
|
||||||
|
}
|
||||||
|
if (!token.startsWith(TAILSCALE_TOKEN_PREFIX)) {
|
||||||
|
return `${fieldName} must start with ${TAILSCALE_TOKEN_PREFIX}`;
|
||||||
|
}
|
||||||
|
if (token.length > TAILSCALE_TOKEN_MAX_LEN) {
|
||||||
|
return `${fieldName} exceeds maximum length of ${TAILSCALE_TOKEN_MAX_LEN} characters`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _validateTags(tags) {
|
||||||
|
if (tags === undefined || tags === null) return null;
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
return 'tags must be an array of strings';
|
||||||
|
}
|
||||||
|
if (tags.length > TAGS_MAX_LEN) {
|
||||||
|
return `tags exceeds maximum length of ${TAGS_MAX_LEN} entries`;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < tags.length; i += 1) {
|
||||||
|
const t = tags[i];
|
||||||
|
if (typeof t !== 'string' || !t) {
|
||||||
|
return `tags[${i}] must be a non-empty string`;
|
||||||
|
}
|
||||||
|
if (t.length > TAG_KEY_MAX_LEN) {
|
||||||
|
return `tags[${i}] exceeds maximum length of ${TAG_KEY_MAX_LEN} characters`;
|
||||||
|
}
|
||||||
|
if (!TAG_KEY_RE.test(t)) {
|
||||||
|
return `tags[${i}] must match ${TAG_KEY_RE} (lowercase alnum + :_-)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _validateDescription(description) {
|
||||||
|
if (description === undefined || description === null) return null;
|
||||||
|
if (typeof description !== 'string') {
|
||||||
|
return 'description must be a string';
|
||||||
|
}
|
||||||
|
if (description.length > DESCRIPTION_MAX_LEN) {
|
||||||
|
return `description exceeds maximum length of ${DESCRIPTION_MAX_LEN} characters`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for direct unit testing in __tests__/routes/tailscale-admin.test.js
|
||||||
|
// (the validator functions are otherwise unreachable from outside the factory
|
||||||
|
// closure; direct tests assert edge cases without supertest overhead).
|
||||||
|
const _validators = {
|
||||||
|
validateApiToken: _validateApiToken,
|
||||||
|
validateTags: _validateTags,
|
||||||
|
validateDescription: _validateDescription,
|
||||||
|
TAILSCALE_TOKEN_PREFIX,
|
||||||
|
TAILSCALE_TOKEN_MAX_LEN,
|
||||||
|
TAG_KEY_MAX_LEN,
|
||||||
|
TAGS_MAX_LEN,
|
||||||
|
DESCRIPTION_MAX_LEN,
|
||||||
|
TAG_KEY_RE,
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = function({
|
module.exports = function({
|
||||||
tailscaleCoord,
|
tailscaleCoord,
|
||||||
asyncHandler,
|
asyncHandler,
|
||||||
@@ -75,9 +187,12 @@ module.exports = function({
|
|||||||
|
|
||||||
router.put('/settings', asyncHandler(async (req, res) => {
|
router.put('/settings', asyncHandler(async (req, res) => {
|
||||||
const token = req.body && req.body.apiToken;
|
const token = req.body && req.body.apiToken;
|
||||||
if (!token || typeof token !== 'string' || !token.startsWith('tskey-api-')) {
|
// DC-080: validate prefix + length cap. The pre-fix code only checked
|
||||||
return errorResponse(res, 400, 'Invalid API token (must start with tskey-api-)');
|
// the prefix — a 1 MB string starting with `tskey-api-` would have been
|
||||||
}
|
// sent to Tailscale's /devices endpoint and wasted server-side CPU
|
||||||
|
// before the inevitable 401.
|
||||||
|
const tokenErr = _validateApiToken(token);
|
||||||
|
if (tokenErr) return errorResponse(res, 400, tokenErr);
|
||||||
|
|
||||||
// Validate before storing
|
// Validate before storing
|
||||||
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
|
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
|
||||||
@@ -130,6 +245,17 @@ module.exports = function({
|
|||||||
|
|
||||||
router.post('/settings/test', asyncHandler(async (req, res) => {
|
router.post('/settings/test', asyncHandler(async (req, res) => {
|
||||||
const token = (req.body && req.body.apiToken) || null;
|
const token = (req.body && req.body.apiToken) || null;
|
||||||
|
// DC-080: validate any caller-provided token before it reaches the
|
||||||
|
// Tailscale API. Pre-fix the test endpoint accepted any string — the
|
||||||
|
// PUT route's prefix check did NOT extend to this path. An operator
|
||||||
|
// could submit arbitrary junk and the container would still call
|
||||||
|
// /devices on the Tailscale API with it (DoS-reflection + fingerprint
|
||||||
|
// timing for a future attacker probing whether this API token format
|
||||||
|
// is accepted at all).
|
||||||
|
if (token !== null && token !== undefined) {
|
||||||
|
const tokenErr = _validateApiToken(token);
|
||||||
|
if (tokenErr) return errorResponse(res, 400, tokenErr);
|
||||||
|
}
|
||||||
const client = await tailscaleCoord.getClient();
|
const client = await tailscaleCoord.getClient();
|
||||||
if (token) {
|
if (token) {
|
||||||
// Caller provided a fresh token to test — don't save it
|
// Caller provided a fresh token to test — don't save it
|
||||||
@@ -214,10 +340,16 @@ module.exports = function({
|
|||||||
return errorResponse(res, 503, 'Tailscale API token not configured');
|
return errorResponse(res, 503, 'Tailscale API token not configured');
|
||||||
}
|
}
|
||||||
const opts = req.body || {};
|
const opts = req.body || {};
|
||||||
// Reject obviously-bad input early
|
// Reject obviously-bad input early.
|
||||||
if (opts.tags && !Array.isArray(opts.tags)) {
|
// DC-080: pre-fix the route only checked `Array.isArray(opts.tags)`.
|
||||||
return errorResponse(res, 400, 'tags must be an array of strings');
|
// A `tags: ['tag:guest', null, 123, {injection: true}]` payload would
|
||||||
}
|
// be forwarded to Tailscale verbatim — Tailscale's API is JSON-strict
|
||||||
|
// and would 400 the request, but the bad shape reached the wire and
|
||||||
|
// would silently pass through the dashboard's JSON.stringify() flow.
|
||||||
|
const tagsErr = _validateTags(opts.tags);
|
||||||
|
if (tagsErr) return errorResponse(res, 400, tagsErr);
|
||||||
|
const descErr = _validateDescription(opts.description);
|
||||||
|
if (descErr) return errorResponse(res, 400, descErr);
|
||||||
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
|
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
|
||||||
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
|
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
|
||||||
}
|
}
|
||||||
@@ -254,4 +386,10 @@ module.exports = function({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DC-080: validators exported for direct unit testing in
|
||||||
|
// __tests__/routes/tailscale-admin.test.js — the route factory closes
|
||||||
|
// over the same functions, so the validators are exercised end-to-end via
|
||||||
|
// supertest AND in isolation here.
|
||||||
|
module.exports._validators = _validators;
|
||||||
@@ -7,6 +7,28 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
// Canonical event names are kebab-case ('container-down'). Emitters and the
|
||||||
|
// settings UI historically send camelCase ('containerDown', 'deploymentSuccess')
|
||||||
|
// and the alias map below folds every known spelling onto the canonical key.
|
||||||
|
// DC-092: before this map, the events gate looked up the RAW event name, so
|
||||||
|
// 'deploymentSuccess' (routes/apps/deploy.js, routes/recipes/deploy.js) and
|
||||||
|
// 'auto-restart' (resource-monitor.js) were absent from DEFAULT events and
|
||||||
|
// silently dropped, and UI camelCase toggles never reached the kebab keys the
|
||||||
|
// gate reads — the toggles were cosmetic.
|
||||||
|
const EVENT_ALIASES = {
|
||||||
|
containerDown: 'container-down',
|
||||||
|
containerUp: 'container-up',
|
||||||
|
deploymentSuccess: 'deploy-success',
|
||||||
|
deploymentFailed: 'deploy-failed',
|
||||||
|
deploySuccess: 'deploy-success',
|
||||||
|
deployFailed: 'deploy-failed',
|
||||||
|
resourceAlert: 'alert',
|
||||||
|
updateAvailable: 'update-available',
|
||||||
|
backupComplete: 'backup-complete',
|
||||||
|
backupFailed: 'backup-failed',
|
||||||
|
autoRestart: 'auto-restart',
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
providers: {
|
providers: {
|
||||||
@@ -21,7 +43,13 @@ const DEFAULT_CONFIG = {
|
|||||||
'alert': true,
|
'alert': true,
|
||||||
'backup-complete': true,
|
'backup-complete': true,
|
||||||
'backup-failed': true,
|
'backup-failed': true,
|
||||||
'update-available': true
|
'update-available': true,
|
||||||
|
// DC-092: emitters (apps/recipes deploy routes) fire these; they were
|
||||||
|
// missing from defaults entirely, so every deploy notification was
|
||||||
|
// silently dropped before this fix.
|
||||||
|
'deploy-success': true,
|
||||||
|
'deploy-failed': true,
|
||||||
|
'auto-restart': true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,6 +76,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
|
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
|
||||||
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
|
||||||
|
this._canonicalizeLegacyKeys(data);
|
||||||
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -55,6 +84,40 @@ class NotificationManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-092: configs saved by older clients may contain the legacy spellings
|
||||||
|
* the old POST /config merged verbatim — email.user/email.pass instead of
|
||||||
|
* username/password, and camelCase event keys instead of kebab-case. Fold
|
||||||
|
* them onto the canonical keys BEFORE the defaults merge (after the merge
|
||||||
|
* the canonical keys always exist from defaults, so the alias guards would
|
||||||
|
* never fire) so a config file written before this fix keeps working: SMTP
|
||||||
|
* auth applies and event toggles gate correctly.
|
||||||
|
*/
|
||||||
|
_canonicalizeLegacyKeys(data) {
|
||||||
|
// Email credentials: user/pass → username/password (only when the
|
||||||
|
// canonical key is absent in the raw data; canonical wins on conflict).
|
||||||
|
const email = data?.providers?.email;
|
||||||
|
if (email && typeof email === 'object') {
|
||||||
|
if (email.user !== undefined && email.username === undefined) email.username = email.user;
|
||||||
|
if (email.pass !== undefined && email.password === undefined) email.password = email.pass;
|
||||||
|
delete email.user;
|
||||||
|
delete email.pass;
|
||||||
|
// secure must be a real boolean: legacy string values (e.g. "false"
|
||||||
|
// from hand-edited JSON) are truthy under !! and would force TLS.
|
||||||
|
if (email.secure !== undefined) email.secure = email.secure === true;
|
||||||
|
}
|
||||||
|
// Event keys: camelCase → kebab-case canonical.
|
||||||
|
if (data?.events && typeof data.events === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(data.events)) {
|
||||||
|
const canonicalKey = EVENT_ALIASES[k];
|
||||||
|
if (canonicalKey) {
|
||||||
|
if (data.events[canonicalKey] === undefined) data.events[canonicalKey] = v;
|
||||||
|
delete data.events[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge loaded config with defaults
|
* Merge loaded config with defaults
|
||||||
*/
|
*/
|
||||||
@@ -130,9 +193,15 @@ class NotificationManager extends EventEmitter {
|
|||||||
return { success: false, error: 'Notifications disabled' };
|
return { success: false, error: 'Notifications disabled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if event is enabled
|
// Fold legacy/camelCase spellings onto canonical kebab-case keys (DC-092).
|
||||||
if (event && this.config.events && !this.config.events[event]) {
|
const canonical = EVENT_ALIASES[event] || event;
|
||||||
return { success: false, error: `Event ${event} not enabled` };
|
|
||||||
|
// Check if event is enabled. 'test' bypasses the gate: it is the settings
|
||||||
|
// UI "Send Test" flow and is not an operator-togglable event (there is no
|
||||||
|
// 'test' key in events; gating on it made the Test button a no-op).
|
||||||
|
const gated = canonical !== 'test';
|
||||||
|
if (gated && this.config.events && this.config.events[canonical] !== true) {
|
||||||
|
return { success: false, error: `Event ${canonical} not enabled` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -141,7 +210,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
// Discord
|
// Discord
|
||||||
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
|
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendDiscord(this._formatText(data, event), this._formatEmbed(data, event, type));
|
const result = await this.sendDiscord(this._formatText(data, canonical), this._formatEmbed(data, canonical, type));
|
||||||
results.push({ provider: 'discord', ...result });
|
results.push({ provider: 'discord', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ provider: 'discord', success: false, error: error.message });
|
results.push({ provider: 'discord', success: false, error: error.message });
|
||||||
@@ -151,7 +220,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
// Telegram
|
// Telegram
|
||||||
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
|
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendTelegram(this._formatText(data, event));
|
const result = await this.sendTelegram(this._formatText(data, canonical));
|
||||||
results.push({ provider: 'telegram', ...result });
|
results.push({ provider: 'telegram', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ provider: 'telegram', success: false, error: error.message });
|
results.push({ provider: 'telegram', success: false, error: error.message });
|
||||||
@@ -161,7 +230,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
// ntfy
|
// ntfy
|
||||||
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
|
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendNtfy(this._formatText(data, event), this._formatTitle(event));
|
const result = await this.sendNtfy(this._formatText(data, canonical), this._formatTitle(canonical));
|
||||||
results.push({ provider: 'ntfy', ...result });
|
results.push({ provider: 'ntfy', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ provider: 'ntfy', success: false, error: error.message });
|
results.push({ provider: 'ntfy', success: false, error: error.message });
|
||||||
@@ -172,8 +241,8 @@ class NotificationManager extends EventEmitter {
|
|||||||
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
|
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendEmail(
|
const result = await this.sendEmail(
|
||||||
this._formatTitle(event),
|
this._formatTitle(canonical),
|
||||||
this._formatText(data, event)
|
this._formatText(data, canonical)
|
||||||
);
|
);
|
||||||
results.push({ provider: 'email', ...result });
|
results.push({ provider: 'email', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -183,9 +252,9 @@ class NotificationManager extends EventEmitter {
|
|||||||
|
|
||||||
const allSucceeded = results.every(r => r.success);
|
const allSucceeded = results.every(r => r.success);
|
||||||
this._addToHistory({
|
this._addToHistory({
|
||||||
title: this._formatTitle(event),
|
title: this._formatTitle(canonical),
|
||||||
type,
|
type,
|
||||||
event,
|
event: canonical,
|
||||||
results
|
results
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,7 +359,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port) || 587,
|
port: parseInt(port) || 587,
|
||||||
secure: !!secure,
|
secure: secure === true,
|
||||||
auth: username ? {
|
auth: username ? {
|
||||||
user: username,
|
user: username,
|
||||||
pass: password
|
pass: password
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(platformP
|
|||||||
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json');
|
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json');
|
||||||
const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour
|
const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour
|
||||||
|
|
||||||
|
// DC-078: registry probe reliability knobs. The container's /etc/resolv.conf points
|
||||||
|
// at Technitium (100.121.150.22) which sometimes returns a mix of A and AAAA
|
||||||
|
// records even when the host's IPv6 path to public registries (Docker Hub,
|
||||||
|
// ghcr.io) is broken or slow. Without `family: 4` Node defaults to dual-stack,
|
||||||
|
// every `https.request` to a registry races dual-stack DNS and stalls 30+ seconds
|
||||||
|
// per ENETUNREACH on the unreachable family. Without an explicit request timeout
|
||||||
|
// the entire `checkForUpdates()` loop (5+ containers) blocks for minutes per
|
||||||
|
// tick — visible in error.log as AggregateError [ETIMEDOUT] with a stack like
|
||||||
|
// `at internalConnectMultiple (node:net:1114:18)`.
|
||||||
|
//
|
||||||
|
// TUNABLES — keep conservative; the digest check is a background poll, not
|
||||||
|
// user-facing. Worst-case latency per query:
|
||||||
|
// 1st attempt: REGISTRY_REQUEST_TIMEOUT_MS (10s)
|
||||||
|
// 1st retry : REGISTRY_RETRY_BACKOFF_MS + REGISTRY_REQUEST_TIMEOUT_MS (10.5s)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// per-container ceiling: 20.5s (REGISTRY_MAX_RETRIES=1)
|
||||||
|
const REGISTRY_REQUEST_TIMEOUT_MS = 10000; // hard per-request socket timeout
|
||||||
|
const REGISTRY_MAX_RETRIES = 1; // extra attempts after first failure
|
||||||
|
const REGISTRY_RETRY_BACKOFF_MS = 500; // delay before retry (transient blips)
|
||||||
|
const REGISTRY_TRANSIENT_ERROR_CODES = new Set([
|
||||||
|
'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN',
|
||||||
|
'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH',
|
||||||
|
]);
|
||||||
|
|
||||||
class UpdateManager extends EventEmitter {
|
class UpdateManager extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -144,12 +168,39 @@ class UpdateManager extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get latest image digest from registry
|
* Get latest image digest from registry
|
||||||
|
*
|
||||||
|
* DC-082: when the image name is a docker-compose prefixed name like
|
||||||
|
* `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash),
|
||||||
|
* the existing code normalized it to `library/dashcaddy-dashcaddy-api`
|
||||||
|
* before probing Docker Hub. The actual upstream namespace for a
|
||||||
|
* compose-prefixed image is `<project>/<service>` (with slash) — Docker
|
||||||
|
* Compose hyphenates the project name and service name when tagging
|
||||||
|
* locally. The pre-fix code probed the wrong repo, Docker Hub returned
|
||||||
|
* HTTP 401 (the repo doesn't exist), and the error log showed
|
||||||
|
* `Docker Hub registry returned HTTP 401 after auth` on every restart
|
||||||
|
* for the local dashcaddy-api image. The fix: split on the FIRST hyphen
|
||||||
|
* for compose-prefixed names so the lookup targets the correct
|
||||||
|
* namespace.
|
||||||
|
*
|
||||||
|
* Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash,
|
||||||
|
* lowercase, both halves non-empty). Examples:
|
||||||
|
* dashcaddy-dashcaddy-api -> dashcaddy/dashcaddy-api
|
||||||
|
* myproject-myservice -> myproject/myservice
|
||||||
|
* nginx -> library/nginx (official, unchanged)
|
||||||
|
* library/nginx -> library/nginx (official, unchanged)
|
||||||
|
* dashcaddy/some-image -> dashcaddy/some-image (already has slash)
|
||||||
|
* ghcr.io/x/y -> ghcr.io/x/y (handled below)
|
||||||
*/
|
*/
|
||||||
async getLatestImageDigest(imageName) {
|
async getLatestImageDigest(imageName) {
|
||||||
|
// DC-082: declare `remainder` at the function scope so the catch block
|
||||||
|
// can classify the error against the image-name shape (compose-prefixed
|
||||||
|
// local images produce a steady-state 401 that should log as info, not
|
||||||
|
// error).
|
||||||
|
let remainder = imageName;
|
||||||
try {
|
try {
|
||||||
// Parse image name — strip any leading registry host first
|
// Parse image name — strip any leading registry host first
|
||||||
let imageTag = 'latest';
|
let imageTag = 'latest';
|
||||||
let remainder = imageName;
|
remainder = imageName;
|
||||||
const lastColon = imageName.lastIndexOf(':');
|
const lastColon = imageName.lastIndexOf(':');
|
||||||
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
|
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
|
||||||
const lastSlash = imageName.lastIndexOf('/');
|
const lastSlash = imageName.lastIndexOf('/');
|
||||||
@@ -163,8 +214,19 @@ class UpdateManager extends EventEmitter {
|
|||||||
return await this.getGhcrDigest(remainder, imageTag);
|
return await this.getGhcrDigest(remainder, imageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker Hub images (library/nginx OR org/image with single slash)
|
// Docker Hub images (library/nginx OR org/image with single slash).
|
||||||
if (!remainder.includes('/') || remainder.split('/').length === 2) {
|
// Special-case docker-compose prefixed names (single hyphen, no slash,
|
||||||
|
// lowercase) — split on the FIRST hyphen to recover the original
|
||||||
|
// `<project>/<service>` namespace. See DC-082.
|
||||||
|
if (!remainder.includes('/')) {
|
||||||
|
const composeRepo = this._composeProjectToRepo(remainder);
|
||||||
|
if (composeRepo) {
|
||||||
|
return await this.getDockerHubDigest(composeRepo, imageTag);
|
||||||
|
}
|
||||||
|
// Not a compose-prefixed name — fall through to the library/ default
|
||||||
|
return await this.getDockerHubDigest(remainder, imageTag);
|
||||||
|
}
|
||||||
|
if (remainder.split('/').length === 2) {
|
||||||
return await this.getDockerHubDigest(remainder, imageTag);
|
return await this.getDockerHubDigest(remainder, imageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,96 +234,278 @@ class UpdateManager extends EventEmitter {
|
|||||||
log.warn('update', 'Custom registry not yet supported', { remainder });
|
log.warn('update', 'Custom registry not yet supported', { remainder });
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// DC-082: a "registry returned HTTP 401 after auth" against a
|
||||||
|
// compose-prefixed local image is the steady-state when the image
|
||||||
|
// is built locally and the upstream namespace on Docker Hub
|
||||||
|
// doesn't exist (or is private). The token endpoint returns 200
|
||||||
|
// with an empty-access JWT, and the authed manifest GET 401s.
|
||||||
|
// Log these as a clean info not-found line instead of an error
|
||||||
|
// so dashboards and PagerDuty don't fire on every restart.
|
||||||
|
if (this._isNotPublishedError(error, remainder)) {
|
||||||
|
log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
log.error('update', error, null, { imageName });
|
log.error('update', error, null, { imageName });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-082: split a docker-compose prefixed image name on the FIRST hyphen
|
||||||
|
* to recover the original `<project>/<service>` namespace. Returns null
|
||||||
|
* for names that don't match the compose-prefixed shape — callers fall
|
||||||
|
* through to the standard library/-prefixed official-image path.
|
||||||
|
*
|
||||||
|
* Compose-prefixed shape:
|
||||||
|
* - Contains exactly one or more hyphens
|
||||||
|
* - No slash
|
||||||
|
* - Lowercase letters / digits / hyphens / underscores only
|
||||||
|
* - Both halves (before first hyphen, after first hyphen) are non-empty
|
||||||
|
* - First char is a letter or digit (not a hyphen)
|
||||||
|
*/
|
||||||
|
_composeProjectToRepo(remainder) {
|
||||||
|
if (typeof remainder !== 'string' || remainder.length === 0) return null;
|
||||||
|
if (remainder.includes('/')) return null; // already namespaced
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||||
|
// Not a compose-prefixed name — let the library/ path handle it
|
||||||
|
// (this is the official-image path: e.g. `nginx`, `alpine`).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const firstHyphen = remainder.indexOf('-');
|
||||||
|
// Defensive: indexOf must find a hyphen (regex requires it), but guard
|
||||||
|
// against any future regex drift.
|
||||||
|
if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null;
|
||||||
|
const project = remainder.substring(0, firstHyphen);
|
||||||
|
const service = remainder.substring(firstHyphen + 1);
|
||||||
|
if (!project || !service) return null;
|
||||||
|
return `${project}/${service}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-082: detect the "registry returned 401 after auth" pattern that
|
||||||
|
* signals "this image has no public upstream on Docker Hub" (as opposed
|
||||||
|
* to a genuine auth failure or transient network error). Steady-state
|
||||||
|
* for compose-prefixed local images that aren't published.
|
||||||
|
*/
|
||||||
|
_isNotPublishedError(error, remainder) {
|
||||||
|
if (!error || typeof error.message !== 'string') return false;
|
||||||
|
if (!error.message.includes('HTTP 401')) return false;
|
||||||
|
// Constrain to the compose-prefixed path — a real auth failure on a
|
||||||
|
// legitimate `library/foo` or `namespace/foo` probe should still log
|
||||||
|
// as an error (it never auto-heals).
|
||||||
|
if (typeof remainder !== 'string' || remainder.includes('/')) return false;
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image digest from GitHub Container Registry (ghcr.io)
|
* Get image digest from GitHub Container Registry (ghcr.io)
|
||||||
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
||||||
* but using ghcr.io's own auth endpoint.
|
* but using ghcr.io's own auth endpoint.
|
||||||
|
*
|
||||||
|
* DC-078: hardened — `family: 4` to avoid the dual-stack DNS race when the
|
||||||
|
* host's IPv6 path is unreachable (was producing AggregateError [ETIMEDOUT] in
|
||||||
|
* error.log every check cycle). Hard request timeout caps each attempt.
|
||||||
*/
|
*/
|
||||||
async getGhcrDigest(repository, tag) {
|
async getGhcrDigest(repository, tag) {
|
||||||
// ghcr.io uses the same OCI distribution spec as Docker Hub
|
// ghcr.io uses the same OCI distribution spec as Docker Hub
|
||||||
const imageRepo = repository.replace(/^ghcr\.io\//, '');
|
const imageRepo = repository.replace(/^ghcr\.io\//, '');
|
||||||
|
const res = await this.fetchWithReliability({
|
||||||
|
hostname: 'ghcr.io',
|
||||||
|
path: `/v2/${imageRepo}/manifests/${tag}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.headers['docker-content-digest'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image digest from Docker Hub
|
||||||
|
*
|
||||||
|
* DC-078: hardened — see getGhcrDigest comment. Resolves a 401 → token via
|
||||||
|
* `fetchAuthToken`, which itself is wrapped in the same retry + IPv4-only +
|
||||||
|
* timeout policy via `fetchWithReliability`.
|
||||||
|
*/
|
||||||
|
async getDockerHubDigest(repository, tag) {
|
||||||
|
// Normalize repository name
|
||||||
|
const repo = repository.includes('/') ? repository : `library/${repository}`;
|
||||||
|
const firstAttempt = await this.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: `/v2/${repo}/manifests/${tag}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (firstAttempt.statusCode !== 401) {
|
||||||
|
if (firstAttempt.statusCode < 200 || firstAttempt.statusCode >= 300) {
|
||||||
|
throw new Error(`Docker Hub registry returned HTTP ${firstAttempt.statusCode}`);
|
||||||
|
}
|
||||||
|
return firstAttempt.headers['docker-content-digest'] || null;
|
||||||
|
}
|
||||||
|
// 401 → acquire a Bearer token via the WWW-Authenticate realm, then retry once.
|
||||||
|
const authHeader = firstAttempt.headers['www-authenticate'];
|
||||||
|
const authUrl = this.parseAuthHeader(authHeader);
|
||||||
|
if (!authUrl) {
|
||||||
|
throw new Error('Authentication required but no auth URL found');
|
||||||
|
}
|
||||||
|
const token = await this.fetchAuthToken(authUrl);
|
||||||
|
const authed = await this.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: `/v2/${repo}/manifests/${tag}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (authed.statusCode < 200 || authed.statusCode >= 300) {
|
||||||
|
throw new Error(`Docker Hub registry returned HTTP ${authed.statusCode} after auth`);
|
||||||
|
}
|
||||||
|
return authed.headers['docker-content-digest'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single hardened HTTPS probe — DC-078.
|
||||||
|
*
|
||||||
|
* Reliability properties:
|
||||||
|
* 1. `family: 4` — IPv4-only DNS lookup. Avoids dual-stack races where a
|
||||||
|
* single unreachable IPv6 destination consumes the default 30-second
|
||||||
|
* connect timeout before the IPv4 fallback succeeds (manifested in
|
||||||
|
* error.log as AggregateError [ETIMEDOUT] with `at internalConnectMultiple`).
|
||||||
|
* 2. Hard per-request timeout (REGISTRY_REQUEST_TIMEOUT_MS) — caps total
|
||||||
|
* latency for any single probe attempt.
|
||||||
|
* 3. Retry on transient network errors (REGISTRY_TRANSIENT_ERROR_CODES)
|
||||||
|
* with REGISTRY_RETRY_BACKOFF_MS delay between attempts. Does NOT
|
||||||
|
* retry on HTTP 4xx/5xx — those are real responses we should surface.
|
||||||
|
*
|
||||||
|
* Returns {statusCode, headers, body} so callers can read whichever response
|
||||||
|
* header or body bytes they need. For digest probes the body is drained and
|
||||||
|
* discarded; for auth-token fetches the JSON body is parsed.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.hostname
|
||||||
|
* @param {string} opts.path
|
||||||
|
* @param {object} [opts.headers]
|
||||||
|
* @param {number} [opts.maxBodyBytes=65536] — protect against runaway bodies
|
||||||
|
*/
|
||||||
|
async fetchWithReliability(opts) {
|
||||||
|
const maxBodyBytes = opts.maxBodyBytes || 65536;
|
||||||
|
let attempt = 0;
|
||||||
|
while (attempt <= REGISTRY_MAX_RETRIES) {
|
||||||
|
try {
|
||||||
|
const result = await this._httpsRequestOnce({
|
||||||
|
hostname: opts.hostname,
|
||||||
|
path: opts.path,
|
||||||
|
headers: opts.headers || {},
|
||||||
|
maxBodyBytes,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// Drain retryable transient errors; non-transient (HTTP status) errors
|
||||||
|
// and code-less errors are surfaced directly to the caller.
|
||||||
|
if (!REGISTRY_TRANSIENT_ERROR_CODES.has(error && error.code)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt >= REGISTRY_MAX_RETRIES) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
attempt += 1;
|
||||||
|
// Brief backoff before retry to let transient blips settle.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, REGISTRY_RETRY_BACKOFF_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Defensive — should not reach here because the loop either throws or returns.
|
||||||
|
throw new Error('fetchWithReliability exhausted retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot HTTPS request helper for fetchWithReliability — DC-078.
|
||||||
|
* Returns {statusCode, headers, body} on 2xx and most non-2xx responses
|
||||||
|
* (the caller decides what to do with non-2xx). Throws on transient
|
||||||
|
* network errors so the retry policy catches them.
|
||||||
|
*/
|
||||||
|
_httpsRequestOnce({ hostname, path: urlPath, headers, maxBodyBytes }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const options = {
|
const options = {
|
||||||
hostname: 'ghcr.io',
|
hostname,
|
||||||
path: `/v2/${imageRepo}/manifests/${tag}`,
|
path: urlPath,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
family: 4, // DC-078: IPv4-only — see top-of-file comment
|
||||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
|
headers,
|
||||||
}
|
timeout: REGISTRY_REQUEST_TIMEOUT_MS, // DC-078: hard per-request cap
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
if (res.statusCode === 401) {
|
let body = '';
|
||||||
const authHeader = res.headers['www-authenticate'];
|
let size = 0;
|
||||||
const authUrl = this.parseAuthHeader(authHeader);
|
let aborted = false;
|
||||||
if (authUrl) {
|
res.on('data', (chunk) => {
|
||||||
// ghcr.io auth endpoint accepts scope=repository:owner/name:pull
|
if (aborted) return;
|
||||||
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
size += chunk.length;
|
||||||
} else {
|
if (size > maxBodyBytes) {
|
||||||
reject(new Error('Authentication required but no auth URL found'));
|
aborted = true;
|
||||||
|
res.destroy();
|
||||||
|
const err = new Error(`response from ${hostname}${urlPath} exceeded ${maxBodyBytes} bytes`);
|
||||||
|
err.code = 'ERR_RESPONSE_TOO_LARGE';
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
body += chunk;
|
||||||
}
|
});
|
||||||
|
res.on('end', () => {
|
||||||
if (res.statusCode !== 200) {
|
if (aborted) return;
|
||||||
// Drain body to avoid socket leak
|
resolve({
|
||||||
res.resume();
|
statusCode: res.statusCode,
|
||||||
reject(new Error(`ghcr.io returned HTTP ${res.statusCode}`));
|
headers: res.headers,
|
||||||
return;
|
body,
|
||||||
}
|
});
|
||||||
|
});
|
||||||
const digest = res.headers['docker-content-digest'];
|
});
|
||||||
resolve(digest || null);
|
// Node 22 emits 'timeout' on the request, not the socket, when socket.setTimeout
|
||||||
|
// is hit — make it an explicit error so fetchWithReliability's retry policy catches it.
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy(new Error('request timeout'));
|
||||||
|
const err = new Error(`registry request to ${hostname}${urlPath} timed out after ${REGISTRY_REQUEST_TIMEOUT_MS}ms`);
|
||||||
|
err.code = 'ETIMEDOUT';
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
req.on('error', (err) => {
|
||||||
|
// Tag errors missing .code so the retry policy recognizes transient ones.
|
||||||
|
if (!err.code && /timeout/i.test(err.message)) err.code = 'ETIMEDOUT';
|
||||||
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image digest from Docker Hub
|
* Fetch an auth token from a registry's WWW-Authenticate realm URL — DC-078.
|
||||||
|
* Uses fetchWithReliability for IPv4-only + timeout + retry. Parses the
|
||||||
|
* JSON body and returns the `token` or `access_token` field.
|
||||||
*/
|
*/
|
||||||
async getDockerHubDigest(repository, tag) {
|
async fetchAuthToken(authUrl) {
|
||||||
return new Promise((resolve, reject) => {
|
const url = new URL(authUrl);
|
||||||
// Normalize repository name
|
const result = await this.fetchWithReliability({
|
||||||
const repo = repository.includes('/') ? repository : `library/${repository}`;
|
hostname: url.hostname,
|
||||||
|
path: url.pathname + url.search,
|
||||||
const options = {
|
maxBodyBytes: 16384, // auth tokens are <2 KB; cap to a small bound
|
||||||
hostname: 'registry-1.docker.io',
|
|
||||||
path: `/v2/${repo}/manifests/${tag}`,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
if (res.statusCode === 401) {
|
|
||||||
// Need to authenticate
|
|
||||||
const authHeader = res.headers['www-authenticate'];
|
|
||||||
const authUrl = this.parseAuthHeader(authHeader);
|
|
||||||
|
|
||||||
if (authUrl) {
|
|
||||||
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
|
||||||
} else {
|
|
||||||
reject(new Error('Authentication required but no auth URL found'));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const digest = res.headers['docker-content-digest'];
|
|
||||||
resolve(digest || null);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
});
|
||||||
|
if (result.statusCode !== 200) {
|
||||||
|
throw new Error(`auth token endpoint ${authUrl} returned HTTP ${result.statusCode}`);
|
||||||
|
}
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = JSON.parse(result.body);
|
||||||
|
} catch (parseErr) {
|
||||||
|
// Surface a clean error — otherwise a malformed token response throws
|
||||||
|
// SyntaxError with the raw body snippet, which is hard to diagnose
|
||||||
|
// against the offending realm URL in a log line.
|
||||||
|
throw new Error(`auth token response from ${authUrl} was not valid JSON: ${parseErr.message}`);
|
||||||
|
}
|
||||||
|
const token = auth.token || auth.access_token;
|
||||||
|
if (!token) throw new Error(`No token in auth response from ${authUrl}`);
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,48 +527,6 @@ class UpdateManager extends EventEmitter {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate and get digest
|
|
||||||
*/
|
|
||||||
async authenticateAndGetDigest(authUrl, originalOptions) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
https.get(authUrl, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const auth = JSON.parse(data);
|
|
||||||
const token = auth.token || auth.access_token;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
reject(new Error('No token in auth response'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry original request with token
|
|
||||||
const options = {
|
|
||||||
...originalOptions,
|
|
||||||
headers: {
|
|
||||||
...originalOptions.headers,
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
const digest = res.headers['docker-content-digest'];
|
|
||||||
resolve(digest || null);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract tag from image name
|
* Extract tag from image name
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10
|
|||||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||||
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||||
|
// DC-088: how long a removal tombstone outlives the removal itself. Only needs
|
||||||
|
// to cover the max in-flight probe lifetime (timeout + scheduling headroom);
|
||||||
|
// swept by cleanupHistory so removed services cannot accumulate map entries.
|
||||||
|
const REMOVED_GENERATION_TTL_MS = parseInt(process.env.HEALTH_REMOVED_GEN_TTL || '600000', 10);
|
||||||
|
|
||||||
|
// DC-086: hysteresis thresholds for badge display.
|
||||||
|
// The raw probe result can flap on a single transient blip (Caddy reload,
|
||||||
|
// container CPU steal, network hiccup, mid-flight TLS handshake). Showing
|
||||||
|
// every probe result as-is to the dashboard creates the "perpetual flicker"
|
||||||
|
// UX. Asymmetric thresholds: going red is slow (don't false-alarm), going
|
||||||
|
// green is fast (don't keep showing red after recovery).
|
||||||
|
// - DOWN_THRESHOLD = N consecutive "down" probes before the badge flips to red
|
||||||
|
// - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green
|
||||||
|
// Single probe flips to green on purpose — false-positive-green is much less
|
||||||
|
// painful than perpetual-red (operators notice red, ignore green).
|
||||||
|
function readPositiveIntEnv(name, fallback) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw === '') return fallback;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOWN_THRESHOLD = readPositiveIntEnv('HEALTH_DOWN_THRESHOLD', 2);
|
||||||
|
const UP_THRESHOLD = readPositiveIntEnv('HEALTH_UP_THRESHOLD', 1);
|
||||||
|
|
||||||
class HealthChecker extends EventEmitter {
|
class HealthChecker extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -39,11 +63,27 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.config = this.loadConfig();
|
this.config = this.loadConfig();
|
||||||
this.history = this.loadHistory();
|
this.history = this.loadHistory();
|
||||||
this.currentStatus = new Map();
|
this.currentStatus = new Map();
|
||||||
|
// DC-086: the status the dashboard SHOULD display (post-hysteresis).
|
||||||
|
// Distinct from currentStatus, which is the latest raw probe result.
|
||||||
|
this.displayedStatus = new Map();
|
||||||
|
// DC-086: counter of consecutive healthy/unhealthy probes since the
|
||||||
|
// last displayed-status change. Reset to 0 whenever displayed status flips.
|
||||||
|
this.consecutiveSinceChange = new Map();
|
||||||
this.incidents = [];
|
this.incidents = [];
|
||||||
this.checking = false;
|
this.checking = false;
|
||||||
this.checkInterval = null;
|
this.checkInterval = null;
|
||||||
this.consecutiveFailures = new Map(); // serviceId -> failure count
|
this.consecutiveFailures = new Map(); // serviceId -> failure count
|
||||||
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
|
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
|
||||||
|
// Invalidate probe completions that race with removal/reconfiguration.
|
||||||
|
this.serviceGenerations = new Map(); // serviceId -> configuration generation
|
||||||
|
// DC-088: monotonically increasing sequence so generation numbers can never
|
||||||
|
// repeat across remove -> re-add cycles (prevents ABA on the stale check).
|
||||||
|
this.generationSeq = 0;
|
||||||
|
// DC-088: serviceId -> { generation, removedAt } tombstones. A live entry in
|
||||||
|
// serviceGenerations means the service is (re)configured; a tombstone with a
|
||||||
|
// HIGHER generation than the captured one marks the capture as stale. Entry
|
||||||
|
// is deleted when the service is removed, so the live map cannot leak.
|
||||||
|
this.removedGenerations = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,11 +151,27 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.cleanupHistory();
|
this.cleanupHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-088: true when a probe's captured generation no longer matches the
|
||||||
|
* service's current configuration state. A live serviceGenerations entry
|
||||||
|
* must match exactly. With no live entry the service was never configured
|
||||||
|
* in this process (disk-loaded / direct callers) — stale only if a removal
|
||||||
|
* tombstone with a HIGHER generation exists.
|
||||||
|
*/
|
||||||
|
_isStaleCapture(serviceId, generation) {
|
||||||
|
if (this.serviceGenerations.has(serviceId)) {
|
||||||
|
return this.serviceGenerations.get(serviceId) !== generation;
|
||||||
|
}
|
||||||
|
const tomb = this.removedGenerations.get(serviceId);
|
||||||
|
return Boolean(tomb && tomb.generation > generation);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check a single service
|
* Check a single service
|
||||||
*/
|
*/
|
||||||
async checkService(serviceId, config) {
|
async checkService(serviceId, config) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const generation = this.serviceGenerations.get(serviceId) || 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.performHealthCheck(config);
|
const result = await this.performHealthCheck(config);
|
||||||
@@ -131,6 +187,10 @@ class HealthChecker extends EventEmitter {
|
|||||||
details: result.details
|
details: result.details
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this._isStaleCapture(serviceId, generation)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
// Track consecutive failures for exponential backoff
|
// Track consecutive failures for exponential backoff
|
||||||
if (result.healthy) {
|
if (result.healthy) {
|
||||||
this.consecutiveFailures.delete(serviceId);
|
this.consecutiveFailures.delete(serviceId);
|
||||||
@@ -138,16 +198,15 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousStatus = this.currentStatus.get(serviceId);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config);
|
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
// Increment failure count for backoff
|
|
||||||
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
|
||||||
|
|
||||||
const status = {
|
const status = {
|
||||||
serviceId,
|
serviceId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -156,8 +215,18 @@ class HealthChecker extends EventEmitter {
|
|||||||
error: error.message
|
error: error.message
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this._isStaleCapture(serviceId, generation)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment failure count for backoff — only after the result is known
|
||||||
|
// to be non-stale, so a removed service cannot re-create map entries.
|
||||||
|
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
||||||
|
|
||||||
|
const previousStatus = this.currentStatus.get(serviceId);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config);
|
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -273,27 +342,109 @@ class HealthChecker extends EventEmitter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the displayed status for a service given the latest raw probe
|
||||||
|
* result. Applies asymmetric hysteresis:
|
||||||
|
* - Going DOWN: requires DOWN_THRESHOLD (default 2) consecutive "down"
|
||||||
|
* probes since the last display-state change. A single blip keeps the
|
||||||
|
* badge green.
|
||||||
|
* - Going UP: requires UP_THRESHOLD (default 1) consecutive "up" probes.
|
||||||
|
* Any single "up" after a down streak flips back to green so the badge
|
||||||
|
* doesn't linger red after the service has recovered.
|
||||||
|
*
|
||||||
|
* Returns the displayed status object (same shape as the raw status) so
|
||||||
|
* recordStatus can use it both for the displayed map and as the broadcast
|
||||||
|
* payload when the displayed status actually changes.
|
||||||
|
*/
|
||||||
|
_computeDisplayedStatus(serviceId, rawStatus) {
|
||||||
|
const currentDisplayed = this.displayedStatus.get(serviceId);
|
||||||
|
const previousStatus = currentDisplayed ? currentDisplayed.status : null;
|
||||||
|
|
||||||
|
// If no prior state, accept the raw probe as-is (first-check bootstrap).
|
||||||
|
if (!previousStatus) {
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe agrees with current displayed → no change, reset the counter so
|
||||||
|
// a brief blip doesn't accumulate against the displayed state.
|
||||||
|
if (rawStatus.status === previousStatus) {
|
||||||
|
this.consecutiveSinceChange.set(serviceId, 0);
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe disagrees with displayed. Bump the streak counter — this counts
|
||||||
|
// CONSECUTIVE probes that disagree with what's shown, regardless of
|
||||||
|
// whether the raw value itself changed between probes. That's what
|
||||||
|
// makes "down, down" flip after threshold but "down, up, down" not flip.
|
||||||
|
const prev = this.consecutiveSinceChange.get(serviceId) || 0;
|
||||||
|
const next = prev + 1;
|
||||||
|
|
||||||
|
if (rawStatus.status === 'down') {
|
||||||
|
// Going DOWN: need DOWN_THRESHOLD consecutive probes that disagree
|
||||||
|
// with the displayed "up" state.
|
||||||
|
if (previousStatus === 'up' && next < DOWN_THRESHOLD) {
|
||||||
|
this.consecutiveSinceChange.set(serviceId, next);
|
||||||
|
// Keep the last internally-consistent displayed snapshot. Mixing the
|
||||||
|
// raw failure metadata with status="up" would expose contradictory
|
||||||
|
// API data (for example statusCode=500 on an "up" service).
|
||||||
|
return currentDisplayed;
|
||||||
|
}
|
||||||
|
// Threshold met (or already down) — flip to red.
|
||||||
|
this.consecutiveSinceChange.set(serviceId, 0);
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawStatus.status === 'up' (must be — the equal-to-displayed case above
|
||||||
|
// already returned). Going UP after a down streak: need UP_THRESHOLD.
|
||||||
|
if (previousStatus === 'down' && next < UP_THRESHOLD) {
|
||||||
|
this.consecutiveSinceChange.set(serviceId, next);
|
||||||
|
return currentDisplayed;
|
||||||
|
}
|
||||||
|
this.consecutiveSinceChange.set(serviceId, 0);
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record service status
|
* Record service status
|
||||||
|
*
|
||||||
|
* DC-086: history + consecutiveFailures are updated for EVERY probe
|
||||||
|
* (operators want full probe history for postmortems). The dashboard's
|
||||||
|
* `status-check` event is only emitted when the DISPLAYED status changes,
|
||||||
|
* so the badge stops re-rendering on every probe.
|
||||||
*/
|
*/
|
||||||
recordStatus(serviceId, status) {
|
recordStatus(serviceId, status) {
|
||||||
// Update current status
|
// Update current (raw) status — used by checkForIncidents and history.
|
||||||
this.currentStatus.set(serviceId, status);
|
this.currentStatus.set(serviceId, status);
|
||||||
|
|
||||||
// Add to history
|
// Add raw probe to history (full fidelity — operators rely on this).
|
||||||
if (!this.history[serviceId]) {
|
if (!this.history[serviceId]) {
|
||||||
this.history[serviceId] = [];
|
this.history[serviceId] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.history[serviceId].push(status);
|
this.history[serviceId].push(status);
|
||||||
|
|
||||||
// Cap entries to prevent unbounded growth (disk explosion fix)
|
// Cap entries to prevent unbounded growth (disk explosion fix)
|
||||||
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
||||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit status event
|
// Compute the post-hysteresis displayed status; only emit when it changes.
|
||||||
this.emit('status-check', status);
|
// _computeDisplayedStatus compares the raw probe against the DISPLAYED
|
||||||
|
// status (not the previous raw status), so the "consecutive since
|
||||||
|
// change" counter doesn't depend on the order of writes here.
|
||||||
|
const displayed = this._computeDisplayedStatus(serviceId, status);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
|
const displayChanged =
|
||||||
|
!previousDisplayed || previousDisplayed.status !== displayed.status;
|
||||||
|
|
||||||
|
this.displayedStatus.set(serviceId, displayed);
|
||||||
|
|
||||||
|
if (displayChanged) {
|
||||||
|
// Emit with the displayed status so the dashboard renders the same
|
||||||
|
// state the hysteresis just decided. The raw probe result is still
|
||||||
|
// in `history` and `currentStatus` for anyone who wants it.
|
||||||
|
this.emit('status-check', displayed);
|
||||||
|
}
|
||||||
|
|
||||||
// Save history periodically
|
// Save history periodically
|
||||||
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
|
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
|
||||||
@@ -304,11 +455,27 @@ class HealthChecker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Check for incidents (downtime, slow response, etc.)
|
* Check for incidents (downtime, slow response, etc.)
|
||||||
*/
|
*/
|
||||||
checkForIncidents(serviceId, status, config) {
|
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId), previousDisplayed = null) {
|
||||||
const previous = this.currentStatus.get(serviceId);
|
|
||||||
|
|
||||||
// Check for status change (up -> down or down -> up)
|
// DC-090: outage incidents follow the DISPLAYED (post-hysteresis) status —
|
||||||
if (previous && previous.status !== status.status) {
|
// the same signal that flips the dashboard badge. A single raw "down"
|
||||||
|
// blip that hysteresis suppresses must not open a critical outage
|
||||||
|
// incident (and a suppressed blip must not resolve a real one). When the
|
||||||
|
// caller supplies the pre-probe displayed state (checkService always
|
||||||
|
// does), transitions are evaluated displayed-vs-displayed using the
|
||||||
|
// post-recordStatus state in this.displayedStatus. Direct callers with
|
||||||
|
// no hysteresis state (previousDisplayed === null) keep the legacy
|
||||||
|
// raw-probe transition semantics.
|
||||||
|
if (previousDisplayed) {
|
||||||
|
const displayed = this.displayedStatus.get(serviceId);
|
||||||
|
if (displayed && displayed.status !== previousDisplayed.status) {
|
||||||
|
if (displayed.status === 'down') {
|
||||||
|
this.createIncident(serviceId, 'outage', 'Service is down', displayed);
|
||||||
|
} else if (displayed.status === 'up') {
|
||||||
|
this.resolveIncident(serviceId, 'outage', displayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (previous && previous.status !== status.status) {
|
||||||
if (status.status === 'down') {
|
if (status.status === 'down') {
|
||||||
this.createIncident(serviceId, 'outage', 'Service is down', status);
|
this.createIncident(serviceId, 'outage', 'Service is down', status);
|
||||||
} else if (status.status === 'up') {
|
} else if (status.status === 'up') {
|
||||||
@@ -445,19 +612,29 @@ class HealthChecker extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current status for all services
|
* Get current status for all services.
|
||||||
|
*
|
||||||
|
* DC-086: returns the DISPLAYED status (post-hysteresis), not the latest
|
||||||
|
* raw probe. A page reload should show the same badge state the live
|
||||||
|
* SSE stream is currently showing — otherwise an operator who reloads
|
||||||
|
* the page after a single blip sees red even though the hysteresis kept
|
||||||
|
* the badge green for them.
|
||||||
*/
|
*/
|
||||||
getCurrentStatus() {
|
getCurrentStatus() {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
for (const [serviceId, status] of this.currentStatus.entries()) {
|
for (const [serviceId, rawStatus] of this.currentStatus.entries()) {
|
||||||
const config = this.config.services[serviceId];
|
const config = this.config.services[serviceId];
|
||||||
const uptime24h = this.calculateUptime(serviceId, 24);
|
const uptime24h = this.calculateUptime(serviceId, 24);
|
||||||
const uptime7d = this.calculateUptime(serviceId, 168);
|
const uptime7d = this.calculateUptime(serviceId, 168);
|
||||||
const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24);
|
const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24);
|
||||||
|
|
||||||
|
// Prefer the displayed status if we've already computed one; fall back
|
||||||
|
// to the raw probe on the very first call (before recordStatus has run).
|
||||||
|
const displayed = this.displayedStatus.get(serviceId) || rawStatus;
|
||||||
|
|
||||||
result[serviceId] = {
|
result[serviceId] = {
|
||||||
...status,
|
...displayed,
|
||||||
name: config?.name || serviceId,
|
name: config?.name || serviceId,
|
||||||
uptime: {
|
uptime: {
|
||||||
'24h': uptime24h,
|
'24h': uptime24h,
|
||||||
@@ -467,7 +644,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
sla: config?.sla
|
sla: config?.sla
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +707,13 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.config.services = {};
|
this.config.services = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: monotonic instance-wide sequence — a re-added service can never
|
||||||
|
// recycle a previous generation number, and any older in-flight capture is
|
||||||
|
// invalidated by definition.
|
||||||
|
this.generationSeq += 1;
|
||||||
|
this.serviceGenerations.set(serviceId, this.generationSeq);
|
||||||
|
// Re-configuration supersedes any prior removal tombstone.
|
||||||
|
this.removedGenerations.delete(serviceId);
|
||||||
this.config.services[serviceId] = {
|
this.config.services[serviceId] = {
|
||||||
enabled: config.enabled !== false,
|
enabled: config.enabled !== false,
|
||||||
name: config.name || serviceId,
|
name: config.name || serviceId,
|
||||||
@@ -552,12 +736,42 @@ class HealthChecker extends EventEmitter {
|
|||||||
* Remove service configuration
|
* Remove service configuration
|
||||||
*/
|
*/
|
||||||
removeService(serviceId) {
|
removeService(serviceId) {
|
||||||
|
// DC-088: tombstone the captured generation instead of leaking an entry.
|
||||||
|
// The live map entry is deleted; an in-flight probe captured BEFORE this
|
||||||
|
// point sees no live entry but a higher tombstone generation, so it is
|
||||||
|
// discarded. configureService clears the tombstone on re-add.
|
||||||
|
this.generationSeq += 1;
|
||||||
|
this.serviceGenerations.delete(serviceId);
|
||||||
|
this.removedGenerations.set(serviceId, {
|
||||||
|
generation: this.generationSeq,
|
||||||
|
removedAt: Date.now()
|
||||||
|
});
|
||||||
if (this.config.services) {
|
if (this.config.services) {
|
||||||
delete this.config.services[serviceId];
|
delete this.config.services[serviceId];
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: open incidents for a removed service must not linger forever.
|
||||||
|
// Close them through the same resolve path a recovery would, annotated so
|
||||||
|
// history shows why (dashboard renders resolved incidents green + duration).
|
||||||
|
for (const incident of this.incidents) {
|
||||||
|
if (incident.serviceId === serviceId && incident.status === 'open') {
|
||||||
|
incident.status = 'resolved';
|
||||||
|
incident.resolvedAt = new Date().toISOString();
|
||||||
|
incident.duration = new Date(incident.resolvedAt) - new Date(incident.createdAt);
|
||||||
|
incident.resolvedBy = 'service-removed';
|
||||||
|
this.emit('incident-resolved', incident);
|
||||||
|
this.emit('log', 'info', `Incident closed by service removal: ${incident.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.currentStatus.delete(serviceId);
|
this.currentStatus.delete(serviceId);
|
||||||
|
this.displayedStatus.delete(serviceId);
|
||||||
|
this.consecutiveSinceChange.delete(serviceId);
|
||||||
|
this.consecutiveFailures.delete(serviceId);
|
||||||
|
const timer = this.serviceTimers.get(serviceId);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
this.serviceTimers.delete(serviceId);
|
||||||
delete this.history[serviceId];
|
delete this.history[serviceId];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +790,18 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: sweep expired removal tombstones. After the TTL no probe that
|
||||||
|
// captured a pre-removal generation can still be in flight (timeout is
|
||||||
|
// bounded by performHealthCheck), so the tombstone has done its job.
|
||||||
|
if (this.removedGenerations.size > 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [serviceId, tomb] of this.removedGenerations) {
|
||||||
|
if (now - tomb.removedAt > REMOVED_GENERATION_TTL_MS) {
|
||||||
|
this.removedGenerations.delete(serviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,53 @@ const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24
|
|||||||
const TAILSCALE_MAX_USES = 1;
|
const TAILSCALE_MAX_USES = 1;
|
||||||
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
|
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
|
||||||
|
|
||||||
|
// DC-083: Public share endpoint input bounds. The two CSRF-exempt public
|
||||||
|
// endpoints accept untrusted body fields — bound shape, length, charset so
|
||||||
|
// an attacker can't bloat data/shares.json, inject CRLF into fields that
|
||||||
|
// flow into Tailscale auth-key descriptions, or smuggle control chars into
|
||||||
|
// the on-disk store. See routes/share.js for the route-layer validation;
|
||||||
|
// these helpers are the defense-in-depth belt under the route's suspenders.
|
||||||
|
const PUBLIC_EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
const PUBLIC_EMAIL_MAX_LENGTH = 254; // RFC 5321 §4.5.3.1.3
|
||||||
|
const PUBLIC_DEVICE_ID_REGEX = /^[a-zA-Z0-9._:-]+$/;
|
||||||
|
const PUBLIC_DEVICE_ID_MIN_LENGTH = 1;
|
||||||
|
const PUBLIC_DEVICE_ID_MAX_LENGTH = 128;
|
||||||
|
|
||||||
|
function validatePublicEmail(raw) {
|
||||||
|
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_email' };
|
||||||
|
// Reject control chars / NUL / CR / LF before they can corrupt the on-disk
|
||||||
|
// JSON or be embedded in subsequent log lines. RFC 5321 forbids these in
|
||||||
|
// SMTP addresses; we mirror that at the API layer.
|
||||||
|
if (raw.length === 0 || raw.length > PUBLIC_EMAIL_MAX_LENGTH) {
|
||||||
|
return { ok: false, reason: 'invalid_email' };
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_email' };
|
||||||
|
// The local-part can technically contain `+`, `.`, `_`, `%`, `-`; the
|
||||||
|
// domain part must have at least one dot and a 2+ letter TLD. Reject
|
||||||
|
// quote-bracket forms (RFC 5321 obs-quote-text) — we don't accept them.
|
||||||
|
if (!PUBLIC_EMAIL_REGEX.test(raw)) return { ok: false, reason: 'invalid_email' };
|
||||||
|
// Block obvious shell-attachment characters that the regex doesn't catch.
|
||||||
|
if (/[<>{}|\\^`\s]/.test(raw)) return { ok: false, reason: 'invalid_email' };
|
||||||
|
return { ok: true, email: raw.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePublicDeviceId(raw) {
|
||||||
|
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
if (raw.length < PUBLIC_DEVICE_ID_MIN_LENGTH || raw.length > PUBLIC_DEVICE_ID_MAX_LENGTH) {
|
||||||
|
return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
}
|
||||||
|
// Tailscale machine IDs are base64url-with-hyphens; we accept a slightly
|
||||||
|
// broader charset (`._:-`) to also accommodate hostname-style IDs and
|
||||||
|
// Caddy's `forward_auth` device headers. Reject CR/LF/NUL/TAB explicitly
|
||||||
|
// so a smuggled control char can't break out of the Tailscale auth-key
|
||||||
|
// description string in routes/share.js:213.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
if (!PUBLIC_DEVICE_ID_REGEX.test(raw)) return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
return { ok: true, deviceId: raw };
|
||||||
|
}
|
||||||
|
|
||||||
function _nowMs() { return Date.now(); }
|
function _nowMs() { return Date.now(); }
|
||||||
function _nowIso() { return new Date().toISOString(); }
|
function _nowIso() { return new Date().toISOString(); }
|
||||||
|
|
||||||
@@ -327,8 +374,18 @@ function createShareStore(opts = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordPublicSubscribe(token) {
|
function recordPublicSubscribe(token, { email } = {}) {
|
||||||
return _enqueue(() => {
|
return _enqueue(() => {
|
||||||
|
// DC-083: validate the optional subscriber email at the store layer too.
|
||||||
|
// The route layer validates first; this is the defense-in-depth catch
|
||||||
|
// for direct callers (cron sweepers, internal jobs, future endpoints).
|
||||||
|
// `email` is OPT-IN — callers omitting it get the original behavior.
|
||||||
|
let normalizedEmail = null;
|
||||||
|
if (email !== undefined && email !== null) {
|
||||||
|
const v = validatePublicEmail(email);
|
||||||
|
if (!v.ok) return { ok: false, reason: v.reason };
|
||||||
|
normalizedEmail = v.email;
|
||||||
|
}
|
||||||
const data = _load();
|
const data = _load();
|
||||||
const hash = _sha256(token);
|
const hash = _sha256(token);
|
||||||
const s = _findByHash(data, hash);
|
const s = _findByHash(data, hash);
|
||||||
@@ -341,6 +398,16 @@ function createShareStore(opts = {}) {
|
|||||||
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
||||||
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
|
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
|
||||||
s.subscribeCount += 1;
|
s.subscribeCount += 1;
|
||||||
|
// DC-083: record the last submitting email (capped to 8 entries to
|
||||||
|
// bound the on-disk size). PII minimization — we keep only the hash
|
||||||
|
// + last 8 emails; full email log would grow unbounded.
|
||||||
|
if (normalizedEmail) {
|
||||||
|
if (!Array.isArray(s.subscriberEmails)) s.subscriberEmails = [];
|
||||||
|
s.subscriberEmails.push(normalizedEmail);
|
||||||
|
if (s.subscriberEmails.length > 8) {
|
||||||
|
s.subscriberEmails.splice(0, s.subscriberEmails.length - 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
_save(data);
|
_save(data);
|
||||||
return { ok: true, count: s.subscribeCount, cap };
|
return { ok: true, count: s.subscribeCount, cap };
|
||||||
});
|
});
|
||||||
@@ -348,6 +415,18 @@ function createShareStore(opts = {}) {
|
|||||||
|
|
||||||
function recordTailscaleUse(token, { deviceId } = {}) {
|
function recordTailscaleUse(token, { deviceId } = {}) {
|
||||||
return _enqueue(() => {
|
return _enqueue(() => {
|
||||||
|
// DC-083: validate deviceId at the store layer. The pre-fix code
|
||||||
|
// accepted ANY string of any length, including control chars and
|
||||||
|
// CR/LF — which would flow into the Tailscale auth-key description
|
||||||
|
// (routes/share.js:213) and into the on-disk shares.json. Reject
|
||||||
|
// early so an attacker can't bloat the store or smuggle characters
|
||||||
|
// out of the Tailscale description field.
|
||||||
|
let normalizedDeviceId = 'unknown';
|
||||||
|
if (deviceId !== undefined && deviceId !== null) {
|
||||||
|
const v = validatePublicDeviceId(deviceId);
|
||||||
|
if (!v.ok) return { ok: false, reason: v.reason };
|
||||||
|
normalizedDeviceId = v.deviceId;
|
||||||
|
}
|
||||||
const data = _load();
|
const data = _load();
|
||||||
const hash = _sha256(token);
|
const hash = _sha256(token);
|
||||||
const s = _findByHash(data, hash);
|
const s = _findByHash(data, hash);
|
||||||
@@ -359,7 +438,7 @@ function createShareStore(opts = {}) {
|
|||||||
return { ok: false, reason: 'expired' };
|
return { ok: false, reason: 'expired' };
|
||||||
}
|
}
|
||||||
s.usedAt = _nowIso();
|
s.usedAt = _nowIso();
|
||||||
s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown';
|
s.usedBy = normalizedDeviceId;
|
||||||
_save(data);
|
_save(data);
|
||||||
return { ok: true, share: _publicView(s) };
|
return { ok: true, share: _publicView(s) };
|
||||||
});
|
});
|
||||||
@@ -411,4 +490,4 @@ function createShareStore(opts = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createShareStore };
|
module.exports = { createShareStore, validatePublicEmail, validatePublicDeviceId };
|
||||||
@@ -14,7 +14,11 @@ const KNOWN_KEYS = [
|
|||||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||||
'customLogoDark', 'customLogoLight', 'language'
|
'customLogoDark', 'customLogoLight', 'language',
|
||||||
|
// license-manager.js persists the last activation to config.licenseBackup
|
||||||
|
// (restore-on-restart path); src/config/migrations.js stamps _version.
|
||||||
|
// Both are first-party writes — see DC-091.
|
||||||
|
'licenseBackup', '_version'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ const RATE_LIMITS = {
|
|||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10,
|
max: 10,
|
||||||
},
|
},
|
||||||
|
// DC-083: Public share endpoint limiter. The two CSRF-exempt public
|
||||||
|
// endpoints (POST /share/:token/subscribe + POST /share/:token/redeem-tailscale)
|
||||||
|
// mutate on-disk state (data/shares.json). Bound them tighter than the
|
||||||
|
// general limiter (1000/15min) so a single attacker can't bloat the
|
||||||
|
// store or saturate the tmp+rename writer. 30/15min is enough for a
|
||||||
|
// legitimate user clicking "subscribe" once or twice — anything beyond
|
||||||
|
// is abuse.
|
||||||
|
SHARE_PUBLIC: {
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Caddy ─────────────────────────────────────────────────────
|
// ── Caddy ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -330,17 +330,22 @@ module.exports = function configureMiddleware(app, {
|
|||||||
const ssoHandoffTokens = new Map();
|
const ssoHandoffTokens = new Map();
|
||||||
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
function createHandoffToken() {
|
function createHandoffToken(expectedHost = null) {
|
||||||
const token = crypto.randomBytes(24).toString('base64url');
|
const token = crypto.randomBytes(24).toString('base64url');
|
||||||
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
|
ssoHandoffTokens.set(token, {
|
||||||
|
exp: Date.now() + SSO_HANDOFF_TTL_MS,
|
||||||
|
expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null,
|
||||||
|
});
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redeemHandoffToken(token) {
|
function redeemHandoffToken(token, actualHost = null) {
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
const entry = ssoHandoffTokens.get(token);
|
const entry = ssoHandoffTokens.get(token);
|
||||||
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
||||||
return !!entry && entry.exp > Date.now();
|
if (!entry || entry.exp <= Date.now()) return false;
|
||||||
|
if (!entry.expectedHost) return true;
|
||||||
|
return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHostOnlySessionCookie(res, durationKey) {
|
function setHostOnlySessionCookie(res, durationKey) {
|
||||||
|
|||||||
@@ -630,10 +630,15 @@ generate_caddyfile() {
|
|||||||
SNIP
|
SNIP
|
||||||
|
|
||||||
local auth_snippet="(dashcaddy_auth) {
|
local auth_snippet="(dashcaddy_auth) {
|
||||||
forward_auth localhost:${API_PORT} {
|
@needsAuth not path /dashcaddy-sso
|
||||||
|
forward_auth @needsAuth localhost:${API_PORT} {
|
||||||
uri /api/v1/auth/gate/{args[0]}
|
uri /api/v1/auth/gate/{args[0]}
|
||||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||||
}
|
}
|
||||||
|
handle /dashcaddy-sso {
|
||||||
|
rewrite * /api/v1/auth/sso-exchange
|
||||||
|
reverse_proxy localhost:${API_PORT}
|
||||||
|
}
|
||||||
}"
|
}"
|
||||||
|
|
||||||
local site_body=" root * ${DASHBOARD_DIR}
|
local site_body=" root * ${DASHBOARD_DIR}
|
||||||
|
|||||||
@@ -51,10 +51,15 @@ class CaddyfileGenerator {
|
|||||||
_authSnippet(apiPort) {
|
_authSnippet(apiPort) {
|
||||||
return `# DashCaddy SSO auth snippet
|
return `# DashCaddy SSO auth snippet
|
||||||
(dashcaddy_auth) {
|
(dashcaddy_auth) {
|
||||||
forward_auth localhost:${apiPort} {
|
@needsAuth not path /dashcaddy-sso
|
||||||
|
forward_auth @needsAuth localhost:${apiPort} {
|
||||||
uri /api/v1/auth/gate/{args[0]}
|
uri /api/v1/auth/gate/{args[0]}
|
||||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||||
}
|
}
|
||||||
|
handle /dashcaddy-sso {
|
||||||
|
rewrite * /api/v1/auth/sso-exchange
|
||||||
|
reverse_proxy localhost:${apiPort}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const CaddyfileGenerator = require('./caddyfile-generator');
|
||||||
|
|
||||||
|
describe('cross-host SSO installer contract', () => {
|
||||||
|
test('generated auth snippet exposes the public one-time exchange landing route', () => {
|
||||||
|
const snippet = new CaddyfileGenerator()._authSnippet(3001);
|
||||||
|
expect(snippet).toContain('@needsAuth not path /dashcaddy-sso');
|
||||||
|
expect(snippet).toContain('handle /dashcaddy-sso');
|
||||||
|
expect(snippet).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||||
|
expect(snippet).toContain('reverse_proxy localhost:3001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shell installer emits the same exchange landing contract', () => {
|
||||||
|
const installer = fs.readFileSync(path.join(__dirname, '..', '..', 'install.sh'), 'utf8');
|
||||||
|
expect(installer).toContain('@needsAuth not path /dashcaddy-sso');
|
||||||
|
expect(installer).toContain('handle /dashcaddy-sso');
|
||||||
|
expect(installer).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Caddy parser accepts a complete service config using the generated snippet', () => {
|
||||||
|
const available = spawnSync('caddy', ['version'], { encoding: 'utf8' });
|
||||||
|
if (available.status !== 0) return;
|
||||||
|
|
||||||
|
const generator = new CaddyfileGenerator();
|
||||||
|
const config = `${generator._authSnippet(3001)}\nexample.test {\n import dashcaddy_auth plex\n respond "ok" 200\n}\n`;
|
||||||
|
const result = spawnSync('caddy', ['validate', '--config', '-', '--adapter', 'caddyfile'], {
|
||||||
|
input: config,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(`${result.stdout}\n${result.stderr}`).toContain('Valid configuration');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ const bundles = {
|
|||||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||||
JS('totp-recovery.js'),
|
JS('totp-recovery.js'),
|
||||||
|
JS('credential-vault-handoff.js'),
|
||||||
JS('service-credentials.js'),
|
JS('service-credentials.js'),
|
||||||
JS('totp-settings.js'),
|
JS('totp-settings.js'),
|
||||||
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
||||||
|
|||||||
Vendored
+108
-108
File diff suppressed because one or more lines are too long
Vendored
+60
-60
File diff suppressed because one or more lines are too long
Vendored
+7
-7
File diff suppressed because one or more lines are too long
+65
-4
@@ -216,8 +216,8 @@
|
|||||||
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
||||||
));
|
));
|
||||||
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
||||||
_el('input', { name: 'sendEmail', type: 'checkbox', checked: true }),
|
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
|
||||||
_el('span', { text: 'Send email' }),
|
_el('span', { text: 'Also send via email (optional)' }),
|
||||||
));
|
));
|
||||||
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
||||||
container.appendChild(form);
|
container.appendChild(form);
|
||||||
@@ -297,16 +297,77 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
banner.appendChild(copyBtn);
|
banner.appendChild(copyBtn);
|
||||||
if (invite.deliveredVia === 'dev-console') {
|
|
||||||
|
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
|
||||||
|
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
|
||||||
|
// copy this as a sentence instead of dealing with the raw URL.
|
||||||
|
if (invite.shareText) {
|
||||||
|
const shareBlock = _el('div', { style: 'margin-top:12px' });
|
||||||
|
shareBlock.appendChild(_el('div', {
|
||||||
|
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
|
||||||
|
text: 'Share this message:',
|
||||||
|
}));
|
||||||
|
shareBlock.appendChild(_el('div', {
|
||||||
|
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
|
||||||
|
text: invite.shareText,
|
||||||
|
}));
|
||||||
|
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
|
||||||
|
const copyTextBtn = _el('button', {
|
||||||
|
class: 'btn-sm', style: 'padding:4px 10px',
|
||||||
|
text: 'Copy message',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(invite.shareText);
|
||||||
|
copyTextBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shareActions.appendChild(copyTextBtn);
|
||||||
|
// Native share sheet on mobile / supported browsers. Falls back silently
|
||||||
|
// (the copy buttons cover the same intent).
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||||
|
const nativeShareBtn = _el('button', {
|
||||||
|
class: 'btn-sm', style: 'padding:4px 10px',
|
||||||
|
text: 'Share via…',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'DashCaddy invite',
|
||||||
|
text: invite.shareText,
|
||||||
|
url: invite.acceptUrl,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// User-cancelled throws AbortError — that's fine, just stay quiet.
|
||||||
|
if (e && e.name && e.name !== 'AbortError') {
|
||||||
|
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shareActions.appendChild(nativeShareBtn);
|
||||||
|
}
|
||||||
|
shareBlock.appendChild(shareActions);
|
||||||
|
banner.appendChild(shareBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.deliveredVia === 'failed') {
|
||||||
banner.appendChild(_el('p', {
|
banner.appendChild(_el('p', {
|
||||||
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
||||||
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
|
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
|
||||||
}));
|
}));
|
||||||
} else if (invite.deliveredVia === 'email') {
|
} else if (invite.deliveredVia === 'email') {
|
||||||
banner.appendChild(_el('p', {
|
banner.appendChild(_el('p', {
|
||||||
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||||
text: 'Email sent to ' + invite.email + '.',
|
text: 'Email sent to ' + invite.email + '.',
|
||||||
}));
|
}));
|
||||||
|
} else if (invite.deliveredVia === 'manual') {
|
||||||
|
banner.appendChild(_el('p', {
|
||||||
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||||
|
text: 'Share the link above via text, chat, or any messenger.',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
parent.appendChild(banner);
|
parent.appendChild(banner);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-2
@@ -267,6 +267,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSsoHandoffTarget(returnUrl, token) {
|
||||||
|
const parsed = new URL(returnUrl, 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 || !token) return null;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeExistingSession(returnUrl) {
|
||||||
|
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
|
||||||
|
try {
|
||||||
|
const parsedReturn = new URL(returnUrl, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const serviceId = parsedReturn.hostname.slice(0, -suffix.length);
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) return false;
|
||||||
|
const res = await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(serviceId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
const target = data.success && buildSsoHandoffTarget(returnUrl, data.ssoToken);
|
||||||
|
if (!target) return false;
|
||||||
|
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||||
|
window.location.replace(target);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.get('auth') === 'required') {
|
if (urlParams.get('auth') === 'required') {
|
||||||
// Preserve the gated service destination so submitTotpCode() can append
|
// Preserve the gated service destination so submitTotpCode() can append
|
||||||
@@ -277,8 +317,12 @@
|
|||||||
}
|
}
|
||||||
// Clean URL — happens after we've captured the redirect
|
// Clean URL — happens after we've captured the redirect
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
// Show on next tick so the DOM (the .totp-card) is ready
|
// Reuse the valid status.sami session first. Only show the TOTP/provider
|
||||||
setTimeout(show, 0);
|
// challenge when that session is genuinely absent or expired.
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (await resumeExistingSession(returnUrl)) return;
|
||||||
|
await show();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose for hot-trigger from other modules (e.g. logout)
|
// Expose for hot-trigger from other modules (e.g. logout)
|
||||||
|
|||||||
@@ -33,6 +33,20 @@
|
|||||||
return server?.name || dnsId.toUpperCase();
|
return server?.name || dnsId.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireSuccessfulDnsMutation(response, label) {
|
||||||
|
if (!response) throw new Error(`${label} failed: no response`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`${label} failed: invalid server response`);
|
||||||
|
}
|
||||||
|
if (!response.ok || data?.success !== true) {
|
||||||
|
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build per-server credential form sections from SITE.dnsServers */
|
/** Build per-server credential form sections from SITE.dnsServers */
|
||||||
function buildCredentialSections() {
|
function buildCredentialSections() {
|
||||||
const container = document.getElementById('dns-cred-sections');
|
const container = document.getElementById('dns-cred-sections');
|
||||||
@@ -258,14 +272,6 @@
|
|||||||
document.getElementById('token-save')?.addEventListener('click', async () => {
|
document.getElementById('token-save')?.addEventListener('click', async () => {
|
||||||
const dnsIds = getDnsIds();
|
const dnsIds = getDnsIds();
|
||||||
|
|
||||||
// Save all to localStorage
|
|
||||||
dnsIds.forEach(dnsId => {
|
|
||||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
|
||||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
|
||||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
|
||||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build per-server credentials payload for backend sync
|
// Build per-server credentials payload for backend sync
|
||||||
const servers = {};
|
const servers = {};
|
||||||
let hasAnyCreds = false;
|
let hasAnyCreds = false;
|
||||||
@@ -304,45 +310,36 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ servers })
|
body: JSON.stringify({ servers })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
|
||||||
|
|
||||||
if (data.results) {
|
if (data.results) {
|
||||||
dnsIds.forEach(dnsId => {
|
const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
|
||||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
if (failed.length) {
|
||||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
|
||||||
const result = data.results[dnsId];
|
throw new Error(details);
|
||||||
if (result?.success) {
|
}
|
||||||
statusEl.textContent = '\u2713 Verified & saved';
|
|
||||||
statusEl.className = 'token-status success';
|
|
||||||
} else if (result?.partial) {
|
|
||||||
statusEl.textContent = '\u2713 ' + result.partial;
|
|
||||||
statusEl.className = 'token-status success';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
|
|
||||||
statusEl.className = 'token-status error';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (data.success) {
|
|
||||||
dnsIds.forEach(dnsId => {
|
|
||||||
if (servers[dnsId]) {
|
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
|
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dnsIds.forEach(dnsId => {
|
|
||||||
if (servers[dnsId]) {
|
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
|
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache locally only after the encrypted server vault confirms success.
|
||||||
|
dnsIds.forEach(dnsId => {
|
||||||
|
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
||||||
|
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
||||||
|
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
||||||
|
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
dnsIds.forEach(dnsId => {
|
||||||
|
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||||
|
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||||
|
const result = data.results?.[dnsId];
|
||||||
|
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
|
||||||
|
statusEl.className = 'token-status success';
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to sync DNS credentials to backend:', e);
|
console.error('Failed to sync DNS credentials to backend:', e);
|
||||||
dnsIds.forEach(dnsId => {
|
dnsIds.forEach(dnsId => {
|
||||||
if (servers[dnsId]) {
|
if (servers[dnsId]) {
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -368,18 +365,24 @@
|
|||||||
|
|
||||||
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
||||||
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
||||||
clearAllCredentials();
|
|
||||||
getDnsIds().forEach(dnsId => {
|
|
||||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-admin-username`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-admin-token`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||||
} catch (_) {}
|
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
|
||||||
|
clearAllCredentials();
|
||||||
|
getDnsIds().forEach(dnsId => {
|
||||||
|
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-admin-username`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-admin-token`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||||
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
getDnsIds().forEach(dnsId => {
|
||||||
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
|
||||||
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@
|
|||||||
await window.loadServices();
|
await window.loadServices();
|
||||||
await loadTemplateCategories();
|
await loadTemplateCategories();
|
||||||
window.buildGrid();
|
window.buildGrid();
|
||||||
|
if (typeof window.openRequestedCredentialForm === 'function') {
|
||||||
|
window.openRequestedCredentialForm();
|
||||||
|
}
|
||||||
animateTopCards();
|
animateTopCards();
|
||||||
window.refreshAll();
|
window.refreshAll();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// ===== ENCRYPTED VAULT -> SERVICE SSO HANDOFF =====
|
||||||
|
(function() {
|
||||||
|
function isAllowedReturnUrl(returnUrl, expectedServiceId) {
|
||||||
|
if (!returnUrl || !expectedServiceId || !/^[a-z0-9][a-z0-9-]*$/.test(expectedServiceId)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(returnUrl, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const expectedHost = `${expectedServiceId}${suffix}`;
|
||||||
|
return parsed.protocol === 'https:' && parsed.hostname === expectedHost;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHandoffTarget(returnUrl, token, expectedServiceId) {
|
||||||
|
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return null;
|
||||||
|
const parsed = new URL(returnUrl, window.location.origin);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
// The shared (dashcaddy_auth) Caddy snippet installs this public landing
|
||||||
|
// route on every protected host. It rewrites to /api/v1/auth/sso-exchange.
|
||||||
|
parsed.pathname = '/dashcaddy-sso';
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.searchParams.set('token', token);
|
||||||
|
parsed.searchParams.set('return', returnPath);
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume(returnUrl, expectedServiceId, runtime = {}) {
|
||||||
|
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return false;
|
||||||
|
const fetchFn = runtime.fetch || window.fetch.bind(window);
|
||||||
|
const locationObj = runtime.location || window.location;
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(expectedServiceId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json();
|
||||||
|
const target = data.success && buildHandoffTarget(returnUrl, data.ssoToken, expectedServiceId);
|
||||||
|
if (!target) return false;
|
||||||
|
locationObj.replace(target);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DCCredentialVault = { isAllowedReturnUrl, buildHandoffTarget, resume };
|
||||||
|
})();
|
||||||
@@ -240,9 +240,23 @@
|
|||||||
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// email fields
|
// email fields — DC-092: prefill the FULL form so a save doesn't
|
||||||
|
// silently wipe fields the GET response previously omitted. Password
|
||||||
|
// is never returned; when one is stored the field shows a keep-hint
|
||||||
|
// and an empty submit preserves the stored credential server-side.
|
||||||
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
|
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
|
||||||
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
|
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
|
||||||
|
if (config.providers?.email?.to) document.getElementById('email-to').value = config.providers.email.to;
|
||||||
|
if (config.providers?.email?.port) document.getElementById('email-port').value = config.providers.email.port;
|
||||||
|
if (config.providers?.email?.secure !== undefined) document.getElementById('email-secure').checked = config.providers.email.secure === true;
|
||||||
|
if (config.providers?.email?.username) document.getElementById('email-user').value = config.providers.email.username;
|
||||||
|
const emailPassEl = document.getElementById('email-pass');
|
||||||
|
if (config.providers?.email?.hasPassword) {
|
||||||
|
emailPassEl.value = '';
|
||||||
|
emailPassEl.placeholder = 'saved — leave blank to keep';
|
||||||
|
} else {
|
||||||
|
emailPassEl.placeholder = 'app password';
|
||||||
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
||||||
@@ -254,12 +268,14 @@
|
|||||||
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
|
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Events
|
// Events — canonical kebab-case keys, matching the backend store
|
||||||
document.getElementById('event-container-down').checked = config.events?.containerDown !== false;
|
// (DC-092: previously read camelCase keys that never existed, so
|
||||||
document.getElementById('event-container-up').checked = config.events?.containerUp !== false;
|
// every toggle re-rendered as 'checked' regardless of stored state).
|
||||||
document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false;
|
document.getElementById('event-container-down').checked = config.events?.['container-down'] !== false;
|
||||||
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
|
document.getElementById('event-container-up').checked = config.events?.['container-up'] === true;
|
||||||
document.getElementById('event-resource-alert').checked = config.events?.resourceAlert !== false;
|
document.getElementById('event-deploy-success').checked = config.events?.['deploy-success'] !== false;
|
||||||
|
document.getElementById('event-deploy-failed').checked = config.events?.['deploy-failed'] !== false;
|
||||||
|
document.getElementById('event-resource-alert').checked = config.events?.['alert'] !== false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
|
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
|
||||||
@@ -325,18 +341,18 @@
|
|||||||
host: document.getElementById('email-host').value.trim(),
|
host: document.getElementById('email-host').value.trim(),
|
||||||
port: parseInt(document.getElementById('email-port').value) || 587,
|
port: parseInt(document.getElementById('email-port').value) || 587,
|
||||||
secure: document.getElementById('email-secure').checked,
|
secure: document.getElementById('email-secure').checked,
|
||||||
user: document.getElementById('email-user').value.trim(),
|
username: document.getElementById('email-user').value.trim(),
|
||||||
pass: document.getElementById('email-pass').value.trim(),
|
password: document.getElementById('email-pass').value.trim(),
|
||||||
from: document.getElementById('email-from').value.trim(),
|
from: document.getElementById('email-from').value.trim(),
|
||||||
to: document.getElementById('email-to').value.trim()
|
to: document.getElementById('email-to').value.trim()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
containerDown: document.getElementById('event-container-down').checked,
|
'container-down': document.getElementById('event-container-down').checked,
|
||||||
containerUp: document.getElementById('event-container-up').checked,
|
'container-up': document.getElementById('event-container-up').checked,
|
||||||
deploymentSuccess: document.getElementById('event-deploy-success').checked,
|
'deploy-success': document.getElementById('event-deploy-success').checked,
|
||||||
deploymentFailed: document.getElementById('event-deploy-failed').checked,
|
'deploy-failed': document.getElementById('event-deploy-failed').checked,
|
||||||
resourceAlert: document.getElementById('event-resource-alert').checked
|
'alert': document.getElementById('event-resource-alert').checked
|
||||||
},
|
},
|
||||||
healthCheck: {
|
healthCheck: {
|
||||||
enabled: document.getElementById('health-check-enabled').checked,
|
enabled: document.getElementById('health-check-enabled').checked,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
|
|
||||||
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
||||||
<div class="service-creds-content">
|
<div class="service-creds-content">
|
||||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
|
||||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
|
||||||
|
|
||||||
<!-- Status indicator -->
|
<!-- Status indicator -->
|
||||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||||
Save
|
Save to encrypted vault
|
||||||
</button>
|
</button>
|
||||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||||
Clear
|
Clear
|
||||||
@@ -105,6 +105,8 @@
|
|||||||
|
|
||||||
const modal = document.getElementById('service-creds-modal');
|
const modal = document.getElementById('service-creds-modal');
|
||||||
let currentService = null;
|
let currentService = null;
|
||||||
|
let credentialReturnUrl = null;
|
||||||
|
let currentServiceHadCreds = false;
|
||||||
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
||||||
const qualityProfileServices = ['sonarr', 'radarr'];
|
const qualityProfileServices = ['sonarr', 'radarr'];
|
||||||
|
|
||||||
@@ -124,8 +126,28 @@
|
|||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.openServiceCredsModal = async function(service) {
|
async function requireSuccessfulWrite(response, label) {
|
||||||
|
if (!response) throw new Error(`${label} failed: no response`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`${label} failed: invalid server response`);
|
||||||
|
}
|
||||||
|
if (!response.ok || data?.success !== true) {
|
||||||
|
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
|
||||||
|
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openServiceCredsModal = async function(service, options = {}) {
|
||||||
currentService = service;
|
currentService = service;
|
||||||
|
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
|
||||||
|
currentServiceHadCreds = false;
|
||||||
hideError();
|
hideError();
|
||||||
const title = document.getElementById('svc-creds-title');
|
const title = document.getElementById('svc-creds-title');
|
||||||
const desc = document.getElementById('svc-creds-desc');
|
const desc = document.getElementById('svc-creds-desc');
|
||||||
@@ -134,7 +156,10 @@
|
|||||||
const basicSection = document.getElementById('svc-creds-basic');
|
const basicSection = document.getElementById('svc-creds-basic');
|
||||||
const qualitySection = document.getElementById('svc-creds-quality');
|
const qualitySection = document.getElementById('svc-creds-quality');
|
||||||
|
|
||||||
title.textContent = service.name + ' Credentials';
|
title.textContent = service.name + ' — Encrypted Vault';
|
||||||
|
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
|
||||||
|
? 'Save to vault & open service'
|
||||||
|
: 'Save to encrypted vault';
|
||||||
// Determine which sections to show
|
// Determine which sections to show
|
||||||
const isExt = !!service.isExternal;
|
const isExt = !!service.isExternal;
|
||||||
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
||||||
@@ -214,6 +239,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasCreds) {
|
if (hasCreds) {
|
||||||
|
currentServiceHadCreds = true;
|
||||||
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
||||||
status.style.color = 'var(--ok-fg, #74dfc4)';
|
status.style.color = 'var(--ok-fg, #74dfc4)';
|
||||||
status.textContent = 'Credentials stored';
|
status.textContent = 'Credentials stored';
|
||||||
@@ -352,16 +378,35 @@
|
|||||||
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
||||||
const svcId = currentService.id || currentService.appTemplate;
|
const svcId = currentService.id || currentService.appTemplate;
|
||||||
|
|
||||||
|
if (credentialReturnUrl && !currentServiceHadCreds) {
|
||||||
|
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
|
||||||
|
const externalPass = document.getElementById('svc-seedhost-pass').value;
|
||||||
|
const apiKeyInput = document.getElementById('svc-apikey-input');
|
||||||
|
const requestedApiKey = apiKeyInput?.value.trim();
|
||||||
|
const basicUser = document.getElementById('svc-basic-user').value.trim();
|
||||||
|
const basicPass = document.getElementById('svc-basic-pass').value;
|
||||||
|
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
|
||||||
|
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
|
||||||
|
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
|
||||||
|
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
|
||||||
|
showError('Enter the login or API key DashCaddy should store for this service.');
|
||||||
|
saveBtn.textContent = 'Save to vault & open service';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save seedhost creds (shared username + per-service password)
|
// Save seedhost creds (shared username + per-service password)
|
||||||
if (currentService.isExternal) {
|
if (currentService.isExternal) {
|
||||||
const user = document.getElementById('svc-seedhost-user').value.trim();
|
const user = document.getElementById('svc-seedhost-user').value.trim();
|
||||||
const pass = document.getElementById('svc-seedhost-pass').value;
|
const pass = document.getElementById('svc-seedhost-pass').value;
|
||||||
if (user) {
|
if (user) {
|
||||||
await secureFetch('/api/v1/seedhost-creds', {
|
const response = await secureFetch('/api/v1/seedhost-creds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Seedhost credential save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,23 +432,18 @@
|
|||||||
qualityProfileName: qualityProfileName || undefined
|
qualityProfileName: qualityProfileName || undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await requireSuccessfulWrite(res, 'ARR credential save');
|
||||||
if (!data.success) {
|
|
||||||
showError(data.error || 'Failed to save API key');
|
|
||||||
saveBtn.textContent = 'Save';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.connectionTest && !data.connectionTest.success) {
|
if (data.connectionTest && !data.connectionTest.success) {
|
||||||
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-arr services use the generic endpoint
|
// Non-arr services use the generic endpoint
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ apiKey })
|
body: JSON.stringify({ apiKey })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'API key save');
|
||||||
}
|
}
|
||||||
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
||||||
// API key unchanged but user may have changed quality profile — save profile only
|
// API key unchanged but user may have changed quality profile — save profile only
|
||||||
@@ -411,11 +451,12 @@
|
|||||||
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
||||||
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
||||||
if (qualityProfileId) {
|
if (qualityProfileId) {
|
||||||
await secureFetch('/api/v1/arr/quality-profiles', {
|
const response = await secureFetch('/api/v1/arr/quality-profiles', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Quality profile save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,20 +465,28 @@
|
|||||||
const user = document.getElementById('svc-basic-user').value.trim();
|
const user = document.getElementById('svc-basic-user').value.trim();
|
||||||
const pass = document.getElementById('svc-basic-pass').value;
|
const pass = document.getElementById('svc-basic-pass').value;
|
||||||
if (user && pass) {
|
if (user && pass) {
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: user, password: pass })
|
body: JSON.stringify({ username: user, password: pass })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Service credential save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadServiceCreds(currentService);
|
await loadServiceCreds(currentService);
|
||||||
|
if (credentialReturnUrl) {
|
||||||
|
const returnUrl = credentialReturnUrl;
|
||||||
|
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
|
||||||
|
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
|
||||||
|
credentialReturnUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
||||||
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
||||||
}
|
}
|
||||||
saveBtn.textContent = 'Save';
|
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,12 +499,15 @@
|
|||||||
const svcId = currentService.id || currentService.appTemplate;
|
const svcId = currentService.id || currentService.appTemplate;
|
||||||
const isArr = arrServices.includes(svcId);
|
const isArr = arrServices.includes(svcId);
|
||||||
if (currentService.isExternal) {
|
if (currentService.isExternal) {
|
||||||
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(response, 'Seedhost credential removal');
|
||||||
}
|
}
|
||||||
// Delete from both namespaces
|
// Delete from both namespaces
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(response, 'Service credential removal');
|
||||||
if (isArr) {
|
if (isArr) {
|
||||||
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
|
||||||
}
|
}
|
||||||
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
||||||
if (btn) btn.classList.remove('has-creds');
|
if (btn) btn.classList.remove('has-creds');
|
||||||
@@ -470,11 +522,13 @@
|
|||||||
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
currentService = null;
|
currentService = null;
|
||||||
|
credentialReturnUrl = null;
|
||||||
});
|
});
|
||||||
modal?.addEventListener('click', (e) => {
|
modal?.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
currentService = null;
|
currentService = null;
|
||||||
|
credentialReturnUrl = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -501,4 +555,18 @@
|
|||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Protected service login pages send missing credentials here. Reuse the
|
||||||
|
// normal vault form, then resume through the existing one-time SSO handoff.
|
||||||
|
window.openRequestedCredentialForm = function() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const serviceId = params.get('credentials');
|
||||||
|
if (!serviceId) return false;
|
||||||
|
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
|
||||||
|
if (!service) return false;
|
||||||
|
const returnUrl = params.get('return');
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
window.openServiceCredsModal(service, { returnUrl });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+12
-2
@@ -90,11 +90,22 @@
|
|||||||
errorEl.textContent = 'Verifying...';
|
errorEl.textContent = 'Verifying...';
|
||||||
errorEl.className = 'totp-error verifying';
|
errorEl.className = 'totp-error verifying';
|
||||||
|
|
||||||
|
const redirect = safeSessionGet('totp_redirect');
|
||||||
|
let serviceId = null;
|
||||||
|
if (redirect) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(redirect, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const candidate = parsed.hostname.slice(0, -suffix.length);
|
||||||
|
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
|
||||||
|
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await secureFetch('/api/v1/totp/verify', {
|
const res = await secureFetch('/api/v1/totp/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code })
|
body: JSON.stringify({ code, serviceId })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -106,7 +117,6 @@
|
|||||||
}
|
}
|
||||||
hideTotpOverlay();
|
hideTotpOverlay();
|
||||||
// Check if redirected here from another service
|
// Check if redirected here from another service
|
||||||
const redirect = safeSessionGet('totp_redirect');
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||||
// .sami is an unregistered TLD, so browsers silently drop the
|
// .sami is an unregistered TLD, so browsers silently drop the
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-a24ef15882';
|
const CACHE = 'dashcaddy-shell-3354f5fd96';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
@@ -93,3 +93,41 @@ test('same-origin and tokenless destinations keep their direct URL', () => {
|
|||||||
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
||||||
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an existing status.sami session returns to a service without another TOTP prompt', async () => {
|
||||||
|
const query = new URLSearchParams({ auth: 'required', return: 'https://plex.sami/web/' });
|
||||||
|
let scheduled;
|
||||||
|
let redirected;
|
||||||
|
const location = {
|
||||||
|
origin: 'https://status.sami',
|
||||||
|
pathname: '/',
|
||||||
|
search: `?${query.toString()}`,
|
||||||
|
replace(value) { redirected = value; },
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
URL,
|
||||||
|
URLSearchParams,
|
||||||
|
SITE: { tld: '.sami' },
|
||||||
|
sessionStorage: { setItem() {} },
|
||||||
|
document: { getElementById() { return null; } },
|
||||||
|
setTimeout(fn) { scheduled = fn; },
|
||||||
|
console,
|
||||||
|
fetch: async (url) => {
|
||||||
|
assert.equal(url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
location,
|
||||||
|
history: { replaceState() {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
context.window.window = context.window;
|
||||||
|
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
|
||||||
|
|
||||||
|
assert.equal(typeof scheduled, 'function');
|
||||||
|
await scheduled();
|
||||||
|
assert.equal(
|
||||||
|
redirected,
|
||||||
|
'https://plex.sami/dashcaddy-sso?token=existing-session-token&return=%2Fweb%2F',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const handoffSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'credential-vault-handoff.js'), 'utf8');
|
||||||
|
const formSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'service-credentials.js'), 'utf8');
|
||||||
|
const initSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'init.js'), 'utf8');
|
||||||
|
|
||||||
|
function loadVault() {
|
||||||
|
const window = { location: { origin: 'https://status.sami' } };
|
||||||
|
const context = vm.createContext({ window, SITE: { tld: '.sami' }, URL });
|
||||||
|
vm.runInContext(handoffSource, context);
|
||||||
|
return window.DCCredentialVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exerciseFailedModalWrite({
|
||||||
|
service,
|
||||||
|
fetchJson,
|
||||||
|
setupInputs,
|
||||||
|
expectedEndpoint,
|
||||||
|
writeResponse,
|
||||||
|
expectedError = /vault write rejected/,
|
||||||
|
}) {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
const writeUrls = [];
|
||||||
|
let resumeCalls = 0;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async (url) => ({ ok: true, json: async () => fetchJson(url) });
|
||||||
|
window.secureFetch = async (url) => {
|
||||||
|
writeUrls.push(url);
|
||||||
|
return writeResponse || {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async () => { resumeCalls++; return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
await window.openServiceCredsModal(service, { returnUrl: `https://${service.id}.sami/` });
|
||||||
|
setupInputs(window.document);
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(writeUrls[0], expectedEndpoint);
|
||||||
|
assert.equal(resumeCalls, 0);
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, expectedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('existing dashboard session mints a one-time token and resumes on the target host', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
const calls = [];
|
||||||
|
const replacements = [];
|
||||||
|
const resumed = await vault.resume('https://plex.sami/web/?direct=1#home', 'plex', {
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, ssoToken: 'one-time-token' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
location: { replace: (target) => replacements.push(target) },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, true);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
assert.equal(calls[0].options.credentials, 'include');
|
||||||
|
assert.equal(calls[0].options.cache, 'no-store');
|
||||||
|
assert.equal(
|
||||||
|
replacements[0],
|
||||||
|
'https://plex.sami/dashcaddy-sso?token=one-time-token&return=%2Fweb%2F%3Fdirect%3D1%23home',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vault handoff rejects an external return URL before minting a token', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
let fetchCalled = false;
|
||||||
|
const resumed = await vault.resume('https://plex.sami.evil.example/phish', 'plex', {
|
||||||
|
fetch: async () => { fetchCalled = true; },
|
||||||
|
location: { replace: () => assert.fail('must not navigate') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, false);
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential request opens the form and save path calls the tested handoff helper', () => {
|
||||||
|
assert.match(formSource, /params\.get\('credentials'\)/);
|
||||||
|
assert.match(formSource, /openServiceCredsModal\(service, \{ returnUrl \}\)/);
|
||||||
|
assert.match(formSource, /DCCredentialVault\?\.resume\(returnUrl, currentService\.id\)/);
|
||||||
|
assert.match(initSource, /openRequestedCredentialForm\(\)/);
|
||||||
|
assert.match(formSource, /Save to vault & open service/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actual vault modal save handler stores credentials then resumes the handoff', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/?credentials=plex&return=https%3A%2F%2Fplex.sami%2Fweb%2F',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
let stored = false;
|
||||||
|
const writes = [];
|
||||||
|
const resumed = [];
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.APPS = [{ id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' }];
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
hasApiKey: false,
|
||||||
|
hasBasicAuth: stored,
|
||||||
|
username: stored ? 'vault-user' : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.secureFetch = async (url, options) => {
|
||||||
|
writes.push({ url, body: JSON.parse(options.body) });
|
||||||
|
stored = true;
|
||||||
|
return { ok: true, json: async () => ({ success: true }) };
|
||||||
|
};
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async (returnUrl, serviceId) => { resumed.push({ returnUrl, serviceId }); return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
await window.openServiceCredsModal(window.APPS[0], { returnUrl: 'https://plex.sami/web/' });
|
||||||
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||||
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.deepEqual(writes, [{
|
||||||
|
url: '/api/v1/services/plex/credentials',
|
||||||
|
body: { username: 'vault-user', password: 'vault-password' },
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(resumed, [{ returnUrl: 'https://plex.sami/web/', serviceId: 'plex' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed credential write does not mint a handoff or navigate', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
let resumeCalls = 0;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
});
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||||
|
});
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async () => { resumeCalls++; return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||||
|
await window.openServiceCredsModal(service, { returnUrl: 'https://plex.sami/web/' });
|
||||||
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||||
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(resumeCalls, 0);
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /vault write rejected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed ARR credential write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/services/')
|
||||||
|
? { success: true, hasApiKey: false, hasBasicAuth: false, username: null }
|
||||||
|
: { success: true, profiles: [] },
|
||||||
|
setupInputs: (document) => { document.getElementById('svc-apikey-input').value = 'arr-key'; },
|
||||||
|
expectedEndpoint: '/api/v1/arr/credentials',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed ARR quality-profile write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/services/')
|
||||||
|
? { success: true, hasApiKey: true, hasBasicAuth: false, username: null }
|
||||||
|
: { success: true, profiles: [{ id: 1, name: 'Default' }], storedProfileId: 1 },
|
||||||
|
setupInputs: () => {},
|
||||||
|
expectedEndpoint: '/api/v1/arr/quality-profiles',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed seedhost write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'torrent', name: 'qBittorrent', isExternal: true, externalUrl: 'https://torrent.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/seedhost-creds')
|
||||||
|
? { success: true, hasCredentials: false, username: null }
|
||||||
|
: { success: true, hasApiKey: false, hasBasicAuth: false, username: null },
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-seedhost-user').value = 'seed-user';
|
||||||
|
document.getElementById('svc-seedhost-pass').value = 'seed-password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/seedhost-creds',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed generic API-key write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'custom', name: 'Custom', url: 'https://custom.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-apikey-input').value = 'custom-key';
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/custom/credentials',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 2xx with malformed JSON does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||||
|
writeResponse: { ok: true, status: 200, json: async () => { throw new Error('bad json'); } },
|
||||||
|
expectedError: /invalid server response/,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 2xx without success:true does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||||
|
writeResponse: { ok: true, status: 200, json: async () => ({ message: 'ambiguous' }) },
|
||||||
|
expectedError: /failed \(200\)/,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed credential clear remains visibly failed and keeps stored-state UI', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body><button id="creds-btn-plex" class="has-creds"></button></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: true, username: 'vault-user' }),
|
||||||
|
});
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'clear rejected' }),
|
||||||
|
});
|
||||||
|
window.DCCredentialVault = { isAllowedReturnUrl: () => false };
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||||
|
await window.openServiceCredsModal(service);
|
||||||
|
window.document.getElementById('svc-creds-clear').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /clear rejected/);
|
||||||
|
assert.equal(window.document.getElementById('creds-btn-plex').classList.contains('has-creds'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handoff rejects a private-TLD host that is not the requested protected service', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
let fetchCalled = false;
|
||||||
|
const resumed = await vault.resume('https://dns1.sami/', 'plex', {
|
||||||
|
fetch: async () => { fetchCalled = true; },
|
||||||
|
location: { replace: () => assert.fail('must not navigate') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, false);
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'credentials.js'), 'utf8');
|
||||||
|
|
||||||
|
function buildDnsCredentialUi() {
|
||||||
|
const dom = new JSDOM('<!doctype html><body><button id="manage-tokens"></button></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
const local = new Map();
|
||||||
|
const session = new Map();
|
||||||
|
window.SITE = { dnsServers: { dns1: { name: 'Primary DNS' } } };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.safeGet = key => local.get(key) || null;
|
||||||
|
window.safeSet = (key, value) => local.set(key, value);
|
||||||
|
window.safeRemove = key => local.delete(key);
|
||||||
|
window.safeSessionGet = key => session.get(key) || null;
|
||||||
|
window.safeSessionSet = (key, value) => session.set(key, value);
|
||||||
|
window.closeModal = () => {};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.TextEncoder = TextEncoder;
|
||||||
|
window.setTimeout = () => 1;
|
||||||
|
window.eval(source);
|
||||||
|
window.document.getElementById('manage-tokens').click();
|
||||||
|
return { window, local };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('failed DNS credential save never populates browser cache or success UI', async () => {
|
||||||
|
const { window, local } = buildDnsCredentialUi();
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'DNS vault rejected' }),
|
||||||
|
});
|
||||||
|
window.document.getElementById('dns1-admin-username').value = 'dns-admin';
|
||||||
|
window.document.getElementById('dns1-admin-token').value = 'dns-password';
|
||||||
|
window.document.getElementById('token-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(local.has('dns1-admin-username-enc'), false);
|
||||||
|
assert.equal(local.has('dns1-admin-token-enc'), false);
|
||||||
|
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS vault rejected/);
|
||||||
|
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed DNS credential clear preserves cached state and shows error', async () => {
|
||||||
|
const { window, local } = buildDnsCredentialUi();
|
||||||
|
local.set('dns1-admin-username-enc', 'existing-user');
|
||||||
|
local.set('dns1-admin-token-enc', 'existing-password');
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ message: 'ambiguous response' }),
|
||||||
|
});
|
||||||
|
window.document.getElementById('token-clear-all').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(local.has('dns1-admin-username-enc'), true);
|
||||||
|
assert.equal(local.has('dns1-admin-token-enc'), true);
|
||||||
|
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS credential removal failed/);
|
||||||
|
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user