Compare commits

..
Author SHA1 Message Date
Hermes 6732a1e1df [glm-grade=B] fix(auth): DC-089 mask invite/user emails in server logs
Two log sites in routes/auth/admin.js wrote raw email PII to the server
log: the SMTP-unconfigured 'auth-invite-send' warn and the 'invite
accepted, user created' info. Both now route through
AuthProvider.maskEmail() with a '[unmaskable-email]' sentinel fallback
(never the raw address). Two regression tests assert the raw address is
absent from log meta and the masked form present. Response contract
unchanged (full email still returned to the authenticated admin).

Judge: GLM-5.3 cold read (deleg_f0896de3), grade B / ship / zero
blockers; polish notes folded in. Verdict URN:
urn:ump:jd2htpwq76ni6bapj3vxjpvypfdnoc4argqbzpcerutmh7u5khea
Full suite: 2610/2610 (109 suites).
2026-08-22 16:22:53 -07:00
Hermes ea96abe95a Merge dc/DC-088-remove-service-tombstones: removeService generation tombstones + incident closure [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 15:55:29 -07:00
Hermes 3ccf66754a [glm-grade=B] fix(monitoring): DC-088 removeService generation tombstones + incident closure
- serviceGenerations no longer leaks entries: removeService deletes the live
  entry and records a TTL'd (10min) tombstone swept by cleanupHistory
- monotonic instance-wide generationSeq prevents generation reuse across
  remove->re-add cycles (ABA) and supersedes tombstones on re-configure
- _isStaleCapture(): presence-aware stale check — live entry must match
  exactly; no entry is stale only under a higher-generation tombstone
  (preserves correct behavior for disk-loaded never-configured services)
- catch path increments consecutiveFailures only after the stale check, so
  a late-rejected probe cannot resurrect state for a removed service
- open incidents for a removed service close via the standard resolve path
  (resolvedBy=service-removed, WS/SSE incident-resolved broadcast)
- 6 regression tests; full suite 2608/2608 green

Judge: GLM-5.3 cold read (Codex stand-in), verdict B/ship, zero blockers
2026-08-22 15:55:26 -07:00
Hermes c71b794ccc Merge dc/DC-087-test-mirrors-fetcht: hermetic caddy-admin test mirrors + raw-fetch guard [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 15:14:35 -07:00
Hermes f2285a2550 test(api): DC-087 hermetic caddy-admin health mirrors + file-level raw-fetch guard [glm-grade=B]
Two mirrored health-handler test suites (health-endpoints, health-probe-aliases)
probed the Caddy admin API with raw Origin-less native fetch. On the prod host
the adversarial cron runs the full jest suite every 30 min against a live Caddy
admin with enforce_origin: 12 journal 403 lines per run (~700/day of
'client is not allowed to access from origin' spam) while tests stayed green.

- Mirrors now call fetchT (byte-identical to src/app.js:930 probe) with fetchT
  jest.spyOn-mocked at buildApp scope; caddyOk-configurable in both suites
- New guard test in utils-http-caddy-admin-origin.test.js: any __tests__ file
  pairing a raw await-fetch with a Caddy-admin token (:2019|adminUrl|
  CADDY_ADMIN) fails the suite — file-level pairing catches the historical
  cross-line drift shape a call-window regex missed
- DC-087-ALLOW-RAW-FETCH comment escape hatch (raw-text marker, guard file
  never self-exempts, skips logged to jest output)

Judge: GLM-5.3 cold read via delegate_task deleg_4d384dea (round 1 C -> round 2
B, zero blockers, polish folded). Verdict URN: urn:ump:azrv2xp72koiwi5r4yb6ureu4aqqloqq64sgmftsajh6ci2mzj2q
Mutation probes: historical drift reintroduction -> guard red; hatch marker ->
skipped+logged; restore -> 33/33. Full suite 2603/2603.
2026-08-22 15:14:25 -07:00
Hermes 54a1df5ac4 [glm-grade=A] build(status): rebuild dist + sw — DC-085 link-first invite frontend bundle
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 14:28:21 -07:00
Hermes eb546bf468 Merge dc/DC-085-link-first-invite-dc-086-flicker-fix: DC-085 link-first invite + DC-086 badge flicker hysteresis + race hardening [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 14:27:54 -07:00
Hermes 84e051d975 Merge earlier-tick staging branch (duplicate DC-085/086 cherry-picks + auth-gate/vault tests) — content identical to eab2b00/f8b99f9
# Conflicts:
#	dashcaddy-api/__tests__/health-checker-hysteresis.test.js
#	dashcaddy-api/src/monitoring/health-checker.js
2026-08-22 14:27:40 -07:00
Hermes 628bbe32f6 [glm-grade=A] fix(monitoring): DC-086 round-2 — probe/config race hardening + env parse + incident compare
Round-2 folds the judge-round fixes into DC-086:

- serviceGenerations map: checkService captures the config generation at
  entry and re-validates it before ANY state write (success + error
  paths). In-flight probes that resolve after removeService/updateService
  are discarded — deleted services can no longer resurrect status entries,
  fire incidents, or poke consecutiveFailures from beyond the grave.
- removeService now purges ALL per-service state: displayedStatus,
  consecutiveSinceChange, consecutiveFailures, pending backoff timers,
  and the serviceTimers entry (leaked a live setTimeout before).
- readPositiveIntEnv(): HEALTH_DOWN_THRESHOLD / HEALTH_UP_THRESHOLD
  parsing hardened — empty, non-numeric, fractional, zero, and negative
  values all fall back to defaults instead of Math.max(1, NaN)=NaN.
- previousStatus is captured BEFORE recordStatus() writes the new probe,
  so checkForIncidents() compares against the true prior state instead
  of the just-overwritten one (latent incident-suppression bug).
- Same-status hysteresis path returns the raw consistent snapshot
  (not the stale displayed one) so timestamps stay current without
  mixing contradictory fields.
- Tests: +14 (86 total across the two suites). New coverage: streak
  reset on agreement, malformed env fallbacks (each.of not-a-number/0/
  -2/1.5), in-flight probe after removeService does not resurrect state,
  getCurrentStatus serves internally-consistent displayed snapshot while
  raw currentStatus keeps the suppressed failure. Full suite 2601/2601.

Judge: GLM-5.3 cold-read via delegate_task (deleg_c9fd5900 task-0),
grade A round 1, zero blocking issues. Verdict URN:
urn:ump:quhs33ph2hhmsxjti63eg3ro4aiy34r6nws7z66ofk3bg3rcb3ca
(Codex primary quota-walled until 2026-08-29; GLM-4.6 direct 401;
stand-in chain per codex-as-judge SKILL.md, Sami 2026-08-17.)
2026-08-22 14:23:25 -07:00
Hermes f8b99f9b5a DC-086 service-status flicker fix — asymmetric hysteresis
Dashboard badges perpetually flip green/red for a few seconds at a time,
never stable. Root cause: health-checker emitted 'status-check' on every
probe (every 30s) and dashboard-ws forwarded every one as 'status-change'
to the browser with no diff; live-events.js then unconditionally called
setBadge(). A single transient 5xx (Caddy reload, container restart, TLS
handshake blip) flipped the badge and the next green probe flipped back.

Fix: _computeDisplayedStatus applies asymmetric hysteresis — DOWN_THRESHOLD
(default 2, env-tunable HEALTH_DOWN_THRESHOLD) consecutive probes that
disagree with the displayed 'up' state flip to red; UP_THRESHOLD (default
1, HEALTH_UP_THRESHOLD) flips back to green. History and consecutiveFailures
still record every raw probe so postmortem analysis is unchanged. Only the
SSE broadcast is filtered. getCurrentStatus now returns the displayed
status so a page reload shows the same badge as the live stream.

10 new tests cover first-emit, same-status-dedup, the actual flicker bug
(one-down-then-up keeps green), two-down flips red, one-up recovers fast,
long-steady-green produces exactly one emit, and env-var tuning. All 63
existing health-checker tests still pass. Full suite: 2484/2484.
2026-08-22 06:14:48 -07:00
Hermes eab2b00b13 DC-085 link-first invite — Discord-style share it however you want
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
2026-08-22 06:14:47 -07:00
Hermes 84edb035e3 [grade=B] feat(auth): onboard missing credentials into encrypted vault
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 05:41:38 -07:00
Hermes d313b1e872 [grade=B] fix(auth): reuse valid session for cross-host SSO
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 04:06:27 -07:00
32 changed files with 1631 additions and 260 deletions
@@ -237,4 +237,49 @@ describe('DC-085: link-first admin invites', () => {
.send({ email: 'a@x.com', ttlHours: 1 }); .send({ email: 'a@x.com', ttlHours: 1 });
expect(res.body.shareText).toContain('expires in 1h'); expect(res.body.shareText).toContain('expires in 1h');
}); });
test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => {
mockEmailSender.isConfigured.mockReturnValueOnce(false);
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
expect(res.status).toBe(200);
expect(res.body.deliveredVia).toBe('failed');
const warn = logCalls.find(c =>
c.level === 'warn' && c.topic === 'auth-invite-send'
);
expect(warn).toBeDefined();
// The raw address must not appear; the masked form must.
expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com');
expect(warn.meta.email).toBe('fr****@example.com');
});
test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => {
// Pre-authorize the email (POST /admin/users) so userStore.login doesn't
// reject with not_authorized — bootstrap already happened in beforeEach.
const preauth = await request(app)
.post('/api/v1/auth/admin/users')
.send({ email: 'newfriend@example.com' });
expect(preauth.status).toBe(200);
const issue = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'newfriend@example.com', role: 'viewer' });
expect(issue.status).toBe(200);
const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
const res = await request(app)
.post(`/api/v1/auth/invites/${token}/accept`)
.send({});
expect(res.status).toBe(200);
const info = logCalls.find(c =>
c.level === 'info' && c.msg === 'invite accepted, user created'
);
expect(info).toBeDefined();
expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com');
expect(info.meta.email).toBe('ne****@example.com');
});
}); });
@@ -22,11 +22,17 @@ process.env.HEALTH_DATA_DIR = tmpDir;
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json'); process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json'); process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
const { HealthChecker } = require('../src/monitoring/health-checker');
// Module exports a singleton instance, not a class — see module.exports in // Module exports a singleton instance, not a class — see module.exports in
// src/monitoring/health-checker.js. The test creates fresh state by replacing // src/monitoring/health-checker.js. The test creates fresh state by replacing
// the relevant maps on the singleton in beforeEach. // the relevant maps on the singleton in beforeEach.
const healthCheckerSingleton = require('../src/monitoring/health-checker'); 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') { function makeUp(serviceId = 'svc1') {
return { return {
@@ -68,6 +74,15 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
hc = healthCheckerSingleton; 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', () => { test('first probe (no prior state) emits', () => {
hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeUp());
expect(emitSpy).toHaveBeenCalledTimes(1); expect(emitSpy).toHaveBeenCalledTimes(1);
@@ -88,6 +103,19 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
expect(hc.displayedStatus.get('svc1').status).toBe('up'); 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', () => { test('two consecutive "down" probes flip the badge to red', () => {
hc.recordStatus('svc1', makeUp()); // baseline: green hc.recordStatus('svc1', makeUp()); // baseline: green
hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1) hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1)
@@ -120,10 +148,28 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
}); });
test('getCurrentStatus returns the displayed status, not the raw probe', () => { test('getCurrentStatus returns the displayed status, not the raw probe', () => {
hc.recordStatus('svc1', makeUp()); const displayedUp = makeUp();
hc.recordStatus('svc1', makeDown()); // raw=down, displayed=up 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(); const out = hc.getCurrentStatus();
expect(out['svc1'].status).toBe('up'); // shown to API consumers 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)', () => { test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
@@ -142,7 +188,6 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
}); });
test('DOWN_THRESHOLD env var is honored', () => { test('DOWN_THRESHOLD env var is honored', () => {
const origDown = process.env.HEALTH_DOWN_THRESHOLD;
process.env.HEALTH_DOWN_THRESHOLD = '3'; process.env.HEALTH_DOWN_THRESHOLD = '3';
jest.resetModules(); jest.resetModules();
const HC2Module = require('../src/monitoring/health-checker'); const HC2Module = require('../src/monitoring/health-checker');
@@ -164,7 +209,89 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
hc2.recordStatus('svc1', makeDown()); // 3 — flip hc2.recordStatus('svc1', makeDown()); // 3 — flip
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
expect(hc2.displayedStatus.get('svc1').status).toBe('down'); expect(hc2.displayedStatus.get('svc1').status).toBe('down');
if (origDown === undefined) delete process.env.HEALTH_DOWN_THRESHOLD; });
else process.env.HEALTH_DOWN_THRESHOLD = origDown;
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'));
@@ -549,6 +591,113 @@ describe('HealthChecker', () => {
}); });
}); });
describe('DC-088: removeService generation tombstones + incident closure', () => {
it('does not leak a serviceGenerations entry and records a tombstone', () => {
healthChecker.configureService('svc1', { url: 'http://test.local' });
expect(healthChecker.serviceGenerations.has('svc1')).toBe(true);
healthChecker.removeService('svc1');
expect(healthChecker.serviceGenerations.has('svc1')).toBe(false);
const tomb = healthChecker.removedGenerations.get('svc1');
expect(tomb).toBeDefined();
expect(tomb.generation).toBeGreaterThan(0);
expect(tomb.removedAt).toBeGreaterThan(0);
});
it('re-added service gets a strictly higher generation (no ABA)', () => {
healthChecker.configureService('svc1', { url: 'http://test.local' });
const gen1 = healthChecker.serviceGenerations.get('svc1');
healthChecker.removeService('svc1');
healthChecker.configureService('svc1', { url: 'http://test.local/v2' });
const gen2 = healthChecker.serviceGenerations.get('svc1');
expect(gen2).toBeGreaterThan(gen1);
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
});
it('closes open incidents for the removed service as resolved', () => {
healthChecker.saveConfig = jest.fn();
healthChecker.incidents.push({
id: 'incident-test-1',
serviceId: 'svc1',
type: 'outage',
status: 'open',
createdAt: new Date(Date.now() - 60_000).toISOString()
});
healthChecker.incidents.push({
id: 'incident-other',
serviceId: 'svc2',
type: 'outage',
status: 'open',
createdAt: new Date(Date.now() - 60_000).toISOString()
});
const resolvedSpy = jest.fn();
healthChecker.on('incident-resolved', resolvedSpy);
healthChecker.removeService('svc1');
const closed = healthChecker.incidents.find(i => i.id === 'incident-test-1');
expect(closed.status).toBe('resolved');
expect(closed.resolvedBy).toBe('service-removed');
expect(closed.resolvedAt).toBeDefined();
expect(closed.duration).toBeGreaterThan(0);
expect(healthChecker.incidents.find(i => i.id === 'incident-other').status).toBe('open');
expect(resolvedSpy).toHaveBeenCalledTimes(1);
});
it('in-flight probe captured before removal is discarded via tombstone', async () => {
let resolveProbe;
healthChecker.config.services.svc1 = { url: 'http://test.local' };
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
resolveProbe = resolve;
}));
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
healthChecker.saveConfig = jest.fn();
healthChecker.removeService('svc1');
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
await pending;
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
});
it('a rejected in-flight probe after removal does not re-create failure state', async () => {
let rejectProbe;
healthChecker.config.services.svc1 = { url: 'http://test.local' };
healthChecker._doRequest = jest.fn(() => new Promise((resolve, reject) => {
rejectProbe = reject;
}));
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
healthChecker.saveConfig = jest.fn();
healthChecker.removeService('svc1');
rejectProbe(new Error('late failure'));
await pending;
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
});
it('sweeps expired tombstones in cleanupHistory', () => {
healthChecker.removedGenerations.set('svc1', {
generation: 1,
removedAt: Date.now() - 60 * 60 * 1000 // 1h ago, TTL default 10m
});
healthChecker.removedGenerations.set('svc2', {
generation: 2,
removedAt: Date.now() // fresh
});
healthChecker.cleanupHistory();
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
expect(healthChecker.removedGenerations.has('svc2')).toBe(true);
});
});
describe('cleanupHistory', () => { describe('cleanupHistory', () => {
it('removes entries older than retention period', () => { it('removes entries older than retention period', () => {
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
@@ -26,6 +26,19 @@ jest.mock('dockerode', () => {
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) { function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1'; process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
// DC-087 — mirror src/app.js faithfully: the caddy check goes through
// fetchT (which injects the Origin header Caddy's enforce_origin allowlist
// requires), and is MOCKED so the suite is hermetic — no live request to a
// real Caddy admin on :2019. The previous raw-`fetch` mirror sent an
// Origin-less probe to the LIVE admin whenever the full suite ran on the
// prod host (adversarial cron every 30 min): 12 journal 403 lines per run,
// ~700/day of `client is not allowed to access from origin ''` noise,
// plus a false checks.caddy.ok=false in the mirrored readiness payload.
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
.mockImplementation(async () => (caddyOk
? { ok: true, status: 200 }
: { ok: false, status: 403 }));
const app = express(); const app = express();
const config = { const config = {
CONFIG_FILE: '/tmp/dc-test-config.json', CONFIG_FILE: '/tmp/dc-test-config.json',
@@ -103,9 +116,13 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk
allOk = false; allOk = false;
} }
// DC-087 — mirror src/app.js exactly (fetchT, not raw fetch). fetchT is
// mocked at buildApp() scope, so this stays hermetic: no live probe to a
// real Caddy admin (the old raw-fetch mirror 403-spammed the prod journal
// every time the adversarial cron ran the full suite on this host).
try { try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019'; const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) }); const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
checks.caddy = { ok: response.ok, status: response.status }; checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false; if (!response.ok) allOk = false;
} catch (e) { } catch (e) {
@@ -33,9 +33,18 @@ jest.mock('dockerode', () => {
// Mirror the canonical handler block from src/app.js — if this drifts from // Mirror the canonical handler block from src/app.js — if this drifts from
// the real handler, these tests will start failing and force a sync. // the real handler, these tests will start failing and force a sync.
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) { function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1'; process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
// DC-087 — mirror src/app.js: caddy check via fetchT (Origin-injecting),
// mocked here so the suite is hermetic. The old raw-fetch mirror probed the
// LIVE Caddy admin on :2019 whenever the full suite ran on the prod host
// (adversarial cron): Origin-less → 403 → 12 journal error lines per run.
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
.mockImplementation(async () => (caddyOk
? { ok: true, status: 200 }
: { ok: false, status: 403 }));
const app = express(); const app = express();
const config = { const config = {
CONFIG_FILE: '/tmp/dc-test-config.json', CONFIG_FILE: '/tmp/dc-test-config.json',
@@ -108,8 +117,10 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {})
allOk = false; allOk = false;
} }
try { try {
// DC-087 — mirror src/app.js exactly: fetchT (mocked above), not raw
// fetch. Hermetic: no live request to a real Caddy admin.
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019'; const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) }); const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
checks.caddy = { ok: response.ok, status: response.status }; checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false; if (!response.ok) allOk = false;
} catch (e) { } catch (e) {
@@ -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\(/);
+3 -2
View File
@@ -29,6 +29,7 @@ const platformPaths = require('../../platform-paths');
const { createUserStore } = require('../../src/security/user-store'); const { createUserStore } = require('../../src/security/user-store');
const { createInviteStore } = require('../../src/security/invite-store'); const { createInviteStore } = require('../../src/security/invite-store');
const emailSender = require('../../src/auth/providers/email-sender'); const emailSender = require('../../src/auth/providers/email-sender');
const AuthProvider = require('../../src/auth/providers/base');
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors'); const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
const { ok, successMessage } = require('../../src/utils/responses'); const { ok, successMessage } = require('../../src/utils/responses');
@@ -254,7 +255,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
// server log on every unconfigured-install invite. // server log on every unconfigured-install invite.
log.warn && log.warn('auth-invite-send', log.warn && log.warn('auth-invite-send',
'invite send skipped: SMTP not configured (operator opted in)', 'invite send skipped: SMTP not configured (operator opted in)',
{ inviteId: issued.id, email: issued.email }); { inviteId: issued.id, email: AuthProvider.maskEmail(issued.email) || '[unmaskable-email]' });
deliveredVia = 'failed'; deliveredVia = 'failed';
} }
} catch (sendErr) { } catch (sendErr) {
@@ -369,7 +370,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
log.info && log.info('auth', 'invite accepted, user created', { log.info && log.info('auth', 'invite accepted, user created', {
userId: userResult.user.id, userId: userResult.user.id,
email: userResult.user.email, email: AuthProvider.maskEmail(userResult.user.email) || '[unmaskable-email]',
role: userResult.user.role, role: userResult.user.role,
inviteId: invite.id, inviteId: invite.id,
}); });
+62 -23
View File
@@ -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));
} }
+13 -4
View File
@@ -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 });
+2 -1
View File
@@ -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'));
+109 -10
View File
@@ -32,6 +32,10 @@ const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10); const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
// DC-088: how long a removal tombstone outlives the removal itself. Only needs
// to cover the max in-flight probe lifetime (timeout + scheduling headroom);
// swept by cleanupHistory so removed services cannot accumulate map entries.
const REMOVED_GENERATION_TTL_MS = parseInt(process.env.HEALTH_REMOVED_GEN_TTL || '600000', 10);
// DC-086: hysteresis thresholds for badge display. // DC-086: hysteresis thresholds for badge display.
// The raw probe result can flap on a single transient blip (Caddy reload, // The raw probe result can flap on a single transient blip (Caddy reload,
@@ -43,8 +47,15 @@ const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION ||
// - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green // - 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 // Single probe flips to green on purpose — false-positive-green is much less
// painful than perpetual-red (operators notice red, ignore green). // painful than perpetual-red (operators notice red, ignore green).
const DOWN_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_DOWN_THRESHOLD || '2', 10)); function readPositiveIntEnv(name, fallback) {
const UP_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_UP_THRESHOLD || '1', 10)); 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() {
@@ -63,6 +74,16 @@ class HealthChecker extends EventEmitter {
this.checkInterval = null; this.checkInterval = null;
this.consecutiveFailures = new Map(); // serviceId -> failure count this.consecutiveFailures = new Map(); // serviceId -> failure count
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
// Invalidate probe completions that race with removal/reconfiguration.
this.serviceGenerations = new Map(); // serviceId -> configuration generation
// DC-088: monotonically increasing sequence so generation numbers can never
// repeat across remove -> re-add cycles (prevents ABA on the stale check).
this.generationSeq = 0;
// DC-088: serviceId -> { generation, removedAt } tombstones. A live entry in
// serviceGenerations means the service is (re)configured; a tombstone with a
// HIGHER generation than the captured one marks the capture as stale. Entry
// is deleted when the service is removed, so the live map cannot leak.
this.removedGenerations = new Map();
} }
/** /**
@@ -130,11 +151,27 @@ class HealthChecker extends EventEmitter {
this.cleanupHistory(); this.cleanupHistory();
} }
/**
* DC-088: true when a probe's captured generation no longer matches the
* service's current configuration state. A live serviceGenerations entry
* must match exactly. With no live entry the service was never configured
* in this process (disk-loaded / direct callers) stale only if a removal
* tombstone with a HIGHER generation exists.
*/
_isStaleCapture(serviceId, generation) {
if (this.serviceGenerations.has(serviceId)) {
return this.serviceGenerations.get(serviceId) !== generation;
}
const tomb = this.removedGenerations.get(serviceId);
return Boolean(tomb && tomb.generation > generation);
}
/** /**
* Check a single service * Check a single service
*/ */
async checkService(serviceId, config) { async checkService(serviceId, config) {
const startTime = Date.now(); const startTime = Date.now();
const generation = this.serviceGenerations.get(serviceId) || 0;
try { try {
const result = await this.performHealthCheck(config); const result = await this.performHealthCheck(config);
@@ -150,6 +187,10 @@ class HealthChecker extends EventEmitter {
details: result.details details: result.details
}; };
if (this._isStaleCapture(serviceId, generation)) {
return status;
}
// Track consecutive failures for exponential backoff // Track consecutive failures for exponential backoff
if (result.healthy) { if (result.healthy) {
this.consecutiveFailures.delete(serviceId); this.consecutiveFailures.delete(serviceId);
@@ -157,16 +198,14 @@ 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) {
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
// Increment failure count for backoff
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
const status = { const status = {
serviceId, serviceId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -175,8 +214,17 @@ class HealthChecker extends EventEmitter {
error: error.message error: error.message
}; };
if (this._isStaleCapture(serviceId, generation)) {
return status;
}
// Increment failure count for backoff — only after the result is known
// to be non-stale, so a removed service cannot re-create map entries.
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
const previousStatus = this.currentStatus.get(serviceId);
this.recordStatus(serviceId, status); this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config); this.checkForIncidents(serviceId, status, config, previousStatus);
return status; return status;
} }
@@ -319,7 +367,7 @@ class HealthChecker extends EventEmitter {
// a brief blip doesn't accumulate against the displayed state. // a brief blip doesn't accumulate against the displayed state.
if (rawStatus.status === previousStatus) { if (rawStatus.status === previousStatus) {
this.consecutiveSinceChange.set(serviceId, 0); this.consecutiveSinceChange.set(serviceId, 0);
return currentDisplayed; return rawStatus;
} }
// Probe disagrees with displayed. Bump the streak counter — this counts // Probe disagrees with displayed. Bump the streak counter — this counts
@@ -334,6 +382,9 @@ class HealthChecker extends EventEmitter {
// with the displayed "up" state. // with the displayed "up" state.
if (previousStatus === 'up' && next < DOWN_THRESHOLD) { if (previousStatus === 'up' && next < DOWN_THRESHOLD) {
this.consecutiveSinceChange.set(serviceId, next); 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; return currentDisplayed;
} }
// Threshold met (or already down) — flip to red. // Threshold met (or already down) — flip to red.
@@ -402,8 +453,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) {
@@ -638,6 +688,13 @@ class HealthChecker extends EventEmitter {
this.config.services = {}; this.config.services = {};
} }
// DC-088: monotonic instance-wide sequence — a re-added service can never
// recycle a previous generation number, and any older in-flight capture is
// invalidated by definition.
this.generationSeq += 1;
this.serviceGenerations.set(serviceId, this.generationSeq);
// Re-configuration supersedes any prior removal tombstone.
this.removedGenerations.delete(serviceId);
this.config.services[serviceId] = { this.config.services[serviceId] = {
enabled: config.enabled !== false, enabled: config.enabled !== false,
name: config.name || serviceId, name: config.name || serviceId,
@@ -660,12 +717,42 @@ class HealthChecker extends EventEmitter {
* Remove service configuration * Remove service configuration
*/ */
removeService(serviceId) { removeService(serviceId) {
// DC-088: tombstone the captured generation instead of leaking an entry.
// The live map entry is deleted; an in-flight probe captured BEFORE this
// point sees no live entry but a higher tombstone generation, so it is
// discarded. configureService clears the tombstone on re-add.
this.generationSeq += 1;
this.serviceGenerations.delete(serviceId);
this.removedGenerations.set(serviceId, {
generation: this.generationSeq,
removedAt: Date.now()
});
if (this.config.services) { if (this.config.services) {
delete this.config.services[serviceId]; delete this.config.services[serviceId];
this.saveConfig(); this.saveConfig();
} }
// DC-088: open incidents for a removed service must not linger forever.
// Close them through the same resolve path a recovery would, annotated so
// history shows why (dashboard renders resolved incidents green + duration).
for (const incident of this.incidents) {
if (incident.serviceId === serviceId && incident.status === 'open') {
incident.status = 'resolved';
incident.resolvedAt = new Date().toISOString();
incident.duration = new Date(incident.resolvedAt) - new Date(incident.createdAt);
incident.resolvedBy = 'service-removed';
this.emit('incident-resolved', incident);
this.emit('log', 'info', `Incident closed by service removal: ${incident.id}`);
}
}
this.currentStatus.delete(serviceId); this.currentStatus.delete(serviceId);
this.displayedStatus.delete(serviceId);
this.consecutiveSinceChange.delete(serviceId);
this.consecutiveFailures.delete(serviceId);
const timer = this.serviceTimers.get(serviceId);
if (timer) clearTimeout(timer);
this.serviceTimers.delete(serviceId);
delete this.history[serviceId]; delete this.history[serviceId];
} }
@@ -684,6 +771,18 @@ class HealthChecker extends EventEmitter {
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE); this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
} }
} }
// DC-088: sweep expired removal tombstones. After the TTL no probe that
// captured a pre-removal generation can still be in flight (timeout is
// bounded by performHealthCheck), so the tombstone has done its job.
if (this.removedGenerations.size > 0) {
const now = Date.now();
for (const [serviceId, tomb] of this.removedGenerations) {
if (now - tomb.removedAt > REMOVED_GENERATION_TTL_MS) {
this.removedGenerations.delete(serviceId);
}
}
}
} }
/** /**
+9 -4
View File
@@ -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) {
+6 -1
View File
@@ -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');
});
});
+1
View File
@@ -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.
+108 -108
View File
File diff suppressed because one or more lines are too long
+7 -7
View File
File diff suppressed because one or more lines are too long
+46 -2
View File
@@ -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)
+43 -40
View File
@@ -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) {
const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
if (failed.length) {
const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
throw new Error(details);
}
}
// 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 => { dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`); const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; } if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results[dnsId]; const result = data.results?.[dnsId];
if (result?.success) { statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
statusEl.textContent = '\u2713 Verified & saved';
statusEl.className = 'token-status success'; 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';
}
});
}
} 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,6 +365,9 @@
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.')) {
try {
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
clearAllCredentials(); clearAllCredentials();
getDnsIds().forEach(dnsId => { getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = ''; document.getElementById(`${dnsId}-readonly-username`).value = '';
@@ -377,9 +377,12 @@
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared'; document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success'; document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
}); });
try { } catch (e) {
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' }); getDnsIds().forEach(dnsId => {
} catch (_) {} document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
});
}
} }
}); });
+3
View File
@@ -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(() => {
+52
View File
@@ -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 };
})();
+88 -20
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-a24ef15882'; const CACHE = 'dashcaddy-shell-497e1f671c';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',
+38
View File
@@ -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);
});
+67
View File
@@ -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);
});