Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71b794ccc | ||
|
|
f2285a2550 | ||
|
|
54a1df5ac4 | ||
|
|
eb546bf468 | ||
|
|
84e051d975 | ||
|
|
628bbe32f6 | ||
|
|
f8b99f9b5a | ||
|
|
eab2b00b13 | ||
|
|
84edb035e3 | ||
|
|
d313b1e872 | ||
|
|
be021588c7 | ||
|
|
d8459a4a87 | ||
|
|
499fcc2742 | ||
|
|
93d6c44e45 | ||
|
|
4c4ffc35ca |
+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,240 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -203,6 +203,48 @@ describe('HealthChecker', () => {
|
|||||||
expect(result.error).toBe('ECONNREFUSED');
|
expect(result.error).toBe('ECONNREFUSED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens and resolves an outage incident across real checkService transitions', async () => {
|
||||||
|
healthChecker._doRequest = jest.fn()
|
||||||
|
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', 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(1);
|
||||||
|
expect(healthChecker.incidents[0]).toMatchObject({
|
||||||
|
serviceId: 'svc1',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open'
|
||||||
|
});
|
||||||
|
|
||||||
|
await healthChecker.checkService('svc1', config);
|
||||||
|
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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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\(/);
|
||||||
|
|||||||
@@ -32,23 +32,6 @@ const emailSender = require('../../src/auth/providers/email-sender');
|
|||||||
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 +223,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 +247,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: issued.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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -33,17 +33,45 @@ const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '30
|
|||||||
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-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() {
|
||||||
super();
|
super();
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +144,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
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 +160,10 @@ class HealthChecker extends EventEmitter {
|
|||||||
details: result.details
|
details: result.details
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ((this.serviceGenerations.get(serviceId) || 0) !== 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,8 +171,9 @@ 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);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config);
|
this.checkForIncidents(serviceId, status, config, previousStatus);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -156,8 +190,13 @@ class HealthChecker extends EventEmitter {
|
|||||||
error: error.message
|
error: error.message
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStatus = this.currentStatus.get(serviceId);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config);
|
this.checkForIncidents(serviceId, status, config, previousStatus);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -273,27 +312,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,8 +425,7 @@ 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)) {
|
||||||
const previous = this.currentStatus.get(serviceId);
|
|
||||||
|
|
||||||
// Check for status change (up -> down or down -> up)
|
// Check for status change (up -> down or down -> up)
|
||||||
if (previous && previous.status !== status.status) {
|
if (previous && previous.status !== status.status) {
|
||||||
@@ -445,19 +565,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 +597,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
sla: config?.sla
|
sla: config?.sla
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +660,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.config.services = {};
|
this.config.services = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
||||||
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 +683,19 @@ class HealthChecker extends EventEmitter {
|
|||||||
* Remove service configuration
|
* Remove service configuration
|
||||||
*/
|
*/
|
||||||
removeService(serviceId) {
|
removeService(serviceId) {
|
||||||
|
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
||||||
if (this.config.services) {
|
if (this.config.services) {
|
||||||
delete this.config.services[serviceId];
|
delete this.config.services[serviceId];
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
+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 };
|
||||||
|
})();
|
||||||
@@ -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-497e1f671c';
|
||||||
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