Compare commits

..
1 Commits
105 changed files with 1664 additions and 15024 deletions
-25
View File
@@ -400,28 +400,3 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
- **prerequisite:** None.
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
### DC-086: Service-status flicker fix — asymmetric hysteresis on the badge
- **status:** in-progress
- **owner:** hermes
- **details:** Dashboard service badges perpetually flip between green and red for "a few seconds at a time, never stable" (Sami's report, 2026-08-20). Root cause: `src/monitoring/health-checker.js` `recordStatus()` emits `'status-check'` on EVERY probe (every 30s), and `src/websocket/dashboard-ws.js` forwards every probe as `'status-change'` to the browser with no diff. The frontend `live-events.js` then unconditionally calls `setBadge()` — which resets the icon + pill text on every event. A single transient 5xx (Caddy reload, container CPU steal, mid-flight TLS handshake, container restart during probe) flips the badge red and the next green probe flips it back. Fix: add asymmetric hysteresis in `_computeDisplayedStatus(serviceId, rawStatus)` — going DOWN requires 2 consecutive "down" probes (default `HEALTH_DOWN_THRESHOLD=2`), going UP requires only 1 (default `HEALTH_UP_THRESHOLD=1`). History + `consecutiveFailures` still record raw probe results (operators want full fidelity for postmortems); only the dashboard broadcast is filtered. `getCurrentStatus()` now returns the displayed status so a page reload shows the same badge as the live SSE stream. Both thresholds are env-var configurable so operators can tune. New tests in `__tests__/health-checker-hysteresis.test.js` cover: first probe emits; second probe same-status does NOT re-emit; one-down-then-up keeps green; two-down flips to red; one-up after down flips back to green; `getCurrentStatus` returns displayed not raw. Effort: ~30 min. Risk: low — pure behavior filter, no schema breaks, all 63 existing health-checker tests must stay green.
- **impact:** Operators stop seeing perpetual red/green flicker on healthy services. Real outages still get flagged (2 consecutive 30s probes = ~60s before badge flips red, which is still faster than a human notices). Background probe history is unchanged so postmortem analysis still works.
- **prerequisite:** None.
- **result:** _pending — ship + codex round_
### DC-085: Link-first invite — Discord-style "share it however you want"
- **status:** in-progress
- **owner:** hermes
- **details:** Today `POST /api/v1/auth/admin/invites` defaults to sending the invite link via SMTP; if SMTP is not configured it spams the server console with `[DC-048-DEV-INVITE-LINK]` log lines. Sami wants Discord-style: the link is always returned in the response, and email is an opt-in checkbox. Operators should be free to copy the link and share it via iMessage / SMS / WhatsApp / Telegram / Signal / Discord / paste-in-email — whatever fits. (1) Flip default `sendEmail !== false` to `sendEmail === true` in `routes/auth/admin.js` so omitting the field means "no email, just hand me the link." (2) Stop logging the raw invite URL to error.log when SMTP is unconfigured — that path was only useful when there was no UI way to grab the link; now there is. (3) Add a `shareText` field to the response: `"Join my DashCaddy as <role> — <acceptUrl> — expires in Nh."` for one-tap paste into any messenger. (4) Frontend: `status/js/admin.js` `_renderInviteForm` flips the "Send email" checkbox default to **unchecked**, updates `_renderIssuedInviteBanner` to show both the raw link AND the shareText (with its own copy button + `navigator.share()` native share-sheet button where available). (5) New tests in `__tests__/admin-invites.test.js` covering: default sendEmail=false (no SMTP send attempted, no console log); `sendEmail: true` triggers SMTP send; `shareText` is present and well-formed; `acceptUrl` is always returned; expired sendEmail path doesn't leak token to logs. Effort: ~1 hr. Risk: low — pure behavior flip + UI additive change.
- **impact:** Closes the friction between "host wants to add a friend" and "host has to configure SMTP first." Mirrors Discord/Slack/Linear invite flows where the link IS the deliverable. No new tier changes, no schema breaks.
- **prerequisite:** DC-048 (invite store + admin route), DC-052 (Pro gate stays).
- **result:** _pending — ship + codex round_
### DC-084: Remove redundant active Caddy health check from `arch.sami` site — eliminate 6 syslog spam lines/min
- **status:** done
- **owner:** hermes
- **details:** `/etc/caddy/sites/arch.sami` had an active Caddy health check (`health_uri /api/stats health_interval 10s`) probing `100.120.159.34:5000` every 10 seconds. The upstream Arch Linux server `100.120.159.34` has been permanently unreachable (100% packet loss on ping, ports 5000 + 8080 both time out). Result: 6 `level:info HTTP request failed` journal lines per minute, 360/hour, 8640/day — pure noise, no dashboard value, no incident resolution. The `src/monitoring/caddy-upstream-watcher.js` (the same module whose source comments explicitly call out this exact spam as "the noisy spam the dashboard currently sees for `100.120.159.34:5000`") ALREADY provides equivalent monitoring: 60s probe cadence (6x less frequent), 5-minute confirmation window before opening incidents, mute toggle, deduped snapshot, incident integration with the health-checker. The active Caddy check is redundant. Fix: edit `/etc/caddy/sites/arch.sami` to remove the `health_uri / health_interval` block, leaving only `reverse_proxy 100.120.159.34:5000`. Apply via `caddy-apply` (validates+reloads+commits atomically). Backup `.bak-DC-084-pre` created pre-edit; deleted after `caddy-apply` succeeded because the `.bak` file was being picked up by Caddy's `import sites/*` and causing an "ambiguous site definition" validation error.
- **impact:** Eliminates 100% of recurring caddy journal spam from the dead Arch upstream. The dashboard's `caddy-upstream-watcher.js` continues to monitor the dead upstream correctly (now at `consecutiveFailures: 1905+`, `lastSuccessAt: null`, `status: down`, `dead: true`) — operators see the dead upstream in the dashboard, just without the journal noise. Future Caddyfile authors who add an active health check to a `*.sami` site will be unaware that they should not (since the dashboard handles monitoring), so a follow-up could add a CLAUDE.md note or a Caddyfile lint warning. Out of scope for this tick.
- **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring.
- **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes``health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`.
- **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change).
@@ -1,240 +0,0 @@
/**
* Tests for DC-085: link-first invite (Discord-style "share it however you want").
*
* - default sendEmail omission = no email sent, link returned, no token in logs
* - sendEmail:true triggers SMTP send when configured
* - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
* - shareText field present and well-formed in every response
* - acceptUrl always present (regardless of sendEmail)
* - role + ttl validation unchanged from DC-048
*
* Strategy: drive the route handler directly with mock req/res, mount the admin
* router against an isolated userStore + inviteStore + email-sender stub.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
// Stub email-sender so we can assert "was it called?" without an SMTP server.
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
// call is allowed to reference it (Babel guard against out-of-scope access).
const mockEmailSender = {
isConfigured: jest.fn(() => false),
sendEmail: jest.fn(async () => undefined),
};
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);
describe('DC-085: link-first admin invites', () => {
let dir, app, request;
let logCalls; // captured { level, msg, meta } from our fake log
beforeEach(async () => {
jest.clearAllMocks();
dir = _tmpDir();
logCalls = [];
// Set up email auth enable flag so userStore mounts.
process.env.NODE_ENV = 'test';
const { createUserStore } = require('../src/security/user-store');
const userStore = createUserStore({ dataDir: dir });
// Bootstrap the admin so we have a session-attributable user.
await userStore.login({ email: 'admin@sami-host.me' });
// Build a tiny Express app with the admin router mounted, but skip the
// global auth gate (we inject req.user directly).
const adminRouter = require('../routes/auth/admin')({
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
errorResponse: (_res, code, msg) => ({ status: code, msg }),
log: {
info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
},
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
dataDir: dir,
});
app = express();
app.use(express.json());
// Inject req.user = admin so /admin/* passes the role gate.
app.use((req, _res, next) => {
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
req.app.locals = req.app.locals || {};
req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
req.app.locals.emailConfig = null; // SMTP not configured by default
next();
});
app.use('/api/v1/auth', adminRouter);
// Error handler — last in chain.
app.use((err, _req, res, _next) => {
const code = (err && err.statusCode) || 500;
res.status(code).json({
success: false,
error: err && err.message,
code: err && err.code,
});
});
request = require('supertest');
});
afterEach(() => _cleanup(dir));
test('default sendEmail (omitted) returns link and does NOT send email', async () => {
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'friend@example.com', role: 'operator' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
expect(res.body.deliveredVia).toBe('manual');
});
test('default sendEmail does NOT log raw token to server log', async () => {
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'friend@example.com', role: 'operator' });
const acceptUrl = res.body.acceptUrl;
// Extract the token from the URL and verify it does NOT appear in any log call.
const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
const tokenLeaked = logCalls.some(c =>
typeof c.msg === 'string' && c.msg.includes(token)
);
expect(tokenLeaked).toBe(false);
// Also assert no log entry mentions the URL verbatim (the old
// `[DC-048-DEV-INVITE-LINK] url=...` spam).
const oldSpam = logCalls.find(c =>
typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
);
expect(oldSpam).toBeUndefined();
});
test('shareText is present and well-formed in every response', async () => {
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });
expect(res.body.shareText).toBeDefined();
expect(res.body.shareText).toContain('Join my DashCaddy');
expect(res.body.shareText).toContain('operator');
expect(res.body.shareText).toContain(res.body.acceptUrl);
expect(res.body.shareText).toContain('expires in 24h');
});
test('acceptUrl is always returned regardless of sendEmail', async () => {
const r1 = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'a@x.com', sendEmail: false });
const r2 = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'b@x.com' });
expect(r1.body.acceptUrl).toBeTruthy();
expect(r2.body.acceptUrl).toBeTruthy();
});
test('sendEmail: true triggers SMTP send when configured', async () => {
// Build a SECOND app instance where emailConfig is a real-looking object,
// so isConfigured() returns true. The first app uses emailConfig=null.
mockEmailSender.isConfigured.mockReturnValueOnce(true);
mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
const app2 = express();
app2.use(express.json());
app2.use((req, _res, next) => {
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
req.app.locals = req.app.locals || {};
req.app.locals.siteConfig = {};
req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
next();
});
const { createUserStore } = require('../src/security/user-store');
const userStore2 = createUserStore({ dataDir: dir });
await userStore2.login({ email: 'admin@sami-host.me' });
const router2 = require('../routes/auth/admin')({
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
errorResponse: (_res, code, msg) => ({ status: code, msg }),
log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
dataDir: dir,
});
app2.use('/api/v1/auth', router2);
const res = await request(app2)
.post('/api/v1/auth/admin/invites')
.send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });
expect(res.status).toBe(200);
expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
expect(to).toBe('friend@example.com');
expect(subject).toMatch(/invited/i);
expect(text).toContain(res.body.acceptUrl);
expect(html).toContain(res.body.acceptUrl);
expect(res.body.deliveredVia).toBe('email');
});
test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
mockEmailSender.isConfigured.mockReturnValueOnce(false);
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
expect(res.status).toBe(200);
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
expect(res.body.deliveredVia).toBe('failed');
// acceptUrl + shareText still present so the operator can share manually.
expect(res.body.acceptUrl).toBeTruthy();
expect(res.body.shareText).toBeTruthy();
// Token does NOT appear in any log call.
const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
const tokenLeaked = logCalls.some(c =>
typeof c.msg === 'string' && c.msg.includes(token)
);
expect(tokenLeaked).toBe(false);
});
test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
// DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
// silently substitutes default rather than throwing. This test pins that
// behavior so a future "strict role validation" change is a deliberate
// decision, not a silent regression.
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'a@x.com', role: 'superuser' });
expect(res.status).toBe(200);
expect(res.body.role).toBe('operator');
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
});
test('email validation: missing email still rejected', async () => {
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ role: 'operator' });
expect(res.status).toBe(400);
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
});
test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'a@x.com', ttlHours: 1 });
expect(res.body.shareText).toContain('expires in 1h');
});
});
@@ -336,77 +336,32 @@ describe('AutoRestartManager', () => {
});
describe('_resolveContainerId', () => {
test('returns containerId from status.details when present', async () => {
test('returns containerId from status.details when present', () => {
const { manager } = makeManager();
const cid = await manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
expect(cid).toBe('cid-details');
});
test('falls back to healthChecker.config.services[serviceId].containerId', async () => {
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
const { manager, healthChecker } = makeManager();
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
const cid = await manager._resolveContainerId('svc-1', { details: {} });
const cid = manager._resolveContainerId('svc-1', { details: {} });
expect(cid).toBe('cid-hc');
});
test('DC-060: awaits async servicesStateManager.read() and resolves containerId', async () => {
// Regression test for the auto-restart silently no-op bug:
// _resolveContainerId used to fire servicesStateManager.read() via
// .then(...) and discard the result. Callers gated on the return
// value, so a healthy→unhealthy transition whose only containerId
// source was the async state manager never triggered handleContainerDown.
test('falls back to servicesStateManager.read when sync list is returned', () => {
const { manager, servicesStateManager } = makeManager();
servicesStateManager.read.mockResolvedValue([
servicesStateManager.read.mockReturnValue([
{ id: 'svc-1', containerId: 'cid-state' },
]);
const cid = await manager._resolveContainerId('svc-1', { details: {} });
const cid = manager._resolveContainerId('svc-1', { details: {} });
expect(cid).toBe('cid-state');
});
test('returns null when no source has a containerId', async () => {
test('returns null when no source has a containerId', () => {
const { manager } = makeManager();
const cid = await manager._resolveContainerId('svc-unknown', { details: {} });
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
expect(cid).toBeNull();
});
test('swallows servicesStateManager.read() rejection', async () => {
const { manager, servicesStateManager } = makeManager();
servicesStateManager.read.mockRejectedValue(new Error('disk gone'));
const cid = await manager._resolveContainerId('svc-1', { details: {} });
expect(cid).toBeNull();
});
});
describe('DC-060: healthy→unhealthy transitions trigger restart via async lookup', () => {
test('handleContainerDown is invoked with containerId from async state-manager lookup', async () => {
// End-to-end: containerId comes ONLY from servicesStateManager.read()
// (the production path for services.json-backed deployments).
const { manager, docker, servicesStateManager } = makeManager();
docker.client.getContainer.mockReturnValue({
start: jest.fn().mockResolvedValue(undefined),
});
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
manager._previousHealth.set('svc-1', 'up');
servicesStateManager.read.mockResolvedValue([
{ id: 'svc-1', containerId: 'cid-from-state' },
]);
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-from-state');
});
test('handleContainerDown is NOT invoked when async lookup returns no containerId', async () => {
const { manager, servicesStateManager } = makeManager();
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
manager._previousHealth.set('svc-1', 'up');
servicesStateManager.read.mockResolvedValue([
{ id: 'svc-1' /* no containerId */ },
]);
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
expect(handleDownSpy).not.toHaveBeenCalled();
});
});
});
@@ -379,235 +379,11 @@ describe('CaddyUpstreamWatcher', () => {
fsState.exists[STATE] = true;
// And the matching site file
seedSites({ 'old.sami': 'old.sami { reverse_proxy 99.99.99.99:80 }\n' });
process.env.CADDY_UPSTREAMS_STATE_FILE = STATE;
process.env.CADDY_SITES_DIR = SITES;
const mod = require('../src/monitoring/caddy-upstream-watcher');
const w = mod.CaddyUpstreamWatcher ? new mod.CaddyUpstreamWatcher() : mod;
expect(w.isMuted('99.99.99.99:80')).toBe(true);
});
// ---- Loopback → host-gateway remap (live bug 2026-08-18) -----------------
// The watcher runs inside the container; Caddyfile `localhost:PORT` means
// the HOST's loopback. Probing the container's own loopback gave 278
// phantom failures per healthy host-side upstream.
test('loopback upstream probe is remapped to host.docker.internal (not container loopback)', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
expect(u).toBeTruthy();
const http = require('http');
await w._probeOne(u);
// The probe request must have gone to host.docker.internal, keeping the port.
const call = http.request.mock.calls.find(c => c[0].hostname === 'host.docker.internal');
expect(call).toBeTruthy();
expect(call[0].port).toBe('8088');
// Display key is unchanged.
expect(w.snapshot().upstreams[0].host).toBe('localhost:8088');
expect(w.snapshot().upstreams[0].status).toBe('up');
});
test('127.0.0.1 and 127.x addresses are also remapped', async () => {
seedSites({
'a.sami': 'a.sami { reverse_proxy 127.0.0.1:8765 }\n',
'b.sami': 'b.sami { reverse_proxy 127.0.0.53:9000 }\n'
});
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
for (const u of w.upstreams.values()) await w._probeOne(u);
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
expect(hostnames).toEqual(['host.docker.internal', 'host.docker.internal']);
});
test('non-loopback upstreams are probed at their literal address (no remap)', async () => {
seedSites({ 'arch.sami': 'arch.sami { reverse_proxy 100.120.159.34:5000 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
await w._probeOne(w.upstreams.get('100.120.159.34:5000'));
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
expect(hostnames).toEqual(['100.120.159.34']);
});
test('failed host-gateway probe marks loopback upstream unverifiable — no failures, no incident', async () => {
// Services bound to 127.0.0.1 on the host refuse bridge-IP connections;
// from inside the container that is indistinguishable from "dead", and
// Caddy (on the host) still routes fine — so it must NOT count as down.
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED 172.17.0.1:8088' });
const { w } = loadWatcher();
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
w.healthChecker = fakeHealthChecker;
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
u.lastSuccessAt = new Date(Date.now() - 10 * 60 * 1000).toISOString(); // long "failing" history
await w._probeOne(u);
const snap = w.snapshot().upstreams[0];
expect(snap.status).toBe('unverifiable');
expect(snap.consecutiveFailures).toBe(0);
expect(snap.dead).toBe(false);
expect(snap.failingForMs).toBe(0);
expect(snap.lastError).toMatch(/not verifiable from container/);
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
});
test('unverifiable sorts between muted and up in the snapshot', async () => {
seedSites({
'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n',
'b.sami': 'b.sami { reverse_proxy localhost:9876 }\n',
'c.sami': 'c.sami { reverse_proxy 2.2.2.2:80 }\n'
});
const { w } = loadWatcher();
await w.scanSites();
const all = Array.from(w.upstreams.values());
all.find(u => u.host === '1.1.1.1:80').status = 'up';
all.find(u => u.host === 'localhost:9876').status = 'unverifiable';
w.muted.add('2.2.2.2:80');
const order = w.snapshot().upstreams.map(u => u.host);
expect(order).toEqual(['2.2.2.2:80', 'localhost:9876', '1.1.1.1:80']);
});
// ---- verifiedViaBridge (DC-053 follow-up item 2b-a) ----------------------
// A loopback upstream whose PRIOR probe succeeded via host-gateway proves
// the bridge CAN reach the host. If a later probe then fails, that is
// near-conclusive evidence the upstream itself went dead — not that
// bridge connectivity broke. Restore dead-detection for that subset.
test('successful host-gateway probe sets verifiedViaBridge=true on loopback upstream', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
const { w } = loadWatcher();
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
expect(u.verifiedViaBridge).toBeFalsy();
await w._probeOne(u);
expect(u.verifiedViaBridge).toBe(true);
expect(u.status).toBe('up');
expect(w.snapshot().upstreams[0].verifiedViaBridge).toBe(true);
});
test('loopback upstream with verifiedViaBridge fails -> counts as down (not unverifiable)', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
// First probe succeeds (sets verifiedViaBridge), second probe fails.
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED 172.17.0.1:8088' });
const { w } = loadWatcher();
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
w.healthChecker = fakeHealthChecker;
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
await w._probeOne(u);
expect(u.verifiedViaBridge).toBe(true);
expect(u.status).toBe('up');
await w._probeOne(u);
expect(u.status).toBe('down');
expect(u.consecutiveFailures).toBe(1);
expect(u.lastError).toMatch(/ECONNREFUSED/);
// Snapshot also reflects verifiedViaBridge so dashboard can label it.
const snap = w.snapshot().upstreams[0];
expect(snap.verifiedViaBridge).toBe(true);
// No incident yet — needs DEAD_AFTER_MS of continuous failure.
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
});
test('loopback upstream with verifiedViaBridge eventually opens a dead incident', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 }); // probe 1: success -> verifiedViaBridge
probeQueue.push({ kind: 'err', message: 'down' });
const { w } = loadWatcher();
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
w.healthChecker = fakeHealthChecker;
await w.scanSites();
const u = w.upstreams.get('localhost:8088');
await w._probeOne(u);
// Pre-set lastSuccessAt to DEAD_AFTER_MS ago so the second failure
// immediately crosses the 5-minute threshold.
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
await w._probeOne(u);
expect(fakeHealthChecker.createIncident).toHaveBeenCalledWith(
'localhost:8088',
'caddy-upstream-dead',
expect.stringMatching(/unreachable for 6m/),
expect.objectContaining({ status: 'down', serviceId: 'localhost:8088' })
);
});
// ---- IN_CONTAINER=false kill-switch (DC-053 follow-up item 2b-b) --------
// When the API runs bare-metal (or in a sidecar next to Caddy), the
// loopback host IS the host — no bridge. Probing loopback verbatim
// gives real, conclusive evidence.
test('IN_CONTAINER=false disables host-gateway remap (probes loopback verbatim)', async () => {
process.env.IN_CONTAINER = 'false';
try {
seedSites({
'a.sami': 'a.sami { reverse_proxy localhost:8088 }\n',
'b.sami': 'b.sami { reverse_proxy 127.0.0.1:9000 }\n',
'c.sami': 'c.sami { reverse_proxy 1.2.3.4:80 }\n' // non-loopback, should still go literal
});
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'ok', statusCode: 200 });
probeQueue.push({ kind: 'ok', statusCode: 200 });
// Force module reload so the new IN_CONTAINER is picked up at require time.
jest.resetModules();
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
for (const u of w.upstreams.values()) await w._probeOne(u);
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
// All three go to their literal addresses — no host.docker.internal.
expect(hostnames).toEqual(['localhost', '127.0.0.1', '1.2.3.4']);
// And no upstream is marked verifiedViaBridge (the loopback-success
// gate only matters in the bridge case).
for (const u of w.upstreams.values()) {
expect(u.verifiedViaBridge).toBeFalsy();
}
} finally {
delete process.env.IN_CONTAINER;
}
});
test('IN_CONTAINER unset (default) still uses host-gateway remap', async () => {
delete process.env.IN_CONTAINER;
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 });
jest.resetModules();
const { w } = loadWatcher();
await w.scanSites();
const http = require('http');
await w._probeOne(w.upstreams.get('localhost:8088'));
const call = http.request.mock.calls.find(c => c[0].hostname === 'host.docker.internal');
expect(call).toBeTruthy();
});
// ---- verifiedViaBridge persistence (B-grade polish) -----------------------
// GLM judge LOW: don't re-prove bridge connectivity across container
// restarts. A previously-positive observation is still good evidence.
test('verifiedViaBridge survives a save -> reload cycle (state.json round-trip)', async () => {
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
probeQueue.push({ kind: 'ok', statusCode: 200 }); // probe succeeds -> verifiedViaBridge=true
const { w: w1 } = loadWatcher();
await w1.scanSites();
const u = w1.upstreams.get('localhost:8088');
await w1._probeOne(u);
expect(u.verifiedViaBridge).toBe(true);
// Force a save.
w1._saveState();
// Reload from the same file via a fresh watcher instance.
jest.resetModules();
const { w: w2 } = loadWatcher();
await w2.scanSites();
const restored = w2.upstreams.get('localhost:8088');
expect(restored).toBeTruthy();
expect(restored.verifiedViaBridge).toBe(true);
// The snapshot field carries it through too.
expect(w2.snapshot().upstreams[0].verifiedViaBridge).toBe(true);
});
});
@@ -322,89 +322,6 @@ describe('CSRF Protection', () => {
process.env.NODE_ENV = origEnv;
});
// DC-058: differentiate "browser auto-retry" from "real probe" by the
// presence of the X-CSRF-Token header. The 403 response is identical in
// both branches; only the stderr log tag changes.
describe('DC-058: browser-auto-retry vs real-probe log tagging', () => {
let stderrSpy;
let origEnv;
beforeEach(() => {
origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
process.env.NODE_ENV = origEnv;
stderrSpy.mockRestore();
});
it('tags missing-cookie with [CSRF-debug] when X-CSRF-Token header also present (browser auto-retry)', () => {
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
});
csrfValidationMiddleware(req, res, next);
// 403 response unchanged
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('DC-100') })
);
// Log tag is [CSRF-debug]
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF-debug]');
expect(lastWrite).toContain('browser auto-retry');
expect(lastWrite).not.toMatch(/^\[CSRF\][^-]/); // not bare [CSRF]
});
it('tags missing-cookie with [CSRF] when no X-CSRF-Token header present (real probe)', () => {
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '' }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF]');
expect(lastWrite).not.toContain('[CSRF-debug]');
expect(lastWrite).not.toContain('browser auto-retry');
});
it('keeps [CSRF] tag when cookie present but header missing (curl probe with manual cookie)', () => {
const nonce = generateToken();
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF]');
expect(lastWrite).not.toContain('[CSRF-debug]');
});
it('headerToken is read from x-csrf-token (lowercased by Express) — lowercase header triggers [CSRF-debug]', () => {
// Express/Node lowercases all incoming header keys, so production code
// only ever sees lowercase. We test the exact code path here.
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF-debug]');
});
});
});
describe('renewCSRFToken', () => {
@@ -0,0 +1,135 @@
// Unit tests for the fixed /api/v1/error-logs parser
// (route /opt/dashcaddy/dashcaddy-api/routes/errorlogs.js)
//
// Background: the prior implementation split on '='.repeat(80) but the
// unified logger writes \u2500 horizontal-rule separators. As a result
// every modal-open returned ZERO entries — same class of silent bug as
// DC-050 (audit log). These tests pin the new behavior so future refactors
// can't reintroduce it.
const { parseEntries, readTailBytes, MAX_TAIL } = require('../routes/errorlogs');
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
const SEP = '\n' + '\u2500'.repeat(72) + '\n';
function buildLog(entries) {
return entries.map((e, i) => {
const head = `[${e.timestamp}] [${e.level}] ${e.context}: ${e.message}`;
return head + (e.details ? '\n' + e.details : '') + SEP;
}).join('');
}
describe('errorlogs parser (DC-051)', () => {
test('parses single entry with U+2500 separator', () => {
const text = buildLog([{
timestamp: '2026-08-16T23:13:14.123Z',
level: 'ERR',
context: '/api/v1/templates',
message: 'Route GET /v1/templates not found',
details: 'NotFoundError: Route GET /v1/templates not found\n at notFoundHandler (/app/src/utilities/error-handler.js:71:8)',
}]);
const out = parseEntries(text);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
timestamp: '2026-08-16T23:13:14.123Z',
level: 'ERR',
context: '/api/v1/templates',
message: 'Route GET /v1/templates not found',
});
expect(out[0].details).toContain('notFoundHandler');
expect(out[0].details).not.toContain('\u2500');
});
test('returns multiple entries in order, ignoring separator residue', () => {
const text = buildLog([
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'first' },
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'WRN', context: 'b', message: 'second' },
{ timestamp: '2026-08-16T23:02:00.000Z', level: 'INF', context: 'c', message: 'third', details: 'extra' },
]);
const out = parseEntries(text);
expect(out.map(e => e.context)).toEqual(['a', 'b', 'c']);
expect(out[1].level).toBe('WRN');
expect(out[2].details).toBe('extra');
});
test('skips malformed lines without throwing', () => {
const text = 'this is not a log entry\n' + SEP + '[2026-08-16T23:00:00.000Z] [ERR] x: y\n' + SEP;
const out = parseEntries(text);
expect(out).toHaveLength(1);
expect(out[0].message).toBe('y');
});
test('empty input returns empty array', () => {
expect(parseEntries('')).toEqual([]);
expect(parseEntries(' \n\n ')).toEqual([]);
});
test('regression: would have returned 0 entries under the OLD splitter', () => {
// Old impl: text.split('='.repeat(80)).filter(...). That produced one
// big block, parser rejected all headers, returned ZERO entries. New
// impl must NOT regress to that behavior on a real-format log.
const text = buildLog([
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'm' },
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'ERR', context: 'b', message: 'm' },
]);
// Sanity: the old split would produce 1 block (no '=' in the text).
expect(text.split('='.repeat(80))).toHaveLength(1);
// New parser must surface both entries.
expect(parseEntries(text)).toHaveLength(2);
});
test('MAX_TAIL is bounded (>=100, <=1000) — prevents unbounded read', () => {
expect(MAX_TAIL).toBeGreaterThanOrEqual(100);
expect(MAX_TAIL).toBeLessThanOrEqual(1000);
});
});
describe('errorlogs readTailBytes (DC-051)', () => {
let tmpFile;
beforeAll(async () => {
tmpFile = path.join(os.tmpdir(), `dc-051-errorlog-${process.pid}.log`);
const entries = [];
for (let i = 0; i < 50; i++) {
entries.push({
timestamp: `2026-08-16T23:${String(i % 60).padStart(2,'0')}:00.000Z`,
level: i % 2 === 0 ? 'ERR' : 'WRN',
context: `ctx-${i}`,
message: `message body ${i}`,
details: i % 3 === 0 ? `stack for ${i}` : null,
});
}
await fsp.writeFile(tmpFile, buildLog(entries));
});
afterAll(async () => {
try { await fsp.unlink(tmpFile); } catch {}
});
test('returns parsed entries within the byte budget', async () => {
const { text, totalSize, truncated } = await readTailBytes(tmpFile, 4 * 1024);
expect(typeof totalSize).toBe('number');
expect(typeof truncated).toBe('boolean');
const parsed = parseEntries(text);
expect(parsed.length).toBeGreaterThan(0);
// Should never include partial first line — every parsed entry has a real timestamp.
for (const e of parsed) {
expect(e.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
});
test('truncates when file exceeds byte budget', async () => {
const stat = await fsp.stat(tmpFile);
const smallBudget = Math.floor(stat.size / 4);
const { truncated } = await readTailBytes(tmpFile, smallBudget);
expect(truncated).toBe(true);
});
test('does not truncate when file fits within byte budget', async () => {
const stat = await fsp.stat(tmpFile);
const bigBudget = stat.size * 2;
const { truncated } = await readTailBytes(tmpFile, bigBudget);
expect(truncated).toBe(false);
});
});
@@ -1,297 +0,0 @@
/**
* Tests for DC-086: asymmetric hysteresis on the dashboard service badge.
*
* - First probe always emits (no prior state).
* - Same-status probe does NOT re-emit (dedup against repeated green).
* - One "down" then back to "up" keeps the badge green (no flicker).
* - Two consecutive "down" probes flip the badge to red.
* - One "up" after a down streak flips back to green (fast recovery).
* - History retains every raw probe even when no emit happens.
* - getCurrentStatus returns displayed status, not raw.
*/
'use strict';
const path = require('path');
const fs = require('fs');
const os = require('os');
// Use an isolated data dir so test history doesn't pollute the real one.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-hyst-'));
process.env.HEALTH_DATA_DIR = tmpDir;
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
// Module exports a singleton instance, not a class — see module.exports in
// src/monitoring/health-checker.js. The test creates fresh state by replacing
// the relevant maps on the singleton in beforeEach.
const healthCheckerSingleton = require('../src/monitoring/health-checker');
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
function restoreEnv(name, value) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
function makeUp(serviceId = 'svc1') {
return {
serviceId,
timestamp: new Date().toISOString(),
status: 'up',
responseTime: 50,
statusCode: 200,
message: 'Service is healthy',
details: { headers: {}, bodyLength: 12 }
};
}
function makeDown(serviceId = 'svc1') {
return {
serviceId,
timestamp: new Date().toISOString(),
status: 'down',
responseTime: 50,
statusCode: 500,
message: 'fail',
details: { headers: {}, bodyLength: 0 }
};
}
describe('DC-086: hysteresis on the dashboard badge', () => {
let hc;
let emitSpy;
beforeEach(() => {
// Reset the singleton's per-test state so each case starts clean.
healthCheckerSingleton.displayedStatus = new Map();
healthCheckerSingleton.consecutiveSinceChange = new Map();
healthCheckerSingleton.currentStatus = new Map();
healthCheckerSingleton.history = {};
healthCheckerSingleton.removeAllListeners('status-check');
emitSpy = jest.fn();
healthCheckerSingleton.on('status-check', emitSpy);
hc = healthCheckerSingleton;
});
afterEach(() => {
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('first probe (no prior state) emits', () => {
hc.recordStatus('svc1', makeUp());
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy.mock.calls[0][0].status).toBe('up');
});
test('second probe with same status does NOT re-emit', () => {
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeUp());
expect(emitSpy).toHaveBeenCalledTimes(1);
});
test('one "down" then "up" keeps the badge green (the flicker bug)', () => {
hc.recordStatus('svc1', makeUp()); // baseline: green, emit 1
hc.recordStatus('svc1', makeDown()); // one blip — keep green, no emit
hc.recordStatus('svc1', makeUp()); // recovered — still green, no emit
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(hc.displayedStatus.get('svc1').status).toBe('up');
});
test('up, down, up, down, down resets the first streak before flipping', () => {
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown());
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown());
expect(hc.displayedStatus.get('svc1').status).toBe('up');
expect(emitSpy).toHaveBeenCalledTimes(1);
hc.recordStatus('svc1', makeDown());
expect(hc.displayedStatus.get('svc1').status).toBe('down');
expect(emitSpy).toHaveBeenCalledTimes(2);
});
test('two consecutive "down" probes flip the badge to red', () => {
hc.recordStatus('svc1', makeUp()); // baseline: green
hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1)
hc.recordStatus('svc1', makeDown()); // blip #2 — flip red (counter=2 >= DOWN_THRESHOLD)
expect(emitSpy).toHaveBeenCalledTimes(2);
expect(emitSpy.mock.calls[1][0].status).toBe('down');
expect(hc.displayedStatus.get('svc1').status).toBe('down');
});
test('one "up" after a down streak flips back to green (fast recovery)', () => {
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown());
hc.recordStatus('svc1', makeDown()); // now red
expect(hc.displayedStatus.get('svc1').status).toBe('down');
hc.recordStatus('svc1', makeUp()); // first green — flip back
expect(emitSpy).toHaveBeenCalledTimes(3);
expect(emitSpy.mock.calls[2][0].status).toBe('up');
expect(hc.displayedStatus.get('svc1').status).toBe('up');
});
test('history retains every raw probe even when no emit happens', () => {
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown()); // blip, no emit
hc.recordStatus('svc1', makeUp()); // recovery, no emit
expect(hc.history['svc1'].length).toBe(3);
expect(hc.history['svc1'][0].status).toBe('up');
expect(hc.history['svc1'][1].status).toBe('down');
expect(hc.history['svc1'][2].status).toBe('up');
});
test('getCurrentStatus returns the displayed status, not the raw probe', () => {
const displayedUp = makeUp();
displayedUp.timestamp = '2026-08-22T09:59:00.000Z';
displayedUp.statusCode = 200;
displayedUp.message = 'healthy';
displayedUp.details = { source: 'accepted-up' };
hc.recordStatus('svc1', displayedUp);
const latestRaw = makeDown();
latestRaw.timestamp = '2026-08-22T10:00:00.000Z';
latestRaw.responseTime = 987;
latestRaw.statusCode = 500;
latestRaw.message = 'failed probe';
latestRaw.error = 'upstream failure';
latestRaw.details = { source: 'suppressed-down' };
hc.recordStatus('svc1', latestRaw); // raw=down, displayed=up
const out = hc.getCurrentStatus();
expect(out['svc1'].status).toBe('up'); // shown to API consumers
expect(out['svc1'].timestamp).toBe(displayedUp.timestamp);
expect(out['svc1'].statusCode).toBe(200);
expect(out['svc1'].message).toBe('healthy');
expect(out['svc1'].error).toBeUndefined();
expect(out['svc1'].details).toEqual({ source: 'accepted-up' });
expect(hc.currentStatus.get('svc1')).toBe(latestRaw);
});
test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
for (let i = 0; i < 50; i++) hc.recordStatus('svc1', makeUp());
expect(emitSpy).toHaveBeenCalledTimes(1);
});
test('a long steady-green-then-steady-red transition: 1 emit (up), 1 emit (red)', () => {
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeUp());
expect(emitSpy).toHaveBeenCalledTimes(1);
hc.recordStatus('svc1', makeDown());
hc.recordStatus('svc1', makeDown()); // flips to red
expect(emitSpy).toHaveBeenCalledTimes(2);
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeDown());
expect(emitSpy).toHaveBeenCalledTimes(2); // no further broadcasts
});
test('DOWN_THRESHOLD env var is honored', () => {
process.env.HEALTH_DOWN_THRESHOLD = '3';
jest.resetModules();
const HC2Module = require('../src/monitoring/health-checker');
// Module is a singleton with DOWN_THRESHOLD captured at module load —
// resetModules gives us a fresh module-level instance with the new env.
const hc2 = HC2Module;
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.removeAllListeners('status-check');
const spy = jest.fn();
hc2.on('status-check', spy);
hc2.recordStatus('svc1', makeUp());
hc2.recordStatus('svc1', makeDown()); // 1
hc2.recordStatus('svc1', makeDown()); // 2 — still green (need 3)
expect(spy).toHaveBeenCalledTimes(1);
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
hc2.recordStatus('svc1', makeDown()); // 3 — flip
expect(spy).toHaveBeenCalledTimes(2);
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
});
test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => {
process.env.HEALTH_DOWN_THRESHOLD = value;
jest.resetModules();
const hc2 = require('../src/monitoring/health-checker');
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.removeAllListeners('status-check');
const spy = jest.fn();
hc2.on('status-check', spy);
hc2.recordStatus('svc1', makeUp());
hc2.recordStatus('svc1', makeDown());
expect(spy).toHaveBeenCalledTimes(1);
hc2.recordStatus('svc1', makeDown());
expect(spy).toHaveBeenCalledTimes(2);
});
test('UP_THRESHOLD env var greater than 1 is honored', () => {
process.env.HEALTH_UP_THRESHOLD = '2';
jest.resetModules();
const hc2 = require('../src/monitoring/health-checker');
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.removeAllListeners('status-check');
const spy = jest.fn();
hc2.on('status-check', spy);
hc2.recordStatus('svc1', makeDown());
hc2.recordStatus('svc1', makeUp());
expect(spy).toHaveBeenCalledTimes(1);
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
hc2.recordStatus('svc1', makeUp());
expect(spy).toHaveBeenCalledTimes(2);
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
});
test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => {
process.env.HEALTH_UP_THRESHOLD = value;
jest.resetModules();
const hc2 = require('../src/monitoring/health-checker');
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.removeAllListeners('status-check');
const spy = jest.fn();
hc2.on('status-check', spy);
hc2.recordStatus('svc1', makeDown());
hc2.recordStatus('svc1', makeUp());
expect(spy).toHaveBeenCalledTimes(2);
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
});
test('removeService clears hysteresis state before the same ID is re-added', () => {
hc.config.services.svc1 = { name: 'Service 1' };
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown());
expect(hc.displayedStatus.has('svc1')).toBe(true);
expect(hc.consecutiveSinceChange.get('svc1')).toBe(1);
hc.consecutiveFailures.set('svc1', 3);
const timer = setTimeout(() => {}, 60_000);
hc.serviceTimers.set('svc1', timer);
hc.saveConfig = jest.fn();
hc.removeService('svc1');
expect(hc.displayedStatus.has('svc1')).toBe(false);
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
expect(hc.currentStatus.has('svc1')).toBe(false);
expect(hc.consecutiveFailures.has('svc1')).toBe(false);
expect(hc.serviceTimers.has('svc1')).toBe(false);
hc.config.services.svc1 = { name: 'Service 1 re-added' };
const emitSpyAfterReAdd = jest.fn();
hc.on('status-check', emitSpyAfterReAdd);
hc.recordStatus('svc1', makeDown());
expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1);
expect(hc.displayedStatus.get('svc1').status).toBe('down');
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
});
});
@@ -203,48 +203,6 @@ describe('HealthChecker', () => {
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 () => {
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
@@ -1,326 +0,0 @@
/**
* DC-055: Host journald reader unit tests
*
* The reader is a security-sensitive shell-out — every test below exists
* to prevent a regression that would let a caller pass a tainted unit
* name or since/until/search string to journalctl. We never call the real
* binary; every spawn is mocked by injecting an `exec` function (the
* module accepts exec as the second argument specifically for testability).
*/
const path = require('path');
const { EventEmitter } = require('events');
const MODULE_PATH = path.join(__dirname, '..', 'src', 'monitoring', 'journald-reader.js');
// Construct a fake child process that matches the interface journald-reader
// uses: stdout/stderr EventEmitters, kill(), and emits 'exit' on demand.
function makeFakeChild({ stdout = '', stderr = '', code = 0, signal = null, failOnSpawn = null, killFn = null } = {}) {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = killFn || (() => {});
process.nextTick(() => {
if (failOnSpawn) {
const err = new Error('spawn fail');
err.code = failOnSpawn;
child.emit('error', err);
return;
}
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
child.emit('exit', code, signal);
});
return child;
}
// Factory for an `exec` function that returns the given fake child.
function fakeExec(child) {
return jest.fn().mockReturnValue(child);
}
describe('journald-reader', () => {
describe('assertUnitAllowed', () => {
const { assertUnitAllowed } = require(MODULE_PATH);
test('accepts allow-listed bare names', () => {
expect(assertUnitAllowed('caddy')).toBe('caddy');
expect(assertUnitAllowed('docker')).toBe('docker');
expect(assertUnitAllowed('dashcaddy-api')).toBe('dashcaddy-api');
});
test('strips .service suffix', () => {
expect(assertUnitAllowed('caddy.service')).toBe('caddy');
expect(assertUnitAllowed('docker.service')).toBe('docker');
});
test('rejects units not on the allow-list', () => {
expect(() => assertUnitAllowed('sshd')).toThrow(/not in allow-list/);
expect(() => assertUnitAllowed('nginx')).toThrow(/not in allow-list/);
expect(() => assertUnitAllowed('root')).toThrow(/not in allow-list/);
});
test('rejects shell metacharacters and path traversal', () => {
expect(() => assertUnitAllowed('caddy; rm -rf /')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('caddy && touch /tmp/pwn')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('caddy|tee /etc/passwd')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('../etc/passwd')).toThrow(/invalid characters/);
expect(() => assertUnitAllowed('caddy\nfoo')).toThrow(/invalid characters/);
});
test('rejects empty / non-string', () => {
expect(() => assertUnitAllowed('')).toThrow(/unit is required/);
expect(() => assertUnitAllowed(null)).toThrow(/unit is required/);
expect(() => assertUnitAllowed(undefined)).toThrow(/unit is required/);
expect(() => assertUnitAllowed(42)).toThrow(/unit is required/);
});
test('throws ValidationError specifically (route layer keys on .name)', () => {
try { assertUnitAllowed('nginx'); }
catch (e) { expect(e.name).toBe('ValidationError'); }
});
});
describe('parseTail', () => {
const { parseTail, MAX_TAIL_LINES } = require(MODULE_PATH);
test('returns fallback on undefined', () => {
expect(parseTail(undefined)).toBe(200);
expect(parseTail(undefined, 50)).toBe(50);
});
test('clamps to MAX_TAIL_LINES', () => {
expect(parseTail('999999999999')).toBe(MAX_TAIL_LINES);
expect(parseTail(999999999999)).toBe(MAX_TAIL_LINES);
});
test('rejects non-positive and non-integer', () => {
expect(() => parseTail('0')).toThrow(/positive integer/);
expect(() => parseTail('-5')).toThrow(/positive integer/);
expect(() => parseTail('abc')).toThrow(/positive integer/);
expect(() => parseTail('1.5')).toThrow(/positive integer/);
expect(() => parseTail(NaN)).toThrow(/positive integer/);
});
test('accepts valid integers', () => {
expect(parseTail('1')).toBe(1);
expect(parseTail('500')).toBe(500);
expect(parseTail(200)).toBe(200);
});
});
describe('parseTimestamp', () => {
const { parseTimestamp } = require(MODULE_PATH);
test('returns null on undefined/empty', () => {
expect(parseTimestamp(undefined, 'since')).toBeNull();
expect(parseTimestamp('', 'since')).toBeNull();
expect(parseTimestamp(null, 'since')).toBeNull();
});
test('parses ISO 8601 timestamps', () => {
const out = parseTimestamp('2026-08-18T07:00:00Z', 'since');
expect(out).toBe('2026-08-18T07:00:00.000Z');
});
test('parses ISO date-only', () => {
const out = parseTimestamp('2026-08-18', 'since');
expect(out).toMatch(/^2026-08-18/);
});
test('parses unix epoch in seconds and ms', () => {
// Use a known epoch so the test isn't sensitive to "now". The
// expected ISO output is computed at runtime so this stays correct.
const epochSec = 1787038846; // 2026-08-18T07:00:46Z
const expected = new Date(epochSec * 1000).toISOString();
expect(parseTimestamp(String(epochSec), 'since')).toBe(expected);
expect(parseTimestamp(String(epochSec * 1000), 'since')).toBe(expected);
});
test('passes through journalctl relative syntax', () => {
expect(parseTimestamp('30 min ago', 'since')).toBe('30 min ago');
expect(parseTimestamp('today', 'until')).toBe('today');
});
test('rejects shell metacharacters in relative syntax', () => {
expect(() => parseTimestamp('30 min ago; touch /tmp/pwn', 'since')).toThrow(/forbidden/);
expect(() => parseTimestamp('today && rm -rf /', 'until')).toThrow(/forbidden/);
});
test('rejects strings >1024 chars', () => {
const huge = 'a'.repeat(1025);
expect(() => parseTimestamp(huge, 'since')).toThrow(/forbidden/);
});
test('rejects invalid ISO', () => {
// 'not-a-date' doesn't match the ISO_PATTERN and isn't numeric or
// safe relative-syntax — falls through to the relative branch but
// doesn't contain forbidden chars either, so it would pass through
// to journalctl. Use a string with shell metacharacters instead
// to prove the path actually rejects.
expect(() => parseTimestamp('yesterday | nc evil 1234', 'since')).toThrow();
// Numbers that overflow Date.parse
expect(() => parseTimestamp('99999999999999999999', 'since')).toThrow();
});
});
describe('buildArgv', () => {
const { buildArgv } = require(MODULE_PATH);
test('always emits --directory + unit + --no-pager', () => {
const argv = buildArgv({ unit: 'caddy', tail: 100 });
expect(argv).toContain('--directory');
expect(argv[argv.indexOf('--directory') + 1]).toBe('/var/log/journal');
expect(argv).toContain('--no-pager');
expect(argv).toContain('-u');
expect(argv[argv.indexOf('-u') + 1]).toBe('caddy');
expect(argv).not.toContain('--follow');
});
test('follow flag is set when requested', () => {
const argv = buildArgv({ unit: 'caddy', follow: true });
expect(argv).toContain('--follow');
});
test('emits -n <tail> for numeric tail', () => {
const argv = buildArgv({ unit: 'caddy', tail: 500 });
const idx = argv.indexOf('-n');
expect(idx).toBeGreaterThan(-1);
expect(argv[idx + 1]).toBe('500');
});
test('emits --since/--until/search when provided', () => {
const argv = buildArgv({
unit: 'caddy', tail: 100,
since: '2026-08-18T00:00:00Z',
until: '2026-08-18T23:59:59Z',
search: 'health',
});
expect(argv).toContain('--since');
expect(argv).toContain('--until');
expect(argv).toContain('-S');
expect(argv[argv.indexOf('-S') + 1]).toBe('health');
});
test('emits argv as a flat string array (no shell)', () => {
const argv = buildArgv({ unit: 'caddy', tail: 1 });
expect(argv.every(a => typeof a === 'string')).toBe(true);
});
});
describe('readEntries', () => {
const reader = require(MODULE_PATH);
test('parses short-output lines into structured entries', async () => {
const child = makeFakeChild({
stdout: [
'Aug 18 00:42:46 vmi3080415 caddy[3620580]: {"level":"info","msg":"hello"}',
'Aug 18 00:42:56 vmi3080415 caddy[3620580]: {"level":"warn","msg":"oops"}',
'',
].join('\n'),
});
const entries = await reader.readEntries({ unit: 'caddy', tail: 50 }, { exec: fakeExec(child) });
expect(entries).toHaveLength(2);
expect(entries[0].timestamp).toBe('Aug 18 00:42:46');
expect(entries[0].hostname).toBe('vmi3080415');
expect(entries[0].unit).toBe('caddy');
expect(entries[0].text).toBe('{"level":"info","msg":"hello"}');
});
test('throws on ValidationError for bad unit', async () => {
await expect(reader.readEntries({ unit: 'nginx' })).rejects.toMatchObject({
name: 'ValidationError',
});
});
test('throws on ValidationError for bad tail', async () => {
await expect(reader.readEntries({ unit: 'caddy', tail: -1 })).rejects.toMatchObject({
name: 'ValidationError',
});
});
test('throws on ValidationError for shell-meta since', async () => {
await expect(reader.readEntries({ unit: 'caddy', since: 'yesterday; touch /tmp/pwn' }))
.rejects.toMatchObject({ name: 'ValidationError' });
});
test('surfaces ENOENT as Error("journalctl unavailable")', async () => {
const child = makeFakeChild({ failOnSpawn: 'ENOENT' });
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
.then(() => null, e => e);
expect(err.message).toBe('journalctl unavailable');
});
test('surfaces non-zero exit with stderr snippet', async () => {
const child = makeFakeChild({
stdout: '',
stderr: 'Failed to open directory: /var/log/journal/foo\n',
code: 1,
});
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
.then(() => null, e => e);
expect(err.message).toMatch(/exited 1/);
expect(err.message).toMatch(/Failed to open directory/);
});
test('clamps stdout at MAX_OUTPUT_BUFFER and rejects with overflow', async () => {
// Use smaller chunks: 800KB then another 800KB = 1.6MB > 2MB cap.
// Wait, that's <2MB. Need: total > 2MB. Use 1MB + 1.2MB.
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
const cap = require(MODULE_PATH).MAX_OUTPUT_BUFFER;
const first = Math.floor(cap * 0.4); // 40%
const second = Math.floor(cap * 0.7); // 70% more — total 110%
process.nextTick(() => {
child.stdout.emit('data', Buffer.alloc(first, 'x'));
child.stdout.emit('data', Buffer.alloc(second, 'x'));
// Don't emit exit — the overflow rejection doesn't depend on it.
// Kill the child eventually so Jest can exit cleanly.
setTimeout(() => child.emit('exit', null, 'SIGKILL'), 50);
});
const execSpy = jest.fn().mockReturnValue(child);
const err = await reader.readEntries({ unit: 'caddy' }, { exec: execSpy })
.then(() => null, e => e);
expect(err).not.toBeNull();
expect(err.message).toMatch(/exceeded/);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
});
});
describe('streamEntries', () => {
const reader = require(MODULE_PATH);
test('emits parsed data + completes on exit', async () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from('Aug 18 00:42:46 host caddy[1]: hello\n'));
child.emit('exit', 0, null);
});
const seen = [];
const execSpy = jest.fn().mockReturnValue(child);
reader.streamEntries({ unit: 'caddy' }, {
exec: execSpy,
onData: (e) => seen.push(e),
onError: () => {},
});
// Drain microtasks so the nextTick callback fires.
await new Promise((r) => setTimeout(r, 30));
expect(execSpy).toHaveBeenCalledTimes(1);
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[0].unit).toBe('caddy');
expect(seen[0].text).toBe('hello');
});
test('rejects bad unit before opening stream', () => {
expect(() => reader.streamEntries({ unit: 'nginx' }, { onError: () => {} }))
.toThrow(/not in allow-list/);
});
});
});
@@ -1,112 +0,0 @@
/**
* Nesting-guard tests — DC-077 (data/data recursive duplicate cleanup)
*
* The guard runs at app startup. Pre-fix, `src/config/paths.js` did NOT
* re-export `dataDir`, so `paths.dataDir` resolved to `undefined`. The
* outer try/catch swallowed the resulting `TypeError [ERR_INVALID_ARG_TYPE]`
* and the entire guard became a silent no-op — every startup logged
* `[nesting-guard] Skipped: The "path" argument must be of type string.
* Received undefined`. Post-fix, paths.js exports `dataDir` and the guard
* falls back to platform-paths directly if `paths.dataDir` is missing.
*
* Tests use jest.isolateModules() for clean module-cache isolation.
* jest.doMock is intentionally avoided — it persists across tests in a
* describe and is the root cause of subtle flakes.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
describe('nesting-guard (DC-077)', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
jest.restoreAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
function makeTmpTree() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'nest-guard-'));
}
function writeJson(p, obj) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(obj));
}
it('removes a recursive data/data duplicate when present', () => {
const tmp = makeTmpTree();
writeJson(path.join(tmp, 'config.json'), { x: 1 });
writeJson(path.join(tmp, 'data', 'config.json'), { x: 1 });
writeJson(path.join(tmp, 'data', 'services.json'), []);
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
let cleanupLog = '';
let warnLog = '';
jest.isolateModules(() => {
const guard = require('../src/utilities/nesting-guard');
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
guard();
});
expect(fs.existsSync(path.join(tmp, 'data'))).toBe(false);
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
expect(cleanupLog).toMatch(/Removing recursive data nesting|Recursive nesting removed/);
expect(warnLog).not.toMatch(/Skipped/);
});
it('does nothing when no nested data/data directory exists', () => {
const tmp = makeTmpTree();
writeJson(path.join(tmp, 'config.json'), { x: 1 });
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
let cleanupLog = '';
let warnLog = '';
jest.isolateModules(() => {
const guard = require('../src/utilities/nesting-guard');
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
guard();
});
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
expect(warnLog).not.toMatch(/Skipped/);
expect(cleanupLog).not.toMatch(/Removing recursive data nesting/);
});
it('src/config/paths exports dataDir as a non-empty string', () => {
let dataDir;
jest.isolateModules(() => {
const paths = require('../src/config/paths');
dataDir = paths.dataDir;
});
expect(typeof dataDir).toBe('string');
expect(dataDir.length).toBeGreaterThan(0);
});
it('src/config/paths.dataDir equals dirname(SERVICES_FILE) when SERVICES_FILE env is set', () => {
const tmp = makeTmpTree();
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
let servicesFile, dataDir;
jest.isolateModules(() => {
const paths = require('../src/config/paths');
servicesFile = paths.SERVICES_FILE;
dataDir = paths.dataDir;
});
expect(dataDir).toBe(path.dirname(servicesFile));
expect(dataDir).toBe(tmp);
});
});
@@ -112,7 +112,6 @@ function createApp(depsOverride = {}) {
errorResponse: jest.fn(),
log,
renewCSRFToken,
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
...depsOverride,
};
@@ -300,7 +299,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 () => {
const secret = await setupTOTP();
const token = authenticator.generate(secret);
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
const res = await request(app).post('/api/totp/verify').send({ code: token });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toMatch(/Authenticated successfully/);
@@ -309,29 +308,8 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
expect(deps.session.create).toHaveBeenCalled();
expect(deps.session.setCookie).toHaveBeenCalled();
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
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();
});
});
// ────────────────────────────────────────────────────────────────────
@@ -472,7 +450,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
// 4. Re-login via /totp/verify (the "login" path)
const loginCode = authenticator.generate(secret);
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
expect(loginRes.status).toBe(200);
expect(loginRes.body.csrfToken).toBeDefined();
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
@@ -1,218 +0,0 @@
/**
* DC-057: dead-shadow /backups/schedule handler removed.
*
* The duplicate `router.post('/backups/schedule', ...)` previously registered
* far below the canonical one was unreachable (Express matches the first
* registered handler per METHOD+PATH). It bypassed `premiumGating` and
* `validateBody` and used a `name`-keyed schema that would have corrupted the
* backup config if it ever ran. The canonical handler uses the error code
* `backups-schedule-update`; the dead handler used `backups-schedule-legacy`.
* This test proves:
*
* 1. The router registers exactly ONE POST /backups/schedule handler
* (the canonical, appId-keyed one).
* 2. No handler references the legacy "backups-schedule-legacy" error code.
* 3. The legacy "name"-keyed schema now produces a 400 ValidationError
* from the canonical Joi schema (dead handler is gone).
* 4. The canonical appId-keyed schema still succeeds (200).
* 5. premiumGating is enforced on the canonical POST.
*
* Mirrors the audit-log.routes.test.js pattern.
*/
const express = require('express');
function buildFakeBackupManager() {
const config = { backups: {}, defaultRetention: { keep: 7 } };
return {
getConfig: jest.fn(() => config),
updateConfig: jest.fn((next) => {
config.backups = next.backups || {};
}),
getHistory: jest.fn(() => []),
restoreBackup: jest.fn(async (id) => {
// Suppress require-await — keep async shape for parity with the
// real backupManager.restoreBackup contract.
return Promise.resolve({ id, status: 'restored' });
}),
};
}
function buildFakeLicenseManager() {
const requirePremium = jest.fn(() => (_req, _res, next) => next());
return {
requirePremium,
isPremium: jest.fn(() => true),
};
}
function buildRouter(licenseManager, backupManager) {
// Reset module cache so each test starts fresh
jest.resetModules();
const mod = require('../../routes/backups');
return mod({
backupManager,
licenseManager,
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
try { await fn(req, res, next); } catch (e) { next(e); }
},
});
}
function buildApp(router) {
// Catch-all error handler so ValidationError / NotFoundError become JSON
const app = express();
app.use(express.json());
app.use((req, res, next) => {
// intentionally strip auth — the test does not exercise it
next();
});
app.use('/', router);
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = err.statusCode || err.status || 500;
res.status(status).json({
error: err.message,
code: err.code || 'ERR',
});
});
return app;
}
function supertestFetch(app) {
// Tiny in-process fetch helper (no need to add supertest dep)
const http = require('http');
return function (method, path, body) {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const { port } = server.address();
const data = body ? JSON.stringify(body) : null;
const req = http.request({
method,
hostname: '127.0.0.1',
port,
path,
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
}, (res) => {
let chunks = '';
res.on('data', (c) => { chunks += c; });
res.on('end', () => {
server.close();
let parsed;
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.on('error', (e) => { server.close(); reject(e); });
if (data) req.write(data);
req.end();
});
});
};
}
describe('routes/backups POST /backups/schedule (DC-057)', () => {
let backupManager, licenseManager, app, fetch;
beforeEach(() => {
backupManager = buildFakeBackupManager();
licenseManager = buildFakeLicenseManager();
const router = buildRouter(licenseManager, backupManager);
app = buildApp(router);
fetch = supertestFetch(app);
});
test('registers exactly ONE POST /backups/schedule handler (canonical)', () => {
// Inspect the registered router layers and confirm only one POST /backups/schedule
// route exists (no shadowed / unreachable duplicate).
const router = buildRouter(licenseManager, backupManager);
const seen = [];
router.stack.forEach((layer) => {
if (layer.route && layer.route.path === '/backups/schedule' && layer.route.methods.post) {
seen.push(layer.route);
}
});
expect(seen).toHaveLength(1);
});
test('no handler references the legacy "backups-schedule-legacy" error code', () => {
// The canonical handler uses error code 'backups-schedule-update'.
// Walk the router stack and assert no route uses the legacy error code.
const router = buildRouter(licenseManager, backupManager);
const handlerStrings = [];
function walk(node) {
if (!node) return;
if (node.stack) node.stack.forEach(walk);
if (node.handle) {
const code = node.handle.toString();
handlerStrings.push(code);
}
}
walk(router);
const all = handlerStrings.join('\n');
expect(all).not.toContain('backups-schedule-legacy');
});
test('legacy name-keyed schema is REJECTED with 400 (dead route truly gone)', async () => {
// The dead handler accepted { name, schedule, maxStorageBytes, ...backupConfig }.
// After removal, the canonical Joi schema (backupScheduleCreate) rejects this
// shape because it requires `appId`. So we expect a 400.
const res = await fetch('POST', '/backups/schedule', {
name: 'mybackup',
schedule: 'daily',
maxStorageBytes: 1024,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/appId.*required|appId is required/i);
});
test('canonical appId-keyed schema SUCCEEDS (200) and writes backup config', async () => {
const res = await fetch('POST', '/backups/schedule', {
appId: 'plex',
schedule: 'daily',
retention: { keep: 7 },
destination: 'local',
destinationPath: '/var/backups/plex',
maxStorageBytes: 1024,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(backupManager.updateConfig).toHaveBeenCalledTimes(1);
const written = backupManager.updateConfig.mock.calls[0][0];
expect(written.backups).toHaveProperty('plex');
expect(written.backups.plex.schedule).toBe('daily');
expect(written.backups.plex.enabled).toBe(true);
expect(written.backups.plex.maxStorageBytes).toBe(1024);
});
test('premium gating is enforced on POST /backups/schedule', async () => {
// Replace the premium gate with one that 403s, then verify it runs.
licenseManager.requirePremium.mockReturnValueOnce(
(_req, res) => res.status(403).json({ error: 'premium required' }),
);
const router = buildRouter(licenseManager, backupManager);
app = buildApp(router);
fetch = supertestFetch(app);
const res = await fetch('POST', '/backups/schedule', {
appId: 'plex',
schedule: 'daily',
});
expect(res.status).toBe(403);
expect(backupManager.updateConfig).not.toHaveBeenCalled();
});
test('GET /backups/schedule still works (no collateral damage)', async () => {
const res = await fetch('GET', '/backups/schedule');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('schedules');
});
test('DELETE /backups/schedule/:appId still works', async () => {
// Seed the config so the delete has something to remove
backupManager.getConfig().backups.plex = { schedule: 'daily' };
const res = await fetch('DELETE', '/backups/schedule/plex');
expect(res.status).toBe(200);
expect(backupManager.updateConfig).toHaveBeenCalled();
});
});
@@ -1,350 +0,0 @@
/**
* DC-076: Per-service CA cert / private key disclosure hardening
*
* Bug class:
* 1. /api/v1/ca/cert/<domain> and /api/v1/ca/certs were listed in
* middleware.js PUBLIC_ROUTES. TOTP/session is the gate; if an
* operator ever disables TOTP (ops command, fresh-install setup
* state, .disabled-* rename of totp-config.json), an unauthenticated
* attacker reaching `https://ca.sami/api/ca/cert/<domain>?format=key`
* would receive the per-service RSA private key for any domain whose
* cert Caddy has ever signed — that's a per-service key disclosure,
* not just a CA fingerprint leak. Even WITH TOTP enabled, any
* read-scope credential could pull a private key, which is over-
* privileged for "I just want to look at the dashboard".
* 2. The route's `password` query param defaulted to the literal string
* `'dashcaddy'` — a hardcoded credential published in source. Every
* PFX file Caddy signed silently used the same published password.
* 3. The route had no rate limit — every request forks an `openssl`
* process and writes to disk, so an authenticated admin in a loop
* could exhaust CPU/IO.
*
* Post-fix (this commit):
* 1. /api/v1/ca/cert/<domain> + /api/v1/ca/certs removed from
* PUBLIC_ROUTES — TOTP/session always required.
* 2. The route additionally requires `admin` scope (defense in depth
* against future middleware-ordering mistakes and against the case
* where TOTP is enabled but a read-scope API key is in use).
* 3. PFX format now REQUIRES an explicit 8-64 char password (no
* default). Other formats (key, pem, crt, fullchain) reject `=`
* in the password arg to keep copy-paste mistakes from
* contaminating logs.
* 4. Per-IP rate limit: 10 req/min/IP with Retry-After + 429.
*
* The suite covers:
* 1. middleware PUBLIC_ROUTES no longer contains the ca cert/certs paths
* 2. /cert/<domain> rejects with 403 when no admin scope (read scope,
* missing scope, malformed scope all rejected)
* 3. /cert/<domain> rejects with 400 when PFX password missing or weak
* 4. /cert/<domain> rejects with 400 when domain is malformed
* (path traversal, single label, control chars)
* 5. /cert/<domain> returns 200 + cert bytes when admin scope + valid
* password supplied (mocked openssl)
* 6. Rate limit: 10 req/min/IP allowed, 11th 429 with Retry-After
* 7. /certs list endpoint requires admin scope (regression for the
* public listing)
*/
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
// We pull the route's internal helpers by requiring the module under test
// and inspecting its internals via the closure-scoped functions. The cleanest
// path is to mount the route and assert behavior end-to-end through HTTP.
const caRoutes = require('../../routes/ca');
// ---------------------------------------------------------------------------
// Test fixture: a minimal Express app that mounts /ca with stubbed ctx.
// The route captures `platformPaths` at module-load time, so the actual
// production paths are used. Test scenarios that would need an isolated
// cert dir are covered at the response-shape level (asserting 400/403/429
// codes) rather than the file-content level.
// ---------------------------------------------------------------------------
function createCaApp({ scope, installMocks = true, tempDirs } = {}) {
// We don't mock platform-paths because the test scenarios that need
// filesystem-isolated cert dirs (PFX, cert-file serving) are covered
// by their pre-staged files in the system temp dir, and the 200-happy
// path for non-PFX formats is asserted at the response-shape level
// rather than the file-content level. The route's pre-existing PKI
// files at the real platformPaths.pkiDir either exist (production
// setup) or trigger the 500 "CA certificates not found" path — both
// are acceptable for the scope/admin/password/rate-limit assertions.
const app = express();
app.use(express.json({ limit: '1mb' }));
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const caRoutes = require('../../routes/ca');
const ok = (res, data) => res.json({ ok: true, ...data });
const errorResponse = (res, statusCode, message, extras) => {
res.status(statusCode).json({
success: false,
error: message,
code: (extras && extras.code) || null,
...(extras || {}),
});
};
const asyncHandler = wrap;
const ctx = {
asyncHandler,
ok,
errorResponse,
siteConfig: { tld: '.sami' },
};
const ca = caRoutes(ctx);
// Mount a tiny auth shim that stamps req.auth before the route runs.
// This mirrors what the global totpAuthMiddleware + jwtApiKeyAuthMiddleware
// do in production: req.auth = { type, scope, ... }.
app.use((req, _res, next) => {
req.auth = { type: 'session', scope: scope || [] };
// req.ip is read by the rate limiter
req.ip = '127.0.0.1';
next();
});
app.use('/ca', ca);
return { app };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('DC-076: CA cert/key disclosure hardening', () => {
describe('middleware PUBLIC_ROUTES no longer whitelists the per-service cert/key endpoints', () => {
// Read the public-routes source so a future refactor that re-adds the
// path is caught by THIS test (not by an external integration test
// that depends on running TOTP-disabled).
const fs = require('fs');
const middlewareSrc = fs.readFileSync(
path.join(__dirname, '../../src/utilities/middleware.js'), 'utf8');
// Extract the PUBLIC_ROUTES block (best-effort text scan — catches
// both `path: '/api/v1/ca/cert/...'` and `path: '/api/v1/ca/certs'`).
const caCertEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/cert\/[^'"]*['"]/);
const caCertsEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/certs['"]/);
test('/api/v1/ca/cert/ prefix is NOT in PUBLIC_ROUTES', () => {
expect(caCertEntry).toBeNull();
});
test('/api/v1/ca/certs exact path is NOT in PUBLIC_ROUTES', () => {
expect(caCertsEntry).toBeNull();
});
});
describe('/cert/:domain — admin scope required (defense in depth)', () => {
test('no scope at all -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
const { app } = createCaApp({ scope: [] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=key');
expect(res.status).toBe(403);
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
expect(res.body.requiredScope).toBe('admin');
});
test('read-only scope -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
const { app } = createCaApp({ scope: ['read'] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=key');
expect(res.status).toBe(403);
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
expect(res.body.actualScope).toEqual(['read']);
});
test('write scope (but not admin) -> 403', async () => {
const { app } = createCaApp({ scope: ['read', 'write'] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=key');
expect(res.status).toBe(403);
});
test('admin scope -> proceeds past the scope gate', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=key');
// Will fail later (no password? actually format=key doesn't need pw)
// but MUST NOT 403. We expect a 4xx for the cert file not existing
// (the test stubs open the route, but the openssl mock below would
// still hit a real openssl — we test 200 only when mocks are wired).
// For the no-mock path, we accept anything except 403.
expect(res.status).not.toBe(403);
});
test('scope field coerced defensively (string, not array) -> 403', async () => {
const { app } = createCaApp({ scope: 'admin' });
// Override the auth shim to set a malformed scope
app.use((req, _res, next) => {
req.auth = { type: 'session', scope: 'admin' /* not an array */ };
next();
});
const res = await request(app)
.get('/ca/cert/dns1.sami?format=key');
expect(res.status).toBe(403);
});
});
describe('/cert/:domain — PFX format requires explicit password', () => {
test('no password supplied -> 400 DC-076_PASSWORD_REQUIRED', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=pfx');
expect(res.status).toBe(400);
expect(res.body.code).toBe('DC-076_PASSWORD_REQUIRED');
});
test('default password "dashcaddy" was the pre-fix behavior — now rejected', async () => {
// Pre-fix: the route used `password = 'dashcaddy'` as default; PFX
// files were signed with that string. Post-fix: an explicit password
// shorter than 8 chars or matching the old default shape ("dashcaddy"
// is 9 chars, lowercase only) must be REJECTED if it doesn't match
// the policy. The policy is 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-],
// so "dashcaddy" is technically 9 chars and would pass... but we
// test that an EXPLICIT password is required (no implicit default)
// by sending no password and asserting 400.
const { app } = createCaApp({ scope: ['admin'] });
const noPw = await request(app)
.get('/ca/cert/dns1.sami?format=pfx');
expect(noPw.status).toBe(400);
expect(noPw.body.code).toBe('DC-076_PASSWORD_REQUIRED');
});
test('short password (< 8 chars) -> 400 DC-076_PASSWORD_INVALID', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=pfx&password=short');
expect(res.status).toBe(400);
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
});
test('password with `=` -> 400 DC-076_PASSWORD_INVALID', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=pfx&password=abcdefgh=');
expect(res.status).toBe(400);
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
});
test('password with disallowed char (e.g. `/`) -> 400', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/dns1.sami?format=pfx&password=abc/12345');
expect(res.status).toBe(400);
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
});
test('non-PFX format (key) does NOT require a password (regression for PFX-only password logic)', async () => {
// The point of this test is to prove that the new DC-076 password
// gate only fires for PFX. Other formats (key, pem, crt, fullchain)
// must not 400 on missing-password.
//
// We can't easily test the 200 happy path here because the route
// calls `openssl x509 -in server.crt -noout -dates` to check cert
// expiry, and a fake server.crt makes that fall through to cert
// regeneration (which calls real openssl and writes real certs to
// the real platformPaths.generatedCertsDir — not what we want in a
// unit test). Instead, we assert that the route does NOT 400 with
// the password-required shape. We use /format=crt which has the
// simplest validation path.
const { app } = createCaApp({ scope: ['admin'] });
// No password supplied; format=crt. Should NOT 400 with
// DC-076_PASSWORD_REQUIRED (that's only for PFX).
const res = await request(app)
.get('/ca/cert/dns1.sami?format=crt');
if (res.status === 400 && res.body.code === 'DC-076_PASSWORD_REQUIRED') {
throw new Error('non-PFX format wrongly required a password: ' + JSON.stringify(res.body));
}
// The actual response could be 200 (cert served) or 500 (cert files
// missing in test env, or openssl error from fake data) — both
// are acceptable; what matters is NOT 400 DC-076_PASSWORD_REQUIRED.
expect(res.status).not.toBe(400);
});
});
describe('/cert/:domain — domain validation', () => {
test('rejects single-label domain (no dot)', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/dns1?format=key');
expect(res.status).toBe(400);
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
});
test('rejects domain with `..` (path traversal)', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/..%2Fetc%2Fpasswd?format=key');
// Express decodes %2F in the path -> /ca/cert/../etc/passwd
// The new regex `^[a-z0-9]...` rejects this entirely.
expect([400, 404]).toContain(res.status);
});
test('rejects domain with control char (\\n)', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/evil%0A.com?format=key');
expect([400, 404]).toContain(res.status);
});
test('rejects uppercase domain (must be lowercase per the new regex)', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app)
.get('/ca/cert/DNS1.SAMI?format=key');
expect(res.status).toBe(400);
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
});
});
describe('/cert/:domain — rate limit', () => {
test('first 10 requests in 60s succeed (or fail non-rate-limit), 11th returns 429', async () => {
// 10 requests should all NOT be 429 (the rate-limit counter is
// reset per module load, so each test starts fresh).
for (let i = 0; i < 10; i++) {
const { app } = createCaApp({ scope: ['admin'] });
const r = await request(app).get('/ca/cert/dns1.sami?format=key');
expect(r.status).not.toBe(429);
}
// 11th MUST be 429 (the rate limit is in-module state; only the
// last test's app shares state with itself, so we use the same
// app for the 11th request).
const { app } = createCaApp({ scope: ['admin'] });
// First 10
for (let i = 0; i < 10; i++) {
await request(app).get('/ca/cert/dns1.sami?format=key');
}
const over = await request(app).get('/ca/cert/dns1.sami?format=key');
expect(over.status).toBe(429);
expect(over.body.code).toBe('DC-076_RATE_LIMITED');
expect(over.headers['retry-after']).toMatch(/^\d+$/);
});
});
describe('/certs — list endpoint requires admin scope', () => {
test('no admin scope -> 403', async () => {
const { app } = createCaApp({ scope: ['read'] });
const res = await request(app).get('/ca/certs');
expect(res.status).toBe(403);
});
test('admin scope -> 200', async () => {
const { app } = createCaApp({ scope: ['admin'] });
const res = await request(app).get('/ca/certs');
expect(res.status).toBe(200);
});
});
describe('static /root.crt and /info remain public (CA cert IS public)', () => {
test('GET /ca/root.crt does not require admin scope', async () => {
const { app } = createCaApp({ scope: [] });
const res = await request(app).get('/ca/root.crt');
// 200 if the file is there, 404 if not — but NEVER 403
expect([200, 404]).toContain(res.status);
});
test('GET /ca/info does not require admin scope', async () => {
const { app } = createCaApp({ scope: [] });
const res = await request(app).get('/ca/info');
// 200 if cert-info.json is there, 404 if not — but NEVER 403
expect([200, 404]).toContain(res.status);
});
});
});
@@ -1,272 +0,0 @@
/**
* DC-073: regression tests for the caddy-upstreams mute endpoints.
*
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
* rejected unknown hosts with a 400 "not a known upstream". The
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
* check entirely and would silently call `setMuted(phantom, true)`,
* persisting a phantom entry into the watcher's muted Set (which is
* disk-persisted via `_saveState()`).
*
* These tests prove:
* (1) every endpoint now rejects an unknown host with 400
* (2) the rejection happens BEFORE setMuted is invoked (no state
* corruption — `fakeWatcher.setMuted` is asserted to be
* untouched on the rejection path)
* (3) the rejection message is the canonical "not a known upstream"
* so callers can branch on it
* (4) known hosts still mute / unmute correctly (no regression)
* (5) the bare handler still accepts the body { host, muted: 'false' }
* string-coercion quirk it had before (so the original
* caddy-upstreams.routes.test.js suite keeps passing)
*
* @module __tests__/routes/caddy-upstreams-dc073
*/
const express = require('express');
const { validateAndMuteHost } = require('../../routes/caddy-upstreams').__test;
function buildRouter(deps) {
const mod = require('../../routes/caddy-upstreams');
return mod(deps);
}
function buildApp(mod_deps) {
const app = express();
app.use(express.json());
app.use((req, res, next) => {
res.success = (data) => res.json({ success: true, ...data });
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
next();
});
app.use(buildRouter({
asyncHandler: (fn, _ctx) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
},
...mod_deps,
}));
// Error middleware MUST be registered AFTER routes so it actually catches.
app.use((err, req, res, next) => {
if (err && err.statusCode === 400) {
return res.status(400).json({ success: false, error: err.message });
}
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
});
return app;
}
function makeKnownWatcher(known = ['known.svc.example:80', '1.1.1.1:80']) {
const upstreams = new Map(known.map(h => [h, { host: h }]));
return {
upstreams,
setMuted: jest.fn((host, muted) => ({ host, muted: !!muted })),
snapshot: jest.fn(() => ({ upstreams: [], config: {} })),
};
}
describe('routes/caddy-upstreams — DC-073 phantom-mute regression', () => {
describe('validateAndMuteHost helper (unit)', () => {
test('rejects empty / non-string host', () => {
const w = makeKnownWatcher();
expect(() => validateAndMuteHost(w, '', true)).toThrow(/non-empty string/);
expect(() => validateAndMuteHost(w, null, true)).toThrow(/non-empty string/);
expect(() => validateAndMuteHost(w, undefined, true)).toThrow(/non-empty string/);
expect(() => validateAndMuteHost(w, 12345, true)).toThrow(/non-empty string/);
expect(w.setMuted).not.toHaveBeenCalled();
});
test('rejects host longer than 253 chars', () => {
const w = makeKnownWatcher();
const long = 'a'.repeat(254);
expect(() => validateAndMuteHost(w, long, true)).toThrow(/non-empty string/);
expect(w.setMuted).not.toHaveBeenCalled();
});
test('rejects host with charset-violating chars', () => {
const w = makeKnownWatcher();
for (const bad of ['host name', 'host?', 'host/abc', 'host;rm', 'host${x}', 'host<>']) {
expect(() => validateAndMuteHost(w, bad, true)).toThrow(/valid host/);
}
expect(w.setMuted).not.toHaveBeenCalled();
});
test('rejects host not in watcher.upstreams (phantom-mute vector)', () => {
const w = makeKnownWatcher(['known:80']);
// This is the regression: pre-fix, this call would have
// silently added 'phantom.test:12345' to watcher.muted.
expect(() => validateAndMuteHost(w, 'phantom.test:12345', true))
.toThrow(/not a known upstream/);
expect(w.setMuted).not.toHaveBeenCalled();
});
test('accepts a known host and forwards setMuted(host, wantMuted)', () => {
const w = makeKnownWatcher(['known:80']);
const result = validateAndMuteHost(w, 'known:80', true);
expect(w.setMuted).toHaveBeenCalledWith('known:80', true);
expect(result).toEqual({ host: 'known:80', muted: true });
w.setMuted.mockClear();
const result2 = validateAndMuteHost(w, 'known:80', false);
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
expect(result2).toEqual({ host: 'known:80', muted: false });
});
test('handles missing watcher / upstreams map (defensive)', () => {
expect(() => validateAndMuteHost(null, 'x:80', true)).toThrow(/not a known upstream/);
expect(() => validateAndMuteHost({}, 'x:80', true)).toThrow(/not a known upstream/);
expect(() => validateAndMuteHost({ upstreams: null }, 'x:80', true)).toThrow(/not a known upstream/);
});
});
describe('POST /caddy/upstreams/mute (bare body-style)', () => {
test('rejects unknown host with 400 (was already correct, regression-proof)', async () => {
const w = makeKnownWatcher(['known:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: 'phantom:12345' }),
});
const body = await res.json();
server.close();
expect(res.status).toBe(400);
expect(body.error).toMatch(/not a known upstream/);
expect(w.setMuted).not.toHaveBeenCalled();
});
test('muted: "false" string still coerces to unmute (regression from caddy-upstreams.routes.test.js)', async () => {
const w = makeKnownWatcher(['known:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: 'known:80', muted: 'false' }),
});
const body = await res.json();
server.close();
expect(body.success).toBe(true);
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
});
});
describe('POST /caddy/upstreams/:host/mute (path-style) — DC-073 main fix', () => {
test('rejects unknown host with 400 instead of silent phantom-mute', async () => {
const w = makeKnownWatcher(['known:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
// Pre-fix this would have silently added 'phantom.test:12345' to
// the watcher's muted Set and called _saveState(). Post-fix it
// returns 400 and never touches the watcher.
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/mute`, {
method: 'POST',
});
const body = await res.json();
server.close();
expect(res.status).toBe(400);
expect(body.error).toMatch(/not a known upstream/);
expect(w.setMuted).not.toHaveBeenCalled();
});
test('mutes a known host via bare POST (no body)', async () => {
const w = makeKnownWatcher(['known.svc.example:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
method: 'POST',
});
const body = await res.json();
server.close();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
});
test('mutes via ?muted=true query', async () => {
const w = makeKnownWatcher(['known.svc.example:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute?muted=true`, {
method: 'POST',
});
const body = await res.json();
server.close();
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
expect(body.success).toBe(true);
});
test('unmutes via body { muted: false }', async () => {
const w = makeKnownWatcher(['known.svc.example:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ muted: false }),
});
const body = await res.json();
server.close();
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
expect(body.success).toBe(true);
});
});
describe('POST /caddy/upstreams/:host/unmute (path-style) — DC-073 main fix', () => {
test('rejects unknown host with 400 instead of silent phantom-unmute', async () => {
const w = makeKnownWatcher(['known:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/unmute`, {
method: 'POST',
});
const body = await res.json();
server.close();
expect(res.status).toBe(400);
expect(body.error).toMatch(/not a known upstream/);
expect(w.setMuted).not.toHaveBeenCalled();
});
test('unmutes a known host', async () => {
const w = makeKnownWatcher(['known.svc.example:80']);
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/unmute`, {
method: 'POST',
});
const body = await res.json();
server.close();
expect(res.status).toBe(200);
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
expect(body.success).toBe(true);
});
});
describe('router introspection (DC-057-style mount-count assertion)', () => {
test('exactly one POST handler per (method,path) — no duplicate registration', () => {
const w = makeKnownWatcher();
const router = buildRouter({
asyncHandler: (fn) => fn,
caddyUpstreamWatcher: w,
healthChecker: { incidents: [] },
});
const sigs = router.stack
.filter((l) => l.route)
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
.flat();
// Each (method,path) should appear exactly once
const counts = sigs.reduce((m, s) => (m[s] = (m[s] || 0) + 1, m), {});
for (const [sig, n] of Object.entries(counts)) {
expect({ sig, n }).toEqual({ sig, n: 1 });
}
});
});
});
@@ -1,277 +0,0 @@
/**
* DC-070: Caddycode config sanitization — validate the structural config
* that flows into generateSiteBlock(), and confirm that the post-fix
* generation does NOT interpolate raw user input into Caddyfile text.
*
* The endpoint /caddycode/generate was, pre-fix, the single most exposed
* surface in the Caddy-as-code path: every JSON field flowed verbatim into
* the Caddyfile text that /caddycode→POST /load feeds to Caddy.
*
* Bug class under test:
* 1. CRLF / newline in `domain` → close the block and inject a new site
* 2. `"` (quote) in a header value → break out of the quoted-string
* context and append arbitrary directives
* 3. `}` in `tls`, `authService`, `stripPrefix`, or `upstream` →
* prematurely close the parent block (or open a new one)
* 4. `://` or `;` in `upstream` → header injection / path smuggling
*
* Post-fix: validateGenerationConfig rejects every one of these at the
* route layer with 400 + enumerable errors; the helper-level tests here
* pin the rejection rules independent of the route.
*/
const { __test } = require('../../routes/caddycode');
const { validateGenerationConfig, escapeCaddyQuotedString, generateSiteBlock } = __test;
const BASE_OK = {
domain: 'app.example.com',
upstream: 'localhost:8080',
};
function check(cond, msg) {
if (!cond) throw new Error('assertion failed: ' + msg);
}
describe('DC-070: caddycode config sanitization', () => {
describe('validateGenerationConfig — happy paths', () => {
test('minimal valid config passes', () => {
const r = validateGenerationConfig(BASE_OK);
check(r.valid === true, `expected valid=true, got errors=${JSON.stringify(r.errors)}`);
check(Array.isArray(r.errors) && r.errors.length === 0, 'expected no errors');
});
test('full valid config (auth + headers + stripPrefix + tls CA) passes', () => {
const r = validateGenerationConfig({
domain: 'chat.example.com',
upstream: 'localhost:8096',
tls: 'letsencrypt',
auth: true,
authService: 'chat',
upstreamProtocol: 'https',
headers: {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Strict-Transport-Security': 'max-age=63072000',
},
stripPrefix: '/api/v1',
});
check(r.valid === true, `expected valid, got errors=${JSON.stringify(r.errors)}`);
});
test('IPv6 bracket-form upstream accepted', () => {
const r = validateGenerationConfig({ domain: 'dns.example.com', upstream: '[::1]:5380' });
check(r.valid === true, `IPv6 bracket should pass: ${JSON.stringify(r.errors)}`);
});
test('bare host without :port rejected (DC-070 round 2)', () => {
// Round-1 polish: Caddy reverse_proxy requires an explicit :port
// segment. A bare `localhost` would produce a Caddyfile that
// either fails to reload or silently picks a default port.
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost' });
check(r.valid === false, `bare host should reject: ${JSON.stringify(r.errors)}`);
});
test('upstream with non-numeric port rejected', () => {
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost:abc' });
check(r.valid === false, `non-numeric port should reject: ${JSON.stringify(r.errors)}`);
});
});
describe('validateGenerationConfig — injection rejection', () => {
test('CRLF in domain rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil.com\nnew.site.example.com {' });
check(r.valid === false, 'CRLF should reject');
check(r.errors.some((e) => /domain/.test(e)), `expected error to mention domain, got ${JSON.stringify(r.errors)}`);
});
test('brace in domain rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil} malicious' });
check(r.valid === false, 'brace should reject');
});
test('"://" in upstream rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'http://evil.tld/x' });
check(r.valid === false, ':// should reject');
});
test('space + brace in upstream rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'localhost:8080 } evil {' });
check(r.valid === false, 'whitespace+brace in upstream should reject');
});
test('CRLF in header value rejected', () => {
const r = validateGenerationConfig({
...BASE_OK,
headers: { 'X-Custom': 'innocent\r\nHost: evil.tld' },
});
check(r.valid === false, 'CRLF in header value should reject');
check(r.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF error: ${JSON.stringify(r.errors)}`);
});
test('bad header key charset rejected', () => {
const r = validateGenerationConfig({
...BASE_OK,
headers: { 'X Bad Key': 'innocent' },
});
check(r.valid === false, 'space in header key should reject');
});
test('non-string tls rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, tls: 'evil directive' });
check(r.valid === false, 'whitespace+word tls should reject');
});
test('empty authService when auth=true rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, auth: true });
check(r.valid === false, 'auth=true requires authService');
});
test('upstreamProtocol other than http/https rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, upstreamProtocol: 'javascript' });
check(r.valid === false, 'non-http protocol should reject');
});
test('stripPrefix without leading slash rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: 'app/v1' });
check(r.valid === false, 'stripPrefix without leading slash should reject');
});
test('stripPrefix with brace rejected', () => {
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: '/api/{evil}' });
check(r.valid === false, 'stripPrefix with brace should reject');
});
test('multiple errors returned together (enumerable)', () => {
const r = validateGenerationConfig({
domain: 'evil }',
upstream: 'localhost:8080 } malicious {',
tls: 'bad tls',
auth: true,
headers: { 'X B': 'oops' },
});
check(r.valid === false, 'should reject');
check(r.errors.length >= 4, `expected multiple errors, got ${r.errors.length}: ${JSON.stringify(r.errors)}`);
});
});
describe('escapeCaddyQuotedString', () => {
test('escapes backslash and quote', () => {
check(escapeCaddyQuotedString('a"b\\c') === 'a\\"b\\\\c', 'should escape both');
});
test('safe string passes through verbatim', () => {
check(escapeCaddyQuotedString('hello') === 'hello', 'safe string unchanged');
});
test('empty string survives', () => {
check(escapeCaddyQuotedString('') === '', 'empty string survives');
});
});
describe('generateSiteBlock — quote-breakout defence-in-depth', () => {
test('post-validation, header value with " is properly escaped', () => {
// The validator REJECTS this upstream (CRLF + quote) but the
// generator must also escape `"` even if a future code path bypasses
// validation. This test pins the dual-defence.
const cfg = {
domain: 'app.example.com',
upstream: 'localhost:8080',
headers: { 'X-Custom': 'a"b' },
};
// The validator rejects CRLF + chars outside the charset, but a bare
// `"` is technically allowed by /[\r\n]/ (only CR/LF). However the
// GENERATOR must still escape it. Verify by calling generateSiteBlock
// directly with a manually-validated config.
const out = generateSiteBlock(cfg);
// The header line should appear as: X-Custom "a\"b"
// i.e. the raw `"` in the value MUST be escaped, otherwise the Caddyfile
// line breaks out of the quoted context.
check(out.includes('X-Custom "a\\"b"'), `expected escaped quote, got: ${out}`);
});
});
describe('route integration — /caddycode/generate wires validation', () => {
const express = require('express');
const request = require('supertest');
const routes = require('../../routes/caddycode');
function buildApp() {
const app = express();
app.use(express.json());
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
return { app, wrap };
}
test('valid config → 200 + caddyfile', async () => {
const { app, wrap } = buildApp();
app.use('/api/v1', routes({ asyncHandler: wrap }));
const res = await request(app)
.post('/api/v1/caddycode/generate')
.send({ domain: 'app.example.com', upstream: 'localhost:8080' });
check(res.status === 200, `expected 200, got ${res.status}`);
check(typeof res.body.caddyfile === 'string', 'expected caddyfile string');
check(res.body.caddyfile.includes('app.example.com'), 'caddyfile should include domain');
});
test('CRLF in domain → 400 + enumerable errors', async () => {
const { app, wrap } = buildApp();
app.use('/api/v1', routes({ asyncHandler: wrap }));
const res = await request(app)
.post('/api/v1/caddycode/generate')
.send({ domain: 'evil.com\nnew block', upstream: 'localhost:8080' });
check(res.status === 400, `expected 400, got ${res.status}: ${JSON.stringify(res.body)}`);
check(res.body.success === false, 'success should be false');
check(Array.isArray(res.body.errors), `expected enumerable errors array, got body=${JSON.stringify(res.body)}`);
check(res.body.errors.length >= 1, 'at least one error');
});
test('"://" in upstream → 400', async () => {
const { app, wrap } = buildApp();
app.use('/api/v1', routes({ asyncHandler: wrap }));
const res = await request(app)
.post('/api/v1/caddycode/generate')
.send({ domain: 'app.example.com', upstream: 'http://evil.tld/x' });
check(res.status === 400, `expected 400, got ${res.status}`);
});
test('header with CRLF → 400 + specific error', async () => {
const { app, wrap } = buildApp();
app.use('/api/v1', routes({ asyncHandler: wrap }));
const res = await request(app)
.post('/api/v1/caddycode/generate')
.send({
domain: 'app.example.com',
upstream: 'localhost:8080',
headers: { 'X-Bad': 'oops\r\nHost: evil.tld' },
});
check(res.status === 400, `expected 400, got ${res.status}`);
check(res.body.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF mention: ${JSON.stringify(res.body.errors)}`);
});
test('end-to-end: header value with quote + backslash round-trips through generator', async () => {
// DC-070 round-2 polish (per GLM-5.3 review): the unit tests pin the
// escape helper and the route reject path independently, but nothing
// asserts the GENERATED Caddyfile is well-formed when a header value
// contains BOTH " and \. Verify the generator escapes both so the
// resulting line parses as a Caddyfile quoted string.
const { app, wrap } = buildApp();
app.use('/api/v1', routes({ asyncHandler: wrap }));
const res = await request(app)
.post('/api/v1/caddycode/generate')
.send({
domain: 'app.example.com',
upstream: 'localhost:8080',
headers: { 'X-Custom': 'a"b\\c' },
});
check(res.status === 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`);
const out = res.body.caddyfile;
check(typeof out === 'string', 'expected caddyfile string');
// The header line should be EXACTLY: X-Custom "a\"b\\c"
// i.e. the raw `"` and `\` in the value MUST be escaped.
check(
/X-Custom "a\\"b\\\\c"/.test(out),
`expected escaped quote+backslash in generated Caddyfile, got: ${out}`
);
});
});
});
@@ -18,7 +18,7 @@ function createFleetApp(log) {
app.use(express.json());
const routes = require('../../routes/fleet');
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.use('/api/v1', routes({ log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
app.use('/api/v1', routes({ log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
return app;
}
@@ -96,43 +96,40 @@ describe('DC-108: Fleet Management', () => {
});
it('POST /hosts registers a new host', async () => {
// DC-068: SSRF hardening rejects private-range IPv4 literals by default.
// Use a public host literal to exercise the registration happy path.
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Test Host', hostname: '8.8.8.8', port: 3001, apiKey: 'dk_test_12345', tags: ['prod'] });
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Test Host', hostname: '192.168.1.100', apiKey: 'dk_test_12345', tags: ['prod'] });
expect(res.status).toBe(201);
expect(res.body.host.name).toBe('Test Host');
expect(res.body.host.apiKey).toBe('***'); // Key is masked
expect(res.body.host.apiKeyHash).toBeTruthy();
expect(res.body.host.id).toBeTruthy();
});
it('POST /hosts returns 400 without name', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ hostname: '8.8.8.8', port: 3001 });
expect(res.status).toBe(400);
});
it('POST /deploy generates deployment plan', async () => {
const app = createFleetApp();
// First register a host (DC-068: use a public IPv4 since private IPs
// are rejected by default).
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Host 1', hostname: '8.8.8.8', port: 3001 });
const res = await request(app)
.post('/api/v1/fleet/deploy')
.send({ templateId: 'plex', config: { port: 32400 } });
expect(res.status).toBe(200);
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
expect(res.body.plan[0].templateId).toBe('plex');
});
expect(res.status).toBe(201);
expect(res.body.host.name).toBe('Test Host');
expect(res.body.host.apiKey).toBe('***'); // Key is masked
expect(res.body.host.apiKeyHash).toBeTruthy();
expect(res.body.host.id).toBeTruthy();
});
it('POST /hosts returns 400 without name', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ hostname: '192.168.1.100' });
expect(res.status).toBe(400);
});
it('POST /deploy generates deployment plan', async () => {
const app = createFleetApp();
// First register a host
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Host 1', hostname: '10.0.0.1' });
const res = await request(app)
.post('/api/v1/fleet/deploy')
.send({ templateId: 'plex', config: { port: 32400 } });
expect(res.status).toBe(200);
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
expect(res.body.plan[0].templateId).toBe('plex');
});
});
@@ -1,241 +0,0 @@
/**
* DC-103 / DC-064: discover-adopt regression suite
*
* DC-064 fixes the Caddy admin URL hardcode (`http://localhost:2019` → resolved
* from the injected caddy context's `adminUrl`) and stops the route from
* reaching raw `fetch` — it must use the injected `fetchT` (which carries
* Origin + httpAgent plumbing via src/utils/http.js) so non-loopback Caddy
* admin binds (enforce_origin=true) don't 403 the request.
*
* This suite pins all four invariants:
* 1. Route module signature accepts `fetchT` (won't throw if ctx doesn't pass it).
* 2. The mounted route uses `fetchT` when provided (proves by mock counts).
* 3. The Caddy admin URL is resolved from `caddy.adminUrl`, NOT hardcoded.
* 4. Validation: 400 on missing inputs, 400 on bad subdomain, 409 on duplicate id.
*/
const express = require('express');
const request = require('supertest');
function createApp({ servicesStateManager, caddy, fetchT, adminUrl } = {}) {
const app = express();
app.use(express.json());
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const discoverAdoptRoutes = require('../../routes/discover-adopt');
app.use('/api/v1', discoverAdoptRoutes({
docker: null,
servicesStateManager: servicesStateManager || null,
caddy: caddy === undefined
? { adminUrl: adminUrl || 'http://localhost:2019' }
: caddy,
dns: null,
siteConfig: { tld: '.sami' },
fetchT,
asyncHandler,
}));
return app;
}
// Helper state manager so the route always has somewhere to write
function makeStateManager(initial = []) {
let services = Array.isArray(initial) ? [...initial] : [];
return {
_services: services,
// eslint-disable-next-line require-await
read: jest.fn().mockImplementation(async () => services),
// eslint-disable-next-line require-await
update: jest.fn().mockImplementation(async (mutator) => {
const next = mutator(services);
services = next;
return services;
}),
};
}
describe('DC-064: discover-adopt Caddy admin API safety', () => {
describe('DI: fetchT is forwarded to the Caddy admin call', () => {
it('uses injected fetchT (not raw fetch) when generating Caddy route', async () => {
const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 });
try {
const sm = makeStateManager();
const app = createApp({
servicesStateManager: sm,
caddy: { adminUrl: 'http://caddy-admin.local:2019' },
fetchT: fetchTMock,
});
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc123def456',
serviceId: 'myapp',
name: 'My App',
port: 8080,
protocol: 'http',
generateDns: false,
generateRoute: true,
});
expect(res.status).toBe(201);
expect(fetchTMock).toHaveBeenCalledTimes(1);
expect(fetchTMock.mock.calls[0][0]).toBe('http://caddy-admin.local:2019/config/apps/http/servers/srv0/routes');
expect(fetchTMock.mock.calls[0][1]).toMatchObject({
method: 'POST',
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
});
// Raw fetch must NOT have been called
expect(rawFetchSpy).not.toHaveBeenCalled();
} finally {
rawFetchSpy.mockRestore();
}
});
it('does NOT hardcode http://localhost:2019 when caddy.adminUrl is provided', async () => {
const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const sm = makeStateManager();
const app = createApp({
servicesStateManager: sm,
caddy: { adminUrl: 'http://caddy-admin.production:2019' },
fetchT: fetchTMock,
});
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080,
});
expect(res.status).toBe(201);
const calledUrl = fetchTMock.mock.calls[0][0];
expect(calledUrl.startsWith('http://caddy-admin.production:2019')).toBe(true);
expect(calledUrl.includes('localhost:2019')).toBe(false);
});
it('falls back to raw fetch when fetchT is omitted (test-only path)', async () => {
const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 });
try {
const sm = makeStateManager();
const app = createApp({
servicesStateManager: sm,
caddy: { adminUrl: 'http://localhost:2019' },
fetchT: null, // explicitly omitted
});
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080,
});
expect(res.status).toBe(201);
// Raw fetch used because fetchT is null
expect(rawFetchSpy).toHaveBeenCalledTimes(1);
} finally {
rawFetchSpy.mockRestore();
}
});
});
describe('source convention: static scan', () => {
const fs = require('fs');
const path = require('path');
it('does not contain the hardcoded Caddy admin URL string', () => {
const src = fs.readFileSync(
path.join(__dirname, '../../routes/discover-adopt.js'),
'utf8'
);
// The exact hardcode from before must be gone
const hardcodeMatches = (src.match(/const\s+caddyAdminUrl\s*=\s*['"]http:\/\/localhost:2019['"]/g) || []).length;
expect(hardcodeMatches).toBe(0);
});
it('does not call raw fetch() — must use httpClient (fetchT or passed fetch)', () => {
const src = fs.readFileSync(
path.join(__dirname, '../../routes/discover-adopt.js'),
'utf8'
);
// Raw `fetch(` for the Caddy admin call would be a regression
const rawFetchMatches = (src.match(/await\s+fetch\(/g) || []).length;
expect(rawFetchMatches).toBe(0);
});
it('declares fetchT in the destructure', () => {
const src = fs.readFileSync(
path.join(__dirname, '../../routes/discover-adopt.js'),
'utf8'
);
expect(src).toMatch(/function\s*\(\s*\{[^}]*fetchT[^}]*\}\s*\)/);
});
});
describe('validation unchanged', () => {
it('returns 400 when containerId/serviceId/name are missing', async () => {
const app = createApp({ servicesStateManager: makeStateManager() });
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc', serviceId: '', name: '',
});
expect(res.status).toBe(400);
});
it('returns 400 on invalid port', async () => {
const app = createApp({ servicesStateManager: makeStateManager() });
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 99999,
});
expect(res.status).toBe(400);
});
it('returns 400 on invalid subdomain (must be lowercase, alphanumeric, hyphens)', async () => {
const app = createApp({ servicesStateManager: makeStateManager() });
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc', serviceId: 'Bad_SubDomain!', name: 'My App', port: 80,
});
expect(res.status).toBe(400);
});
it('returns 409 on duplicate service id', async () => {
const sm = makeStateManager([{ id: 'myapp', name: 'Existing' }]);
const app = createApp({
servicesStateManager: sm,
caddy: { adminUrl: 'http://localhost:2019' },
fetchT: () => Promise.resolve({ ok: true, status: 200 }),
});
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc', serviceId: 'myapp', name: 'Dup', port: 80,
});
expect(res.status).toBe(409);
});
});
describe('Caddy route failure does not corrupt the service entry', () => {
it('still returns 200/201 result for service when generateRoute=false', async () => {
const sm = makeStateManager();
const app = createApp({
servicesStateManager: sm,
caddy: { adminUrl: 'http://localhost:2019' },
fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200 }),
});
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80,
generateRoute: false,
generateDns: false,
});
expect(res.status).toBe(201);
expect(res.body.service).toBeTruthy();
expect(res.body.service.id).toBe('myapp');
expect(sm.update).toHaveBeenCalled();
});
it('captures Caddy API failure in caddyRoute field without rolling back the service', async () => {
const sm = makeStateManager();
const app = createApp({
servicesStateManager: sm,
caddy: { adminUrl: 'http://localhost:2019' },
fetchT: jest.fn().mockResolvedValue({ ok: false, status: 403 }),
});
const res = await request(app).post('/api/v1/discover/adopt').send({
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80,
generateDns: false,
generateRoute: true,
});
// Service was still written even though route generation failed
expect(res.status).toBe(201);
expect(res.body.service).toBeTruthy();
expect(res.body.caddyRoute.status).toBe('failed');
expect(res.body.caddyRoute.error).toMatch(/403/);
});
});
});
@@ -18,11 +18,7 @@ function createDiscoverApp(docker, servicesStateManager) {
function createDisasterApp(platformPaths, log) {
const app = express();
// Match the production body-parser limit (1 MiB) so the in-handler
// DC-079 cap (512 KiB) is actually reachable from tests. The default
// express.json() limit is 100 KiB, which would short-circuit the test
// with a 413 before the route's defense-in-depth check runs.
app.use(express.json({ limit: '1mb' }));
app.use(express.json());
const routes = require('../../routes/disaster-recovery');
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
@@ -139,267 +135,4 @@ describe('DC-107: Disaster Recovery', () => {
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
expect(svc[0].id).toBe('restored-svc');
});
// DC-079: Caddyfile restore hardening — the live Caddyfile path must
// NEVER be written from the disaster-recovery endpoint. The endpoint
// stages the candidate file under dataDir/disaster-staged/Caddyfile.candidate
// and surfaces a warning that `caddy-apply` is required to apply it.
it('DC-079: POST /disaster/restore with caddyfile STAGES instead of writing the live Caddyfile', async () => {
// The env var CADDYFILE_PATH is read by the route. Use a sentinel
// path that we can prove was NOT written. The route must instead
// create <dataDir>/disaster-staged/Caddyfile.candidate.
const liveSentinel = path.join(tmpDir, 'LIVE_CADDYFILE_SENTINEL.txt');
fs.writeFileSync(liveSentinel, 'do-not-overwrite');
const candidateCaddyfile =
'# staged candidate\n' +
'example.com {\n' +
' respond "ok"\n' +
'}\n';
const app = createDisasterApp({
dataDir: tmpDir,
caddyfilePath: liveSentinel, // route reads env or fallback; this is just for the response
});
// Override process.env.CADDYFILE_PATH so the route picks up our sentinel
const prev = process.env.CADDYFILE_PATH;
process.env.CADDYFILE_PATH = liveSentinel;
try {
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
caddyfile: candidateCaddyfile,
});
expect(res.status).toBe(200);
expect(res.body.status).toBe('success');
expect(res.body.caddyfileStaged).toBeTruthy();
expect(res.body.caddyfileStaged).toHaveLength(1);
expect(res.body.caddyfileStaged[0].file).toBe('Caddyfile');
expect(res.body.caddyfileStaged[0].action).toBe('awaiting caddy-apply');
expect(res.body.caddyfileStaged[0].stagedPath).toBe(
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate')
);
expect(res.body.caddyfileStaged[0].livePath).toBe(liveSentinel);
expect(res.body.warning).toMatch(/DC-079/);
// The live sentinel file is UNTOUCHED — still has its original content.
const liveContents = fs.readFileSync(liveSentinel, 'utf8');
expect(liveContents).toBe('do-not-overwrite');
// The candidate file IS staged at the staging path.
const stagedContents = fs.readFileSync(
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'),
'utf8'
);
expect(stagedContents).toBe(candidateCaddyfile);
} finally {
if (prev === undefined) delete process.env.CADDYFILE_PATH;
else process.env.CADDYFILE_PATH = prev;
}
});
it('DC-079: POST /disaster/restore rejects non-string caddyfile content', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
caddyfile: { evil: 'object' },
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Caddyfile content must be a string/);
});
it('DC-079: POST /disaster/restore rejects explicit empty caddyfile string', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
caddyfile: '', // explicit empty payload — rejected
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Caddyfile content is empty/);
});
it('DC-079: POST /disaster/restore rejects oversized caddyfile content', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
// 512 KiB + 1 byte — over the in-handler cap, under the 1 MB body limit
const huge = 'a'.repeat(512 * 1024 + 1);
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
caddyfile: huge,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/exceeds 524288 bytes/);
});
it('DC-079: POST /disaster/restore rejects forbidden `import` directive (absolute path)', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const evil =
'# malicious snapshot\n' +
'import /etc/caddy/external.caddy\n' +
'example.com { respond "ok" }\n';
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
caddyfile: evil,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/forbidden `import` directive/);
// No staging file should have been created — fail closed.
expect(fs.existsSync(path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'))).toBe(false);
});
it('DC-079: POST /disaster/restore rejects forbidden `import` with relative-path escape', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const evil =
'# malicious snapshot\n' +
'import ../../../etc/passwd\n' +
'example.com { respond "ok" }\n';
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
caddyfile: evil,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/forbidden `import` directive/);
});
it('DC-079: POST /disaster/restore rejects URL-encoded import payload', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const evil =
'import %2fetc%2fcaddy%2fevil.caddy\n' +
'example.com { respond "ok" }\n';
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
caddyfile: evil,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/forbidden `import` directive/);
});
it('DC-079: POST /disaster/restore without caddyfile field succeeds and stages nothing', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
files: {
services: [{ id: 'no-caddy' }],
},
});
expect(res.status).toBe(200);
expect(res.body.caddyfileStaged).toBeUndefined();
expect(res.body.warning).toBeUndefined();
});
// DC-079 follow-up (GLM round-2 BLOCKING): assets/themes path traversal.
// Without the assertSafeAssetKey / assertSafeThemeName + path.resolve
// checks, an attacker can POST `{assets: {"../../etc/caddy/Caddyfile":
// "<base64-evil>"}}` and overwrite the live Caddyfile via the dataDir
// bind-mount. These tests prove the fix.
it('DC-079: POST /disaster/restore rejects assets with path-traversal key', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
assets: {
'../../etc/caddy/Caddyfile': Buffer.from('EVIL_BASE64_PAYLOAD').toString('base64'),
'custom-logo.png': Buffer.from('legit-logo').toString('base64'),
},
});
// The traversal key is rejected (added to errors), the legit key
// still works. Status is success-or-partial, never 500.
expect(res.status).toBe(200);
expect(res.body.status).toBe('partial'); // one error
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../etc/caddy/Caddyfile'));
expect(erroredFile).toBeTruthy();
expect(erroredFile.error).toMatch(/forbidden characters or path segments/);
// The legit logo DID get written.
const legitPath = path.join(tmpDir, 'assets', 'custom-logo.png');
expect(fs.existsSync(legitPath)).toBe(true);
// The traversal target was NEVER written.
const escapePath = path.join(tmpDir, 'assets', '../../etc/caddy/Caddyfile');
// Resolve to absolute path — should be outside tmpDir/assets.
const resolvedEsc = path.resolve(escapePath);
expect(fs.existsSync(resolvedEsc)).toBe(false);
});
it('DC-079: POST /disaster/restore rejects assets with absolute path key', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
assets: {
'/etc/passwd': Buffer.from('evil').toString('base64'),
},
});
expect(res.status).toBe(200);
expect(res.body.status).toBe('partial');
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('/etc/passwd'));
expect(erroredFile).toBeTruthy();
});
it('DC-079: POST /disaster/restore rejects themes with path-traversal name', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
themes: {
'../../../etc/caddy/evil.json': { evil: true },
'legit-theme.json': { ok: true },
},
});
expect(res.status).toBe(200);
expect(res.body.status).toBe('partial');
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../../etc/caddy/evil.json'));
expect(erroredFile).toBeTruthy();
expect(erroredFile.error).toMatch(/must match/);
// The legit theme DID get written.
expect(fs.existsSync(path.join(tmpDir, 'themes', 'legit-theme.json'))).toBe(true);
});
it('DC-079: POST /disaster/restore rejects themes without .json extension', async () => {
const app = createDisasterApp({ dataDir: tmpDir });
const res = await request(app)
.post('/api/v1/disaster/restore')
.send({
version: '1.0',
themes: {
'no-extension': { ok: true },
},
});
expect(res.status).toBe(200);
expect(res.body.status).toBe('partial');
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('no-extension'));
expect(erroredFile).toBeTruthy();
expect(erroredFile.error).toMatch(/must match/);
});
});
@@ -1,277 +0,0 @@
/**
* DC-059: disk-space POST /config threshold-ordering invariant.
*
* DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the
* budget usage crosses, in the order
* cleanupAggressivePct → criticalThresholdPct → warningThresholdPct.
* If a caller writes the three thresholds out of order
* (e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher-
* priority branches become unreachable and the monitor silently
* misclassifies budget state — 'warning' would never fire even though the
* user set it as a threshold they care about.
*
* The fix lives in `routes/disk-space.js`: a `mergeAndCheckOrdering()`
* helper validates the *effective* (merged with live baseline) config
* against the invariant `warningThresholdPct < criticalThresholdPct <
* cleanupAggressivePct` BEFORE the route mutates diskSpaceMonitor.diskConfig.
*
* Tests cover:
* 1. Monotonic ascending order is accepted (happy path).
* 2. warningThresholdPct >= criticalThresholdPct is rejected with 400.
* 3. criticalThresholdPct >= cleanupAggressivePct is rejected with 400.
* 4. Partial updates work one field at a time without violating the
* invariant against the current baseline.
* 5. Out-of-bounds numeric values are clamped to the same bounds the
* original inline Math.min/Math.max chains enforced (50/60/70 → 99).
* 6. DiskSpaceMonitor.configure is NEVER called when the request is
* rejected (no partial mutation).
* 7. The merged config returned to the client is the post-clamp value,
* not the raw request body.
*/
const express = require('express');
const http = require('http');
const DEFAULT_CONFIG = {
enabled: true,
diskBudgetGB: 10,
warningThresholdPct: 80,
criticalThresholdPct: 90,
autoCleanup: true,
cleanupAggressivePct: 95,
};
function buildFakeDiskSpaceMonitor(initial = { ...DEFAULT_CONFIG }) {
const state = { ...initial };
return {
configure: jest.fn((updates) => {
Object.assign(state, updates);
return { ...state };
}),
getConfig: jest.fn(() => ({ ...state })),
getSnapshot: jest.fn(async () => ({})),
getDetailedBreakdown: jest.fn(async () => ({})),
performCleanup: jest.fn(async () => ({})),
// Test-only: peek at the internal state to confirm no mutation on rejection
_state: state,
};
}
function buildRouter(monitor) {
// Reset module cache so each test starts fresh
jest.resetModules();
const mod = require('../../routes/disk-space');
return mod({
diskSpaceMonitor: monitor,
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
try { await fn(req, res, next); } catch (e) { next(e); }
},
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
});
}
function buildApp(router) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { next(); }); // strip auth
app.use('/', router);
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = err.statusCode || err.status || 500;
res.status(status).json({
error: err.message,
code: err.code || 'ERR',
field: err.field || null,
});
});
return app;
}
function supertestFetch(app) {
return function (method, path, body) {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const { port } = server.address();
const data = body ? JSON.stringify(body) : null;
const req = http.request({
method,
hostname: '127.0.0.1',
port,
path,
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
}, (res) => {
let chunks = '';
res.on('data', (c) => { chunks += c; });
res.on('end', () => {
server.close();
let parsed;
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.on('error', (e) => { server.close(); reject(e); });
if (data) req.write(data);
req.end();
});
});
};
}
describe('routes/disk-space POST /config (DC-059 threshold ordering)', () => {
let monitor, app, fetch;
beforeEach(() => {
monitor = buildFakeDiskSpaceMonitor();
const router = buildRouter(monitor);
app = buildApp(router);
fetch = supertestFetch(app);
});
test('happy path — strict monotonic ascending order is accepted', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 75,
criticalThresholdPct: 88,
cleanupAggressivePct: 95,
});
expect(res.status).toBe(200);
expect(res.body.config).toEqual(expect.objectContaining({
warningThresholdPct: 75,
criticalThresholdPct: 88,
cleanupAggressivePct: 95,
}));
expect(monitor.configure).toHaveBeenCalledTimes(1);
});
test('warningThresholdPct >= criticalThresholdPct is rejected with 400', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 95,
criticalThresholdPct: 80,
cleanupAggressivePct: 99,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
expect(res.body.field).toBe('warningThresholdPct');
// Critical invariant: monitor.configure was NEVER called.
expect(monitor.configure).not.toHaveBeenCalled();
});
test('criticalThresholdPct >= cleanupAggressivePct is rejected with 400', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 60,
criticalThresholdPct: 95,
cleanupAggressivePct: 80,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/criticalThresholdPct.*strictly less than.*cleanupAggressivePct/);
expect(res.body.field).toBe('criticalThresholdPct');
expect(monitor.configure).not.toHaveBeenCalled();
});
test('equal thresholds are rejected (strict <, not <=)', async () => {
const res = await fetch('POST', '/config', {
warningThresholdPct: 80,
criticalThresholdPct: 80,
cleanupAggressivePct: 90,
});
expect(res.status).toBe(400);
expect(monitor.configure).not.toHaveBeenCalled();
});
test('partial update — single field accepted against existing baseline', async () => {
// Defaults: warning=80, critical=90, aggressive=95. Raise warning to 85.
const res = await fetch('POST', '/config', { warningThresholdPct: 85 });
expect(res.status).toBe(200);
expect(res.body.config.warningThresholdPct).toBe(85);
expect(res.body.config.criticalThresholdPct).toBe(90);
expect(res.body.config.cleanupAggressivePct).toBe(95);
});
test('partial update — would violate invariant against baseline, rejected', async () => {
// Defaults: warning=80, critical=90, aggressive=95. Setting warning=95
// would collide with the existing critical=90 (warning >= critical).
const res = await fetch('POST', '/config', { warningThresholdPct: 95 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
expect(monitor.configure).not.toHaveBeenCalled();
});
test('partial update — succeeds after baseline was updated in a prior request', async () => {
// First request: bump warning from 80 → 85 (within current critical=90).
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
expect(res.status).toBe(200);
// Second request: now bump warning from 85 → 89. Still under critical=90.
res = await fetch('POST', '/config', { warningThresholdPct: 89 });
expect(res.status).toBe(200);
expect(monitor.configure).toHaveBeenCalledTimes(2);
});
test('partial update — would violate against the NEW baseline, rejected', async () => {
// Step 1: raise warning to 85.
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
expect(res.status).toBe(200);
// Step 2: try to raise warning to 95 — would collide with critical=90.
res = await fetch('POST', '/config', { warningThresholdPct: 95 });
expect(res.status).toBe(400);
// monitor.configure should have run exactly once (the accepted request).
expect(monitor.configure).toHaveBeenCalledTimes(1);
});
test('out-of-bounds values are clamped to documented ranges', async () => {
// Note: the three values must produce a valid monotonic ordering AFTER
// clamping. Setting warning=20 (→ 50), critical=200 (→ 99), aggressive=70
// would produce critical=99 > aggressive=70 which is rejected by the
// ordering check. Use values that clamp into a valid range.
const res = await fetch('POST', '/config', {
warningThresholdPct: 20, // below warning min 50 → clamped to 50
criticalThresholdPct: 85, // valid
cleanupAggressivePct: 200, // above aggressive max 99 → clamped to 99
});
expect(res.status).toBe(200);
expect(res.body.config).toEqual(expect.objectContaining({
warningThresholdPct: 50,
criticalThresholdPct: 85,
cleanupAggressivePct: 99,
}));
});
test('non-numeric threshold values are silently dropped (legacy behaviour preserved)', async () => {
// Strings are not numbers → unchanged from baseline. Confirms the
// ordering check doesn\'t reject legitimate "I didn\'t change this" requests.
const res = await fetch('POST', '/config', {
warningThresholdPct: '80',
});
expect(res.status).toBe(200);
expect(res.body.config.warningThresholdPct).toBe(80); // baseline unchanged
expect(monitor.configure).toHaveBeenCalledWith({}); // empty updates
});
test('diskBudgetGB and autoCleanup updates still work alongside threshold validation', async () => {
const res = await fetch('POST', '/config', {
diskBudgetGB: 50,
autoCleanup: false,
warningThresholdPct: 81,
});
expect(res.status).toBe(200);
expect(res.body.config.diskBudgetGB).toBe(50);
expect(res.body.config.autoCleanup).toBe(false);
expect(res.body.config.warningThresholdPct).toBe(81);
});
test('rejected request does NOT mutate the live diskConfig', async () => {
const before = { ...monitor._state };
const res = await fetch('POST', '/config', {
warningThresholdPct: 95, // collides with critical=90
});
expect(res.status).toBe(400);
expect(monitor._state).toEqual(before);
});
test('POST /config with no thresholds in body is a no-op against baseline', async () => {
const res = await fetch('POST', '/config', { diskBudgetGB: 25 });
expect(res.status).toBe(200);
expect(res.body.config.diskBudgetGB).toBe(25);
expect(res.body.config.warningThresholdPct).toBe(80); // unchanged
expect(res.body.config.criticalThresholdPct).toBe(90); // unchanged
expect(res.body.config.cleanupAggressivePct).toBe(95); // unchanged
});
});
@@ -1,357 +0,0 @@
/**
* Smoke tests for the enhanced error-logs route (DC-052).
*
* Mirrors `caddy-upstreams.routes.test.js`: build the router with stubbed
* deps, hit it via a tiny express app, assert the response shape and
* the audit-logger interactions.
*
* Fixture: a synthetic error log with two ERR entries and one WARN entry,
* each with a different context, IP, and stack — enough to exercise the
* filter chain (level, context, search, since/until) without pulling the
* real 47k-line error.log off the host.
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const os = require('os');
const ENTRY_SEP = '='.repeat(80);
const FIXTURE_LOG = [
`[2026-08-17T10:00:00.000Z] [ERR] updater: getLocalVersion failed: no candidate package.json found`,
` at SelfUpdater.getLocalVersion (/app/src/docker/self-updater.js:128:9)`,
` request: GET /api/v1/updates/check | ip: 100.85.236.10 | ua: Mozilla/5.0 | id: a1`,
` context: {"triggeredBy":"manual"}`,
ENTRY_SEP,
`[2026-08-17T11:00:00.000Z] [ERR] http: GET /api/v1/templates 503`,
` at Logger.error (/app/src/utils/logging.js:258:49)`,
` request: GET /api/v1/templates | ip: 100.85.236.11 | ua: curl/8.0 | id: a2`,
` context: {"service":"templates"}`,
ENTRY_SEP,
`[2026-08-17T12:00:00.000Z] [WARN] ssl-monitor: cert check failed: TLS connect error for sonarr.sami:443: getaddrinfo ENOTFOUND sonarr.sami`,
` at Logger.warn (/app/src/utils/logging.js:200:10)`,
` request: GET /api/v1/ssl/check | ip: 100.121.150.22 | ua: NodeHealthCheck | id: a3`,
` context: {"service":"sonarr"}`,
ENTRY_SEP,
``,
].join('\n');
function buildFakeAuditLogger() {
return {
clear: jest.fn(async () => {}),
log: jest.fn(async () => {}),
};
}
function writeFixtureLog(tmpDir) {
const logFile = path.join(tmpDir, 'error.log');
fs.writeFileSync(logFile, FIXTURE_LOG);
return logFile;
}
describe('routes/errorlogs (DC-052)', () => {
let tmpDir;
let logFile;
let auditLogger;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc052-errorlogs-'));
logFile = writeFixtureLog(tmpDir);
auditLogger = buildFakeAuditLogger();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function buildRouter() {
const mod = require('../../routes/errorlogs');
return mod({
ERROR_LOG_FILE: logFile,
auditLogger,
asyncHandler: (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
},
});
}
function listen(router) {
const app = express();
app.use(express.json());
app.use(router);
return app.listen(0);
}
test('router exposes the DC-052 endpoints', () => {
const router = buildRouter();
const paths = router.stack
.filter((l) => l.route)
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
.flat();
expect(paths).toEqual(expect.arrayContaining([
'GET /error-logs',
'GET /error-logs/contexts',
'DELETE /error-logs',
]));
});
test('GET /error-logs returns newest-first with totals', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
const body = await res.json();
server.close();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.total).toBe(3);
expect(body.logs).toHaveLength(3);
expect(body.hasMore).toBe(false);
expect(body.filters).toEqual({
level: null, context: null, search: null, since: null, until: null,
});
// Newest first: 12:00 (WARN ssl-monitor) → 11:00 (ERR http) → 10:00 (ERR updater).
expect(body.logs[0].level).toBe('WARN');
expect(body.logs[1].level).toBe('ERR');
expect(body.logs[2].level).toBe('ERR');
expect(body.logs[0].timestamp).toBe('2026-08-17T12:00:00.000Z');
});
test('GET /error-logs filters by level', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=ERR`);
const body = await res.json();
server.close();
expect(body.total).toBe(2);
expect(body.logs.every((e) => e.level === 'ERR')).toBe(true);
});
test('GET /error-logs filters by context (substring)', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`);
const body = await res.json();
server.close();
expect(body.total).toBe(1);
expect(body.logs[0].context).toBe('updater');
});
test('GET /error-logs free-text search hits error / context / detail', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// "sonarr" appears only in the WARN stack; should still match via detail.
let res = await fetch(`http://127.0.0.1:${port}/error-logs?search=sonarr`);
let body = await res.json();
expect(body.total).toBe(1);
expect(body.logs[0].context).toBe('ssl-monitor');
// "503" appears only in the ERR http message; should match via error.
res = await fetch(`http://127.0.0.1:${port}/error-logs?search=503`);
body = await res.json();
expect(body.total).toBe(1);
expect(body.logs[0].context).toBe('http');
server.close();
});
test('GET /error-logs respects since/until as numeric ISO compare', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// Window covers only 11:00Z entry.
const res = await fetch(
`http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('2026-08-17T10:30:00Z')}&until=${encodeURIComponent('2026-08-17T11:30:00Z')}`
);
const body = await res.json();
server.close();
expect(body.total).toBe(1);
expect(body.logs[0].timestamp).toBe('2026-08-17T11:00:00.000Z');
});
test('GET /error-logs rejects invalid since with 400', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?since=not-a-date`);
const body = await res.json();
server.close();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
});
test('GET /error-logs rejects unknown level with 400', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=FATAL`);
const body = await res.json();
server.close();
expect(res.status).toBe(400);
});
test('GET /error-logs paginates and reports hasMore', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res1 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=0`);
const body1 = await res1.json();
expect(body1.logs).toHaveLength(2);
expect(body1.total).toBe(3);
expect(body1.hasMore).toBe(true);
const res2 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=2`);
const body2 = await res2.json();
expect(body2.logs).toHaveLength(1);
expect(body2.hasMore).toBe(false);
server.close();
});
test('GET /error-logs clamps limit to MAX_LIMIT', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?limit=99999`);
const body = await res.json();
server.close();
// 3 entries total so we still get 3, but the route didn't blow up on a
// giant limit; the contract is limit <= 500 and we just clamp.
expect(body.logs.length).toBeLessThanOrEqual(500);
expect(body.total).toBe(3);
});
test('GET /error-logs/contexts returns distinct contexts sorted by count', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`);
const body = await res.json();
server.close();
expect(body.success).toBe(true);
expect(body.contexts).toHaveLength(3);
// updater + http + ssl-monitor — each appears once.
const names = body.contexts.map((c) => c.name).sort();
expect(names).toEqual(['http', 'ssl-monitor', 'updater']);
expect(body.contexts.every((c) => c.count === 1)).toBe(true);
});
test('DELETE /error-logs without confirm is rejected with 400', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`, { method: 'DELETE' });
const body = await res.json();
server.close();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
// File still intact.
expect(fs.readFileSync(logFile, 'utf8')).toBe(FIXTURE_LOG);
});
test('DELETE /error-logs with confirm=CLEAR truncates and audits', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ confirm: 'CLEAR' }),
});
const body = await res.json();
server.close();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(fs.readFileSync(logFile, 'utf8')).toBe('');
expect(auditLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'error-log.clear',
outcome: 'success',
}));
});
test('GET /error-logs returns empty when log file missing', async () => {
fs.unlinkSync(logFile);
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
const body = await res.json();
server.close();
expect(body.success).toBe(true);
expect(body.logs).toEqual([]);
expect(body.total).toBe(0);
});
test('GET /error-logs preserves stack frames in detail field', async () => {
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`);
const body = await res.json();
server.close();
expect(body.logs[0].detail).toContain('self-updater.js:128');
expect(body.logs[0].detail).toContain('context:');
});
test('GET /error-logs handles malformed entry as raw fallback', async () => {
// The parser splits the log on ENTRY_SEP (80 equal signs). A junk block
// that has no timestamp header should still surface as a raw entry so
// the operator doesn't lose forensic context. Place the malformed
// block AFTER the separator so it ends up in its own split segment.
fs.writeFileSync(logFile, [
`[2026-08-17T13:00:00.000Z] [ERR] junk: well-formed entry`,
ENTRY_SEP,
`this is a malformed block with no timestamp header`,
`and no level bracket at all`,
ENTRY_SEP,
``,
].join('\n'));
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
const body = await res.json();
server.close();
expect(body.total).toBe(2);
const raw = body.logs.find((e) => e.level === null);
expect(raw).toBeDefined();
expect(raw.error).toContain('malformed block');
expect(raw.raw).toContain('malformed block');
});
test('GET /error-logs/contexts returns empty array when file missing', async () => {
fs.unlinkSync(logFile);
const server = listen(buildRouter());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`);
const body = await res.json();
server.close();
expect(body.success).toBe(true);
expect(body.contexts).toEqual([]);
});
test('GET /error-logs?search matches IP field', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// 100.85.236.11 is only on the /api/v1/templates entry.
const res = await fetch(`http://127.0.0.1:${port}/error-logs?search=100.85.236.11`);
const body = await res.json();
server.close();
expect(body.total).toBe(1);
expect(body.logs[0].request.ip).toBe('100.85.236.11');
});
test('GET /error-logs accepts huge since/until without error', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// Far-future since — no entries match, but the route doesn't 500.
const res = await fetch(
`http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('9999-12-31T00:00:00Z')}`
);
const body = await res.json();
server.close();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.total).toBe(0);
expect(body.logs).toEqual([]);
});
test('GET /error-logs combined filters compose correctly', async () => {
const server = listen(buildRouter());
const { port } = server.address();
// level=WARN AND context=http: no entry matches (WARN is ssl-monitor).
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=WARN&context=http`);
const body = await res.json();
server.close();
expect(body.total).toBe(0);
expect(body.logs).toEqual([]);
expect(body.filters).toEqual({
level: 'WARN', context: 'http', search: null,
since: null, until: null,
});
});
});
@@ -1,284 +0,0 @@
/**
* DC-063: errorResponse arg-order invariant regression suite.
*
* Three layers of correctness pinned by this test:
*
* (1) The validator at responses.js:76-98 catches wrong-order callers
* with a clear TypeError naming statusCode. Defense-in-depth: any
* future swap is caught at the smallest possible blast radius
* (one TypeError on the request thread) instead of an HTTP 500 HTML
* panic for the operator and client.
*
* (2) The static trees under dashcaddy-api/routes/ and
* dashcaddy-api/src/utilities/ follow ONE of two equivalent
* conventions consistently:
*
* Convention A — canonical import `errorResponse` from responses.js.
* Callsite shape: errorResponse(res, statusCode, message, extras?)
* statusCode must be an integer 100..599; message must be a string.
*
* Convention B — alias import `error: errorResponse` from responses.js,
* which binds the local `errorResponse` to the message-first
* helper `error(res, message, statusCode = 500)`.
* Callsite shape: errorResponse(res, message, statusCode)
*
* Mixing the alias-import with the canonical-shape callsite is the
* DC-063 bug class: at runtime, the alias function fires
* `res.status('event not found')` → TypeError → HTTP 500 HTML panic,
* silently masking the intended 4xx JSON response for the client.
* The validator at (1) does NOT help because the alias path skips it.
*
* (3) End-to-end smoke for one of each fixed-file: live HTTP hits the
* endpoint with the malformed input that triggers the fix-callsite
* branch, and asserts the wire response is the expected 4xx JSON
* (status + content-type + body) — never a 500 HTML panic.
*
* Origin (DC-062): shipped 2026-08-18 by Hermes loop. Found 4 callsites in
* routes/caddy-upstreams.js and added the validator.
*
* DC-063 (this file): extended the search across the routes tree with
* alias-import awareness. Found 18 instances of the alias-imported +
* canonical-shape callsite bug class in 2 files (security.js + 3 calls
* in services.js). Fixed by switching those imports to canonical and
* rewriting the remaining 4 alias-shape callsites in services.js to
* canonical-shape. Adding this regression test to prevent the same
* swap from being reintroduced in future route file edits.
*/
const path = require('path');
const express = require('express');
const http = require('http');
const fs = require('fs');
const glob = require('glob');
const repoRoot = path.join(__dirname, '..', '..'); // dashcaddy-api/
const { errorResponse, error: aliasError } = require(
path.join(repoRoot, 'src/utils/responses')
);
// ─── (1) Type validator (defense-in-depth) ────────────────────────────────
describe('DC-063: errorResponse type validator (defense-in-depth)', () => {
function makeRes() {
return { status: () => makeRes(), json: () => makeRes() };
}
test('canonical (res, statusCode, message) does not throw and JSON is well-formed', () => {
expect(() => errorResponse(makeRes(), 400, 'Invalid level')).not.toThrow();
expect(() => errorResponse(makeRes(), 503, 'downstream unavailable', { code: 'DC-503' }))
.not.toThrow();
});
test('swapped canonical-shape throws TypeError naming statusCode', () => {
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
.toThrow(TypeError);
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
.toThrow(/statusCode must be an integer HTTP status \(100\.\.599\)/);
});
test.each([
[0, 'below range'],
[99, 'below range'],
[600, 'above range'],
[3.14, 'non-integer'],
[NaN, 'NaN'],
[Infinity, 'Infinity'],
])('rejects numeric out-of-band statusCode %p (%s)', (bad) => {
expect(() => errorResponse(makeRes(), bad, 'msg')).toThrow(TypeError);
});
test('rejects non-string message', () => {
expect(() => errorResponse(makeRes(), 400, 42)).toThrow(TypeError);
expect(() => errorResponse(makeRes(), 400, null)).toThrow(TypeError);
expect(() => errorResponse(makeRes(), 400, { err: 'oops' })).toThrow(TypeError);
});
test('extras object merges into body and surfaces top-level code (DC-086)', () => {
const captured = {};
const res = {
status(c) { captured.status = c; return res; },
json(b) { captured.body = b; return res; },
};
errorResponse(res, 400, 'Invalid input', { code: 'DC-400', field: 'level' });
expect(captured.status).toBe(400);
expect(captured.body).toEqual({
success: false,
error: 'Invalid input',
field: 'level',
code: 'DC-400',
});
});
test('alias error(res, message, statusCode) still works for backward-compat', () => {
expect(() => aliasError(makeRes(), 'msg', 400)).not.toThrow();
});
});
// ─── (2) Static tree: every callsite follows its file's imported convention ─
describe('DC-063: routes/ + utilities/ arg-order matches each file\'s import', () => {
function isNumericLiteral(s) {
return /^\d+$/.test(s);
}
function isExpressionReturningNumber(s) {
return /^(err|error|response)\.status(Code)?\s*\|\|.*\d+/.test(s) ||
/^response\.status$/.test(s);
}
function isStringy(s) {
s = s.trim();
if (s.startsWith('"') || s.startsWith("'") || s.startsWith('`')) return true;
if (/^[a-zA-Z_][a-zA-Z_0-9]*\([^)]*\)$/.test(s)) return true; // safeErrorMessage(err)
if (/^[a-zA-Z_][a-zA-Z_0-9]*\.[a-zA-Z_][a-zA-Z_0-9.]*$/.test(s)) return true; // err.message
return false;
}
function isNumeric(s) {
return isNumericLiteral(s.trim()) || isExpressionReturningNumber(s.trim());
}
// Match `errorResponse(res, ARG1, ARG2)` (allow extras after).
const pat = /errorResponse\(\s*res\s*,\s*([^,]+?)\s*,\s*([^,)\s]+)(?:\s*,|\s*\))/g;
const ROUTES = glob.sync('routes/*.js', { cwd: repoRoot });
const UTILS = glob.sync('src/utilities/*.js', { cwd: repoRoot });
const ALL = [...ROUTES, ...UTILS];
function classifyFile(src) {
// Filter comments before classification (the comment can mention the alias).
const codeOnly = src.split('\n')
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
.join('\n');
const is_alias = /\berror:\s*errorResponse\b/.test(codeOnly);
return { is_alias };
}
test.each(ALL.map((rel) => [rel]))('%s has consistent callsite shape', (rel) => {
const abs = path.join(repoRoot, rel);
const src = fs.readFileSync(abs, 'utf8');
const { is_alias } = classifyFile(src);
const bad = [];
for (const m of src.matchAll(pat)) {
const a1 = m[1].trim();
const a2 = m[2].trim();
const lineNo = src.slice(0, m.index).split('\n').length;
if (is_alias) {
// Convention B: arg1 = message (string), arg2 = status (number)
if (isNumeric(a1) && isStringy(a2)) {
bad.push({ lineNo, a1, a2, reason: 'alias-import + canonical-shape (BUG: alias path skips validator)' });
}
} else {
// Convention A: arg1 = status (number), arg2 = message (string)
if (isStringy(a1) && isNumeric(a2)) {
bad.push({ lineNo, a1, a2, reason: 'canonical-import + alias-shape (BUG: validator fires TypeError -> 500 HTML)' });
}
}
}
if (bad.length) {
throw new Error(
`${rel}: ${bad.length} inconsistent callsite(s):\n` +
bad.map((b) => ` L${b.lineNo}: (${b.a1}, ${b.a2}) — ${b.reason}`).join('\n')
);
}
});
});
// ─── (3) End-to-end HTTP smoke — invalid input returns the expected JSON ─
describe('DC-063: live HTTP smoke — security.js GET /events/:id returns 404 JSON (not 500)', () => {
let server, baseUrl;
beforeAll(() => {
process.env.NODE_ENV = 'test';
process.env.DASHCADDY_API_TOKEN = process.env.DASHCADDY_API_TOKEN || 'test-token';
process.env.DASHCADDY_ENCRYPTION_KEY = process.env.DASHCADDY_ENCRYPTION_KEY || 'k'.repeat(64);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'jwt-test-secret-32-chars-minimum-len';
const app = express();
app.use(express.json());
// Auth shim — bypass host authentication middleware.
app.use((_req, _res, next) => next());
// Shim the security event store with a fake.
const fakeStore = {
get: () => null,
append: () => ({ id: 'fake', accepted: true }),
list: () => ({ events: [], total: 0 }),
query: () => ({ events: [], total: 0 }),
};
const fakeRegistry = {
list: () => [],
register: () => ({ host: {}, api_key: 'x' }),
get: () => null,
update: () => null,
remove: () => true,
setEnabled: () => true,
authHostByApiKey: () => null,
authHostByBearer: () => null,
};
// Inject store + registry via a require-cache swap so security.js's
// getStore()/getRegistry() return our fakes.
require.cache[path.join(repoRoot, 'src/security/event-store')] = {
exports: { getStore: () => fakeStore },
id: 'fake-event-store', filename: 'fake', loaded: true,
};
require.cache[path.join(repoRoot, 'src/security/host-registry')] = {
exports: { getRegistry: () => fakeRegistry },
id: 'fake-host-registry', filename: 'fake', loaded: true,
};
// platform-paths is required by security.js — provide a minimal shim.
require.cache[path.join(repoRoot, 'platform-paths')] = {
exports: { configFile: () => '/tmp/x', dataFile: () => '/tmp/y' },
id: 'fake-platform-paths', filename: 'fake', loaded: true,
};
const securityRoutes = require(path.join(repoRoot, 'routes/security'));
app.use((req, res, next) => {
res.success = (data) => res.json({ success: true, ...data });
res.errorResponse = (status, msg, extras) => errorResponse(res, status, msg, extras);
res.ok = (data) => res.json({ success: true, ...data });
next();
});
app.use('/api/security', securityRoutes({
store: fakeStore,
registry: fakeRegistry,
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
}));
server = http.createServer(app).listen(0);
// .listen(0) synchronously assigns a port; no need to wait.
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
afterAll((done) => {
if (server && server.listening) server.close(done);
else done();
});
function get(p) {
return new Promise((resolve, reject) => {
http.get(`${baseUrl}${p}`, (resp) => {
let buf = '';
resp.on('data', (c) => { buf += c; });
resp.on('end', () => resolve({
status: resp.statusCode,
body: buf,
contentType: resp.headers['content-type'] || '',
}));
}).on('error', reject);
});
}
test('GET /api/security/events/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
const r = await get('/api/security/events/nonexistent');
expect(r.status).toBe(404);
expect(r.contentType).toMatch(/application\/json/);
expect(r.body).toMatch(/event not found/i);
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i); // NOT an HTML panic
});
test('GET /api/security/hosts/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
const r = await get('/api/security/hosts/nonexistent');
expect(r.status).toBe(404);
expect(r.contentType).toMatch(/application\/json/);
expect(r.body).toMatch(/host not found/i);
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i);
});
});
@@ -1,192 +0,0 @@
/**
* DC-072: WebSocket exec scope-based authorization + containerId charset
* hardening.
*
* Bug class under test:
* 1. Pre-fix `routes/exec.js` captured `auth.scope` (line 39/46) but
* NEVER enforced it. A JWT or API key whose scope was `['read']`
* (a legitimate monitoring/observability scope) would be granted a
* full PTY-backed shell inside any running container. Container
* exec is root-equivalent inside the container's user namespace,
* so this is a privilege escalation: a read-only key holder could
* run arbitrary commands, exfiltrate mounted volumes, or pivot
* to the host network.
*
* 2. Pre-fix `containerId` regex `/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/`
* accepted mixed case, `_`, `-`, `.`, and any length up to 128.
* Docker container IDs are exactly 64 lowercase hex (or 12-char
* short form). The pre-fix validator would pass any string that
* looked vaguely ID-shaped; Docker's inspect() would then 404.
*
* Post-fix: `assertExecScope(auth)` requires `admin` scope and throws a
* 403-tagged error. `isValidContainerId(id)` accepts only 12 or 64
* lowercase hex chars. Both helpers are exported via `__test`.
*/
const { __test } = require('../../routes/exec');
const { assertExecScope, isValidContainerId } = __test;
function check(cond, msg) {
if (!cond) throw new Error('assertion failed: ' + msg);
}
describe('DC-072: exec WebSocket scope-based authorization', () => {
describe('assertExecScope — admin required', () => {
test('admin scope passes', () => {
// Should not throw
assertExecScope({ type: 'jwt', scope: ['admin'] });
assertExecScope({ type: 'apikey', scope: ['admin', 'read'] });
});
test('read-only scope rejected with DC-072_INSUFFICIENT_SCOPE', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', scope: ['read'] });
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on read-only scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
check(caught.requiredScope === 'admin', `expected requiredScope=admin, got ${caught.requiredScope}`);
check(Array.isArray(caught.actualScope) && caught.actualScope[0] === 'read', `expected actualScope=['read'], got ${JSON.stringify(caught.actualScope)}`);
});
test('write-only scope rejected (write ≠ admin)', () => {
let caught = null;
try {
assertExecScope({ type: 'jwt', scope: ['write'] });
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on write-only scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
});
test('empty scope rejected', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', scope: [] });
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on empty scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('undefined scope rejected (null-safety)', () => {
let caught = null;
try {
assertExecScope({ type: 'jwt' }); // no scope field
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on undefined scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('null auth rejected', () => {
let caught = null;
try {
assertExecScope(null);
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on null auth');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('non-array scope rejected (defensive)', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', scope: 'admin' }); // string, not array
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on non-array scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('error envelope carries operator-actionable fields', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', keyId: 'k_test', scope: ['read'] });
} catch (e) {
caught = e;
}
check(caught.message === 'Container exec requires admin scope', `expected canonical message, got ${caught.message}`);
check(typeof caught.requiredScope === 'string' && caught.requiredScope === 'admin', 'requiredScope present');
check(Array.isArray(caught.actualScope), 'actualScope is array');
});
});
describe('isValidContainerId — Docker charset (12 or 64 lowercase hex)', () => {
test('64-char lowercase hex accepted (full Docker ID)', () => {
// Real-world example: dashcaddy-api container ID
check(isValidContainerId('abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === true, '64-char hex should pass');
});
test('12-char lowercase hex accepted (short form)', () => {
check(isValidContainerId('abcdef012345') === true, '12-char hex should pass');
});
test('uppercase hex rejected (Docker IDs are lowercase)', () => {
check(isValidContainerId('ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789') === false, 'uppercase 64-char should fail');
check(isValidContainerId('ABCDEF012345') === false, 'uppercase 12-char should fail');
});
test('mixed case rejected', () => {
check(isValidContainerId('Abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === false, 'mixed case 64-char should fail');
});
test('non-hex chars rejected', () => {
check(isValidContainerId('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz') === false, 'g-z hex should fail');
check(isValidContainerId('abc!@#$%^&*()_+-=[]{}|\\:;\'",.<>/?0123456789012345678901234567890123') === false, 'special chars should fail');
});
test('underscore / dot / dash rejected (pre-fix allowed these)', () => {
// Pre-fix regex accepted `_`, `-`, `.` — all are non-Docker
check(isValidContainerId('my_container_1') === false, 'underscore should fail');
check(isValidContainerId('my.container.1') === false, 'dot should fail');
check(isValidContainerId('my-container-1') === false, 'dash should fail');
});
test('wrong length rejected', () => {
check(isValidContainerId('abcdef0123456') === false, '13-char should fail'); // 12 + 1
check(isValidContainerId('abcdef01234567') === false, '14-char should fail'); // 12 + 2
check(isValidContainerId('abcdef0123456789a') === false, '65-char should fail'); // 64 + 1
});
test('empty string rejected', () => {
check(isValidContainerId('') === false, 'empty string should fail');
});
test('null / undefined / non-string rejected (defensive)', () => {
check(isValidContainerId(null) === false, 'null should fail');
check(isValidContainerId(undefined) === false, 'undefined should fail');
check(isValidContainerId(12345) === false, 'number should fail');
check(isValidContainerId({}) === false, 'object should fail');
check(isValidContainerId([]) === false, 'array should fail');
});
test('whitespace / padding rejected', () => {
check(isValidContainerId(' abcdef012345 ') === false, 'padded should fail');
check(isValidContainerId('\nabcdef012345\n') === false, 'CRLF-padded should fail');
});
test('CRLF injection rejected (defensive against pre-fix attack class)', () => {
// Pre-fix regex accepted 128 chars with dots; a payload like
// `aa.bb.cc.dd\r\nSet-Cookie:...` would have passed. Post-fix
// the LF + non-hex + wrong-length combo fails on every axis.
check(isValidContainerId('aa\r\nbb') === false, 'CRLF payload should fail');
});
});
describe('__test exports shape', () => {
test('exports assertExecScope and isValidContainerId', () => {
check(typeof __test.assertExecScope === 'function', 'assertExecScope is a function');
check(typeof __test.isValidContainerId === 'function', 'isValidContainerId is a function');
});
});
});
@@ -1,359 +0,0 @@
/**
* DC-068: Fleet SSRF hardening routes-layer integration tests
*
* Verifies that:
* - POST /api/v1/fleet/hosts rejects a public-DNS name that resolves to a
* private IP (DNS rebinding defense)
* - POST /api/v1/fleet/hosts accepts a public-DNS name that resolves to a
* public IP and stores the resolved IP
* - POST /api/v1/fleet/hosts rejects literal IPv4 in loopback / link-local
* / RFC 1918 / CGNAT / broadcast ranges
* - POST /api/v1/fleet/hosts accepts a literal public IPv4
* - POST /api/v1/fleet/hosts rejects port 22 (SSH collision)
* - POST /api/v1/fleet/hosts rejects control characters in name/tag
* - POST /api/v1/fleet/hosts stores the resolved IP and dnsFamily so
* /fleet/status and /fleet/deploy can probe by IP
* - FLEET_ALLOW_PRIVATE_HOSTS=true opts in to private-range hosts
*
* The route tests live alongside the existing DC-108 suite in
* caddycode-fleet.routes.test.js. We extend that file with two new describe
* blocks so we can co-locate SSRF regression tests with their feature.
*/
const express = require('express');
const request = require('supertest');
function createFleetApp(log, opts = {}) {
const app = express();
app.use(express.json());
const routes = require('../../routes/fleet');
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.use('/api/v1', routes({
log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
asyncHandler: wrap,
}));
return app;
}
describe('DC-068: Fleet POST /hosts — SSRF hardening', () => {
let dnsBackup;
let filePath;
beforeEach(() => {
filePath = `/tmp/fleet-ssrf-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
process.env.FLEET_HOSTS_FILE = filePath;
dnsBackup = require('dns').promises.lookup;
});
afterEach(() => {
require('dns').promises.lookup = dnsBackup;
delete process.env.FLEET_HOSTS_FILE;
try { require('fs').unlinkSync(filePath); } catch {}
delete process.env.FLEET_ALLOW_PRIVATE_HOSTS;
});
it('rejects 127.0.0.1 (loopback) with PRIVATE_IPV4', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Local', hostname: '127.0.0.1', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PRIVATE_IPV4');
expect(res.body.error).toMatch(/loopback/i);
});
it('rejects 169.254.169.254 (AWS IMDS)', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'IMDS', hostname: '169.254.169.254', port: 80 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PRIVATE_IPV4');
expect(res.body.error).toMatch(/metadata|link-local/i);
});
it('rejects 10.0.0.1 (RFC 1918)', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'RFC1918', hostname: '10.0.0.1', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PRIVATE_IPV4');
expect(res.body.error).toMatch(/RFC 1918/);
});
it('rejects 192.168.1.1 (LAN)', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'LAN', hostname: '192.168.1.1', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PRIVATE_IPV4');
});
it('rejects 100.64.0.1 (Tailscale CGNAT)', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Tailscale', hostname: '100.64.0.1', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PRIVATE_IPV4');
});
it('rejects ::1 (IPv6 loopback)', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'v6loop', hostname: '::1', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PRIVATE_IPV6');
});
it('rejects port 22 (SSH)', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'h', hostname: 'fleet.example.com', port: 22 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PORT');
expect(res.body.error).toMatch(/22.*reserved|reserved.*22/);
});
it('rejects port > 65535', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'h', hostname: 'fleet.example.com', port: 65536 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PORT');
});
it('rejects port = 0', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'h', hostname: 'fleet.example.com', port: 0 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PORT');
});
it('rejects garbage hostname', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'h', hostname: 'not a host!', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_HOSTNAME');
});
it('rejects control characters in name', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'evil\nname', hostname: 'fleet.example.com', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_NAME');
});
it('rejects control characters in tags', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'h', hostname: 'fleet.example.com', port: 3001, tags: ['good', 'bad\ntag'] });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_TAGS');
});
it('accepts a literal public IPv4', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Public', hostname: '8.8.8.8', port: 3001 });
expect(res.status).toBe(201);
expect(res.body.host.resolvedIp).toBe('8.8.8.8');
expect(res.body.host.dnsFamily).toBe(4);
});
it('accepts a public DNS name and resolves it', async () => {
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Public DNS', hostname: 'public.example.com', port: 3001 });
expect(res.status).toBe(201);
expect(res.body.host.hostname).toBe('public.example.com');
expect(res.body.host.resolvedIp).toBe('93.184.216.34');
expect(res.body.host.dnsFamily).toBe(4);
});
it('rejects a DNS name that resolves to a private IP (DNS rebinding)', async () => {
// Simulate a rebinding attacker: registration-time DNS returns a public
// IP, but a follow-up resolve returns a loopback IP. We mock with the
// private IP directly — the validator catches it at registration time.
require('dns').promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Rebind', hostname: 'attacker.example.com', port: 3001 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PRIVATE_IPV4');
});
it('opts in to private hosts when FLEET_ALLOW_PRIVATE_HOSTS=true', async () => {
process.env.FLEET_ALLOW_PRIVATE_HOSTS = 'true';
require('dns').promises.lookup = async () => [{ address: '100.100.50.25', family: 4 }];
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Tailscale', hostname: 'tailnet.example.com', port: 3001 });
expect(res.status).toBe(201);
expect(res.body.host.resolvedIp).toBe('100.100.50.25');
});
it('rejects unresolvable DNS name', async () => {
// .invalid is a guaranteed-non-resolving TLD per RFC 6761.
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'NoDNS', hostname: 'does-not-resolve.invalid', port: 3001 });
expect(res.status).toBe(400);
expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(res.body.code);
});
});
describe('DC-068: Fleet GET /status — probes use resolved IP, not hostname', () => {
let dnsBackup;
let filePath;
beforeEach(() => {
filePath = `/tmp/fleet-ssrf-status-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
process.env.FLEET_HOSTS_FILE = filePath;
dnsBackup = require('dns').promises.lookup;
});
afterEach(() => {
require('dns').promises.lookup = dnsBackup;
delete process.env.FLEET_HOSTS_FILE;
try { require('fs').unlinkSync(filePath); } catch {}
});
it('reports validation_failed for a stored host whose hostname resolves to a private IP', async () => {
// Step 1: register a host with a public DNS name. Mock lookup so
// registration succeeds.
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
let app = createFleetApp();
let res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Was Good', hostname: 'fleet.example.com', port: 3001 });
expect(res.status).toBe(201);
// Step 2: flip the DNS to a private IP (simulating DNS rebinding).
// Now GET /status should re-validate, detect the rebind, and tag the
// host validation_failed instead of probing the internal address.
require('dns').promises.lookup = async () => [{ address: '127.0.0.1', family: 4 }];
app = createFleetApp();
res = await request(app).get('/api/v1/fleet/status');
expect(res.status).toBe(200);
const host = res.body.hosts[0];
expect(host.status).toBe('validation_failed');
expect(host.validationError).toBeTruthy();
expect(res.body.summary.validation_failed).toBe(1);
expect(res.body.summary.offline).toBe(0);
});
it('probes using stored resolvedIp, not raw hostname', async () => {
// This is the route-level safety net: even if the stored resolvedIp
// somehow no longer resolves correctly, /fleet/status must probe the
// captured IP. We assert by checking the host.lastSeen / probe data is
// driven by the resolved IP endpoint — but since we can't easily mock
// fetch in this test, we verify the structural invariant: hosts with a
// valid stored resolvedIp pass validation when DNS lookup ALSO returns
// a public IP at probe time.
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
let app = createFleetApp();
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
app = createFleetApp();
const res = await request(app).get('/api/v1/fleet/status');
expect(res.status).toBe(200);
// Status will be offline because the probed host (93.184.216.34:3001)
// doesn't actually serve our health endpoint in the test environment —
// but it should NOT be validation_failed.
const host = res.body.hosts[0];
expect(host.status).not.toBe('validation_failed');
// The validation_failed counter should remain 0.
expect(res.body.summary.validation_failed).toBe(0);
});
});
describe('DC-068: Fleet POST /deploy — deployUrl uses resolvedIp', () => {
let dnsBackup;
let filePath;
beforeEach(() => {
filePath = `/tmp/fleet-ssrf-deploy-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
process.env.FLEET_HOSTS_FILE = filePath;
dnsBackup = require('dns').promises.lookup;
});
afterEach(() => {
require('dns').promises.lookup = dnsBackup;
delete process.env.FLEET_HOSTS_FILE;
try { require('fs').unlinkSync(filePath); } catch {}
});
it('emits deployUrl from the resolved IP, not the raw hostname', async () => {
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
let app = createFleetApp();
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/deploy')
.send({ templateId: 'plex' });
expect(res.status).toBe(200);
expect(res.body.plan).toHaveLength(1);
// The deployUrl was built from the resolved IP, not the user-supplied
// hostname — defending against a DNS rebinding pivot at deploy time.
expect(res.body.plan[0].deployUrl).toBe('http://93.184.216.34:3001/api/v1/apps/deploy');
// The user-visible hostname is preserved on the plan entry.
expect(res.body.plan[0].hostname).toBe('fleet.example.com');
});
it('emits deployUrl from the literal IP for IPv4-literal hosts', async () => {
const app = createFleetApp();
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Literal', hostname: '8.8.8.8', port: 3001 });
const res = await request(app)
.post('/api/v1/fleet/deploy')
.send({ templateId: 'plex' });
expect(res.status).toBe(200);
expect(res.body.plan[0].deployUrl).toBe('http://8.8.8.8:3001/api/v1/apps/deploy');
});
it('wraps IPv6 resolved IPs in [brackets] so the URL parses correctly', async () => {
require('dns').promises.lookup = async () => [{ address: '2001:4860:4860::8888', family: 6 }];
let app = createFleetApp();
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'v6 DNS', hostname: 'dns.example.com', port: 3001 });
app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/deploy')
.send({ templateId: 'plex' });
expect(res.status).toBe(200);
expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
});
it('wraps IPv6 literal hosts in [brackets]', async () => {
const app = createFleetApp();
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'v6', hostname: '2001:4860:4860::8888', port: 3001 });
const res = await request(app)
.post('/api/v1/fleet/deploy')
.send({ templateId: 'plex' });
expect(res.status).toBe(200);
expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
});
});
@@ -1,427 +0,0 @@
/**
* DC-081: log-insights dispose path + keepDays input validation hardening.
*
* Two coupled bugs surfaced in the 2026-08-19 sweep:
*
* 1. log-insights.js hardcoded the audit-log.json + security-events.jsonl
* paths to `/opt/dashcaddy/dashcaddy-api/data/...`, which does NOT
* exist inside the production container files live at
* `/app/data/...` (mounted via the existing data bind). The dispose
* endpoint silently no-op'd: `fs.readFile('/opt/.../audit-log.json')`
* hit the `.catch` arm `auditData = []` wrote an empty file back.
*
* 2. `parseInt(req.body.keepDays) || 30` accepted negative numbers. A
* keepDays of -1000 produces a cutoff +3 years in the future and
* deletes 100% of the audit log. Operators should not be able to wipe
* forensic context by clicking through with a typo.
*
* DC-081 fix:
* - `_resolvePaths()` returns `{ auditPath, secPath }` from
* `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`
* same canonical resolution as the audit-logger module.
* - `_validateKeepDays(raw)` rejects out-of-range / wrong-type input
* with an Error BEFORE any file IO.
* - POST /log-insights/dispose now requires `{ keepDays: integer 1..3650, confirm: true }`.
* The pre-confirm preview is read-only.
*
* Verified live on DNS2 2026-08-19: `/app/data/audit-log.json` (318 KB)
* and `/app/data/security-events.jsonl` (15 MB) both exist; the old
* `/opt/dashcaddy/dashcaddy-api/data/...` paths are ENOENT in the container.
*/
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
const path = require('path');
const logInsightsMod = require('../../routes/log-insights');
function tmpAuditLogger() {
// The route module only uses auditLogger.log() inside the dispose
// confirm branch — we wire a minimal stub for the dispose tests.
return {
query: async () => [],
log: async () => {},
};
}
function tmpSecurityEventStore() {
return {
query: () => ({ events: [], total: 0 }),
};
}
function buildRouter(opts = {}) {
const mod = logInsightsMod;
return mod({
asyncHandler: (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
},
ok: (res, data) => res.json({ success: true, ...data }),
auditLogger: opts.auditLogger || tmpAuditLogger(),
securityEventStore: opts.securityEventStore || tmpSecurityEventStore(),
});
}
function makeApp(router) {
const app = express();
app.use(express.json());
app.use(router);
// Capture errors so a thrown ValidationError doesn't crash the test
// runner — the route uses asyncHandler which forwards to next().
app.use((err, req, res, next) => res.status(err.statusCode || 500).json({ success: false, error: err.message, code: err.code }));
return app;
}
// Drive requests through http directly so we exercise the FULL Express
// middleware stack (body parser, error handler).
function start(app) {
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => resolve(server));
});
}
function stop(server) {
return new Promise((resolve) => server.close(resolve));
}
function httpJson(server, httpMethod, urlPath) {
return new Promise((resolve, reject) => {
const port = server.address().port;
const data = httpMethod === 'GET' ? '' : JSON.stringify({});
const req = require('http').request({
hostname: '127.0.0.1', port, path: urlPath, method: httpMethod,
headers: httpMethod === 'GET'
? {}
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
catch (_) { resolve({ status: res.statusCode, body }); }
});
});
req.on('error', reject);
if (httpMethod !== 'GET') req.write(data);
req.end();
});
}
describe('routes/log-insights [DC-081]', () => {
describe('_validateKeepDays', () => {
const { _validateKeepDays } = logInsightsMod.__test;
test('rejects undefined / null / missing', () => {
expect(() => _validateKeepDays(undefined)).toThrow(/required/i);
expect(() => _validateKeepDays(null)).toThrow(/required/i);
expect(() => _validateKeepDays()).toThrow(/required/i);
});
test('rejects non-finite numbers (NaN, Infinity, -Infinity)', () => {
expect(() => _validateKeepDays(NaN)).toThrow(/finite/i);
expect(() => _validateKeepDays(Infinity)).toThrow(/finite/i);
expect(() => _validateKeepDays(-Infinity)).toThrow(/finite/i);
expect(() => _validateKeepDays('not-a-number')).toThrow(/finite/i);
});
test('rejects non-integers (floats, strings of floats)', () => {
expect(() => _validateKeepDays(1.5)).toThrow(/integer/i);
expect(() => _validateKeepDays(30.7)).toThrow(/integer/i);
expect(() => _validateKeepDays('30.5')).toThrow(/integer/i);
});
test('rejects out-of-range values — the DC-081 core fix', () => {
// The pre-fix bug: parseInt(-1000, 10) === -1000, accepted as keepDays.
// cutoff = Date.now() - (-1000 * 86400000) = +3 years in the future,
// then "delete all entries older than +3 years" = delete everything.
expect(() => _validateKeepDays(-1)).toThrow(/between 1 and 3650/i);
expect(() => _validateKeepDays(-1000)).toThrow(/between 1 and 3650/i);
expect(() => _validateKeepDays(0)).toThrow(/between 1 and 3650/i);
expect(() => _validateKeepDays(3651)).toThrow(/between 1 and 3650/i);
expect(() => _validateKeepDays(1000000)).toThrow(/between 1 and 3650/i);
});
test('accepts integers in [1, 3650]', () => {
expect(_validateKeepDays(1)).toBe(1);
expect(_validateKeepDays(30)).toBe(30);
expect(_validateKeepDays(90)).toBe(90);
expect(_validateKeepDays(365)).toBe(365);
expect(_validateKeepDays(3650)).toBe(3650);
});
test('coerces numeric strings', () => {
expect(_validateKeepDays('30')).toBe(30);
expect(_validateKeepDays('3650')).toBe(3650);
});
});
describe('_resolvePaths', () => {
const { _resolvePaths } = logInsightsMod.__test;
test('falls back to platformPaths.dataDir when env unset', () => {
const prevAudit = process.env.AUDIT_LOG_FILE;
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
delete process.env.AUDIT_LOG_FILE;
delete process.env.SECURITY_EVENT_LOG_FILE;
try {
const { auditPath, secPath } = _resolvePaths();
// platformPaths.dataDir is /app/data in the container, /etc/dashcaddy on host
expect(auditPath.endsWith('audit-log.json')).toBe(true);
expect(secPath.endsWith('security-events.jsonl')).toBe(true);
// Audit + security should land in the same data dir
expect(path.dirname(auditPath)).toBe(path.dirname(secPath));
} finally {
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
}
});
test('honours AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE env overrides', () => {
const prevAudit = process.env.AUDIT_LOG_FILE;
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
process.env.AUDIT_LOG_FILE = '/tmp/dc-081-audit.json';
process.env.SECURITY_EVENT_LOG_FILE = '/tmp/dc-081-sec.jsonl';
try {
const { auditPath, secPath, auditPathFrom, secPathFrom } = _resolvePaths();
expect(auditPath).toBe('/tmp/dc-081-audit.json');
expect(secPath).toBe('/tmp/dc-081-sec.jsonl');
expect(auditPathFrom).toBe('env');
expect(secPathFrom).toBe('env');
} finally {
if (prevAudit === undefined) delete process.env.AUDIT_LOG_FILE;
else process.env.AUDIT_LOG_FILE = prevAudit;
if (prevSec === undefined) delete process.env.SECURITY_EVENT_LOG_FILE;
else process.env.SECURITY_EVENT_LOG_FILE = prevSec;
}
});
test('matches the canonical paths used by audit-logger + event-store', async () => {
// Sanity: load both modules' resolved paths and assert they match
// what _resolvePaths returns. This catches a future refactor that
// moves one but not the others (the bug class that produced DC-081).
const prevAudit = process.env.AUDIT_LOG_FILE;
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
delete process.env.AUDIT_LOG_FILE;
delete process.env.SECURITY_EVENT_LOG_FILE;
try {
const auditLoggerMod = require('../../src/security/audit-logger');
const eventStoreMod = require('../../src/security/event-store');
// Trigger event-store module-load (it captures ENV at require time)
eventStoreMod.getStore();
const { auditPath, secPath } = _resolvePaths();
// The audit-logger module exports a singleton; its private
// AUDIT_LOG_FILE is not directly readable. Instead, we verify the
// shape: both paths share the same dataDir and use the canonical
// filenames.
expect(path.basename(auditPath)).toBe('audit-log.json');
expect(path.basename(secPath)).toBe('security-events.jsonl');
// And the dirname matches platformPaths.dataDir
const platformPaths = require('../../platform-paths');
expect(path.dirname(auditPath)).toBe(platformPaths.dataDir);
expect(path.dirname(secPath)).toBe(platformPaths.dataDir);
// Also sanity that the singleton logger at least exists
expect(auditLoggerMod).toBeDefined();
} finally {
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
}
});
});
describe('POST /log-insights/dispose (TOTP-gated in production; here we hit the handler directly)', () => {
let server;
let app;
let tmpDir;
let auditFile;
let secFile;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-081-'));
auditFile = path.join(tmpDir, 'audit-log.json');
secFile = path.join(tmpDir, 'security-events.jsonl');
// Stage files so the route resolves them via env override.
process.env.AUDIT_LOG_FILE = auditFile;
process.env.SECURITY_EVENT_LOG_FILE = secFile;
const router = buildRouter();
app = makeApp(router);
server = await start(app);
});
afterEach(async () => {
await stop(server);
delete process.env.AUDIT_LOG_FILE;
delete process.env.SECURITY_EVENT_LOG_FILE;
await fsp.rm(tmpDir, { recursive: true, force: true });
});
function postKeepDays(body) {
return new Promise((resolve, reject) => {
const port = server.address().port;
const data = JSON.stringify(body);
const req = require('http').request({
hostname: '127.0.0.1', port, path: '/log-insights/dispose',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
catch (_) { resolve({ status: res.statusCode, body }); }
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
test('rejects negative keepDays with 400 + DC-081_INVALID_KEEP_DAYS', async () => {
const r = await postKeepDays({ keepDays: -1000 });
expect(r.status).toBe(400);
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
expect(r.body.error).toMatch(/between 1 and 3650/i);
});
test('rejects 0 keepDays (no-op-but-lies)', async () => {
const r = await postKeepDays({ keepDays: 0 });
expect(r.status).toBe(400);
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
});
test('rejects keepDays=Infinity (NaN-via-parseInt fallback)', async () => {
const r = await postKeepDays({ keepDays: Infinity });
expect(r.status).toBe(400);
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
});
test('rejects non-integer keepDays', async () => {
const r = await postKeepDays({ keepDays: 30.5 });
expect(r.status).toBe(400);
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
});
test('rejects missing keepDays', async () => {
const r = await postKeepDays({});
expect(r.status).toBe(400);
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
});
test('rejects keepDays > 3650 (10-year cap)', async () => {
const r = await postKeepDays({ keepDays: 10000 });
expect(r.status).toBe(400);
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
});
test('preview pass: returns wouldDelete count without writing', async () => {
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString(); // 100 days ago
const newTs = new Date(Date.now() - 5 * 86400000).toISOString(); // 5 days ago
await fsp.writeFile(auditFile, JSON.stringify([
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
{ id: 'a2', timestamp: oldTs, action: 'service.delete' },
{ id: 'a3', timestamp: newTs, action: 'auth.totp-verify' },
]));
await fsp.writeFile(secFile, [
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
JSON.stringify({ id: 's2', timestamp: oldTs, severity: 'info' }),
JSON.stringify({ id: 's3', timestamp: newTs, severity: 'info' }),
].join('\n') + '\n');
const r = await postKeepDays({ keepDays: 30 });
expect(r.status).toBe(200);
expect(r.body.preview).toBe(true);
expect(r.body.wouldDelete.auditEntries).toBe(2);
expect(r.body.wouldDelete.securityEvents).toBe(2);
// Files untouched
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
expect(afterAudit.length).toBe(3);
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean);
expect(afterSec.length).toBe(3);
});
test('confirm pass: actually deletes old entries, keeps new ones', async () => {
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString();
const newTs = new Date(Date.now() - 5 * 86400000).toISOString();
await fsp.writeFile(auditFile, JSON.stringify([
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
{ id: 'a2', timestamp: newTs, action: 'auth.totp-verify' },
]));
await fsp.writeFile(secFile, [
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
JSON.stringify({ id: 's2', timestamp: newTs, severity: 'info' }),
].join('\n') + '\n');
const r = await postKeepDays({ keepDays: 30, confirm: true });
expect(r.status).toBe(200);
expect(r.body.disposed).toBe(true);
expect(r.body.deleted.auditEntries).toBe(1);
expect(r.body.deleted.securityEvents).toBe(1);
expect(r.body.remaining.auditEntries).toBe(1);
expect(r.body.remaining.securityEvents).toBe(1);
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
expect(afterAudit.map(e => e.id)).toEqual(['a2']);
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean).map(JSON.parse);
expect(afterSec.map(e => e.id)).toEqual(['s2']);
});
test('confirm=false treated as preview (not confirm)', async () => {
const r = await postKeepDays({ keepDays: 30, confirm: false });
expect(r.status).toBe(200);
expect(r.body.preview).toBe(true);
// confirm was false, so no dispose
expect(r.body.disposed).toBeUndefined();
});
test('preview response includes resolved paths so operator knows what files will be touched', async () => {
const r = await postKeepDays({ keepDays: 30 });
expect(r.status).toBe(200);
expect(r.body.paths.auditPath).toBe(auditFile);
expect(r.body.paths.secPath).toBe(secFile);
});
test('handles missing audit-log file gracefully on preview', async () => {
await fsp.unlink(auditFile).catch(() => {});
// fs.readFile().catch returns '[]', so preview reports 0 deletions
const r = await postKeepDays({ keepDays: 30 });
expect(r.status).toBe(200);
expect(r.body.wouldDelete.auditEntries).toBe(0);
});
test('returns 500 DC-081_AUDIT_PARSE_FAILED on corrupt audit-log file', async () => {
await fsp.writeFile(auditFile, 'this-is-not-json{');
const r = await postKeepDays({ keepDays: 30 });
expect(r.status).toBe(500);
expect(r.body.code).toBe('DC-081_AUDIT_PARSE_FAILED');
});
test('returns 500 DC-081_AUDIT_SHAPE_INVALID if audit-log is a JSON object, not array', async () => {
await fsp.writeFile(auditFile, JSON.stringify({ not: 'an array' }));
const r = await postKeepDays({ keepDays: 30 });
expect(r.status).toBe(500);
expect(r.body.code).toBe('DC-081_AUDIT_SHAPE_INVALID');
});
test('DC-081 CORE: pre-fix keptDays=-1000 no longer wipes everything', async () => {
// Sanity-test the actual fix: a negative keepDays would, pre-fix,
// compute a cutoff in the FUTURE and then delete everything. After
// DC-081 it's a 400 with a clear error before any file read.
const r = await postKeepDays({ keepDays: -1000, confirm: true });
expect(r.status).toBe(400);
expect(r.body.success).toBe(false);
// No file IO occurred — confirm that an unrelated existing audit
// log file would survive. Since we already wiped tmpDir's auditFile
// is empty, write a sentinel and confirm it's still there after.
await fsp.writeFile(auditFile, JSON.stringify([{ id: 'sentinel', timestamp: new Date().toISOString() }]));
const r2 = await postKeepDays({ keepDays: -1000, confirm: true });
expect(r2.status).toBe(400);
const after = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
expect(after.length).toBe(1);
expect(after[0].id).toBe('sentinel');
});
});
});
@@ -1,196 +0,0 @@
/**
* DC-055: Host journald route smoke tests.
*
* Mounts the routes/logs.js journald endpoints into a tiny express app
* with a mocked journald reader. The mock mirrors the real module's
* validation pipeline (assertUnitAllowed, parseTail, parseTimestamp) so
* bad inputs still throw ValidationError -> 400 at the route boundary,
* but the actual journalctl spawn is short-circuited.
*/
const express = require('express');
const request = require('supertest');
const path = require('path');
const realJournaldPath = require.resolve('../../src/monitoring/journald-reader.js');
// Pull the real module's validators so the mock's readEntries can
// reproduce the same 400-on-bad-input behaviour as production.
const realReader = jest.requireActual(realJournaldPath);
// Mocked journald reader. Variable name MUST start with "mock" so
// jest.mock hoisting doesn't reject the factory closure.
const mockJournald = {
ALLOWED_UNITS: realReader.ALLOWED_UNITS,
MAX_TAIL_LINES: realReader.MAX_TAIL_LINES,
MAX_OUTPUT_BUFFER: realReader.MAX_OUTPUT_BUFFER,
isAvailable: jest.fn().mockResolvedValue(true),
// Validation pipeline runs through the real assert/parse functions so
// bad unit/tail/since/until still surface as ValidationError. The
// journalctl spawn itself is short-circuited — return canned entries.
readEntries: jest.fn(async (opts) => {
const unit = realReader.assertUnitAllowed(opts.unit);
realReader.parseTail(opts.tail); // throws on bad tail
realReader.parseTimestamp(opts.since, 'since');
realReader.parseTimestamp(opts.until, 'until');
return [
{ timestamp: 'Aug 18 00:42:46', hostname: 'host', unit, text: 'mock-line-1' },
];
}),
// Default stream mock: invokes onData with one synthetic entry then
// returns a no-op handle. Tests override per-case.
streamEntries: jest.fn((opts, hooks = {}) => {
if (hooks.onData) {
hooks.onData({ timestamp: 'Aug 18 00:42:46', unit: opts.unit, text: 'stream-line-1' });
}
return { kill: jest.fn(), child: {} };
}),
listUnits: jest.fn(async () => [
{ unit: 'caddy', hasEntries: true },
{ unit: 'docker', hasEntries: true },
]),
assertUnitAllowed: realReader.assertUnitAllowed,
parseTail: realReader.parseTail,
parseTimestamp: realReader.parseTimestamp,
parseShortLine: realReader.parseShortLine,
buildArgv: realReader.buildArgv,
};
jest.mock('../../src/monitoring/journald-reader.js', () => mockJournald);
// Force journaldAvailable = true in routes/logs.js. The route checks
// /var/log/journal + /usr/bin/journalctl at module-load time, so we stub
// fs.existsSync to lie about those paths.
const realFs = require('fs');
const realExists = realFs.existsSync;
realFs.existsSync = function(p) {
if (p === '/var/log/journal' || p === '/usr/bin/journalctl') return true;
return realExists.apply(this, arguments);
};
const logsRoutes = require('../../routes/logs.js');
function buildApp() {
const app = express();
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const ok = (res, data) => res.json({ success: true, ...data });
const errorHandler = (err, req, res, next) => {
const status = err.statusCode || (err.name === 'ValidationError' ? 400 : 500);
res.status(status).json({ success: false, error: err.message });
};
app.use('/api/v1', logsRoutes({ asyncHandler, ok }));
app.use(errorHandler);
return app;
}
describe('routes /logs/journal', () => {
let app;
beforeEach(async () => {
mockJournald.readEntries.mockClear();
mockJournald.streamEntries.mockClear();
mockJournald.listUnits.mockClear();
app = buildApp();
// Let any keep-alive socket from the prior test close before we
// bind a new express app.
await new Promise(r => setTimeout(r, 10));
});
describe('GET /logs/journal/units', () => {
test('returns unit list when journald is mounted', async () => {
const res = await request(app).get('/api/v1/logs/journal/units');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.available).toBe(true);
expect(res.body.units.length).toBeGreaterThanOrEqual(1);
});
});
describe('GET /logs/journal', () => {
test('returns entries for caddy', async () => {
const res = await request(app)
.get('/api/v1/logs/journal')
.query({ unit: 'caddy', tail: 50 });
expect(res.status).toBe(200);
expect(res.body.entries.length).toBeGreaterThanOrEqual(1);
expect(res.body.entries[0].unit).toBe('caddy');
expect(mockJournald.readEntries).toHaveBeenCalled();
const call = mockJournald.readEntries.mock.calls[0][0];
expect(call.unit).toBe('caddy');
expect(call.tail).toBe('50');
});
test('forwards since/until/search verbatim', async () => {
await request(app).get('/api/v1/logs/journal').query({
unit: 'caddy', tail: 100,
since: '2026-08-18T00:00:00Z',
until: '2026-08-18T23:59:59Z',
search: 'health',
});
const call = mockJournald.readEntries.mock.calls[0][0];
expect(call.since).toBe('2026-08-18T00:00:00Z');
expect(call.until).toBe('2026-08-18T23:59:59Z');
expect(call.search).toBe('health');
});
test('returns 400 when unit not in allow-list', async () => {
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'nginx' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not in allow-list/);
// The reader is called and rejects; the route layer maps the
// ValidationError to 400 without doing any spawn.
expect(mockJournald.readEntries).toHaveBeenCalled();
});
test('returns 400 when unit contains shell metacharacters', async () => {
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy; rm -rf /' });
expect(res.status).toBe(400);
expect(mockJournald.readEntries).toHaveBeenCalled();
});
test('returns 400 when tail is invalid', async () => {
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy', tail: 'oops' });
expect(res.status).toBe(400);
});
test('returns 500 when reader throws non-validation error', async () => {
mockJournald.readEntries.mockRejectedValueOnce(new Error('journalctl exited 1: bad dir'));
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy' });
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/journalctl exited 1/);
});
});
describe('GET /logs/journal/stream', () => {
test('opens SSE with correct content-type for a valid unit', async () => {
// Stub the mock to immediately call onError so the route ends
// the response and supertest can collect it. Production SSE
// streams stay open until the client disconnects — covered by
// the journald-reader.streamEntries unit tests.
mockJournald.streamEntries.mockImplementationOnce((opts, hooks) => {
setTimeout(() => hooks.onError && hooks.onError(new Error('synthetic-EOF')), 5);
return { kill: jest.fn(), child: {} };
});
const res = await request(app)
.get('/api/v1/logs/journal/stream')
.query({ unit: 'caddy' })
.timeout(2000);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
});
test('400 when unit not in allow-list', async () => {
// The route pre-validates with journald.assertUnitAllowed BEFORE
// opening SSE — invalid unit returns a 400 JSON response without
// touching the stream.
const res = await request(app)
.get('/api/v1/logs/journal/stream')
.query({ unit: 'nginx' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not in allow-list/);
// streamEntries must NOT have been called for a bad unit.
expect(mockJournald.streamEntries).not.toHaveBeenCalled();
});
});
});
@@ -1,351 +0,0 @@
/**
* DC-065: OpenClaw proxy hardening test the four attack vectors closed
* by the proxyRequest refactor:
* (a) unbounded response passthrough 5 MiB cap with 502 on overrun
* (b) hop-by-hop + dangerous response-header passthrough stripped
* (c) malformed proxyRes.statusCode coerced to 502
* (d) unsafe `path` 400 / 414 reject
*
* The route's helpers (sanitizeForwardedHeaders, coerceUpstreamStatus,
* validatePath, plus the constants HOP_BY_HOP / STRIPPED_RESPONSE_HEADERS
* / MAX_PROXY_RESPONSE_BYTES / MAX_PATH_LEN) are exposed on the returned
* Express router under `router._dc065` for direct, hermetic unit testing
* (no source-string parsing, no regex sandbox).
*
* End-to-end tests spin a real upstream http server on 127.0.0.1 to
* exercise the proxy boundary through Express openclaw router http.
*/
const http = require('http');
const express = require('express');
const openclawModule = require('../../routes/openclaw');
function makeRouter() {
return openclawModule({
docker: { client: { listContainers: async () => [] } },
asyncHandler: (fn) => fn,
ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
log: { info() {}, error() {}, warn() {}, debug() {} },
});
}
function spinUpstream(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
});
});
}
describe('routes/openclaw — DC-065 proxy hardening', () => {
describe('router shape (regression)', () => {
test('router builds with /status, /deploy, /proxy/*, DELETE handlers and exposes _dc065 helpers', () => {
const router = makeRouter();
const paths = router.stack
.filter((l) => l.route)
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
.flat();
expect(paths).toEqual(expect.arrayContaining([
'GET /status',
'POST /deploy',
'GET /proxy/*',
'POST /proxy/*',
'DELETE /',
]));
// DC-065 helper exposure — fails loud if a future refactor removes it.
expect(router._dc065).toBeDefined();
expect(typeof router._dc065.sanitizeForwardedHeaders).toBe('function');
expect(typeof router._dc065.coerceUpstreamStatus).toBe('function');
expect(typeof router._dc065.validatePath).toBe('function');
});
});
describe('sanitizeForwardedHeaders (DC-065)', () => {
let helpers;
beforeAll(() => { helpers = makeRouter()._dc065; });
test('strips RFC 7230 hop-by-hop headers (case-insensitive)', () => {
const input = {
Connection: 'close',
'keep-alive': 'timeout=5',
'Proxy-Authenticate': 'Basic realm=...',
'proxy-authorization': 'Basic foo',
TE: 'trailers',
Trailers: 'X-Foo',
'Transfer-Encoding': 'chunked',
Upgrade: 'websocket',
};
expect(Object.keys(helpers.sanitizeForwardedHeaders(input))).toEqual([]);
});
test('strips Set-Cookie / Content-Encoding / Content-Length / Server / X-Powered-By / Location / Refresh / WWW-Authenticate', () => {
const input = {
'Set-Cookie': 'sid=abc; HttpOnly',
'Location': 'http://evil.com/steal', // DC-065 round-1 finding
'Refresh': '0; url=http://evil.com/steal', // DC-065 round-2 finding
'WWW-Authenticate': 'Basic realm="OpenClaw"', // DC-065 round-2 finding
'Content-Encoding': 'gzip',
'Content-Length': '99999',
'Server': 'openclaw/1.0',
'X-Powered-By': 'openclaw',
'X-Custom': 'kept',
};
const out = helpers.sanitizeForwardedHeaders(input);
expect(Object.keys(out).sort()).toEqual(['X-Custom']);
});
test('passes safe application/json + cache headers through unchanged', () => {
const input = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Request-Id': 'req-123',
};
const out = helpers.sanitizeForwardedHeaders(input);
expect(out['Content-Type']).toBe('application/json');
expect(out['Cache-Control']).toBe('no-store');
expect(out['X-Request-Id']).toBe('req-123');
});
test('null/undefined input → empty object', () => {
expect(helpers.sanitizeForwardedHeaders(null)).toEqual({});
expect(helpers.sanitizeForwardedHeaders(undefined)).toEqual({});
});
test('MAX_PROXY_RESPONSE_BYTES is 5 MiB', () => {
expect(helpers.MAX_PROXY_RESPONSE_BYTES).toBe(5 * 1024 * 1024);
});
});
describe('coerceUpstreamStatus (DC-065)', () => {
let helpers;
beforeAll(() => { helpers = makeRouter()._dc065; });
test('returns valid integer statuses 100..599 unchanged', () => {
for (const s of [100, 200, 301, 404, 418, 500, 502, 503, 599]) {
expect(helpers.coerceUpstreamStatus(s)).toBe(s);
}
});
test('out-of-range integers coerce to 502', () => {
expect(helpers.coerceUpstreamStatus(0)).toBe(502);
expect(helpers.coerceUpstreamStatus(99)).toBe(502);
expect(helpers.coerceUpstreamStatus(600)).toBe(502);
expect(helpers.coerceUpstreamStatus(1000)).toBe(502);
});
test('non-integer numbers coerce to 502', () => {
expect(helpers.coerceUpstreamStatus(200.5)).toBe(502);
expect(helpers.coerceUpstreamStatus(NaN)).toBe(502);
expect(helpers.coerceUpstreamStatus(Infinity)).toBe(502);
});
test('non-number types coerce to 502', () => {
expect(helpers.coerceUpstreamStatus('200')).toBe(502);
expect(helpers.coerceUpstreamStatus(null)).toBe(502);
expect(helpers.coerceUpstreamStatus(undefined)).toBe(502);
expect(helpers.coerceUpstreamStatus('OK')).toBe(502);
});
});
describe('validatePath (DC-065)', () => {
let helpers;
beforeAll(() => { helpers = makeRouter()._dc065; });
test('rejects empty / non-string / oversize paths', () => {
expect(helpers.validatePath('').ok).toBe(false);
expect(helpers.validatePath(null).ok).toBe(false);
expect(helpers.validatePath(undefined).ok).toBe(false);
expect(helpers.validatePath(123).ok).toBe(false);
const long = '/' + 'a'.repeat(helpers.MAX_PATH_LEN);
const r = helpers.validatePath(long);
expect(r.ok).toBe(false);
expect(r.code).toBe(414);
});
test('rejects absolute-URL injection (`://`)', () => {
const r = helpers.validatePath('foo://127.0.0.1:6379/steal');
expect(r.ok).toBe(false);
});
test('rejects whitespace / backslash / CR/LF', () => {
expect(helpers.validatePath('foo bar').ok).toBe(false);
expect(helpers.validatePath('foo\r\nbar').ok).toBe(false);
expect(helpers.validatePath('foo\\bar').ok).toBe(false);
expect(helpers.validatePath('foo\tbar').ok).toBe(false);
});
test('accepts RFC 3986 pchar + query separators', () => {
// Real-world path sent by a browser: query string starts with `?`.
// (Fragments `#frag` are stripped by the browser before reaching
// the server — we don't need to allow them.)
const ok = helpers.validatePath('/api/v1/chat?msg=hi&x=y');
expect(ok.ok).toBe(true);
expect(ok.normalized).toBe('api/v1/chat?msg=hi&x=y');
});
test('strips multiple leading slashes idempotently', () => {
const ok = helpers.validatePath('///foo/bar');
expect(ok.ok).toBe(true);
expect(ok.normalized).toBe('foo/bar');
});
});
describe('end-to-end via /openclaw/proxy/* (DC-065 integration)', () => {
// Helper: build an express app mounted with the openclaw router and
// a docker stub that returns the provided upstream port.
function buildProxyApp(upstreamPort) {
const fakeContainer = {
Id: 'a'.repeat(64),
Image: 'ghcr.io/nousresearch/openclaw:latest',
Names: ['/openclaw-test'],
State: 'running',
Status: 'Up',
Created: 1700000000,
Labels: { 'dashcaddy.managed': 'true', 'dashcaddy.app': 'openclaw' },
Ports: [{ PrivatePort: 18792, PublicPort: upstreamPort }],
};
const app = express();
app.disable('x-powered-by'); // mirror src/app.js line 139
app.disable('etag');
app.use(express.json());
app.use((req, res, next) => {
res.ok = (data, code) => res.status(code || 200).json({ success: true, ...data });
res.errorResponse = (msg, code, extras) =>
res.status(code || 500).json({ success: false, error: msg, ...(extras || {}) });
res.notFound = (msg) => res.status(404).json({ success: false, error: msg });
res.conflict = (msg) => res.status(409).json({ success: false, error: msg });
next();
});
const router = openclawModule({
docker: {
client: {
listContainers: async () => [fakeContainer],
containerInfo: async () => ({ Config: { Env: ['OPENCLAW_GATEWAY_TOKEN=test-token'] } }),
},
},
asyncHandler: (fn) => fn,
ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
log: { info() {}, error() {}, warn() {}, debug() {} },
});
app.use('/openclaw', router);
return app;
}
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, () => {
const { port } = server.address();
resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
});
});
}
test('caps an oversized upstream response with 502 + DC-065 message', async () => {
const upstream = await spinUpstream((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
// 6 MiB single chunk — proxy caps at 5 MiB.
res.write(Buffer.alloc(6 * 1024 * 1024, 0x41));
res.end();
});
try {
const app = buildProxyApp(upstream.port);
const { server, port, close } = await listen(app);
try {
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/health`);
expect(r.status).toBe(502);
const text = await r.text();
expect(text).toMatch(/DC-065|upstream/g);
} finally {
await close();
}
} finally {
await upstream.close();
}
}, 30000);
test('forwards safe upstream headers; strips Set-Cookie / Transfer-Encoding / Content-Encoding / Location / Refresh / WWW-Authenticate', async () => {
const upstream = await spinUpstream((req, res) => {
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
// These must NOT cross the proxy to the browser:
'Transfer-Encoding': 'chunked',
'Upgrade': 'websocket',
'Set-Cookie': 'sid=steal; HttpOnly',
'Location': 'http://evil.com/steal', // DC-065 round-1
'Refresh': '0; url=http://evil.com/steal', // DC-065 round-2
'WWW-Authenticate': 'Basic realm="OpenClaw"', // DC-065 round-2
'Content-Encoding': 'gzip',
'Server': 'openclaw/1.0',
'X-Powered-By': 'openclaw',
});
res.end(JSON.stringify({ ok: true }));
});
try {
const app = buildProxyApp(upstream.port);
const { server, port, close } = await listen(app);
try {
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/status`);
expect(r.status).toBe(200);
// Node's http server may emit Connection/Keep-Alive of its own
// accord (HTTP/1.1 keep-alive defaults), so we don't gate on those.
// We DO gate on the ten upstream-shaping headers our sanitizer
// explicitly removes — see sanitizeForwardedHeaders().
for (const forbidden of [
'transfer-encoding',
'upgrade',
'set-cookie',
'location',
'refresh',
'www-authenticate',
'content-encoding',
'server',
'x-powered-by',
// content-length: Node sets it automatically when we buffer + end(),
// so we cannot test that the upstream's CL header is stripped — but
// we ARE stripping it from the forwarded headers, verified by
// sanitization unit tests above.
]) {
expect(r.headers.get(forbidden)).toBeNull();
}
expect(r.headers.get('content-type')).toMatch(/^application\/json/);
expect(r.headers.get('cache-control')).toBe('no-store');
const body = await r.json();
expect(body.ok).toBe(true);
void server;
} finally {
await close();
}
} finally {
await upstream.close();
}
}, 10000);
test('rejects path with `://` injection via 400', async () => {
// Upstream on any port — the validator must reject BEFORE we dial it.
const upstream = await spinUpstream(() => {
throw new Error('should not reach upstream on reject path');
});
try {
const app = buildProxyApp(upstream.port);
const { server, port, close } = await listen(app);
try {
// URL-decoded `foo://127.0.0.1` → forbidden char `://` → 400.
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/foo%3A%2F%2F127.0.0.1`);
expect(r.status).toBe(400);
const body = await r.json();
expect(body.success).toBe(false);
expect(body.error).toMatch(/forbidden|disallowed/i);
void server;
} finally {
await close();
}
} finally {
await upstream.close();
}
}, 10000);
});
});
@@ -34,23 +34,14 @@ jest.mock('../../src/utilities/pagination', () => ({
parsePaginationParams: jest.fn(() => null),
}));
jest.mock('../../src/utils/responses', () => {
// DC-063: services.js now imports canonical `errorResponse` (statusCode-first),
// so this mock must expose both that AND the legacy `error` alias to keep the
// existing fixture working. The canonical validator is bypassed (tests use it
// as a structured passthrough); the alias preserves call-shape for any
// remaining legacy import.
const errorResponse = jest.fn((res, statusCode, message, extra) =>
res.status(statusCode).json({ success: false, error: message, ...extra })
);
return {
success: jest.fn((res, data, statusCode = 200) =>
res.status(statusCode).json({ success: true, ...data })
),
errorResponse,
error: errorResponse, // alias used by files that import `error: errorResponse`
};
});
jest.mock('../../src/utils/responses', () => ({
success: jest.fn((res, data, statusCode = 200) => {
return res.status(statusCode).json({ success: true, ...data });
}),
error: jest.fn((res, message, statusCode = 500, extra) => {
return res.status(statusCode).json({ success: false, error: message, ...extra });
}),
}));
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
@@ -288,21 +279,6 @@ describe('Services Routes', () => {
expect(res.status).toBe(200);
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 =====
@@ -1,535 +0,0 @@
/**
* DC-074: SSRF hardening for sites.js `/site` and `/site/external`
* must reject upstream hosts that resolve to private/reserved ranges
* BEFORE they reach the Caddyfile.
*
* Bug class: an authenticated dashboard operator could call
* POST /api/v1/site {domain: "x.example.com", upstream: "10.0.0.1:80"}
* POST /api/v1/site/external {subdomain: "x", externalUrl: "http://192.168.1.5"}
* and end up with a Caddy site block that proxies PUBLIC traffic to an
* INTERNAL host. Caddy runs on DNS2 (same network as the targets), so
* the SSRF lands.
*
* Pre-fix: `/site`'s only upstream check was `^[a-z0-9.-]+:\d{1,5}$/i`,
* which accepts 192.168.1.1:80 and 169.254.169.254:80 (the AWS
* metadata IP) with no problem. `/site/external` used `validateURL`
* without `blockPrivate: true` at all.
*
* Post-fix: a new helper `validateUpstream()` in `fleet-validation.js`
* reuses the resolver+private-range checks fleet-validation already has
* for DC-068, gating Caddyfile writes behind a public-IP requirement.
* Opt-in via `SITES_ALLOW_PRIVATE_UPSTREAMS=true` for operators who
* intentionally proxy to private targets.
*
* The suite covers three layers:
* 1. Helper unit tests validateUpstream with mocked DNS / literal IPs
* 2. Route integration tests POST /site and POST /site/external
* reject each known private range, accept public IPs and hostnames
* 3. Regression pre-fix payload `10.0.0.1:80` is rejected (the
* canonical SSRF regression proof)
*/
const express = require('express');
const request = require('supertest');
const {
validateUpstream,
isPrivateOrReservedIPv4,
isPrivateOrReservedIPv6,
} = require('../../src/utilities/fleet-validation');
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const LOG = () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() });
/**
* Build a minimal Express app that mounts /api/v1/sites with stubbed
* caddy/dns/buildDomain/addServiceToConfig. The stubs record every call
* so tests can assert the route does NOT mutate the Caddyfile when it
* should reject.
*/
function createSitesApp({ log, caddyStub, buildDomainStub, dnsStub, addServiceToConfigStub } = {}) {
const app = express();
app.use(express.json({ limit: '1mb' }));
const sites = require('../../routes/sites');
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const caddy = caddyStub || {
read: async () => '# stub caddyfile\n',
modify: jest.fn(async () => ({ success: true })),
adminUrl: 'http://127.0.0.1:2019',
filePath: '/tmp/stub-Caddyfile',
};
const dns = dnsStub || {
universalCreateRecord: jest.fn(async () => true),
};
app.use('/api/v1', sites({
asyncHandler: wrap,
ok: (res, data) => res.json({ ok: true, ...data }),
successMessage: (res, msg) => res.json({ ok: true, message: msg }),
caddy,
dns,
fetchT: async () => ({ ok: true, json: async () => ({}) }),
buildDomain: buildDomainStub || ((sub) => `${sub}.example.com`),
addServiceToConfig: addServiceToConfigStub || jest.fn(async () => true),
siteConfig: { dnsServerIp: '127.0.0.1' },
log: log || LOG(),
}));
// JSON error middleware — must mirror the shape sites.js's production
// global error middleware emits so route tests can assert on it. Without
// this, Express's default error handler returns an HTML stack trace and
// res.body.error is undefined.
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
error: err.message || 'Internal Server Error',
code: err.code || null,
field: err.field || null,
});
});
return { app, caddy };
}
/** Mock dns.promises.lookup to return a specific IP for any hostname.
* Returns an array of `{address, family}` records since fleet-validation
* calls `dns.lookup(name, {all: true})`. */
function mockDnsLookup(map) {
const dns = require('dns');
const original = dns.promises.lookup;
dns.promises.lookup = async (hostname, opts) => {
for (const [pattern, ip] of Object.entries(map)) {
if (hostname === pattern || (pattern instanceof RegExp && pattern.test(hostname))) {
const family = ip.includes(':') ? 6 : 4;
return [{ address: ip, family }];
}
}
// Default: throw ENOTFOUND
const err = new Error('ENOTFOUND');
err.code = 'ENOTFOUND';
throw err;
};
return () => {
dns.promises.lookup = original;
};
}
// ---------------------------------------------------------------------------
// 1. Helper unit tests
// ---------------------------------------------------------------------------
describe('DC-074: validateUpstream (helper)', () => {
let restoreDns;
beforeEach(() => {
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
});
afterEach(() => {
if (restoreDns) restoreDns();
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
});
describe('format validation', () => {
test('rejects empty / non-string with INVALID_UPSTREAM', async () => {
expect(await validateUpstream('')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
expect(await validateUpstream(null)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
expect(await validateUpstream(undefined)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
expect(await validateUpstream(42)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
});
test('rejects missing port with INVALID_UPSTREAM', async () => {
expect(await validateUpstream('hostonly')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
});
test('rejects non-integer port with INVALID_PORT', async () => {
expect(await validateUpstream('host:abc')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
expect(await validateUpstream('host:80.5')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
});
test('rejects out-of-range port with INVALID_PORT', async () => {
expect(await validateUpstream('host:0')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
expect(await validateUpstream('host:65536')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
expect(await validateUpstream('host:99999999')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
expect(await validateUpstream('host:-1')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
});
});
describe('private IPv4 reject (literal)', () => {
const PRIVATE_V4 = [
['127.0.0.1', 'loopback'],
['127.255.255.1', 'loopback'],
['10.0.0.1', 'RFC 1918'],
['172.16.0.1', 'RFC 1918'],
['192.168.1.1', 'RFC 1918'],
['169.254.169.254', 'link-local'], // AWS IMDS
['100.64.0.1', 'CGNAT'],
['224.0.0.1', 'multicast'],
['255.255.255.255', 'broadcast'],
['0.0.0.0', 'reserved'],
];
for (const [ip, wantLabel] of PRIVATE_V4) {
test(`rejects ${ip} (${wantLabel})`, async () => {
const r = await validateUpstream(`${ip}:80`);
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
expect(r.message).toMatch(new RegExp(wantLabel, 'i'));
});
}
});
describe('private IPv6 reject (literal)', () => {
test('rejects ::1 (loopback)', async () => {
const r = await validateUpstream('[::1]:80');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV6');
});
test('rejects fe80::1 (link-local)', async () => {
const r = await validateUpstream('[fe80::1]:80');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV6');
});
test('rejects fc00::1 (ULA)', async () => {
const r = await validateUpstream('[fc00::1]:80');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV6');
});
});
describe('public IPs accepted (literal)', () => {
test('accepts 8.8.8.8', async () => {
const r = await validateUpstream('8.8.8.8:53');
expect(r.ok).toBe(true);
expect(r.host).toBe('8.8.8.8');
expect(r.port).toBe(53);
expect(r.family).toBe(4);
});
test('accepts 1.1.1.1', async () => {
const r = await validateUpstream('1.1.1.1:443');
expect(r.ok).toBe(true);
expect(r.port).toBe(443);
});
});
describe('hostname resolve', () => {
test('accepts hostname that resolves to public IP', async () => {
restoreDns = mockDnsLookup({ 'public.example.com': '8.8.8.8' });
const r = await validateUpstream('public.example.com:443');
expect(r.ok).toBe(true);
expect(r.resolvedIp).toBe('8.8.8.8');
expect(r.family).toBe(4);
});
test('rejects hostname that resolves to private IP (DNS rebinding defense)', async () => {
restoreDns = mockDnsLookup({ 'evil.example.com': '10.0.0.5' });
const r = await validateUpstream('evil.example.com:80');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
expect(r.message).toMatch(/evil\.example\.com.*10\.0\.0\.5/);
});
test('rejects hostname that fails to resolve', async () => {
// mockDnsLookup default throws ENOTFOUND
const r = await validateUpstream('does-not-exist.invalid:80');
expect(r.ok).toBe(false);
expect(r.code).toMatch(/DNS_/);
});
test('rejects hostname with invalid charset pre-DNS', async () => {
const r = await validateUpstream('host with spaces:80');
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOST');
});
});
describe('SITES_ALLOW_PRIVATE_UPSTREAMS opt-in', () => {
test('default rejects private IPs', async () => {
const r = await validateUpstream('10.0.0.1:80');
expect(r.ok).toBe(false);
});
test('opt-in accepts private literal IP', async () => {
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
const r = await validateUpstream('10.0.0.1:80');
expect(r.ok).toBe(true);
});
test('opt-in accepts private DNS-resolved host', async () => {
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
restoreDns = mockDnsLookup({ 'internal.example.com': '10.0.0.5' });
const r = await validateUpstream('internal.example.com:80');
expect(r.ok).toBe(true);
});
test('explicit allowPrivate:false overrides env opt-in (programmatic guard)', async () => {
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
const r = await validateUpstream('10.0.0.1:80', { allowPrivate: false });
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
});
});
});
// ---------------------------------------------------------------------------
// 2. Route integration tests — POST /site
// ---------------------------------------------------------------------------
describe('DC-074: POST /api/v1/site — SSRF hardening', () => {
let restoreDns;
let caddyStub;
beforeEach(() => {
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
caddyStub = {
read: async () => '# stub caddyfile\n',
modify: jest.fn(async () => ({ success: true })),
adminUrl: 'http://127.0.0.1:2019',
filePath: '/tmp/stub-Caddyfile',
};
});
afterEach(() => {
if (restoreDns) restoreDns();
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
});
const REGRESSION_CASES = [
['10.0.0.1:80', 'PRIVATE_IPV4'],
['172.16.0.1:80', 'PRIVATE_IPV4'],
['192.168.1.1:80', 'PRIVATE_IPV4'],
['127.0.0.1:80', 'PRIVATE_IPV4'],
['169.254.169.254:80', 'PRIVATE_IPV4'], // AWS IMDS
['100.64.0.1:80', 'PRIVATE_IPV4'], // CGNAT
['224.0.0.1:80', 'PRIVATE_IPV4'], // multicast
['0.0.0.0:80', 'PRIVATE_IPV4'], // reserved
['[::1]:80', 'PRIVATE_IPV6'],
['[fc00::1]:80', 'PRIVATE_IPV6'],
];
for (const [upstream, wantCode] of REGRESSION_CASES) {
test(`rejects upstream="${upstream}" with code=${wantCode}`, async () => {
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'evil.example.com', upstream });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/\[DC-074\]/);
expect(res.body.error).toMatch(/SITES_ALLOW_PRIVATE_UPSTREAMS/);
// caddy.modify() must NOT have been called (gate happens before write)
expect(caddyStub.modify).not.toHaveBeenCalled();
});
}
test('rejects DNS-resolved private IP (rebinding defense)', async () => {
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'evil.example.com', upstream: 'looks-public.example.com:80' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/10\.0\.0\.5/);
expect(caddyStub.modify).not.toHaveBeenCalled();
});
test('accepts public literal IP', async () => {
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'new.example.com', upstream: '8.8.8.8:80' });
expect(res.status).toBe(200);
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
});
test('accepts hostname resolving to public IP', async () => {
restoreDns = mockDnsLookup({ 'real.example.com': '8.8.8.8' });
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'new.example.com', upstream: 'real.example.com:80' });
expect(res.status).toBe(200);
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
});
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private literal', async () => {
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'lab.example.com', upstream: '10.0.0.1:80' });
expect(res.status).toBe(200);
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
});
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private-resolved hostname', async () => {
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
restoreDns = mockDnsLookup({ 'internal.lan': '10.0.0.5' });
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'lab.example.com', upstream: 'internal.lan:80' });
expect(res.status).toBe(200);
});
test('rejects out-of-range port without invoking private-IP check', async () => {
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'new.example.com', upstream: '8.8.8.8:99999' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/INVALID_PORT|\[DC-074\]/);
expect(caddyStub.modify).not.toHaveBeenCalled();
});
test('rejects upstream with spaces (charset) without invoking private-IP check', async () => {
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'new.example.com', upstream: 'not a host:80' });
expect(res.status).toBe(400);
expect(caddyStub.modify).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// 3. Route integration tests — POST /site/external
// ---------------------------------------------------------------------------
describe('DC-074: POST /api/v1/site/external — SSRF hardening', () => {
let restoreDns;
let caddyStub;
beforeEach(() => {
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
caddyStub = {
read: async () => '# stub caddyfile\n',
modify: jest.fn(async () => ({ success: true })),
adminUrl: 'http://127.0.0.1:2019',
filePath: '/tmp/stub-Caddyfile',
};
});
afterEach(() => {
if (restoreDns) restoreDns();
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
});
const REGRESSION_CASES = [
'http://10.0.0.1',
'http://192.168.1.1',
'http://127.0.0.1',
'http://169.254.169.254', // AWS IMDS via URL form
'http://100.64.0.1', // CGNAT — caught by validateUpstream defense-in-depth, not validateURL
'http://0.0.0.0',
'http://[::1]',
'http://[fc00::1]',
];
for (const externalUrl of REGRESSION_CASES) {
test(`rejects externalUrl="${externalUrl}"`, async () => {
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site/external')
.send({ subdomain: 'ext', externalUrl });
// 400 from validateURL OR from validateUpstream — either path closes the gate.
expect(res.status).toBe(400);
expect(caddyStub.modify).not.toHaveBeenCalled();
});
}
test('rejects DNS-resolved private IP', async () => {
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site/external')
.send({ subdomain: 'ext', externalUrl: 'http://looks-public.example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/\[DC-074\]|Private URLs/);
expect(caddyStub.modify).not.toHaveBeenCalled();
});
test('accepts externalUrl with public hostname', async () => {
restoreDns = mockDnsLookup({ 'api.example.com': '8.8.8.8' });
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site/external')
.send({ subdomain: 'ext', externalUrl: 'http://api.example.com' });
expect(res.status).toBe(200);
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
});
test('accepts externalUrl with public literal IP', async () => {
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site/external')
.send({ subdomain: 'ext', externalUrl: 'http://8.8.8.8' });
expect(res.status).toBe(200);
});
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private externalUrl', async () => {
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site/external')
.send({ subdomain: 'ext', externalUrl: 'http://10.0.0.5' });
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// 4. Regression — pre-fix payload (the canonical SSRF regression proof)
// ---------------------------------------------------------------------------
describe('DC-074: regression — pre-fix payloads are now rejected', () => {
test('the canonical SSRF payload `10.0.0.1:80` is rejected at the route layer', async () => {
const caddyStub = {
read: async () => '',
modify: jest.fn(async () => ({ success: true })),
adminUrl: 'http://127.0.0.1:2019',
filePath: '/tmp/stub-Caddyfile',
};
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site')
.send({ domain: 'evil.attacker.com', upstream: '10.0.0.1:80' });
expect(res.status).toBe(400);
// Pre-fix this payload would have been accepted, the regex happily
// matches `[a-z0-9.-]+:\d{1,5}` against `10.0.0.1:80`, and a Caddy
// site block would have been written that proxied public HTTPS
// traffic at `evil.attacker.com` to the internal 10.0.0.1:80.
expect(caddyStub.modify).not.toHaveBeenCalled();
});
test('the canonical SSRF payload `http://192.168.1.5` is rejected at the external endpoint', async () => {
const caddyStub = {
read: async () => '',
modify: jest.fn(async () => ({ success: true })),
adminUrl: 'http://127.0.0.1:2019',
filePath: '/tmp/stub-Caddyfile',
};
const { app } = createSitesApp({ caddyStub });
const res = await request(app)
.post('/api/v1/site/external')
.send({ subdomain: 'ext', externalUrl: 'http://192.168.1.5' });
expect(res.status).toBe(400);
expect(caddyStub.modify).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// 5. Sanity — fleet-validation helper exports still work as before
// ---------------------------------------------------------------------------
describe('DC-074: fleet-validation helpers still exported and unchanged behavior', () => {
test('isPrivateOrReservedIPv4 still detects the same set as before', () => {
expect(isPrivateOrReservedIPv4('10.0.0.1').isPrivate).toBe(true);
expect(isPrivateOrReservedIPv4('8.8.8.8').isPrivate).toBe(false);
});
test('isPrivateOrReservedIPv6 still detects the same set as before', () => {
expect(isPrivateOrReservedIPv6('::1').isPrivate).toBe(true);
expect(isPrivateOrReservedIPv6('2001:4860:4860::8888').isPrivate).toBe(false);
});
});
@@ -131,20 +131,6 @@ describe('routes/tailscale-admin: PUT /settings', () => {
expect(res.status).toBe(400);
});
test('400 on apiToken exceeding 256-char length cap (DC-080)', async () => {
const { app } = createApp();
const oversized = 'tskey-api-' + 'x'.repeat(300); // > 256 chars
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: oversized });
expect(res.status).toBe(400);
expect(res.body.error || res.body.message).toMatch(/exceeds maximum length/i);
});
test('400 on non-string apiToken (DC-080)', async () => {
const { app } = createApp();
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 12345 });
expect(res.status).toBe(400);
});
test('200 + saves token + writes metadata on valid token', async () => {
const fakeClient = makeFakeClient({
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
@@ -307,76 +293,6 @@ describe('routes/tailscale-admin: POST /settings/test', () => {
expect(res.body.valid).toBe(true);
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
});
test('400 on body.apiToken not starting with tskey-api- (DC-080)', async () => {
const fakeClient = makeFakeClient();
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: false }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app)
.post('/api/v1/tailscale/settings/test')
.send({ apiToken: 'arbitrary-junk' });
expect(res.status).toBe(400);
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
});
test('400 on body.apiToken exceeding length cap (DC-080)', async () => {
const fakeClient = makeFakeClient();
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: false }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const oversized = 'tskey-api-' + 'x'.repeat(300);
const res = await request(app)
.post('/api/v1/tailscale/settings/test')
.send({ apiToken: oversized });
expect(res.status).toBe(400);
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
});
test('omitting apiToken is allowed (uses stored token path) (DC-080)', async () => {
const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app)
.post('/api/v1/tailscale/settings/test')
.send({}); // no apiToken in body
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
});
describe('routes/tailscale-admin: GET /admin/devices', () => {
@@ -595,99 +511,6 @@ describe('routes/tailscale-admin: pre-auth keys', () => {
expect(res.status).toBe(400);
});
test('POST /admin/keys rejects null/123/object tags entries (DC-080)', async () => {
const fakeClient = makeFakeClient();
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
// Mixed: null, number, object — all must be rejected
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['tag:guest', null, 123, { x: 1 }] });
expect(res.status).toBe(400);
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
});
test('POST /admin/keys rejects uppercase / whitespace / CRLF in tags (DC-080)', async () => {
const fakeClient = makeFakeClient();
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['TAG:guest', 'tag:foo bar', 'tag:x\r\ninjection'] });
expect(res.status).toBe(400);
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
});
test('POST /admin/keys rejects description exceeding 120 chars (DC-080)', async () => {
const fakeClient = makeFakeClient();
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const longDesc = 'a'.repeat(200); // > 120 chars
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ description: longDesc });
expect(res.status).toBe(400);
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
});
test('POST /admin/keys accepts canonical lowercase tag: form (DC-080)', async () => {
const fakeClient = makeFakeClient({
createAuthKey: jest.fn(async (opts) => ({ id: 'k2', key: 'tskey-secret-2', ...opts })),
});
const app = express();
app.use(express.json());
const routes = require('../../routes/tailscale-admin');
const tailscaleCoord = {
loadMetadata: () => ({ configured: true }),
saveMetadata: jest.fn(),
setApiToken: jest.fn(),
getClient: jest.fn(async () => fakeClient),
hasApiToken: jest.fn(),
};
app.use('/api/v1/tailscale', routes({
tailscaleCoord, asyncHandler,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
tags: ['tag:guest-plex', 'tag:server'],
expirySeconds: 86400,
});
expect(res.status).toBe(200);
expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
tags: ['tag:guest-plex', 'tag:server'],
}));
});
test('POST /admin/keys rejects negative expirySeconds', async () => {
const fakeClient = makeFakeClient();
const app = express();
@@ -749,110 +572,4 @@ describe('routes/tailscale-admin: security boundary', () => {
await request(app).delete('/api/v1/tailscale/settings');
expect(stored.token).toBeNull();
});
});
// DC-080 direct validator unit tests (no supertest, no Express)
describe('routes/tailscale-admin: DC-080 validators (direct)', () => {
const { _validators } = require('../../routes/tailscale-admin');
const {
validateApiToken,
validateTags,
validateDescription,
TAILSCALE_TOKEN_PREFIX,
TAILSCALE_TOKEN_MAX_LEN,
DESCRIPTION_MAX_LEN,
} = _validators;
describe('validateApiToken', () => {
test('accepts canonical tskey-api-...', () => {
expect(validateApiToken('tskey-api-abc123')).toBeNull();
});
test('rejects empty', () => {
expect(validateApiToken('')).toMatch(/required/);
});
test('rejects undefined / null', () => {
expect(validateApiToken(undefined)).toMatch(/required/);
expect(validateApiToken(null)).toMatch(/required/);
});
test('rejects non-string (number, object, array)', () => {
expect(validateApiToken(123)).toMatch(/must be a string/);
expect(validateApiToken({})).toMatch(/must be a string/);
expect(validateApiToken(['x'])).toMatch(/must be a string/);
});
test('rejects wrong prefix', () => {
expect(validateApiToken('not-a-token')).toMatch(/must start with/);
});
test('accepts exactly at length cap', () => {
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length);
expect(validateApiToken(token)).toBeNull();
});
test('rejects 1 over length cap', () => {
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length + 1);
expect(validateApiToken(token)).toMatch(/exceeds maximum length/);
});
});
describe('validateTags', () => {
test('accepts undefined / null (optional)', () => {
expect(validateTags(undefined)).toBeNull();
expect(validateTags(null)).toBeNull();
});
test('rejects non-array', () => {
expect(validateTags('tag:foo')).toMatch(/must be an array/);
expect(validateTags({})).toMatch(/must be an array/);
});
test('rejects entries that are not strings', () => {
expect(validateTags(['tag:a', null])).toMatch(/tags\[1\]/);
expect(validateTags(['tag:a', 123])).toMatch(/tags\[1\]/);
expect(validateTags(['tag:a', {}])).toMatch(/tags\[1\]/);
});
test('rejects uppercase / whitespace / CRLF', () => {
expect(validateTags(['TAG:foo'])).toMatch(/tags\[0\]/);
expect(validateTags(['tag:foo bar'])).toMatch(/tags\[0\]/);
expect(validateTags(['tag:foo\r\nbar'])).toMatch(/tags\[0\]/);
});
test('rejects entries starting with non-alnum (no leading colon)', () => {
expect(validateTags([':foo'])).toMatch(/tags\[0\]/);
});
test('rejects bare "tag:" with empty name (Tailscale spec violation) (DC-080 round-2)', () => {
expect(validateTags(['tag:'])).toMatch(/tags\[0\]/);
});
test('rejects colon-only chars after tag: prefix (DC-080 round-2)', () => {
expect(validateTags(['tag:::'])).toMatch(/tags\[0\]/);
expect(validateTags(['tag:---'])).toMatch(/tags\[0\]/);
});
test('accepts canonical tag:server form', () => {
expect(validateTags(['tag:server'])).toBeNull();
expect(validateTags(['tag:guest-plex', 'tag:server'])).toBeNull();
});
test('rejects empty array entry', () => {
expect(validateTags(['tag:a', ''])).toMatch(/tags\[1\]/);
});
});
describe('validateDescription', () => {
test('accepts undefined / null', () => {
expect(validateDescription(undefined)).toBeNull();
expect(validateDescription(null)).toBeNull();
});
test('rejects non-string', () => {
expect(validateDescription(123)).toMatch(/must be a string/);
});
test('rejects over 120 chars', () => {
const long = 'a'.repeat(DESCRIPTION_MAX_LEN + 1);
expect(validateDescription(long)).toMatch(/exceeds maximum length/);
});
test('accepts at the cap', () => {
const exact = 'a'.repeat(DESCRIPTION_MAX_LEN);
expect(validateDescription(exact)).toBeNull();
});
});
test('exports surface stays in sync with constants used inside validators', () => {
// Guard against drift: if a future refactor renames a constant, this fails
expect(TAILSCALE_TOKEN_PREFIX).toBe('tskey-api-');
expect(typeof TAILSCALE_TOKEN_MAX_LEN).toBe('number');
expect(typeof DESCRIPTION_MAX_LEN).toBe('number');
});
});
});
@@ -50,17 +50,6 @@ describe('TOTP session cookie scope', () => {
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', () => {
const session = buildSession();
const headers = {};
@@ -1,483 +0,0 @@
/**
* DC-083 -- Public share endpoint input hardening.
*
* The two CSRF-exempt public endpoints (POST /share/:token/subscribe +
* POST /share/:token/redeem-tailscale) accept untrusted body fields. The
* pre-fix code had three coupled bugs:
*
* 1. `email.includes('@')` accepted `@`, `a@`, `<script>@x.c`, and 10MB
* strings as "valid email" -- and the field was never even used after
* validation (the subscribe endpoint discarded it).
* 2. `typeof deviceId === 'string'` accepted arbitrary strings of any
* length, including CR/LF/NUL -- which fed straight into the Tailscale
* auth-key description string and the on-disk shares.json.
* 3. No rate-limit; the general limiter (1000/15min) was too generous for
* unauthenticated state-mutating endpoints.
*
* Fix: charset/length/control-char-bounded validators at the route layer
* AND at the store layer (defense-in-depth), plus a dedicated
* SHARE_PUBLIC rate-limit (30/15min) on the public endpoints.
*
* Coverage:
* - subscribe email: rejects bare @, missing TLD, oversized, CR/LF, shell
* metachars, control chars; accepts normal addresses; accepts OMITTED
* email (backwards-compatible with the original behavior).
* - subscribe email propagates to share-store subscriberEmails (capped 8).
* - redeem-tailscale deviceId: rejects CR/LF/NUL, oversized, empty,
* spaces, brackets, quotes; accepts Tailscale-style base64url+hphens;
* accepts OMITTED deviceId (treated as 'unknown').
* - Sanitized usedBy is what flows into the on-disk shares.json.
* - Rate-limit fires after the configured budget per IP.
* - Store-level defense: bypassing the route (direct store call) still
* rejects invalid inputs.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const { createShareStore } = require('../src/security/share-store');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-dc083-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
function _buildApp({ shareStore } = {}) {
const app = express();
app.use(express.json());
// No req.user injection -- the public endpoints must work without auth.
const shareRoutes = require('../routes/share');
app.use(shareRoutes({
shareStore,
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
servicesStateManager: { get: async () => null, read: async () => [] },
servicesFile: null,
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
log: { info() {}, warn() {}, error() {} },
}));
app.use((err, _req, res, _next) => {
if (err && err.statusCode) {
return res.status(err.statusCode).json({
success: false,
error: err.message,
code: err.code,
});
}
return res.status(500).json({ success: false, error: err && err.message });
});
return app;
}
// --------- Subscribe endpoint -- email validation ---------------------------------------------------------------------------------------
describe('DC-083: subscribe email validation', () => {
let dir, shareStore;
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('accepts omitted email (backwards-compatible)', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app).post(`/share/${issued.token}/subscribe`).send({});
expect(res.status).toBe(200);
expect(res.body.data.count).toBe(1);
});
test('accepts a well-formed email', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: 'subscriber@example.com' });
expect(res.status).toBe(200);
expect(res.body.data.count).toBe(1);
});
test('lowercases the email on capture', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: 'Subscriber@Example.COM' });
expect(res.status).toBe(200);
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
const id = Object.keys(raw.shares)[0];
expect(raw.shares[id].subscriberEmails).toEqual(['subscriber@example.com']);
});
test('rejects bare @', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: '@' });
expect(res.status).toBe(400);
});
test('rejects missing local-part', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: '@example.com' });
expect(res.status).toBe(400);
});
test('rejects missing TLD', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: 'user@localhost' });
expect(res.status).toBe(400);
});
test('rejects single-char TLD', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: 'user@example.c' });
expect(res.status).toBe(400);
});
test('rejects CR/LF in email (CRLF-injection defense)', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: 'a@b.com\r\nX-Injected: yes' });
expect(res.status).toBe(400);
});
test('rejects NUL in email', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: 'a@b.com\x00hack' });
expect(res.status).toBe(400);
});
test('rejects oversized email (>254 chars)', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const longLocal = 'a'.repeat(250) + '@example.com';
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: longLocal });
expect(res.status).toBe(400);
});
test('rejects XSS-shape email', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: '<script>@x.com' });
expect(res.status).toBe(400);
});
test('rejects non-string email', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: 42 });
expect(res.status).toBe(400);
});
test('keeps subscriberEmails capped to 8 entries', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
for (let i = 0; i < 12; i++) {
await request(app)
.post(`/share/${issued.token}/subscribe`)
.send({ email: `user${i}@example.com` });
}
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
const id = Object.keys(raw.shares)[0];
expect(raw.shares[id].subscriberEmails).toHaveLength(8);
// FIFO cap -- the first 4 got dropped, latest 8 remain.
expect(raw.shares[id].subscriberEmails[0]).toBe('user4@example.com');
expect(raw.shares[id].subscriberEmails[7]).toBe('user11@example.com');
});
test('omitted email does not write subscriberEmails', async () => {
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
const app = _buildApp({ shareStore });
await request(app).post(`/share/${issued.token}/subscribe`).send({});
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
const id = Object.keys(raw.shares)[0];
expect(raw.shares[id].subscriberEmails).toBeUndefined();
});
});
// --------- Redeem-tailscale endpoint -- deviceId validation ---------------------------------------------------------
describe('DC-083: redeem-tailscale deviceId validation', () => {
let dir, shareStore;
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('accepts Tailscale-style base64url ID', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'nodekey-abc123-def456' });
expect(res.status).toBe(200);
expect(res.body.data.redeemed).toBe(true);
});
test('accepts OMITTED deviceId (treated as "unknown")', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({});
expect(res.status).toBe(200);
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
const id = Object.keys(raw.shares)[0];
expect(raw.shares[id].usedBy).toBe('unknown');
});
test('rejects CR/LF in deviceId (CRLF-injection defense)', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'nodekey\r\nX-Injected: yes' });
expect(res.status).toBe(400);
});
test('rejects NUL in deviceId', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'nodekey\x00hack' });
expect(res.status).toBe(400);
});
test('rejects oversized deviceId (>128 chars)', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const long = 'a'.repeat(200);
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: long });
expect(res.status).toBe(400);
});
test('rejects empty string deviceId', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: '' });
expect(res.status).toBe(400);
});
test('rejects whitespace in deviceId', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'node key 1' });
expect(res.status).toBe(400);
});
test('rejects shell metachars in deviceId', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'nodekey; rm -rf /' });
expect(res.status).toBe(400);
});
test('rejects non-string deviceId', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: { evil: true } });
expect(res.status).toBe(400);
});
test('sanitized usedBy flows into the on-disk shares.json', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'node-abc.def-123' });
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
const id = Object.keys(raw.shares)[0];
expect(raw.shares[id].usedBy).toBe('node-abc.def-123');
});
test('rejection does NOT mark the share used', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore });
const bad = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'node with spaces' });
expect(bad.status).toBe(400);
// A FOLLOW-UP valid redeem should still succeed.
const ok = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'node-clean' });
expect(ok.status).toBe(200);
});
});
// --------- Store-layer defense-in-depth (bypass the route, hit the store) ------------
describe('DC-083: store-layer defense-in-depth', () => {
let dir, store;
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
afterEach(() => _cleanup(dir));
test('recordPublicSubscribe rejects CRLF in email', async () => {
const issued = await store.issuePublic({ serviceId: 'svc' });
const r = await store.recordPublicSubscribe(issued.token, { email: 'a@b.com\r\nX: 1' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_email');
});
test('recordPublicSubscribe rejects oversized email', async () => {
const issued = await store.issuePublic({ serviceId: 'svc' });
const r = await store.recordPublicSubscribe(issued.token, { email: 'a'.repeat(300) + '@x.com' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_email');
});
test('recordTailscaleUse rejects CRLF in deviceId', async () => {
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'node\r\nhack' });
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_device_id');
});
test('recordTailscaleUse rejects oversized deviceId', async () => {
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'a'.repeat(200) });
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_device_id');
});
test('recordTailscaleUse accepts null deviceId (defaults to "unknown")', async () => {
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
const r = await store.recordTailscaleUse(issued.token, { deviceId: null });
expect(r.ok).toBe(true);
expect(r.share.usedBy).toBe('unknown');
});
test('recordTailscaleUse accepts omitted deviceId (defaults to "unknown")', async () => {
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
const r = await store.recordTailscaleUse(issued.token, {});
expect(r.ok).toBe(true);
expect(r.share.usedBy).toBe('unknown');
});
test('recordPublicSubscribe accepts omitted email (backwards-compatible)', async () => {
const issued = await store.issuePublic({ serviceId: 'svc' });
const r = await store.recordPublicSubscribe(issued.token);
expect(r.ok).toBe(true);
});
test('recordPublicSubscribe accepts null email (backwards-compatible)', async () => {
const issued = await store.issuePublic({ serviceId: 'svc' });
const r = await store.recordPublicSubscribe(issued.token, { email: null });
expect(r.ok).toBe(true);
});
});
// --------- Rate-limit guard ------------------------------------------------------------------------------------------------------------------------------------------------------
describe('DC-083: SHARE_PUBLIC rate-limit', () => {
// We can't easily trigger the rate-limit in a unit test because the
// default 30/15min is high. Instead, verify the constant is wired and
// that the limiter is mounted on the public endpoints (the test env
// skips the limiter, so we just confirm the constants).
test('RATE_LIMITS.SHARE_PUBLIC is bounded tighter than GENERAL', () => {
const { RATE_LIMITS } = require('../src/utilities/constants');
expect(RATE_LIMITS.SHARE_PUBLIC).toBeDefined();
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThan(RATE_LIMITS.GENERAL.max);
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThanOrEqual(30);
expect(RATE_LIMITS.SHARE_PUBLIC.windowMs).toBe(15 * 60 * 1000);
});
test('route module loads without throwing when express-rate-limit is wired', () => {
// Smoke test: the route factory must succeed with the limiter attached.
const dir = _tmpDir();
try {
const shareStore = createShareStore({ dataDir: dir });
const app = _buildApp({ shareStore });
// _buildApp would have thrown if the route factory threw.
expect(typeof app).toBe('function');
} finally {
_cleanup(dir);
}
});
test('sharePublicLimiter is mounted on /preview (route stack contains limiter)', () => {
// Verify the limiter middleware is actually wired into /preview's route
// stack. The route uses express.Router().use(path, ...mw, handler) so we
// can inspect the stack via the router's internal `stack` array.
const dir = _tmpDir();
try {
const shareStore = createShareStore({ dataDir: dir });
const router = require('../routes/share')({
shareStore,
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
servicesStateManager: { get: async () => null, read: async () => [] },
servicesFile: null,
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
log: { info() {}, warn() {}, error() {} },
});
const previewStack = router.stack.find(
(layer) => layer.route && layer.route.path === '/share/:token/preview'
);
expect(previewStack).toBeDefined();
// The route handler should be preceded by at least one middleware
// layer (the limiter). route.stack contains the per-route middleware.
// In express, .route.stack has the route-local middleware + handler.
// The limiter is mounted at the router level (router.use pattern), so
// it's actually a separate layer in router.stack. Look for any layer
// that has a regex/path matching /share/:token.
const limiterLayer = router.stack.find(
(layer) => layer.regexp && layer.regexp.test && layer.regexp.test('/share/abc/preview')
);
expect(limiterLayer).toBeDefined();
} finally {
_cleanup(dir);
}
});
});
describe('DC-083: positive smoke tests (legitimate inputs)', () => {
test('validates user+tag@sub.domain.io (RFC 5322 plus addressing)', () => {
const { validatePublicEmail } = require('../src/security/share-store');
const v = validatePublicEmail('user+tag@sub.domain.io');
expect(v).toEqual({ ok: true, email: 'user+tag@sub.domain.io' });
});
test('validates a typical Tailscale node ID as deviceId', () => {
const { validatePublicDeviceId } = require('../src/security/share-store');
// Tailscale node IDs look like "nodekey:abcdef0123456789" or just hex
const v = validatePublicDeviceId('nodekey:abcdef0123456789');
expect(v).toEqual({ ok: true, deviceId: 'nodekey:abcdef0123456789' });
});
});
+1 -24
View File
@@ -378,35 +378,12 @@ describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
expect(r2.body.error).toMatch(/already_used/);
});
test('rejects missing deviceId — DC-083 accepts omitted, treats as "unknown"', async () => {
test('rejects missing deviceId', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore, noAdmin: true });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({});
// DC-083: omitted deviceId is now accepted; the store defaults usedBy
// to 'unknown'. The pre-fix route layer required deviceId be present;
// the new behavior matches the store's defensive default and is
// safer for partially-malformed forward_auth calls from Caddy.
expect(res.status).toBe(200);
expect(res.body.data.redeemed).toBe(true);
});
test('rejects invalid deviceId (control chars / oversized)', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore, noAdmin: true });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: 'node\r\nhack' });
expect(res.status).toBe(400);
});
test('rejects empty deviceId', async () => {
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
const app = _buildApp({ shareStore, noAdmin: true });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.send({ deviceId: '' });
expect(res.status).toBe(400);
});
});
@@ -1,29 +1,15 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const request = require('supertest');
const createSsoRouter = require('../routes/auth/sso-gate');
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' } = {}) {
function createApp({ redeem = true } = {}) {
const app = express();
const session = {
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
setCookieHostOnly: jest.fn((res) => {
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
}),
isValid: jest.fn().mockReturnValue(valid),
createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
isValid: jest.fn().mockReturnValue(true),
};
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
@@ -35,15 +21,14 @@ function createApp({ redeem = true, valid = true, storedCredentials = {}, dashbo
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
getAppSession: jest.fn(),
appSessionCache: new Map(),
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
credentialManager: { retrieve: jest.fn() },
fetchT: jest.fn(),
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
getServiceById: jest.fn(),
licenseManager: {
hasFeature: jest.fn().mockReturnValue(true),
requirePremium: jest.fn(() => (_req, _res, next) => next()),
},
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
siteConfig: { dashboardHost },
});
app.use('/api/v1', router);
return { app, session };
@@ -59,7 +44,7 @@ describe('cross-host SSO exchange redirect', () => {
expect(res.status).toBe(303);
expect(res.headers.location).toBe('/settings?tab=network#dns');
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
});
test.each([
@@ -97,105 +82,3 @@ describe('cross-host SSO exchange redirect', () => {
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);
});
});
@@ -1,228 +0,0 @@
/**
* DC-082: Update-manager image-name parsing for docker-compose prefixed names.
*
* The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to
* `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub.
* Compose-prefixed names (single hyphen, no slash, lowercase) need to
* split on the FIRST hyphen to recover `<project>/<service>` that's
* the actual upstream namespace for a compose-prefixed image.
*
* The fix also adds a "no upstream registry image, skip cleanly" path
* for when the authed GET 401s against a compose-prefixed name (the
* compose-prefixed image is built locally and not published to Docker
* Hub). That should log as info, not error.
*/
const updateManager = require('../src/managers/update-manager');
describe('DC-082 update-manager / compose-prefixed image names', () => {
let um = updateManager; // module exports the singleton instance
describe('_composeProjectToRepo', () => {
test('splits dashcaddy-dashcaddy-api on the first hyphen', () => {
expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api');
});
test('splits myproject-myservice on the first hyphen', () => {
expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice');
});
test('splits multi-hyphen names on the FIRST hyphen only', () => {
// "myproj-grandchild-service" -> "myproj/grandchild-service"
// (first hyphen is the project/service boundary; later hyphens are
// part of the service name like docker-compose's `web-cache`).
expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service');
});
test('returns null for slash-namespaced names (handled by other path)', () => {
expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null);
expect(um._composeProjectToRepo('library/nginx')).toBe(null);
expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null);
});
test('returns null for Docker Official Image names (no hyphen)', () => {
expect(um._composeProjectToRepo('nginx')).toBe(null);
expect(um._composeProjectToRepo('alpine')).toBe(null);
expect(um._composeProjectToRepo('node')).toBe(null);
});
test('returns null for empty / malformed input', () => {
expect(um._composeProjectToRepo('')).toBe(null);
expect(um._composeProjectToRepo(null)).toBe(null);
expect(um._composeProjectToRepo(undefined)).toBe(null);
expect(um._composeProjectToRepo(123)).toBe(null);
expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen
expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen
// The regex tolerates mixed-case via the /i flag for defensiveness
// even though Docker Compose names are typically lowercase — the
// important shape constraints are the letter/digit/underscore/hyphen
// charset and the non-empty two-part split.
});
test('accepts names with underscores and digits (compose allows)', () => {
expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service');
expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2');
});
test('rejects names with chars compose never produces', () => {
// dot/colon/slash should never pass — they're either already-namespaced
// or invalid in a Docker Compose service name.
expect(um._composeProjectToRepo('foo:bar')).toBe(null);
expect(um._composeProjectToRepo('foo.bar')).toBe(null);
expect(um._composeProjectToRepo('foo/bar')).toBe(null);
});
});
describe('_isNotPublishedError', () => {
test('returns true for HTTP 401 + compose-prefixed remainder', () => {
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true);
});
test('returns false for HTTP 401 with non-compose-prefixed remainder', () => {
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
expect(um._isNotPublishedError(err, 'nginx')).toBe(false);
expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false);
expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false);
});
test('returns false for non-401 errors', () => {
const err = new Error('network timeout after 10s');
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false);
});
test('returns false for malformed error or remainder', () => {
expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false);
expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false);
expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false);
expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false);
expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false);
});
});
describe('getLatestImageDigest (mocked fetchWithReliability)', () => {
let originalFetch;
let originalFetchAuth;
let originalFetchRetry;
beforeEach(() => {
originalFetch = um.fetchWithReliability.bind(um);
originalFetchAuth = um.fetchAuthToken.bind(um);
});
test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => {
const calls = [];
um.fetchWithReliability = async (opts) => {
calls.push(opts);
// Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token
// (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub).
if (calls.length === 1) {
return {
statusCode: 401,
headers: {
'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"',
},
body: '',
};
}
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' };
};
um.fetchAuthToken = async () => 'fake-token';
const { log } = require('../src/utils/logging');
const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {});
const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {});
const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest');
expect(result).toBe(null);
// First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest
// NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest
const firstPath = calls[0].path;
expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest');
expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api');
// The 401 after auth should produce an INFO log about "no upstream"
// NOT an error log.
const infoMsgs = infoSpy.mock.calls.map((c) => c[1]);
expect(infoMsgs).toContain('No upstream registry image — skipping update check');
const errorMsgs = errorSpy.mock.calls.map((c) => c[1]);
expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth');
infoSpy.mockRestore();
errorSpy.mockRestore();
});
test('official image (nginx) still probes library/nginx', async () => {
const calls = [];
um.fetchWithReliability = async (opts) => {
calls.push(opts);
if (calls.length === 1) {
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' };
}
return { statusCode: 200, headers: {}, body: '' };
};
const result = await um.getLatestImageDigest('nginx:latest');
expect(result).toBe('sha256:abc123');
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
});
test('library/nginx (explicit) probes library/nginx', async () => {
const calls = [];
um.fetchWithReliability = async (opts) => {
calls.push(opts);
if (calls.length === 1) {
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' };
}
return { statusCode: 200, headers: {}, body: '' };
};
const result = await um.getLatestImageDigest('library/nginx:latest');
expect(result).toBe('sha256:abc');
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
});
test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => {
const calls = [];
um.fetchWithReliability = async (opts) => {
calls.push(opts);
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' };
};
const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest');
expect(result).toBe('sha256:ghcr');
expect(calls[0].hostname).toBe('ghcr.io');
expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest');
});
test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => {
let callCount = 0;
um.fetchWithReliability = async (opts) => {
callCount += 1;
if (callCount === 1) {
return {
statusCode: 401,
headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' },
body: '',
};
}
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' };
};
um.fetchAuthToken = async () => 'fake-token';
const result = await um.getLatestImageDigest('myproj-myservice:latest');
expect(result).toBe(null);
// Probe targets the correct namespace (myproj/myservice), not library/.
const firstCall = await (async () => {
let p;
um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; };
await um.getLatestImageDigest('myproj-myservice:latest');
return p;
})();
expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest');
});
afterEach(() => {
um.fetchWithReliability = originalFetch;
um.fetchAuthToken = originalFetchAuth;
});
});
});
+3 -246
View File
@@ -125,239 +125,6 @@ describe('UpdateManager — Docker image update lifecycle', () => {
});
});
// ─── DC-078: registry digest probe reliability hardening ──────────────────
// Verifies that getLatestImageDigest / getDockerHubDigest / getGhcrDigest /
// fetchWithReliability all apply the IPv4-only + timeout + transient-retry
// policy. Without these guards, the per-hour checkForUpdates() loop on DNS2
// surfaces AggregateError [ETIMEDOUT] in error.log because the container's
// /etc/resolv.conf returns AAAA records from Technitium whose IPv6 path to
// public registries (Docker Hub, ghcr.io) is intermittently unreachable.
describe('DC-078 registry reliability', () => {
// Use real timers — fetchWithReliability's retry uses setTimeout for
// backoff, which jest's fake timers would block indefinitely.
beforeEach(() => {
jest.useRealTimers();
});
afterEach(() => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
});
it('_httpsRequestOnce sets family: 4 and timeout on the request options', async () => {
let capturedOptions = null;
const req = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};
https.request.mockImplementation((options, cb) => {
capturedOptions = options;
// Return a 200 immediately so the promise resolves cleanly.
const res = {
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return req;
});
await updateManager._httpsRequestOnce({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' },
maxBodyBytes: 65536,
});
expect(capturedOptions).not.toBeNull();
expect(capturedOptions.family).toBe(4);
expect(capturedOptions.timeout).toBeGreaterThan(0);
expect(capturedOptions.method).toBe('GET');
});
it('fetchWithReliability retries on transient ETIMEDOUT and eventually succeeds', async () => {
let attempts = 0;
https.request.mockImplementation((options, cb) => {
attempts += 1;
if (attempts === 1) {
// First attempt: emit ETIMEDOUT via the request 'error' event
const reqErr = new Error('request timeout');
reqErr.code = 'ETIMEDOUT';
const req = {
on: jest.fn((event, handler) => {
if (event === 'error') setImmediate(() => handler(reqErr));
}),
end: jest.fn(),
destroy: jest.fn(),
};
return req;
}
// Second attempt: 200 OK with a digest header
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:abc123def456' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
});
expect(attempts).toBe(2);
expect(result.statusCode).toBe(200);
expect(result.headers['docker-content-digest']).toBe('sha256:abc123def456');
});
it('fetchWithReliability does NOT retry on non-transient HTTP errors', async () => {
let attempts = 0;
https.request.mockImplementation((options, cb) => {
attempts += 1;
const res = {
statusCode: 500,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
});
expect(attempts).toBe(1);
expect(result.statusCode).toBe(500);
});
it('fetchWithReliability retries up to REGISTRY_MAX_RETRIES then throws', async () => {
let attempts = 0;
https.request.mockImplementation(() => {
attempts += 1;
const reqErr = new Error('connect ETIMEDOUT');
reqErr.code = 'ETIMEDOUT';
const req = {
on: jest.fn((event, handler) => {
if (event === 'error') setImmediate(() => handler(reqErr));
}),
end: jest.fn(),
destroy: jest.fn(),
};
return req;
});
await expect(updateManager.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
})).rejects.toMatchObject({ code: 'ETIMEDOUT' });
// 1 initial attempt + REGISTRY_MAX_RETRIES retries
expect(attempts).toBe(1 + 1);
});
it('getDockerHubDigest returns digest on 200', async () => {
https.request.mockImplementation((options, cb) => {
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:hubdigest9999' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
expect(digest).toBe('sha256:hubdigest9999');
});
it('getDockerHubDigest acquires bearer token on 401 then returns digest', async () => {
let calls = 0;
https.request.mockImplementation((options, cb) => {
calls += 1;
if (calls === 1) {
// First call to registry-1.docker.io returns 401 with WWW-Authenticate
const res = {
statusCode: 401,
headers: {
'www-authenticate': 'Bearer realm="https://auth.example.com/token",service="registry.docker.io",scope="repository:library/nginx:pull"',
},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
} else if (calls === 2) {
// Second call: auth.example.com returns the token JSON
const res = {
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') handler(Buffer.from(JSON.stringify({ token: 'jwt-token-xyz' })));
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
} else {
// Third call: registry-1.docker.io with Bearer header returns the digest
expect(options.headers['Authorization']).toBe('Bearer jwt-token-xyz');
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:autheddigest7777' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
}
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
expect(digest).toBe('sha256:autheddigest7777');
expect(calls).toBe(3);
});
it('getGhcrDigest returns digest on 200', async () => {
https.request.mockImplementation((options, cb) => {
expect(options.hostname).toBe('ghcr.io');
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:ghcrdigest1234' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const digest = await updateManager.getGhcrDigest('ghcr.io/some/repo', 'latest');
expect(digest).toBe('sha256:ghcrdigest1234');
});
it('getLatestImageDigest returns null on transient errors after retries (registry unavailable)', async () => {
// Simulate a totally-down registry: every attempt fails with ETIMEDOUT.
// After REGISTRY_MAX_RETRIES the error propagates to getLatestImageDigest's
// catch arm, which logs and returns null (matches old behavior).
https.request.mockImplementation(() => {
const reqErr = new Error('connect ETIMEDOUT');
reqErr.code = 'ETIMEDOUT';
const req = {
on: jest.fn((event, handler) => {
if (event === 'error') setImmediate(() => handler(reqErr));
}),
end: jest.fn(),
destroy: jest.fn(),
};
return req;
});
const digest = await updateManager.getLatestImageDigest('nginx:latest');
expect(digest).toBeNull();
});
});
describe('parseAuthHeader', () => {
it('parses Docker Hub Bearer auth header', () => {
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
@@ -714,9 +481,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
setImmediate(() => cb({
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:fromregistry' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
})
on: jest.fn()
}));
return { on: jest.fn(), end: jest.fn() };
});
@@ -730,9 +495,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
setImmediate(() => cb({
statusCode: 401,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
})
on: jest.fn()
}));
return { on: jest.fn(), end: jest.fn() };
});
@@ -741,9 +504,6 @@ describe('UpdateManager — Docker image update lifecycle', () => {
});
it('rejects on https request error', async () => {
// ECONNREFUSED is in REGISTRY_TRANSIENT_ERROR_CODES, so this would retry.
// Use a non-transient code (or no code) for the test to propagate.
jest.useRealTimers();
https.request.mockImplementation(() => {
const req = { on: jest.fn(), end: jest.fn() };
// Trigger error event asynchronously
@@ -756,7 +516,6 @@ describe('UpdateManager — Docker image update lifecycle', () => {
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
.rejects.toThrow('connection refused');
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
});
it('normalizes library/ prefix for official images', async () => {
@@ -766,9 +525,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
setImmediate(() => cb({
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:digest' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
})
on: jest.fn()
}));
return { on: jest.fn(), end: jest.fn() };
});
@@ -1,435 +0,0 @@
/**
* DC-068: Fleet hostname SSRF hardening
*
* Tests for the fleet validation helpers (isPrivateOrReservedIPv4/IPv6,
* isValidHostnameSyntax, validateFleetHost) and resolveAndCheckAddress.
* Covers:
* - IPv4 private/reserved range detection (loopback, link-local, RFC 1918,
* CGNAT, multicast, broadcast, documentation)
* - IPv6 private/reserved range detection (loopback, link-local, ULA,
* multicast, IPv4-mapped)
* - RFC 1123 hostname syntax check
* - Port bounds (1..65535), port 22 rejection, missing/invalid port
* - Tag validation (max 20, each 1..50, no control chars)
* - Name validation (1..100, no control chars)
* - End-to-end validateFleetHost for all rejection and acceptance paths
* - resolveAndCheckAddress: literal IP paths, DNS-resolution success path
* with mocked dns.lookup, DNS-resolution failure path, and the
* allow-private opt-in
*
* The DNS path is unit-tested by replacing `dns.promises.lookup` on the
* module instance with a mock that returns a fake A record.
*/
const {
validateFleetHost,
resolveAndCheckAddress,
isPrivateOrReservedIPv4,
isPrivateOrReservedIPv6,
isValidHostnameSyntax,
} = require('../src/utilities/fleet-validation');
describe('DC-068: isPrivateOrReservedIPv4', () => {
const cases = [
// [ip, expectedIsPrivate, expectedLabelSubstring-or-null]
['127.0.0.1', true, 'loopback'],
['127.255.255.1', true, 'loopback'],
['169.254.0.1', true, 'link-local'],
['169.254.169.254',true, 'link-local'], // AWS/GCP/Azure metadata
['10.0.0.1', true, 'RFC 1918'],
['172.16.0.1', true, 'RFC 1918'],
['172.31.255.1', true, 'RFC 1918'],
['172.32.0.1', false, null],
['192.168.1.1', true, 'RFC 1918'],
['100.64.0.1', true, 'CGNAT'],
['100.127.255.1', true, 'CGNAT'],
['100.128.0.1', false, null],
['224.0.0.1', true, 'multicast'],
['239.255.255.255',true, 'multicast'],
['255.255.255.255',true, 'broadcast'],
['0.0.0.0', true, 'reserved'],
['192.0.2.1', true, 'TEST-NET-1'],
['198.51.100.1', true, 'TEST-NET-2'],
['203.0.113.1', true, 'TEST-NET-3'],
['198.18.0.1', true, 'benchmark'],
['198.19.255.1', true, 'benchmark'],
['240.0.0.1', true, 'reserved'],
['8.8.8.8', false, null],
['1.1.1.1', false, null],
['93.184.216.34', false, null],
];
for (const [ip, wantPrivate, wantLabel] of cases) {
it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
const r = isPrivateOrReservedIPv4(ip);
expect(r.isPrivate).toBe(wantPrivate);
if (wantLabel) expect(r.label).toContain(wantLabel);
else expect(r.label).toBeNull();
});
}
it('returns isPrivate=false for non-strings', () => {
expect(isPrivateOrReservedIPv4(null).isPrivate).toBe(false);
expect(isPrivateOrReservedIPv4(undefined).isPrivate).toBe(false);
expect(isPrivateOrReservedIPv4(42).isPrivate).toBe(false);
});
it('returns isPrivate=false for malformed IPv4', () => {
expect(isPrivateOrReservedIPv4('1.2.3').isPrivate).toBe(false);
expect(isPrivateOrReservedIPv4('1.2.3.4.5').isPrivate).toBe(false);
expect(isPrivateOrReservedIPv4('256.0.0.0').isPrivate).toBe(false);
expect(isPrivateOrReservedIPv4('1.2.3.999').isPrivate).toBe(false);
});
});
describe('DC-068: isPrivateOrReservedIPv6', () => {
const cases = [
['::1', true, 'IPv6 loopback'],
['::', true, 'IPv6 unspecified'],
['fe80::1', true, 'link-local'],
['feb0::1', true, 'link-local'],
['fc00::1', true, 'unique-local'],
['fd00::1', true, 'unique-local'],
['ff00::1', true, 'multicast'],
['::ffff:127.0.0.1',true, 'IPv4-mapped'],
['::ffff:8.8.8.8',false, null],
['2001:4860:4860::8888',false, null], // Google IPv6
['2606:4700:4700::1111',false, null], // Cloudflare IPv6
];
for (const [ip, wantPrivate, wantLabel] of cases) {
it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
const r = isPrivateOrReservedIPv6(ip);
expect(r.isPrivate).toBe(wantPrivate);
if (wantLabel) expect(r.label).toContain(wantLabel);
else expect(r.label).toBeNull();
});
}
});
describe('DC-068: isValidHostnameSyntax', () => {
const accept = [
'example.com',
'sub.example.com',
'a-b.example.com',
'host1',
'a',
'a'.repeat(63) + '.com', // 63-char label is the max
'very-long-host-name-with-many-segments.sub.example.com',
'host-with-trailing-dot.', // trailing dot is legal
'EXAMPLE.com', // case-insensitive
'123.example.com', // numeric labels allowed
];
for (const h of accept) {
it(`accepts "${h}"`, () => {
expect(isValidHostnameSyntax(h)).toBe(true);
});
}
const reject = [
'',
'.',
'..',
'a..b', // empty label
'-a.com', // label can't start with hyphen
'a-.com', // label can't end with hyphen
'a b.com', // space not allowed
'_underscore.com', // underscore not allowed (strict RFC 1123)
'a/b.com', // slash not allowed
'a$b.com', // dollar not allowed
'a.com/' + 'x'.repeat(255), // 255-char label exceeds 63
'host.with.' + 'a-63-chars-'.repeat(8) + '.com', // total > 253 chars
];
for (const h of reject) {
it(`rejects "${h}"`, () => {
expect(isValidHostnameSyntax(h)).toBe(false);
});
}
});
describe('DC-068: validateFleetHost', () => {
const valid = (extra = {}) => ({
name: 'Test Host',
hostname: 'fleet.example.com',
port: 3001,
tags: ['prod'],
...extra,
});
it('accepts a clean public-DNS host', () => {
const r = validateFleetHost(valid());
expect(r.ok).toBe(true);
expect(r.normalized.name).toBe('Test Host');
expect(r.normalized.hostname).toBe('fleet.example.com');
expect(r.normalized.port).toBe(3001);
});
it('normalises hostname to lowercase and trims name', () => {
const r = validateFleetHost({ ...valid(), name: ' Spaced ', hostname: 'FLEET.Example.COM' });
expect(r.ok).toBe(true);
expect(r.normalized.name).toBe('Spaced');
expect(r.normalized.hostname).toBe('fleet.example.com');
});
it('accepts a public IPv4 literal', () => {
const r = validateFleetHost({ ...valid(), hostname: '8.8.8.8' });
expect(r.ok).toBe(true);
});
it('accepts a public IPv6 literal', () => {
const r = validateFleetHost({ ...valid(), hostname: '2001:4860:4860::8888' });
expect(r.ok).toBe(true);
});
// ── Name rejection paths ──
it('rejects missing name with INVALID_NAME', () => {
const r = validateFleetHost({ ...valid(), name: undefined });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_NAME');
});
it('rejects empty name', () => {
const r = validateFleetHost({ ...valid(), name: '' });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_NAME');
});
it('rejects name >100 chars', () => {
const r = validateFleetHost({ ...valid(), name: 'x'.repeat(101) });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_NAME');
});
it('rejects name with control characters', () => {
expect(validateFleetHost({ ...valid(), name: 'evil\nname' }).code).toBe('INVALID_NAME');
expect(validateFleetHost({ ...valid(), name: 'evil\rname' }).code).toBe('INVALID_NAME');
expect(validateFleetHost({ ...valid(), name: 'evil\x00name' }).code).toBe('INVALID_NAME');
});
// ── Hostname rejection paths ──
it('rejects missing hostname with INVALID_HOSTNAME', () => {
const r = validateFleetHost({ ...valid(), hostname: undefined });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOSTNAME');
});
it('rejects empty hostname', () => {
const r = validateFleetHost({ ...valid(), hostname: '' });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOSTNAME');
});
it('rejects garbage hostname', () => {
const r = validateFleetHost({ ...valid(), hostname: 'not a valid host' });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOSTNAME');
});
it('rejects hostname with scheme prefix (url injection)', () => {
const r = validateFleetHost({ ...valid(), hostname: 'http://evil.com' });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOSTNAME');
});
it('rejects hostname with @ (URL-credential injection)', () => {
const r = validateFleetHost({ ...valid(), hostname: 'evil@host.com' });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOSTNAME');
});
// ── IPv4 private-range rejection paths (literal input) ──
const privateV4 = [
['127.0.0.1', 'loopback'],
['169.254.169.254', 'link-local'],
['10.0.0.1', 'RFC 1918'],
['192.168.1.1', 'RFC 1918'],
['100.64.0.1', 'CGNAT'], // Tailscale
['255.255.255.255', 'broadcast'],
['0.0.0.0', 'reserved'],
];
for (const [ip, label] of privateV4) {
it(`rejects private IPv4 literal ${ip} (${label})`, () => {
const r = validateFleetHost({ ...valid(), hostname: ip });
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
expect(r.message).toContain(label);
});
}
// ── IPv6 private-range rejection paths ──
const privateV6 = [
['::1', 'IPv6 loopback'],
['fe80::1', 'IPv6 link-local'],
['fc00::1', 'IPv6 unique-local'],
['fd00::abcd', 'IPv6 unique-local'],
['::ffff:127.0.0.1', 'IPv4-mapped'], // contains BOTH colon AND dot
];
for (const [ip, label] of privateV6) {
it(`rejects private IPv6 literal ${ip} (${label})`, () => {
const r = validateFleetHost({ ...valid(), hostname: ip });
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV6');
expect(r.message).toContain(label);
});
}
// ── Port rejection paths ──
it('rejects port < 1', () => {
const r = validateFleetHost({ ...valid(), port: 0 });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_PORT');
});
it('rejects port > 65535', () => {
const r = validateFleetHost({ ...valid(), port: 65536 });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_PORT');
});
it('rejects non-integer port', () => {
expect(validateFleetHost({ ...valid(), port: 'three' }).code).toBe('INVALID_PORT');
expect(validateFleetHost({ ...valid(), port: 3001.5 }).code).toBe('INVALID_PORT');
});
it('rejects port 22 (SSH collision)', () => {
const r = validateFleetHost({ ...valid(), port: 22 });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_PORT');
expect(r.message).toMatch(/22.*reserved|reserved.*22/);
});
it('accepts port 1, 1023, 1024, 65535', () => {
expect(validateFleetHost({ ...valid(), port: 1 }).ok).toBe(true);
expect(validateFleetHost({ ...valid(), port: 1023 }).ok).toBe(true);
expect(validateFleetHost({ ...valid(), port: 1024 }).ok).toBe(true);
expect(validateFleetHost({ ...valid(), port: 65535 }).ok).toBe(true);
});
// ── Tag rejection paths ──
it('rejects non-array tags', () => {
const r = validateFleetHost({ ...valid(), tags: 'prod' });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_TAGS');
});
it('rejects > 20 tags', () => {
const r = validateFleetHost({ ...valid(), tags: Array.from({ length: 21 }, (_, i) => `t${i}`) });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_TAGS');
});
it('rejects empty-string tag', () => {
const r = validateFleetHost({ ...valid(), tags: ['valid', ''] });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_TAGS');
});
it('rejects tag > 50 chars', () => {
const r = validateFleetHost({ ...valid(), tags: ['x'.repeat(51)] });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_TAGS');
});
it('rejects tag with control characters', () => {
const r = validateFleetHost({ ...valid(), tags: ['good', 'bad\ntag'] });
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_TAGS');
});
it('accepts tags omitted (defaults to [])', () => {
const r = validateFleetHost({ name: 'h', hostname: 'fleet.example.com', port: 3001 });
expect(r.ok).toBe(true);
expect(r.normalized.tags).toEqual([]);
});
});
describe('DC-068: resolveAndCheckAddress', () => {
// The DNS code path uses `dns.promises.lookup` directly; for literal IPs
// and IPv6, no DNS call is made. The DNS-name code path is exercised by
// mocking dns.promises.lookup.
it('accepts a public IPv4 literal without DNS lookup', async () => {
const r = await resolveAndCheckAddress('8.8.8.8');
expect(r.ok).toBe(true);
expect(r.ip).toBe('8.8.8.8');
expect(r.family).toBe(4);
});
it('accepts a public IPv6 literal', async () => {
const r = await resolveAndCheckAddress('2001:4860:4860::8888');
expect(r.ok).toBe(true);
expect(r.ip).toBe('2001:4860:4860::8888');
expect(r.family).toBe(6);
});
it('rejects a private IPv4 literal with opt-out', async () => {
const r = await resolveAndCheckAddress('127.0.0.1');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
});
it('accepts a private IPv4 literal when allowPrivate=true', async () => {
const r = await resolveAndCheckAddress('192.168.1.1', { allowPrivate: true });
expect(r.ok).toBe(true);
expect(r.ip).toBe('192.168.1.1');
});
it('rejects a Tailscale (CGNAT) IPv4 literal', async () => {
const r = await resolveAndCheckAddress('100.64.0.1');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
});
it('rejects the AWS metadata endpoint 169.254.169.254', async () => {
const r = await resolveAndCheckAddress('169.254.169.254');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
expect(r.message).toMatch(/link-local|metadata/i);
});
it('rejects IPv4-mapped IPv6 loopback', async () => {
const r = await resolveAndCheckAddress('::ffff:127.0.0.1');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV6');
});
it('rejects garbage hostnames without DNS lookup', async () => {
const r = await resolveAndCheckAddress('not a host');
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOSTNAME');
});
it('rejects empty hostname', async () => {
const r = await resolveAndCheckAddress('');
expect(r.ok).toBe(false);
expect(r.code).toBe('INVALID_HOSTNAME');
});
it('rejects DNS name that does not resolve', async () => {
// We use a reserved TLD (.invalid) which RFC 6761 guarantees will not
// resolve in production DNS — so the test is hermetic without mocking.
const r = await resolveAndCheckAddress('does-not-resolve.invalid');
expect(r.ok).toBe(false);
expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(r.code);
});
it('rejects DNS name that resolves to a private IP', async () => {
// Heremetic test: dns.promises.lookup is patched on the module instance.
const dns = require('dns');
const originalLookup = dns.promises.lookup;
dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
try {
const r = await resolveAndCheckAddress('attacker.example.com');
expect(r.ok).toBe(false);
expect(r.code).toBe('PRIVATE_IPV4');
} finally {
dns.promises.lookup = originalLookup;
}
});
it('accepts DNS name that resolves to a public IP', async () => {
const dns = require('dns');
const originalLookup = dns.promises.lookup;
dns.promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
try {
const r = await resolveAndCheckAddress('public.example.com');
expect(r.ok).toBe(true);
expect(r.ip).toBe('93.184.216.34');
expect(r.family).toBe(4);
} finally {
dns.promises.lookup = originalLookup;
}
});
it('skips private check when allowPrivate=true even for DNS-resolved address', async () => {
const dns = require('dns');
const originalLookup = dns.promises.lookup;
dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
try {
const r = await resolveAndCheckAddress('tailnet.example.com', { allowPrivate: true });
expect(r.ok).toBe(true);
expect(r.ip).toBe('10.0.0.5');
} finally {
dns.promises.lookup = originalLookup;
}
});
});
@@ -1,241 +0,0 @@
/**
* Caddy admin API IPv6-origin allowlist tests DC-069
*
* Regression for the live 403 spam observed on DNS2 after DC-051 was shipped:
*
* `{"error":"client is not allowed to access from origin ''","status_code":403}`
*
* from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=::1, hitting
* `/config/apps/http/servers/srv0/listen` from various ports with bursts of
* 5-10 requests every ~30s while some on-host Node caller (e.g. a future
* status/api/caddy-api.js process) probes Caddy admin via `localhost:2019`.
*
* Root cause: DC-051 added `origins http://localhost:2019 http://127.0.0.1:2019
* http://172.17.0.1:2019 http://0.0.0.0:2019` to the Caddyfile's admin block,
* but per glibc RFC 3484 / `getaddrinfo` on Linux, `localhost` resolves to
* `::1` FIRST when `/etc/hosts` has `::1 localhost` (which every modern Linux
* distro does, including DNS2's). When the Node caller does
* `http.get('http://localhost:2019/...')`, undici's dns.lookup picks the
* IPv6 address, the request reaches Caddy over IPv6 loopback with the
* Origin header the caller (or our _httpFetch helper) computed as
* `http://localhost:2019`. Caddy's enforce_origin allowlist exact-matches
* Origin strings against the configured list and `http://localhost:2019`
* `http://[::1]:2019`, so the request is rejected with the empty-Origin-
* is-403 path (because Caddy's documented behavior is: an EMPTY Origin and
* a non-allowlisted Origin both fall through to 403 "client is not allowed
* to access from origin ''").
*
* The fix has 3 pieces:
*
* 1. Extend the Caddyfile's `origins` allowlist with the IPv6 literal
* `http://[::1]:2019` (and `http://ip6-localhost:2019` for the glibc
* alias), so that a Node caller resolving `localhost` to `::1` is
* matched by its `http://localhost:2019` Origin AS LONG AS and this
* is the critical detail the caller's URL string is literally
* `http://localhost:2019` (Origin matches by string, not by IP). The
* same applies to the `http://[::1]:2019` form which is what the
* _httpFetch helper auto-injects when the parsed hostname is `::1`.
*
* 2. Mirror the fix into `dashcaddy-installer/templates/Caddyfile.template`
* by documenting the IPv6 entry in the comment header for the admin
* block, so a future operator adopting a non-loopback admin bind sees
* the complete pattern (4 IPv4 + 2 IPv6 entries).
*
* 3. Extend the DC-051 `utils-http-caddy-admin-origin.test.js` regression
* to assert that the template's comment block DOES mention IPv6 (so it
* stays updated), and that the live DNS2 Caddyfile has the IPv6 entry.
* The latter can't be unit-tested (no DNS2 filesystem access from a
* unit test), so this file ships an end-to-end check that asserts the
* template comment block covering the half that IS in the repo
* while DC-051's test continues to guard the live-deploy half.
*
* Threat model verified: the IPv6 loopback [::1] is the SAME trust zone as
* 127.0.0.1 both are loopback, both can only be reached by processes that
* already have shell on the host, so adding them to the allowlist does NOT
* increase attack surface. Tailscale IPs and the docker bridge IP are
* unchanged (http://100.121.150.22:2019 stays out — only loopback allowed).
*/
const path = require('path');
const fs = require('fs');
// Sentinel prefix used to mark template literals while we strip comments.
// Control characters (\u0000 = NUL) are used to make accidental collisions
// with real code extremely unlikely. Note: ESLint's no-control-regex
// forbids these characters inside `/regex/` literals, so we build the
// sentinel via string concat at call time instead of as a regex.
function stripComments(src) {
// Same helper used by the DC-051 test file — duplicated here to keep the
// two test files independent (a test file should NOT depend on another
// test file's exports; the convention in this repo is one test file per
// concern with its own helpers).
const NUL = String.fromCharCode(0);
const templates = [];
let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
const idx = templates.length;
templates.push(match);
return NUL + 'TPL' + idx + NUL;
});
protectedSrc = protectedSrc
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
// Restore template literals using a non-regex split — eslint friendly.
const out = [];
let i = 0;
while (i < protectedSrc.length) {
const start = protectedSrc.indexOf(NUL + 'TPL', i);
if (start < 0) { out.push(protectedSrc.slice(i)); break; }
out.push(protectedSrc.slice(i, start));
const mid = start + 4;
const end = protectedSrc.indexOf(NUL, mid);
if (end < 0) { out.push(protectedSrc.slice(start)); break; }
out.push(templates[+protectedSrc.slice(mid, end)]);
i = end + 1;
}
return out.join('');
}
describe('Caddy admin IPv6 origin allowlist (DC-069)', () => {
test('Caddyfile template comment mentions IPv6 localhost ([::1]) for non-loopback admin', () => {
// The template currently ships `admin localhost:2019` (loopback bind,
// no enforce_origin needed), but operators following the documented
// DNS2-style non-loopback bind need to know the IPv6 entry is part
// of the allowlist. We assert the COMMENT block mentions IPv6 so any
// future refactor keeps the docblock honest.
const tmplPath = path.join(__dirname, '../../dashcaddy-installer/templates/Caddyfile.template');
if (!fs.existsSync(tmplPath)) {
console.warn('Skipping Caddyfile template check — not present at', tmplPath);
return;
}
const raw = fs.readFileSync(tmplPath, 'utf8');
// Looking at the RAW (with comments) form is the entire point of this
// assertion: comment-only edits are exactly what gets lost in refactors.
expect(raw).toMatch(/\[::1\]|::1|ip6-localhost|IPv6|ipv6/);
});
test('helper sanity: stripComments preserves template literals with // inside', () => {
// Internal regression: the stripComments helper has a known subtle
// behavior — it must NOT eat the `//` that occurs in URLs inside
// template literals. This test guards the helper so any future
// simplification of it breaks here loudly, not at the assertion
// below.
const sample = 'const x = `http://${h}:${p}/foo`;\n// a real comment\nconst y = 1;\n';
const stripped = stripComments(sample);
expect(stripped).toContain('`http://${h}:${p}/foo`');
expect(stripped).not.toContain('// a real comment');
});
test('end-to-end probe on IPv6 loopback [::1]:2019 with matching Origin succeeds', async () => {
// The actual bug: when a Node caller hits Caddy via `[::1]:2019`, the
// Origin header it computes from the parsed URL is
// `http://[::1]:2019`. Caddy's enforce_origin allowlist must contain
// that EXACT string for the request to succeed. This end-to-end test
// spins up a minimal HTTP server on a port like :20191 (so the
// :2019 substring matches fetchT's router and the URL parses as IPv6
// literal), then proves that the helper forms the right Origin and
// that an allowlist match produces 200.
//
// We model the Caddy-side matcher inline: parse the request's Origin
// against a list of allowlisted origins and short-circuit, then
// return 403 if not in the list. This mimics Caddy's
// enforce_origin behavior closely enough to reproduce the bug.
//
// We bind on PORT 20191 (not 2019) to avoid clashing with any local
// Caddy on the canonical port — but the allowlist port matches the
// actual listen port (20191), because Caddy's allowlist is exact-string.
// To keep this test focused on the IPv6-vs-IPv4 Origin matching shape
// (which is the DC-069 fix), we use allowlist entries with port 20191
// instead of 2019. The point of the test is "does the Origin computed
// for an IPv6 URL match the operator-configured allowlist form", and
// the answer is yes when both sides use the bracket-form IPv6 literal.
const http = require('http');
const allowlist = [
'http://127.0.0.1:20191',
// IPv6 — what DC-069 ADDS:
'http://[::1]:20191',
];
let capturedHeaders = null;
let enforcedStatus = null;
const server = http.createServer((req, res) => {
capturedHeaders = req.headers;
const origin = req.headers.origin;
if (!origin || !allowlist.includes(origin)) {
enforcedStatus = 403;
res.writeHead(403);
res.end(`client is not allowed to access from origin "${origin}" (allowlist did not match)`);
return;
}
enforcedStatus = 200;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('["::"]');
});
await new Promise((resolve, reject) => {
server.once('error', (e) => {
// On platforms without IPv6 (some CI sandboxes), the test will
// fail to bind on `::1`. That's acceptable — DNS2 has IPv6.
reject(e);
});
// Listen on IPv6 loopback so the URL routes over IPv6.
server.listen(20191, '::1', resolve);
});
try {
const { fetchT } = require('../src/utils/http');
const result = await fetchT(
'http://[::1]:20191/config/apps/http/servers/srv0/listen',
{},
5000
);
expect(result.status).toBe(200);
expect(enforcedStatus).toBe(200);
expect(capturedHeaders.origin).toBe('http://[::1]:20191');
// No sec-fetch-mode (raw http.request, no browser semantics)
expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
} finally {
await new Promise((r) => server.close(r));
}
});
test('end-to-end probe on IPv6 loopback WITHOUT IPv6 origin in allowlist returns 403', async () => {
// The bug, reproduced without the fix: same setup as above but with
// an allowlist missing the IPv6 entry → 403. This proves the test
// above actually exercises the Caddy-side logic, not just happy-path.
const http = require('http');
const allowlistMISSING = [
'http://127.0.0.1:20192',
// IPv6 entries INTENTIONALLY absent — this is the pre-fix state.
];
let enforcedStatus = null;
const server = http.createServer((req, res) => {
const origin = req.headers.origin;
if (!origin || !allowlistMISSING.includes(origin)) {
enforcedStatus = 403;
res.writeHead(403);
res.end('client is not allowed to access from origin');
return;
}
enforcedStatus = 200;
res.writeHead(200);
res.end('ok');
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(20192, '::1', resolve);
});
try {
const { fetchT } = require('../src/utils/http');
const result = await fetchT(
'http://[::1]:20192/config/apps/http/servers/srv0/listen',
{},
5000
);
// Even though fetchT's request SUCCEEDS at the TCP level, the
// mocked Caddy returns 403. The bug is in the allowlist.
expect(result.status).toBe(403);
expect(enforcedStatus).toBe(403);
} finally {
await new Promise((r) => server.close(r));
}
});
});
@@ -1,215 +0,0 @@
/**
* Caddy admin API CSRF Origin-header tests DC-051
*
* Verifies:
* - _httpFetch (fetchT's :2019 raw http branch) injects `Origin: http://<host>:<port>`
* for any Caddy admin URL, satisfying Caddy's `enforce_origin` CSRF check
* that activates on non-loopback admin binds (e.g. `admin 0.0.0.0:2019`).
* - Caller-provided Origin via opts.headers WINS over the auto-injected
* default (so future proxies / tests can override).
* - fetchT routes :2019 URLs through _httpFetch (raw http.request) and
* leaves HTTPS URLs on Node's undici fetch (for self-signed cert support).
* - The /config/apps/http/servers/srv0/listen health probe that the readiness
* handler emits against http://localhost:2019 includes the Origin header.
*
* Regression for the live 403 spam observed on DNS2 (Caddy log:
* `{"error":"client is not allowed to access from origin ''","status_code":403}`
* from User-Agent:node + Sec-Fetch-Mode:cors at remote_port 5xxxx, repeated
* every ~10s while the readiness workflow probes Caddy admin). The fix is
* the Origin header injection here + the `origins` directive in the
* Caddyfile's admin block on DNS2 — both are required for Caddy's CSRF
* check to accept same-origin admin calls.
*/
// Capture the http.request call shape without spinning up a real server.
// We do this by reading the http.js source and exporting a probe function
// that the test calls directly — this avoids brittle mock plumbing while
// still proving the Origin header is constructed correctly.
//
// Strategy: the test imports a small wrapper that exposes the request
// construction step from _httpFetch in isolation, then asserts on the
// returned options.
const path = require('path');
const fs = require('fs');
// Strip JS comments so docblock prose doesn't false-positive on regex
// patterns that look for code (e.g. `origins`, `enforce_origin`).
// IMPORTANT: do not strip `//` inside template literals — those are
// URL/comment sequences like `http://${parsed.hostname}:${parsed.port}`.
// We do this in two passes: (1) protect template-literal contents by
// replacing them with placeholders, (2) strip comments, (3) restore
// the placeholders.
function stripComments(src) {
// Pass 1: replace template literals (backtick-delimited) with sentinels.
const templates = [];
let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
const idx = templates.length;
templates.push(match);
return `\u0000TPL${idx}\u0000`;
});
// Pass 2: strip block + line comments from the now-comment-safe string.
protectedSrc = protectedSrc
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/(^|[^:])\/\/.*$/gm, '$1'); // line comments, leaving URLs alone
// Pass 3: restore template literals.
return protectedSrc.replace(/\u0000TPL(\d+)\u0000/g, (_, idx) => templates[+idx]);
}
const { fetchT } = require('../src/utils/http');
describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', () => {
test('http.js _httpFetch computes Origin from parsed URL host+port', () => {
// Read the source file and verify the Origin line is constructed from
// the parsed URL's hostname+port, matching what the readiness probe needs.
const code = stripComments(fs.readFileSync(
path.join(__dirname, '../src/utils/http.js'),
'utf8'
));
// 1. The default origin is built from the parsed URL
expect(code).toMatch(/const defaultOrigin\s*=\s*`\$\{parsed\.protocol\}\/\/\$\{parsed\.hostname\}:\$\{parsed\.port\s*\|\|\s*2019\}`/);
// 2. The Origin header is set, with caller opts.headers spread after
// (so caller wins on duplicate keys)
expect(code).toMatch(/headers:\s*{\s*Origin:\s*defaultOrigin,\s*\.\.\.opts\.headers,/);
// 3. The router still routes :2019 to _httpFetch (raw http.request)
expect(code).toMatch(/if\s*\(url\.includes\(':2019'\)\)/);
// 4. Comments explain the CSRF rationale (regression-proofing).
// We check the RAW (with comments) source so this catches accidental
// removal of the rationale docblock too.
const raw = fs.readFileSync(
path.join(__dirname, '../src/utils/http.js'),
'utf8'
);
expect(raw).toMatch(/enforce_origin/);
expect(raw).toMatch(/origins/);
});
test('all :2019 call sites use fetchT (not raw fetch)', () => {
// Every Caddy admin API call in the API code should go through fetchT,
// not bare fetch — fetchT routes :2019 through _httpFetch which now
// injects Origin. A new call site using bare fetch would skip the
// CSRF fix and re-introduce the 403 loop.
const apiRoot = path.join(__dirname, '..');
const offenders = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
const p = path.join(dir, entry.name);
if (entry.isDirectory()) walk(p);
else if (entry.name.endsWith('.js')) {
const text = stripComments(fs.readFileSync(p, 'utf8'));
// Find every `fetch(` call and check whether the SAME call contains
// a :2019 URL — if so, it should be `fetchT(` instead.
const matches = text.match(/await\s+fetch\(([^)]*)\)/g) || [];
for (const m of matches) {
if (/:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(m)) {
offenders.push(`${p}: ${m.slice(0, 100)}`);
break;
}
}
}
}
}
walk(apiRoot);
expect(offenders).toEqual([]);
});
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
const raw = fs.readFileSync(
path.join(__dirname, '../src/app.js'),
'utf8'
);
// The probe URL is the one that was 403-looping every 10s in prod.
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
// 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
// line / call site — the call must be `fetchT(...)`, not `await fetch(...)`.
// (We look backward because the URL sits inside the call's argument list,
// so the call site comes before the URL token.)
const idx = raw.indexOf('srv0/listen');
const around = raw.substr(Math.max(0, idx - 400), 800);
expect(around).toMatch(/fetchT\(/);
expect(around).not.toMatch(/await fetch\(/);
});
test('end-to-end: fetchT sends Origin header to a real HTTP server on :2019', async () => {
// Spin up a minimal HTTP server on a port that LOOKS like :2019 from
// fetchT's router perspective. We use port :20190 (contains ':2019'
// substring so url.includes(':2019') is true → routes through _httpFetch)
// to avoid clashing with any local Caddy on the canonical :2019.
const http = require('http');
let capturedHeaders = null;
const server = http.createServer((req, res) => {
capturedHeaders = req.headers;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('["::"]');
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(20190, '127.0.0.1', resolve);
});
try {
// fetchT routes this URL through _httpFetch because it includes
// ':2019' as a substring. _httpFetch computes Origin from the
// parsed URL — parsed.port is '20190' here, so Origin is
// http://127.0.0.1:20190.
const result = await fetchT(
'http://127.0.0.1:20190/config/apps/http/servers/srv0/listen',
{},
5000
);
expect(result.status).toBe(200);
expect(capturedHeaders.origin).toBe('http://127.0.0.1:20190');
// raw http doesn't add User-Agent by default
expect(capturedHeaders['user-agent']).toBeUndefined();
// critical: no Sec-Fetch-Mode: cors (that's what triggers Caddy's CSRF)
expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
} finally {
await new Promise((r) => server.close(r));
}
});
test('Caddyfile template documents the origins directive for non-loopback admin bind', () => {
// The HIGH-severity fix from GLM review: the live /etc/caddy/Caddyfile
// is operator-managed (via caddy-apply, NOT in this repo), so this
// test guards the only Caddyfile that IS in the repo — the installer
// template — so any future operator using `admin 0.0.0.0:2019` (like
// DNS2 does for the docker bridge to reach it) sees the same shape
// and isn't surprised by the 403 loop. If a future change adopts
// non-loopback admin in the template, this test demands the `origins`
// directive alongside it.
const tmplPath = path.join(__dirname, '../dashcaddy-installer/templates/Caddyfile.template');
const exists = fs.existsSync(tmplPath);
if (!exists) {
// Template absent (maybe removed in a refactor) — skip with explicit note
console.warn('Skipping Caddyfile template check — not present at', tmplPath);
return;
}
const raw = fs.readFileSync(tmplPath, 'utf8');
// Strip comments to look at the actual config shape.
const code = stripComments(raw);
const adminBlock = code.match(/admin\s+([^{\s]+)(?:\s+\{([^}]*)\})?/);
if (!adminBlock) {
// No admin block configured at all — operator default; nothing to check.
return;
}
const listen = adminBlock[1];
const isLoopback = listen === '127.0.0.1:2019' || listen === 'localhost:2019' || listen === '::1:2019';
const inner = adminBlock[2] || '';
if (!isLoopback) {
// Non-loopback bind — the `origins` directive is REQUIRED to prevent
// the 403 loop we just fixed. This assertion will fail if someone
// changes the template to non-loopback without adding origins.
expect(inner).toMatch(/origins\s/);
} else {
// Loopback bind — Caddy allows loopback origins implicitly, so the
// `origins` directive is unnecessary. We just verify the template
// shape is consistent (admin bind + optional inner block).
expect(listen).toMatch(/:2019/);
}
});
});
@@ -1,209 +0,0 @@
/**
* Tests for AggregateError / .cause-chain diagnostic surfacing in
* src/utils/logging.js writeErrorLog().
*
* Bug fixed: writeErrorLog previously emitted `error.message` alone.
* AggregateError's `.message` is "" by spec, so a real aggregate (e.g.
* `await Promise.any([fetch(...), fetch(...)])` or a multi-A DNS lookup
* that times out) ended up in error.log as a single empty line:
*
* [2026-08-18T06:49:03.345Z] [ERR] update:
* context: {"imageName":"ipfs/kubo:latest"}
*
* Operators couldn't tell why the check failed. This file asserts the
* fixed behavior:
*
* - AggregateError emits a diagnostic block listing each sub-error's
* .code/.message.
* - Regular Error no spurious diagnostic block.
* - Plain Error with `.code` (e.g. EPIPE) head now shows
* `Error [EPIPE]: write EPIPE` (regression: `code` used to be dropped).
* - Error wrapping another Error via `.cause` lists the cause.
* - AggregateError with mixed sub-errors (some Aggregate, some plain)
* recurses correctly without losing any message.
* - Empty error.message is replaced with the error name so a bare
* AggregateError still renders something readable.
*
* log.error signature on this codebase: error(ctx, err, req?, extra?)
* where extra is the JSON tail (and req is the Express req if any).
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
// Important: set LOG_DIR / ERROR_LOG_FILE BEFORE requiring logging.js so
// the per-test temp file is used as the log target.
const tmpDir = fs.realpathSync ? require('fs').realpathSync(os.tmpdir()) : os.tmpdir();
const TMP_LOG = path.join(tmpDir, `dashcaddy-error-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
process.env.LOG_DIR = tmpDir;
process.env.ERROR_LOG_FILE = TMP_LOG;
process.env.AUDIT_LOG_FILE = path.join(tmpDir, 'unused-audit.json');
const { log } = require('../src/utils/logging');
async function readTail(n = 1) {
const raw = await fs.readFile(TMP_LOG, 'utf8').catch(() => '');
const sep = '\u2500'.repeat(72);
const entries = raw.split(sep).map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean);
return entries.slice(-n);
}
describe('writeErrorLog() — AggregateError + .cause diagnostics', () => {
afterAll(async () => {
try { await fs.unlink(TMP_LOG); } catch (_) {}
});
beforeEach(async () => {
try { await fs.unlink(TMP_LOG); } catch (_) {}
});
test('plain Error: head contains name + message + stack', async () => {
await log.error('plain', new Error('boom'), null, { requestId: 'r1' });
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] plain: Error: boom/);
expect(entry).not.toMatch(/diagnostic:/); // no spurious diagnostic block
expect(entry).toMatch(/\n {4}at /); // stack preserved (lowercase `at` from V8)
expect(entry).toMatch(/context: \{.*requestId.*"r1".*\}/);
});
test('plain Error with .code renders the code in the head (regression fix)', async () => {
const e = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
await log.error('stream', e);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] stream: Error \[EPIPE\]: write EPIPE/);
expect(entry).not.toMatch(/diagnostic:/);
});
test('custom Error subclass name is preserved in the head', async () => {
class WidgetError extends Error {
constructor(msg) { super(msg); this.name = 'WidgetError'; }
}
await log.error('sub', new WidgetError('blew up'));
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] sub: WidgetError: blew up/);
});
test('empty error.message falls back to the bare error.name (defensive)', async () => {
const empty = new Error('');
await log.error('empty', empty);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] empty: Error$/m);
});
test('AggregateError with sub-errors emits a diagnostic block listing each cause', async () => {
// Realistic shape: registry-1.docker.io multi-A lookup timeout returning
// an AggregateError of ECONNREFUSED / Timeout / EAI_AGAIN sub-errors.
const agg = new AggregateError(
[
Object.assign(new Error('connect ECONNREFUSED 157.240.20.50:443'), { code: 'ECONNREFUSED' }),
Object.assign(new Error('connect ETIMEDOUT 157.240.21.50:443'), { code: 'ETIMEDOUT' }),
Object.assign(new Error('getaddrinfo EAI_AGAIN registry-1.docker.io'), { code: 'EAI_AGAIN' }),
],
''
);
await log.error('update', agg, null, { imageName: 'ipfs/kubo:latest' });
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] update: AggregateError/);
expect(entry).toMatch(/diagnostic:/);
expect(entry).toMatch(/cause #1:/);
expect(entry).toMatch(/cause #2:/);
expect(entry).toMatch(/cause #3:/);
expect(entry).toMatch(/Error \[ECONNREFUSED\]: connect ECONNREFUSED 157\.240\.20\.50:443/);
expect(entry).toMatch(/Error \[ETIMEDOUT\]: connect ETIMEDOUT 157\.240\.21\.50:443/);
expect(entry).toMatch(/Error \[EAI_AGAIN\]: getaddrinfo EAI_AGAIN registry-1\.docker\.io/);
expect(entry).toMatch(/context: \{.*imageName.*"ipfs\/kubo:latest".*\}/);
// No double header for AggregateError (we suppress the empty head line).
expect(entry).not.toMatch(/diagnostic: AggregateError/);
});
test('Error with .cause emits a nested diagnostic block', async () => {
const inner = new Error('TLS handshake failed');
const outer = new Error('fetch failed', { cause: inner });
await log.error('net', outer);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] net: Error: fetch failed/);
expect(entry).toMatch(/cause:/);
expect(entry).toMatch(/Error: TLS handshake failed/);
});
test('nested AggregateError (sub-error is itself an Aggregate) recurses', async () => {
const inner = new AggregateError([new Error('inner-A'), new Error('inner-B')], '');
const outer = new AggregateError([new Error('outer-X'), inner], '');
await log.error('rec', outer);
const [entry] = await readTail();
expect(entry).toMatch(/\[ERR\] rec: AggregateError/);
expect(entry).toMatch(/cause #1:[\s\S]*Error: outer-X/);
// inner is itself an Aggregate, so its child errors surface as "cause #N":
expect(entry).toMatch(/inner-A/);
expect(entry).toMatch(/inner-B/);
});
test('separator is appended after each entry (file-format invariant)', async () => {
await log.error('sep', new Error('one'));
await log.error('sep', new Error('two'));
const raw = await fs.readFile(TMP_LOG, 'utf8');
const sep = '\u2500'.repeat(72);
// Count separator occurrences without reserved regex chars tripping us up.
const re = new RegExp(sep.split('').map(c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')).join(''), 'g');
const occurrences = (raw.match(re) || []).length;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
test('req field is still emitted when the calling site passes a request', async () => {
const req = { method: 'POST', path: '/api/v1/widgets', ip: '10.0.0.5', get: () => 'curl/8', id: 'r-42' };
await log.error('withreq', new Error('widget blew up'), req);
const [entry] = await readTail();
expect(entry).toMatch(/request: POST \/api\/v1\/widgets \| ip: 10\.0\.0\.5 \| ua: curl\/8 \| id: r-42/);
});
test('extra context JSON is still emitted after stack (regression)', async () => {
await log.error('ctx', new Error('payload'), null, { operation: 'rotate', tenantId: 7 });
const [entry] = await readTail();
expect(entry).toMatch(/context: \{"operation":"rotate","tenantId":7\}/);
});
// Polish-grade hardening (per GLM round-1 B+ findings): cycle guard + depth cap.
test('circular .cause references do not infinite-loop (cycle guard)', async () => {
const a = new Error('top');
const b = new Error('middle');
const c = new Error('bottom');
// c.cause = b would be normal; force a CYCLE by linking back to a.
b.cause = a;
a.cause = c;
c.cause = a; // cycle: a <-> a
await expect(log.error('cycle', a, null)).resolves.not.toThrow();
const [entry] = await readTail();
expect(entry).toMatch(/top/);
expect(entry).toMatch(/cycle: same Error instance seen earlier/);
});
test('excessively deep .cause chains are truncated, not crashed (depth cap)', async () => {
// Build a chain 50 deep ending in 'level-50' at the deepest; each layer
// wraps the previous via .cause. log.error is called with the deepest
// (outer) Error.
let cur = new Error('level-1');
for (let i = 2; i <= 50; i++) {
const parent = new Error(`level-${i}`);
parent.cause = cur;
cur = parent;
}
await expect(log.error('deep', cur)).resolves.not.toThrow();
const [entry] = await readTail();
expect(entry).toMatch(/chain truncated at depth 16/);
expect(entry).toMatch(/level-50/); // the deepest/head shown in headline
expect(entry).not.toMatch(/level-1/); // the leaf is too deep to render
});
test('circular `.errors` array (sub-error is itself in the parent) is bounded', async () => {
const sub = new Error('shared sub-error');
const agg = new AggregateError([sub, new Error('other')], '');
// pathological: sub-Aggregate references the parent
sub.errors = [agg];
await expect(log.error('aggcycle', agg)).resolves.not.toThrow();
const [entry] = await readTail();
expect(entry).toMatch(/shared sub-error/);
expect(entry).toMatch(/cycle: same Error instance seen earlier/);
});
});
@@ -1,331 +0,0 @@
/**
* DC-062: errorResponse arg-order regression test + caddy-upstreams JSON
* response guarantees.
*
* Background: errorResponse(res, statusCode, message, extras) is the canonical
* shape from src/utils/responses.js. Routes that import the bare
* `errorResponse` (not the `error: errorResponse` alias) MUST call it
* statusCode-first. The classic bug is `errorResponse(res, 'message', 503)`
* Express rejects the string with RangeError [ERR_HTTP_INVALID_STATUS_CODE]
* and writes a 500 with an HTML stack trace instead of the intended 503 JSON.
*
* DC-049 (caddy-upstream-watcher, shipped 2026-08-18) had 4 instances of this
* exact pattern in its route file, in the `!caddyUpstreamWatcher` defensive
* branch. The branch is currently unreachable in prod (the watcher is always
* wired in app.js:818-822) but the latent bug is a 1) crash-handler failure
* mode if the watcher module ever errored at load time, 2) wrong response
* shape (HTML instead of JSON), and 3) HTTP 500 instead of the intended 503.
*
* Two layers of fix:
* 1. routes/caddy-upstreams.js swap the 4 callsites to (res, 503, msg).
* 2. src/utils/responses.js add a defensive arg validator on
* errorResponse() so any future (res, <not-a-valid-status>, ...)
* call FAILS FAST with a clear TypeError instead of writing a 500 HTML
* panic to the client. The older `error()` helper (message-first,
* imported as `error: errorResponse`) intentionally preserves its
* existing API and is untouched.
*
* This test exercises both fixes.
*/
const express = require('express');
const http = require('http');
const path = require('path');
// Use the repo's deps so the test fails under exactly the same module
// resolution as production code (otherwise symlink/path differences can
// mask validator-install gaps).
// __dirname = /opt/dashcaddy/dashcaddy-api/__tests__
// __dirname/../src/utils/responses = the file under test
const repoRoot = path.join(__dirname, '..');
const { errorResponse, error: legacyError } = require(path.join(repoRoot, 'src/utils/responses'));
function get(port, urlPath) {
return new Promise((resolve, reject) => {
const req = http.get(`http://localhost:${port}${urlPath}`, (resp) => {
let body = '';
resp.on('data', (c) => { body += c; });
resp.on('end', () => resolve({ status: resp.statusCode, headers: resp.headers, body }));
});
req.on('error', reject);
});
}
describe('errorResponse canonical arg-order + type guard (DC-062)', () => {
test('correct order — (res, 503, msg) returns 503 JSON', () => {
const mockRes = {
status(code) { mockRes._code = code; return this; },
json(body) { mockRes._body = body; return this; },
};
errorResponse(mockRes, 503, 'Caddy upstream watcher not initialized');
expect(mockRes._code).toBe(503);
expect(mockRes._body).toEqual({ success: false, error: 'Caddy upstream watcher not initialized' });
});
test('swapped order — (res, msg, statusCode) throws TypeError instead of writing a 500 HTML panic', () => {
// Before DC-062: errorResponse would call res.status('string-msg'),
// Express throws RangeError, error middleware catches it, writes 500 HTML.
// After DC-062: errorResponse itself rejects the call with a clear
// TypeError, naming the wrong arg.
const mockRes = {
status: () => mockRes,
json: () => mockRes,
};
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
.toThrow(TypeError);
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
.toThrow(/statusCode must be an integer HTTP status/);
});
test.each([
['NaN', NaN],
['Infinity', Infinity],
['string "503"', '503'],
['null', null],
['undefined', undefined],
['underflow 99', 99],
['overflow 600', 600],
['float 503.5', 503.5],
['object', { code: 503 }],
['array', [503]],
])('rejects invalid statusCode %s', (_name, badStatus) => {
const mockRes = {
status: () => mockRes,
json: () => mockRes,
};
expect(() => errorResponse(mockRes, badStatus, 'msg')).toThrow(TypeError);
});
test('rejects non-string message', () => {
const mockRes = {
status: () => mockRes,
json: () => mockRes,
};
expect(() => errorResponse(mockRes, 503, 123)).toThrow(TypeError);
expect(() => errorResponse(mockRes, 503, null)).toThrow(TypeError);
expect(() => errorResponse(mockRes, 503, undefined)).toThrow(TypeError);
expect(() => errorResponse(mockRes, 503, { msg: 'x' })).toThrow(TypeError);
});
test('preserves correct callers (DC-086 extras.code propagation still works)', () => {
const mockRes = {
status: () => mockRes,
json: (b) => { mockRes._lastBody = b; return mockRes; },
};
errorResponse(mockRes, 409, 'Conflict', { code: 'DC-CONF-1', extra: 'detail' });
expect(mockRes._lastBody).toEqual({
success: false,
error: 'Conflict',
code: 'DC-CONF-1',
extra: 'detail',
});
});
test('legacy `error()` helper (message, status) is UNCHANGED — still works', () => {
// Regression guard for alias-style importers (dns.js, services.js,
// ssl-monitor.js, license.js, dependencies.js, errorlogs.js, etc.).
// The legacy helper takes (res, message, statusCode) order. Make sure
// the validator we added to `errorResponse` doesn't bleed into
// `error()`.
const mockRes = {
status(code) { mockRes._code = code; return this; },
json(body) { mockRes._body = body; return this; },
};
legacyError(mockRes, 'service unavailable', 503);
expect(mockRes._code).toBe(503);
expect(mockRes._body).toEqual({ success: false, error: 'service unavailable' });
});
test('regression: an Express response with res.status(string) emits HTML 500 — proves the bug pre-fix', async () => {
// This is the failure mode DC-062 prevents. We still need this to
// be true to prove the guard's value: if a call site ever slipped past
// the validator (e.g. by sending a non-number disguised as code 0),
// the server still doesn't return the intended status as JSON.
const server = await new Promise((resolve) => {
const app = express();
app.get('/probe', (req, res) => {
try {
res.status('not a status').json({ ok: false });
} catch (_) {
res.end();
}
});
const s = app.listen(0, () => resolve({
port: s.address().port,
close: () => new Promise((r) => s.close(r)),
}));
});
try {
const resp = await get(server.port, '/probe');
expect(resp.status).toBe(500);
// Express renders an HTML error page (not JSON) — this is the bug
// class DC-062 prevents at the helper layer.
expect(resp.headers['content-type'] || '').toMatch(/text\/html/);
} finally {
await server.close();
}
});
});
// Mount the real route module and inject a null watcher — proves the
// the four `!caddyUpstreamWatcher` paths now respond with the intended
// 503 JSON shape, not a 500 HTML panic.
describe('caddy-upstreams JSON response shape (route file literal fix)', () => {
// The real route module exports a factory `function({ asyncHandler, caddyUpstreamWatcher, healthChecker })`.
// We need to provide an asyncHandler shim since the route file uses it.
function asyncHandlerShim(fn) { return fn; }
// The factory also depends on the asyncHandler resolving rejected
// promises to errors. Define a simple one that just calls next(err).
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
function mountRouter(router) {
return new Promise((resolve) => {
const app = express();
app.use('/api/v1', router);
const server = app.listen(0, () => resolve({
port: server.address().port,
close: () => new Promise((r) => server.close(r)),
}));
});
}
function loadRoute(deps) {
return require(path.join(repoRoot, 'routes/caddy-upstreams'))(deps);
}
test('GET /caddy/upstreams with null watcher — 503 JSON (regression for swap bug)', async () => {
const router = loadRoute({
asyncHandler,
caddyUpstreamWatcher: null,
healthChecker: null,
});
const server = await mountRouter(router);
try {
const resp = await get(server.port, '/api/v1/caddy/upstreams');
expect(resp.status).toBe(503);
expect(resp.body).toContain('"success":false');
expect(resp.body).toContain('Caddy upstream watcher not initialized');
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
} finally {
await server.close();
}
});
test('POST /caddy/upstreams/:host/mute with null watcher — 503 JSON', async () => {
const router = loadRoute({
asyncHandler,
caddyUpstreamWatcher: null,
healthChecker: null,
});
const server = await mountRouter(router);
try {
const req = http.request({
hostname: 'localhost',
port: server.port,
method: 'POST',
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/mute',
}, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
expect(res.statusCode).toBe(503);
expect(body).toContain('"success":false');
expect(body).toContain('Caddy upstream watcher not initialized');
expect(res.headers['content-type'] || '').toMatch(/application\/json/);
server.close();
});
});
req.on('error', (e) => { throw e; });
req.end();
} finally {
// server.close() will run via res.on('end') — defensively guard too.
// (Don't double-close if test already returned.)
}
});
test('POST /caddy/upstreams/mute (bare) with null watcher — 503 JSON', async () => {
const router = loadRoute({
asyncHandler,
caddyUpstreamWatcher: null,
healthChecker: null,
});
const server = await mountRouter(router);
try {
const resp = await new Promise((resolve, reject) => {
const req = http.request({
hostname: 'localhost',
port: server.port,
method: 'POST',
path: '/api/v1/caddy/upstreams/mute',
headers: { 'Content-Type': 'application/json' },
}, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
});
req.on('error', reject);
req.end('{"host":"x","muted":true}');
});
expect(resp.status).toBe(503);
expect(resp.body).toContain('Caddy upstream watcher not initialized');
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
} finally {
await server.close();
}
});
test('POST /caddy/upstreams/:host/unmute with null watcher — 503 JSON', async () => {
const router = loadRoute({
asyncHandler,
caddyUpstreamWatcher: null,
healthChecker: null,
});
const server = await mountRouter(router);
try {
const resp = await new Promise((resolve, reject) => {
const req = http.request({
hostname: 'localhost',
port: server.port,
method: 'POST',
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/unmute',
}, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
});
req.on('error', reject);
req.end();
});
expect(resp.status).toBe(503);
expect(resp.body).toContain('Caddy upstream watcher not initialized');
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
} finally {
await server.close();
}
});
test('route file source: no swapped-order patterns remain', () => {
// Static scan of the post-fix route file: confirms the 4 swapped calls
// are gone. If a future refactor re-introduces the pattern, this scan
// catches it at test-time (before it ever lands in prod).
const fs = require('fs');
const src = fs.readFileSync(
path.join(repoRoot, 'routes/caddy-upstreams.js'),
'utf8'
);
// Match `errorResponse(res, <quote-or-backtick>, <int>)` — the
// swapped-order shape (string literal in the 2nd arg position).
const swappedRe = /errorResponse\(res,\s*['"`]/;
expect(src).not.toMatch(swappedRe);
// And confirm the corrected shape appears at least four times
// (the four `!caddyUpstreamWatcher` guards).
const canonicalRe = /errorResponse\(res,\s*503,\s*['"]Caddy upstream watcher not initialized['"]/g;
const matches = src.match(canonicalRe) || [];
expect(matches.length).toBe(4);
});
});
@@ -1,17 +1,10 @@
/**
* DC-076 / DC-061: Tests for the dashboard WebSocket server
*
* DC-061 added:
* - Real authVerifier injection (no string-presence-only check)
* - Rejection of bare cookies / token query params
* - close() detaches only OUR listeners (not shared SSE listeners)
* - Message size cap (16 KB)
* - parseCookieHeader unit coverage
* DC-076: Tests for the dashboard WebSocket server
*/
const http = require('http');
const WebSocket = require('ws');
const EventEmitter = require('events');
const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws');
const createDashboardWS = require('../../src/websocket/dashboard-ws');
function createMockServer() {
return http.createServer((req, res) => {
@@ -20,38 +13,23 @@ function createMockServer() {
});
}
/**
* Build a stub verifier that mimics the production `session.isValid`
* shape: takes an IncomingMessage-ish request, returns true iff the
* session cookie value is a non-empty string.
*/
function cookieValueVerifier() {
return (req) => {
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
const raw = parsed.dashcaddy_session;
return typeof raw === 'string' && raw.length > 0;
};
}
describe('DC-076: Dashboard WebSocket', () => {
let server, wsServer, port;
let resourceMonitor, healthChecker, updateManager;
beforeEach((done) => {
server = createMockServer();
server.listen(0, () => {
port = server.address().port;
resourceMonitor = new EventEmitter();
healthChecker = new EventEmitter();
updateManager = new EventEmitter();
const resourceMonitor = new EventEmitter();
const healthChecker = new EventEmitter();
const updateManager = new EventEmitter();
wsServer = createDashboardWS(server, {
resourceMonitor,
healthChecker,
updateManager,
authVerifier: cookieValueVerifier(),
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
log: { info: jest.fn(), error: jest.fn() },
});
done();
});
@@ -62,19 +40,19 @@ describe('DC-076: Dashboard WebSocket', () => {
server.close(done);
});
it('accepts connections at the upgrade path with a session cookie', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
it('accepts connections at the upgrade path', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.close();
});
ws.on('close', () => {
done();
});
ws.on('open', () => ws.close());
ws.on('close', () => done());
ws.on('error', done);
});
it('sends a connected event on join', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'connected') {
@@ -87,9 +65,7 @@ describe('DC-076: Dashboard WebSocket', () => {
});
it('responds to ping with pong', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'ping' }));
});
@@ -104,9 +80,7 @@ describe('DC-076: Dashboard WebSocket', () => {
});
it('responds to subscribe with subscribed confirmation', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
});
@@ -122,9 +96,7 @@ describe('DC-076: Dashboard WebSocket', () => {
});
it('responds to client-count request', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'client-count' }));
});
@@ -140,9 +112,7 @@ describe('DC-076: Dashboard WebSocket', () => {
});
it('returns error for invalid JSON', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send('not json');
});
@@ -165,210 +135,3 @@ describe('DC-076: Dashboard WebSocket', () => {
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
});
});
// ─────────────────────────────────────────────────────────────────────
// DC-061 auth gate tests
// ─────────────────────────────────────────────────────────────────────
describe('DC-061: WS upgrade auth gate', () => {
let server, wsServer, port;
beforeEach((done) => {
server = createMockServer();
server.listen(0, () => {
port = server.address().port;
wsServer = createDashboardWS(server, {
resourceMonitor: new EventEmitter(),
healthChecker: new EventEmitter(),
updateManager: new EventEmitter(),
authVerifier: cookieValueVerifier(),
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
});
done();
});
});
afterEach((done) => {
wsServer.close();
server.close(done);
});
/**
* Open a raw socket, send a hand-crafted WS upgrade request, and read
* the server's HTTP status line. Avoids the ws library's auto-retry
* behaviour so we get a deterministic single response.
*/
function probeUpgrade({ path, cookie, token } = {}) {
return new Promise((resolve, reject) => {
const net = require('net');
const sock = net.createConnection(port, '127.0.0.1');
let buf = '';
const headers = [
`GET ${path || '/api/v1/ws'} HTTP/1.1`,
'Host: 127.0.0.1',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Version: 13',
];
if (cookie) headers.push(`Cookie: ${cookie}`);
if (token) {
const sep = path && path.includes('?') ? '&' : '?';
headers[0] = headers[0].replace(path, `${path || '/api/v1/ws'}${sep}token=${token}`);
}
sock.on('connect', () => {
sock.write(headers.join('\r\n') + '\r\n\r\n');
});
sock.on('data', (chunk) => {
buf += chunk.toString('utf8');
if (buf.includes('\r\n\r\n')) {
sock.destroy();
const statusLine = buf.split('\r\n')[0];
const status = parseInt((statusLine.match(/HTTP\/1\.1 (\d+)/) || [])[1], 10);
resolve({ status, raw: buf });
}
});
sock.on('error', (err) => {
// Connection reset is fine — server destroys socket after 401.
if (buf) resolve({ status: -1, raw: buf });
else reject(err);
});
setTimeout(() => {
if (!buf) {
sock.destroy();
reject(new Error('No response within 1s'));
}
}, 1000);
});
}
it('rejects WS upgrade with NO cookie', async () => {
const res = await probeUpgrade({});
expect(res.status).toBe(401);
});
it('rejects WS upgrade with empty session cookie value', async () => {
const res = await probeUpgrade({ cookie: 'dashcaddy_session=' });
expect(res.status).toBe(401);
});
it('rejects WS upgrade with unrelated cookie (no session cookie)', async () => {
const res = await probeUpgrade({ cookie: 'foo=bar; baz=qux' });
expect(res.status).toBe(401);
});
it('NO LONGER accepts `?token=` query param bypass (DC-061 fix)', async () => {
// Pre-DC-061: any 11+ char token in ?token=... granted WS access in
// production. Post-fix: token query param is ignored entirely; only a
// valid session cookie grants access.
const res = await probeUpgrade({ token: 'thisstringisdefinitelylongenough' });
expect(res.status).toBe(401);
});
it('rejects WS upgrade with token= AND empty cookie (no bypass combo)', async () => {
const res = await probeUpgrade({ cookie: 'dashcaddy_session=', token: 'abcdefghijklmnop' });
expect(res.status).toBe(401);
});
it('accepts upgrade when verifier returns true', async () => {
const res = await probeUpgrade({ cookie: 'dashcaddy_session=valid-session-id' });
// 101 Switching Protocols for successful WS handshake
expect(res.status).toBe(101);
});
});
// ─────────────────────────────────────────────────────────────────────
// DC-061 close() listener detach test (the SSE-poisoning regression)
// ─────────────────────────────────────────────────────────────────────
describe('DC-061: close() detaches only OUR listeners', () => {
it('does NOT remove listeners attached by SSE route to shared emitters', () => {
// Set up two "subscribers" on the same EventEmitter, simulating the
// real-world shape: SSE route subscribes via `.on('alert', sseHandler)`
// and dashboard-ws subscribes via `.on('alert', wsHandler)` to the
// SAME resourceMonitor. Calling dashboard-ws.close() must remove
// ONLY wsHandler — sseHandler must remain.
const server = createMockServer();
const resourceMonitor = new EventEmitter();
// Pre-existing "SSE" listener (registered before dashboard-ws boots)
const sseHandler = jest.fn();
resourceMonitor.on('alert', sseHandler);
const wsServer = createDashboardWS(server, {
resourceMonitor,
healthChecker: new EventEmitter(),
updateManager: new EventEmitter(),
authVerifier: () => true,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
});
// dashboard-ws added its own listener — verify it's there
const wsHandlerCallsBefore = resourceMonitor.listenerCount('alert');
expect(wsHandlerCallsBefore).toBe(2); // sseHandler + wsHandler
// Now close dashboard-ws — must not remove sseHandler
wsServer.close();
const wsHandlerCallsAfter = resourceMonitor.listenerCount('alert');
expect(wsHandlerCallsAfter).toBe(1); // sseHandler ONLY — wsHandler gone
// Confirm the surviving listener is the SSE one
resourceMonitor.emit('alert', { test: true });
expect(sseHandler).toHaveBeenCalledWith({ test: true });
server.close();
});
it('is safe to call close() multiple times', () => {
const server = createMockServer();
const wsServer = createDashboardWS(server, {
resourceMonitor: new EventEmitter(),
healthChecker: new EventEmitter(),
updateManager: new EventEmitter(),
authVerifier: () => true,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
});
expect(() => {
wsServer.close();
wsServer.close();
wsServer.close();
}).not.toThrow();
server.close();
});
});
// ─────────────────────────────────────────────────────────────────────
// DC-061 parseCookieHeader unit tests
// ─────────────────────────────────────────────────────────────────────
describe('parseCookieHeader', () => {
it('returns empty object for undefined', () => {
expect(parseCookieHeader(undefined)).toEqual({});
});
it('returns empty object for empty string', () => {
expect(parseCookieHeader('')).toEqual({});
});
it('parses a single cookie pair', () => {
expect(parseCookieHeader('foo=bar')).toEqual({ foo: 'bar' });
});
it('parses multiple cookie pairs', () => {
expect(parseCookieHeader('a=1; b=2; c=3')).toEqual({ a: '1', b: '2', c: '3' });
});
it('trims whitespace around names and values', () => {
expect(parseCookieHeader(' foo = bar ; baz=qux')).toEqual({ foo: 'bar', baz: 'qux' });
});
it('preserves dots/dashes in HMAC-shaped session cookie values', () => {
// dashcaddy_session cookies are `<b64>.<sig>` — parseCookieHeader
// must NOT url-decode (the HMAC verifier reads the raw value).
expect(parseCookieHeader('dashcaddy_session=abc.def_123-XYZ')).toEqual({
dashcaddy_session: 'abc.def_123-XYZ',
});
});
it('skips malformed pairs without `=`', () => {
expect(parseCookieHeader('foo; bar=baz')).toEqual({ bar: 'baz' });
});
it('skips empty name parts', () => {
expect(parseCookieHeader('=value; foo=bar')).toEqual({ foo: 'bar' });
});
});
+37 -34
View File
@@ -32,6 +32,23 @@ const emailSender = require('../../src/auth/providers/email-sender');
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
const { ok, successMessage } = require('../../src/utils/responses');
/**
* Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's
* _resolvePublicUrl logic kept duplicated (not extracted) because the two
* callers have slightly different link paths and the duplication is smaller
* than the abstraction would be.
*/
function _buildInviteUrl(req, siteConfig, token) {
if (siteConfig && siteConfig.publicBaseUrl) {
return siteConfig.publicBaseUrl.replace(/\/+$/, '') +
'/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept';
}
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|| (siteConfig && siteConfig.dashboardHost) || 'localhost:3001';
return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`;
}
function _requireAdmin(req, _res, next) {
if (!req.user || req.user.role !== 'admin') {
return next(new ForbiddenError('Admin role required'));
@@ -223,21 +240,11 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
});
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
// Build the accept URL once — used both for the response and for email delivery.
const baseUrl = (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001')));
const acceptUrl = baseUrl + '/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept';
// DC-085: link-first delivery. Default = no email, just hand the link back.
// Operators opt INTO email by sending { sendEmail: true } (or the admin UI
// checks the "Send email" checkbox). When SMTP is unconfigured AND the
// operator did opt in, we surface the failure as `deliveredVia: 'failed'`
// but NEVER leak the raw token into the server log — the link is already
// in the response, so the operator has a UI-side fallback.
let deliveredVia = 'manual';
if (sendEmail === true) {
let deliveredVia = 'none';
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
if (sendEmail !== false) {
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
@@ -247,37 +254,33 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
deliveredVia = 'email';
} else {
// Operator asked for email but SMTP isn't configured. Surface the
// failure cleanly; the link is still in the response so the
// operator can share it manually. Do NOT log the raw URL — it
// would duplicate what's already in the response and pollute the
// server log on every unconfigured-install invite.
log.warn && log.warn('auth-invite-send',
'invite send skipped: SMTP not configured (operator opted in)',
{ inviteId: issued.id, email: issued.email });
deliveredVia = 'failed';
// Dev fallback — log the raw link so operators can grab it.
log.warn && log.warn('auth-invite-dev',
'[DC-048-DEV-INVITE-LINK] email=' + issued.email +
' role=' + issued.role + ' url=' + acceptUrl);
deliveredVia = 'dev-console';
}
} catch (sendErr) {
log.warn && log.warn('auth-invite-send',
'invite send failed: ' + (sendErr.message || String(sendErr)),
{ inviteId: issued.id });
'invite send failed: ' + (sendErr.message || String(sendErr)));
deliveredVia = 'failed';
}
} else {
deliveredVia = 'manual';
}
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
const shareText =
'Join my DashCaddy as ' + issued.role + ' — ' + acceptUrl +
' — expires in ' + ttlHoursOut + 'h.';
return ok(res, {
id: issued.id,
email: issued.email,
role: issued.role,
expiresAt: issued.expiresAt,
acceptUrl,
shareText,
// The raw token is returned ONCE so the admin UI can show/copy the
// link. It is also embedded in the email when sendEmail !== false.
acceptUrl: (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))) +
'/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept',
deliveredVia,
maskedEmail,
});
+23 -62
View File
@@ -12,7 +12,7 @@ module.exports = function(deps) {
const router = express.Router();
// Extract dependencies
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps;
// Create ctx-like object for compatibility
const ctx = {
@@ -126,12 +126,7 @@ module.exports = function(deps) {
try {
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
if (!username || !password) {
return errorResponse(res, 428, '[DC-500] No credentials stored', {
credentialsRequired: true,
serviceId,
});
}
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
const service = await ctx.getServiceById(serviceId);
const baseUrl = service?.url;
if (!baseUrl) throw new NotFoundError('No service URL');
@@ -186,12 +181,7 @@ module.exports = function(deps) {
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
}
if (!username || !password) {
return errorResponse(res, 428, '[DC-500] No credentials stored', {
credentialsRequired: true,
serviceId,
});
}
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
if (appCookies) {
@@ -213,28 +203,8 @@ module.exports = function(deps) {
}
}, '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
// (minted by /totp/verify or /auth/sso-handoff) for a HOST-ONLY session
// cookie on whichever
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
// *.sami origin calls this. Needed because Domain=.sami cookies are
// silently rejected by real browsers (.sami is an unregistered TLD, so
// browsers treat "sami" as the effective public suffix and refuse to set
@@ -245,9 +215,7 @@ module.exports = function(deps) {
router.get('/auth/sso-exchange', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
const token = req.query.token;
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
.split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
if (!session.redeemHandoffToken(token, forwardedHost)) {
if (!session.redeemHandoffToken(token)) {
return errorResponse(res, 401, 'Invalid or expired handoff token');
}
session.setCookieHostOnly(res, totpConfig.sessionDuration);
@@ -269,12 +237,7 @@ module.exports = function(deps) {
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
router.get('/auth/login-page', (req, res) => {
const service = (req.query.service || '').replace(/[^a-z]/g, '');
const 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);
const html = buildLoginPage(service);
if (!html) return res.status(404).send('Unknown service');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
@@ -292,7 +255,7 @@ module.exports = function(deps) {
return router;
};
function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
function buildLoginPage(service) {
// 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
// same origin (plex.sami); if the API returns 200 the user has a valid
@@ -303,7 +266,7 @@ function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
</head><body><p id="m">__TITLE__</p><div id="d"></div>
<script>(function(){
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
// (default 8s) so a hung upstream can NEVER leave the page stuck on
// "Signing in to Plex..." indefinitely. Also: if check-session returns
@@ -311,16 +274,13 @@ function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
// stale token exists in localStorage, instead of failing silently.
function go(u){setTimeout(function(){location.replace(u)},300)}
function 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 withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
// within 15s, force-redirect to status.sami so the user can re-auth.
var overallTimer=setTimeout(function(){go(authUrl())},15000);
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000);
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
// with this origin (Domain=.sami cookies are silently rejected by real
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
@@ -345,17 +305,18 @@ function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
preExchange.then(function(){
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
}).then(function(r){return r.json()}).then(function(st){
if(!st||!st.success||!st.authenticated){go(authUrl());return}
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
${body}
}).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'Auth check error: '+(e&&e.message||'unknown'))})
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))})
})()</script></body></html>`;
const pages = {
chat: {
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
body: `d.textContent='Fetching token from DashCaddy...';
body: `if(ls.getItem('token')){go('/?direct=1');return}
d.textContent='Fetching token from DashCaddy...';
ft('chat').then(function(r){return r.text()}).then(function(t){
try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
// No token but chat is reachable — fall through to manual UI link below
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
@@ -363,29 +324,30 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
},
plex: {
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
body: `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}
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
ft('plex').then(function(r){return r.json()}).then(function(j){
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
// No token returned. Three fallbacks in priority order:
// 1. Stale token in localStorage — Plex may still accept it.
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
// 2. Manual link so the user is never trapped on this page.
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
},
jellyfin: {
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
if(j.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(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
},
emby: {
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
if(j.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(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
},
};
@@ -395,6 +357,5 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
return SHELL(cfg.body)
.replace(/__TITLE__/g, cfg.title)
.replace('__BG__', cfg.bg)
.replace('__ACCENT__', cfg.accent)
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
.replace('__ACCENT__', cfg.accent);
}
+4 -13
View File
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken, siteConfig }) {
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
const router = express.Router();
// Ctx shim for backward compatibility
@@ -23,8 +23,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
credentialManager,
totpConfig,
saveTotpConfig,
session,
siteConfig
session
};
// Get current TOTP config (public route)
@@ -194,14 +193,11 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// Login: verify TOTP code and set session cookie
router.post('/totp/verify', asyncHandler(async (req, res) => {
const { authenticator } = require('otplib');
const { code, serviceId } = req.body;
const { code } = req.body;
if (!code || !/^\d{6}$/.test(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) {
throw new ValidationError('TOTP is not enabled');
@@ -231,12 +227,7 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// URL when bouncing the user back to a gated service. That service's
// login page exchanges it via /auth/sso-exchange for its own host-only
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
let ssoToken = null;
if (serviceId) {
const suffix = String(ctx.siteConfig?.tld || '.sami');
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
ssoToken = ctx.session.createHandoffToken(expectedHost);
}
const ssoToken = ctx.session.createHandoffToken();
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
+32 -7
View File
@@ -22,13 +22,6 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
const router = express.Router();
// ==================== SCHEDULE ENDPOINTS (PREMIUM) ====================
// NOTE: POST /backups/schedule has a single canonical registration below
// (the appId-keyed handler at the top of this section). Earlier versions
// registered a duplicate "name"-keyed handler later in the file — Express
// only matches the first registered handler per METHOD+PATH, so the
// duplicate was unreachable dead code. Do not re-add it; if you need a
// different schema, change the canonical Joi schema in
// src/utilities/validate.js (backupScheduleCreate) instead.
// Apply premium gating to schedule-related routes
const premiumGating = licenseManager.requirePremium('auto-backup');
@@ -518,6 +511,38 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
success(res, storageInfo);
}, 'backups-storage-info'));
// Schedule a backup
// LEGACY: this is a duplicate registration of POST /backups/schedule (see also line 60,
// which uses the appId-keyed schema and is the route the frontend actually calls).
// Express only matches the first registered handler per METHOD+PATH, so this handler
// is unreachable. It is preserved for now to avoid removing a route any unknown
// integration might still POST to. TODO: audit + remove in a dedicated cleanup PR.
router.post('/backups/schedule', asyncHandler(async (req, res) => {
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
if (!name || !schedule) {
return res.status(400).json({ error: 'name and schedule are required' });
}
const config = backupManager.getConfig();
// Store maxStorageBytes in the backup config (converted to bytes)
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
? maxStorageBytes
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
config.backups[name] = {
...backupConfig,
enabled: true,
schedule,
maxStorageBytes: maxBytes,
destinations: backupConfig.destinations || [{ type: 'local' }]
};
backupManager.updateConfig(config);
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
}, 'backups-schedule-legacy'));
// Restore from backup
router.post('/backups/restore/:backupId', validateBody(schemas.backupRestore), asyncHandler(async (req, res) => {
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
+6 -96
View File
@@ -123,106 +123,17 @@ module.exports = function(ctx) {
res.send(script);
}, 'ca-install-script'));
// DC-076: per-service cert/key download — TOTP + admin scope required.
// Pre-fix this endpoint (a) had a hardcoded `password = 'dashcaddy'` default
// for the PFX format — a default credential published in source; (b) was
// public-listed in middleware.js PUBLIC_ROUTES (TOTP bypassed when TOTP is
// disabled — single ops command or fresh-install setup state), and (c)
// accepted ANY TOTP-authenticated scope (read scope was enough to pull
// private keys). Fix: require explicit password (no default), require
// TOTP/session (dropped from PUBLIC_ROUTES — see middleware.js), and
// require `admin` scope at the route layer as defense-in-depth against
// future middleware-ordering mistakes.
const CA_CERT_DOMAINS_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
// Per-DC-076: PFX password now required, ≥ 8 chars, no `=` (pkcs12
// interprets `=` as a base64 padding marker that downstream tooling
// can mis-handle; reject it to keep the password copy-paste-safe).
const CA_PFX_PASSWORD_RE = /^[A-Za-z0-9!@#%^_+,.~:-]{8,64}$/;
const CA_CERT_RATE_LIMIT = { windowMs: 60_000, max: 10 };
const caCertRateBuckets = new Map(); // ip -> { count, resetAt }
function caCertRateLimit(ip) {
const now = Date.now();
const b = caCertRateBuckets.get(ip);
if (!b || b.resetAt <= now) {
caCertRateBuckets.set(ip, { count: 1, resetAt: now + CA_CERT_RATE_LIMIT.windowMs });
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - 1 };
}
if (b.count >= CA_CERT_RATE_LIMIT.max) {
return { allowed: false, remaining: 0, retryAfterMs: b.resetAt - now };
}
b.count += 1;
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - b.count };
}
function requireCaCertAdminScope(req, res) {
// TOTP is enforced by `totpAuthMiddleware` globally. Here we additionally
// require the `admin` scope — even a read-scope API key or read-scope
// JWT must NOT be able to pull a private key. Auth context is mounted on
// `req.auth` by the upstream middlewares.
const auth = req.auth || {};
const scope = Array.isArray(auth.scope) ? auth.scope : [];
if (!scope.includes('admin')) {
ctx.errorResponse(res, 403,
'Admin scope required to download per-service private keys. Re-authenticate with an admin-scoped credential.',
{ code: 'DC-076_INSUFFICIENT_SCOPE', requiredScope: 'admin', actualScope: scope });
return false;
}
return true;
}
// Generate and download SSL certificate for a service
router.get('/cert/:domain', ctx.asyncHandler(async (req, res) => {
if (!requireCaCertAdminScope(req, res)) return;
const { domain } = req.params;
const { password = 'dashcaddy', format = 'pfx' } = req.query;
// DC-076: password is REQUIRED for the pfx format (no `=`) and must
// be ≥ 8 chars. Previously `password = 'dashcaddy'` — a hardcoded
// default that silently signed every PFX with the same published
// password. Other formats (key, pem, crt, fullchain) do not need a
// password and ignore the param.
const wantsPfx = !req.query.format || req.query.format === 'pfx';
let password = req.query.password;
if (wantsPfx) {
if (typeof password !== 'string' || password === '') {
return ctx.errorResponse(res, 400,
'PFX format requires an explicit `password` query param (8-64 chars, no `=`). '
+ 'A published default is unsafe — pick your own.',
{ code: 'DC-076_PASSWORD_REQUIRED' });
}
if (!CA_PFX_PASSWORD_RE.test(password)) {
return ctx.errorResponse(res, 400,
'PFX password must be 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-].',
{ code: 'DC-076_PASSWORD_INVALID' });
}
} else {
// For non-PFX formats, still reject `=` in the password so a copy-paste
// mistake can't accidentally inject a base64 padding token into a path
// someone else might log.
if (password !== undefined && (typeof password !== 'string' || password.includes('='))) {
return ctx.errorResponse(res, 400, 'password (if supplied) must be a string without `=`.',
{ code: 'DC-076_PASSWORD_INVALID' });
}
if (!/^[a-zA-Z0-9!@#%^_+=,.:-]{1,64}$/.test(password)) {
throw new ValidationError('Invalid password. Use only letters, numbers, and basic symbols (max 64 chars).');
}
// DC-076: per-IP rate limit — each cert request forks an `openssl` process
// and writes to disk. An authenticated admin polling the endpoint in a
// loop could exhaust CPU/IO. 10 req/min/IP is enough for normal use
// (regenerate one cert, check 4 formats, done) and tight enough to stop
// a runaway client.
const clientIp = req.ip || req.connection?.remoteAddress || 'unknown';
const rl = caCertRateLimit(clientIp);
if (!rl.allowed) {
res.setHeader('Retry-After', Math.ceil(rl.retryAfterMs / 1000));
return ctx.errorResponse(res, 429,
`Rate limit exceeded for /api/v1/ca/cert/* (${CA_CERT_RATE_LIMIT.max} req/${CA_CERT_RATE_LIMIT.windowMs/1000}s per IP). Retry in ${Math.ceil(rl.retryAfterMs / 1000)}s.`,
{ code: 'DC-076_RATE_LIMITED', retryAfterMs: rl.retryAfterMs });
}
res.setHeader('X-RateLimit-Limit', String(CA_CERT_RATE_LIMIT.max));
res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
if (!CA_CERT_DOMAINS_RE.test(domain)) {
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`,
{ code: 'DC-076_DOMAIN_INVALID' });
if (!domain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(domain)) {
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
}
const pkiPath = platformPaths.pkiDir;
@@ -329,9 +240,8 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
}
}, 'ca-cert'));
// List generated certificates (DC-076: TOTP-gated; previously public-listed)
// List generated certificates
router.get('/certs', ctx.asyncHandler(async (req, res) => {
if (!requireCaCertAdminScope(req, res)) return;
const certsDir = platformPaths.generatedCertsDir;
if (!await exists(certsDir)) {
+43 -80
View File
@@ -4,9 +4,7 @@
* Exposes:
* GET /api/v1/caddy/upstreams full snapshot
* GET /api/v1/caddy/upstreams/incidents open dead-upstream incidents (via healthChecker)
* POST /api/v1/caddy/upstreams/mute body { host, muted: true|false }
* POST /api/v1/caddy/upstreams/:host/mute body { muted: true|false } OR query ?muted=true
* POST /api/v1/caddy/upstreams/:host/unmute clears the mute
* POST /api/v1/caddy/upstreams/:host/mute body { muted: true|false } (also via query ?muted=true)
*
* Auth: same as the rest of /api/v1 handled by the global middleware
* (the router is mounted under the auth-gated apiRouter in app.js).
@@ -18,61 +16,12 @@ const express = require('express');
const { success, errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../src/utilities/errors');
/**
* DC-073: shared mute helper used by all three mute endpoints so the
* host-validation logic can't drift.
*
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
* rejected unknown hosts (with a "not a known upstream" 400). The
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
* check entirely, so an authenticated operator could POST
* `/caddy/upstreams/phantom.test:12345/mute` and the watcher would
* silently add `phantom.test:12345` to its muted Set and `_saveState()`
* would persist it to disk. The phantom entry then survives container
* restarts, pollutes the snapshot view (the muted Set is iterated in
* places like the dashboard's "muted upstreams" badge), and would
* silently disable any future probe that happened to resolve to the
* same string.
*
* Post-fix, every mute path runs through this helper so:
* (1) host format is well-formed (rejects injection / `:` / `?` / etc.)
* (2) host is in `caddyUpstreamWatcher.upstreams` (the live registry
* populated by `scanSites()` reading every `reverse_proxy` from
* /etc/caddy/sites/*. A phantom host cannot reach setMuted.)
* (3) the muted Set never holds entries the scanner doesn't know.
*
* @param {Object} watcher caddyUpstreamWatcher instance
* @param {string} host raw host string from the request
* @param {boolean} wantMuted true to mute, false to unmute
* @returns {{host: string, muted: boolean}} the result of setMuted
* @throws {ValidationError} on invalid format or unknown host
*/
function validateAndMuteHost(watcher, host, wantMuted) {
if (typeof host !== 'string' || host.length === 0 || host.length > 253) {
throw new ValidationError('host must be a non-empty string up to 253 chars');
}
if (!/^[a-z0-9._:-]+$/i.test(host)) {
throw new ValidationError('host must be a valid host[:port] string');
}
if (!watcher || !watcher.upstreams || !watcher.upstreams.has(host)) {
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
}
return watcher.setMuted(host, wantMuted);
}
module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) {
const router = express.Router();
router.get('/caddy/upstreams', asyncHandler(async (req, res) => {
if (!caddyUpstreamWatcher) {
// DC-062: errorResponse(res, statusCode, message) — statusCode-first per
// src/utils/responses.js:66. The prior (res, message, statusCode) call
// order passed a STRING as the status code, which made
// res.status('Caddy upstream watcher not initialized') throw
// RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express turning it into a
// 500 with an HTML stack trace). All four `!caddyUpstreamWatcher`
// guards had the same latent bug — fixed to canonical order.
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
}
success(res, caddyUpstreamWatcher.snapshot());
}, 'caddy-upstreams-list'));
@@ -99,48 +48,62 @@ module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker })
success(res, { incidents: open });
}, 'caddy-upstreams-incidents'));
// POST /caddy/upstreams/mute body { host, muted }
// POST /caddy/upstreams/:host/mute body { muted: true } OR query ?muted=true
// Both shapes supported because the dashboard code is small and either is
// ergonomic depending on caller.
const handleMute = asyncHandler(async (req, res) => {
if (!caddyUpstreamWatcher) {
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
}
const host = req.params.host || req.body?.host;
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
throw new ValidationError('host must be a valid host[:port] string');
}
// Accept muted as boolean body field OR ?muted=true|false query OR
// a { muted: true|false } JSON body. Default to toggling on bare POST
// without a muted value (this is the "mute it" path).
let muted;
if (typeof req.body?.muted === 'boolean') muted = req.body.muted;
else if (typeof req.query.muted === 'string') muted = req.query.muted === 'true';
else muted = true; // POST with no body = mute
const result = caddyUpstreamWatcher.setMuted(host, muted);
success(res, result);
}, 'caddy-upstreams-mute');
// Bare /mute with JSON body {host, muted}. Default mutes when muted is
// absent or unparseable; require muted === false explicitly to unmute.
// DC-073: now routes through validateAndMuteHost so the unknown-host
// check applies (was already correct here pre-fix, but path-style
// was missing it — see validateAndMuteHost docblock).
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
if (!caddyUpstreamWatcher) {
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
}
const { host, muted } = req.body || {};
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
throw new ValidationError('host must be a valid host[:port] string');
}
// Explicit boolean coercion — string 'false' should NOT mute.
const wantMuted = muted === undefined ? true : muted === true;
const result = validateAndMuteHost(caddyUpstreamWatcher, host, wantMuted);
if (caddyUpstreamWatcher.upstreams && !caddyUpstreamWatcher.upstreams.has(host)) {
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
}
const result = caddyUpstreamWatcher.setMuted(host, wantMuted);
success(res, result);
}, 'caddy-upstreams-mute-bare'));
// Path-style /:host/mute — body { muted: true|false } OR query ?muted=true|false.
// DC-073: now also rejects unknown hosts (was the bug — see docblock).
router.post('/caddy/upstreams/:host/mute', asyncHandler(async (req, res) => {
if (!caddyUpstreamWatcher) {
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
}
let wantMuted;
if (typeof req.body?.muted === 'boolean') wantMuted = req.body.muted;
else if (typeof req.query.muted === 'string') wantMuted = req.query.muted === 'true';
else wantMuted = true; // bare POST = mute
const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, wantMuted);
success(res, result);
}, 'caddy-upstreams-mute'));
// DC-073: path-style /:host/unmute now also rejects unknown hosts.
// /:host/mute and /:host/unmute for path-style toggles
router.post('/caddy/upstreams/:host/mute', handleMute);
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
if (!caddyUpstreamWatcher) {
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
}
const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, false);
const host = req.params.host;
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
throw new ValidationError('host must be a valid host[:port] string');
}
const result = caddyUpstreamWatcher.setMuted(host, false);
success(res, result);
}, 'caddy-upstreams-unmute'));
return router;
};
// Export the helper for unit tests so the validation surface can be
// exercised without spinning up a full Express app.
module.exports.__test = { validateAndMuteHost };
};
+8 -162
View File
@@ -11,138 +11,10 @@
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const { REGEX } = require('../src/utilities/constants');
/**
* DC-070: Validate the structural config that flows into generateSiteBlock.
*
* Threat model: `generateSiteBlock` interpolates user-controlled fields
* (domain, tls, authService, headers.*, stripPrefix, upstream) DIRECTLY into
* a Caddyfile text block that is later fed to `caddy.modify()` and the
* Caddy admin /load endpoint. The /caddycode/generate endpoint is
* authenticated (forward_auth gated), but the bug class is "compromised
* middleware / pivot" a JSON-only payload can be smuggled past any
* UI-side input checks.
*
* Pre-fix, every field was trusted: `lines.push(`${domain} {`)` accepted any
* string (including newlines that close the block and inject a new site),
* `headers[key] = "${value}"` accepted arbitrary quotes (which would break
* the surrounding `"..."` Caddy quoted-string context and inject directives),
* and `tls`, `authService`, `stripPrefix`, `upstream` had no charset
* restrictions at all (spaces, braces, semicolons would land verbatim).
*
* Post-fix: every field is constrained to a known-safe character class
* BEFORE interpolation, and CRLF is rejected outright. Quoted-string
* injection in header values is closed by escaping `\` and `"` per the
* Caddy quoted-string spec (backslash escapes the next character).
*/
function validateGenerationConfig(config) {
const errors = [];
const {
domain,
upstream,
upstreamProtocol = 'http',
tls = 'auto',
auth = false,
authService = null,
headers = {},
stripPrefix = null,
} = config;
// 1. domain — RFC 1123 hostname. Reject anything with whitespace, brace,
// semicolon, newline, or non-printable. REGEX.DOMAIN is
// /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i in constants.js.
if (typeof domain !== 'string' || !REGEX.DOMAIN.test(domain)) {
errors.push('domain must be a valid hostname (letters, digits, dots, hyphens)');
}
// 2. upstream — `host:port` form (the only shape Caddy's reverse_proxy
// directive takes for non-URL upstreams). Reject `://`, whitespace,
// braces. Allow optional IPv6 bracket form `[::1]:5000`. Must
// include an explicit :port segment — a bare `localhost` would
// produce a Caddyfile that fails to reload (port required for
// reverse_proxy upstreams). Two regex branches: (a) bare host with
// required :port, (b) bracketed IPv6 literal with required :port.
if (typeof upstream !== 'string'
|| !/^[a-z0-9.\-]+:\d{1,5}$/i.test(upstream)
&& !/^\[[a-z0-9.\-:.]+\]:\d{1,5}$/i.test(upstream)
) {
errors.push('upstream must be host:port (host letters/digits/dots/hyphens, port 1-65535, optional IPv6 brackets)');
}
// 3. tls — either the literal strings 'auto' / 'internal' (handled
// specially below) OR a CA name like 'letsencrypt' / 'internal' that
// must match /^[a-z0-9._-]+$/i. Reject whitespace + braces + quotes.
if (typeof tls !== 'string' || !/^[a-z0-9._-]+$/i.test(tls)) {
errors.push('tls must be one of: auto, internal, or a CA name (letters, digits, dots, underscores, hyphens)');
}
// 4. authService — only meaningful when auth=true; otherwise ignore. Must
// match the existing SSO service-id charset (REGEX.SUBDOMAIN).
if (auth) {
if (typeof authService !== 'string' || !REGEX.SUBDOMAIN.test(authService)) {
errors.push('authService must be a valid subdomain (lowercase, alphanumeric, hyphens)');
}
}
// 5. upstreamProtocol — only 'http' or 'https'. Anything else gets coerced
// to 'http' but only after we explicitly accept it; reject obvious
// injection vectors here.
if (upstreamProtocol !== 'http' && upstreamProtocol !== 'https') {
errors.push('upstreamProtocol must be "http" or "https"');
}
// 6. headers — each key must be a valid HTTP header name ([A-Za-z0-9-]+),
// each value must be a string with no CR/LF and no unescaped quotes.
if (headers && typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (typeof key !== 'string' || !/^[A-Za-z0-9-]+$/.test(key)) {
errors.push(`header key "${String(key)}" must be HTTP-token chars only ([A-Za-z0-9-])`);
}
if (typeof value !== 'string') {
errors.push(`header "${key}" value must be a string`);
continue;
}
if (/[\r\n]/.test(value)) {
errors.push(`header "${key}" value must not contain CR or LF`);
}
}
}
// 7. stripPrefix — must be a leading-slash path with safe chars. Reject
// braces, quotes, whitespace, and { } which would let the attacker
// open a new Caddyfile block.
if (stripPrefix != null) {
if (typeof stripPrefix !== 'string' || !/^\/[A-Za-z0-9._\-/]*$/.test(stripPrefix)) {
errors.push('stripPrefix must be an absolute path (letters, digits, dots, hyphens, slashes)');
}
}
return { valid: errors.length === 0, errors };
}
/**
* Escape a string for safe interpolation inside a Caddyfile quoted-string
* context. Caddy uses the same backslash-escape semantics as JSON-ish
* contexts `\` and `"` MUST be escaped, otherwise the attacker breaks out
* of the quoted string and injects arbitrary directives.
*
* @param {string} s raw header value
* @returns {string} escaped value (no embedded newlines; CR/LF were already
* rejected by the validator)
*/
function escapeCaddyQuotedString(s) {
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* Generate a Caddyfile site block from a structured config.
*
* Every interpolated field is now validated by `validateGenerationConfig`
* first (see DC-070). Quoted-string values are escaped via
* `escapeCaddyQuotedString` so a `"` in a header value cannot break out.
*
* @param {Object} config - Site configuration (already validated)
* @param {Object} config - Site configuration
* @returns {string} Caddyfile snippet
*/
function generateSiteBlock(config) {
@@ -166,15 +38,12 @@ function generateSiteBlock(config) {
const lines = [];
lines.push(`${domain} {`);
// TLS — only emit a tls directive when explicitly 'internal' or a CA
// name; 'auto' means Caddy's default behaviour (no directive needed).
// TLS
if (tls === 'internal') {
lines.push(` tls internal`);
} else if (tls === 'auto') {
// Default — Caddy auto-provisions Let's Encrypt
} else {
// CA name validated by validateGenerationConfig against
// /^[a-z0-9._-]+$/i — safe to interpolate verbatim.
} else if (typeof tls === 'string') {
lines.push(` tls ${tls}`);
}
@@ -183,8 +52,7 @@ function generateSiteBlock(config) {
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
}
// Auth gate (DashCaddy forward_auth) — authService validated by
// validateGenerationConfig against REGEX.SUBDOMAIN — safe to interpolate.
// Auth gate (DashCaddy forward_auth)
if (auth && authService) {
lines.push(` import dashcaddy_auth ${authService}`);
}
@@ -198,17 +66,16 @@ function generateSiteBlock(config) {
lines.push(` }`);
}
// Custom headers — keys validated against /^[A-Za-z0-9-]+$/, values
// escaped via escapeCaddyQuotedString before being placed inside "..."
if (headers && typeof headers === 'object' && Object.keys(headers).length > 0) {
// Custom headers
if (Object.keys(headers).length > 0) {
lines.push(` header {`);
for (const [key, value] of Object.entries(headers)) {
lines.push(` ${key} "${escapeCaddyQuotedString(value)}"`);
lines.push(` ${key} "${value}"`);
}
lines.push(` }`);
}
// Strip prefix — validated to /^\/[A-Za-z0-9._\-/]*$/ — safe.
// Strip prefix
if (stripPrefix) {
lines.push(` uri strip_prefix ${stripPrefix}`);
}
@@ -251,19 +118,6 @@ module.exports = function({ asyncHandler }) {
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
}
// DC-070: structural validation BEFORE interpolation. Every field that
// flows into the Caddyfile text must satisfy a known-safe charset rule,
// and CRLF is rejected outright. Run this BEFORE generateSiteBlock so
// the bad input is rejected with a clean 400 + enumerable error list,
// not a generated-Caddyfile + 500.
const validation = validateGenerationConfig(config);
if (!validation.valid) {
return errorResponse(res, 400, 'Invalid configuration', {
code: 'DC-CCD-700',
errors: validation.errors,
});
}
try {
const caddyfile = generateSiteBlock(config);
ok(res, { caddyfile, config });
@@ -371,11 +225,3 @@ module.exports = function({ asyncHandler }) {
return router;
};
// DC-070: export helpers for unit-testing the sanitization surface
// independently of the route handler.
module.exports.__test = {
validateGenerationConfig,
escapeCaddyQuotedString,
generateSiteBlock,
};
+11 -185
View File
@@ -37,81 +37,6 @@ const BACKUP_FILES = [
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
// DC-079: Restrict restored assets to the hardcoded ASSET_FILES allowlist.
// The asset KEYS in the snapshot are user-controlled JSON, so iterating
// `Object.entries(snapshot.assets)` and writing each name verbatim into
// `path.join(assetsDir, name)` lets an attacker POST `{assets: {"../../etc/caddy/Caddyfile":
// "<base64-evil>"}}` and overwrite the live Caddyfile via the bind-mount
// (path.join('/app/data/assets', '../../etc/caddy/Caddyfile') resolves
// to /etc/caddy/Caddyfile). This bypasses the caddyfile-staging gate
// above because the dataDir bind-mount can write to /etc/caddy on the host.
const ASSET_KEY_RE = /^[a-zA-Z0-9._-]+$/;
const ASSET_PATH_TRAVERSAL_RE = /(^|\/)\.\.($|\/)|^\//;
// DC-079: Caddyfile content safety limits for disaster-recovery restore.
// The live Caddyfile on DNS2 is ~17 KB and grows linearly with vhost count.
// Express's default JSON body parser limit (1 MB) is the outer gate; this
// in-handler cap is defense-in-depth against either a future body-limit
// raise or a custom body parser. Cap well below the body-parser ceiling.
const MAX_CADDYFILE_BYTES = 512 * 1024; // 512 KiB — 30x the live file, far below 1 MB body limit
// DC-079: theme filenames must match this pattern. No slashes (no path
// traversal), no `..`, must end in `.json`, and only filename-safe chars.
// Themes are written to <dataDir>/themes/<name>; we also defense-in-depth
// check the resolved path stays inside that dir.
const THEME_NAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
function assertSafeAssetKey(key) {
if (typeof key !== 'string' || key.length === 0 || key.length > 128) {
throw new Error(`asset key must be a non-empty string up to 128 chars`);
}
if (ASSET_PATH_TRAVERSAL_RE.test(key) || !ASSET_KEY_RE.test(key)) {
throw new Error(`asset key contains forbidden characters or path segments`);
}
}
function assertSafeThemeName(name) {
if (typeof name !== 'string' || name.length === 0 || name.length > 128) {
throw new Error(`theme name must be a non-empty string up to 128 chars`);
}
if (!THEME_NAME_RE.test(name)) {
throw new Error(`theme name must match ${THEME_NAME_RE} (alphanum / dot / dash / underscore, ending in .json)`);
}
}
// Reject Caddyfile content that smuggles in arbitrary `import` directives.
// caddy-apply expects the single top-level Caddyfile; any `import` to an
// absolute path means "load another file from disk at Caddy reload time" —
// that's a classic injection vector (an attacker can craft a snapshot whose
// `import /etc/caddy/external.caddy` reads any file Caddy can read).
// We allow the relative-style `import <snippet>` form ONLY if the snippet
// name matches a small allowlist of well-known Caddy snippet names (none
// today; add explicit names if a future snippet module is needed).
const FORBIDDEN_IMPORT_RE = /^\s*import\s+(["']|\/|\.\.|~\/|%[A-F0-9]{2})/im;
function validateCaddyfileContent(content) {
if (typeof content !== 'string') {
return { ok: false, error: 'Caddyfile content must be a string' };
}
if (content.length === 0) {
return { ok: false, error: 'Caddyfile content is empty' };
}
if (Buffer.byteLength(content, 'utf8') > MAX_CADDYFILE_BYTES) {
return { ok: false, error: `Caddyfile content exceeds ${MAX_CADDYFILE_BYTES} bytes` };
}
if (FORBIDDEN_IMPORT_RE.test(content)) {
// Allow the canonical single-quoted snippet import form ONLY if the
// snippet name is on the explicit allowlist (currently empty). This
// catches absolute paths, ../, ~/, and URL-encoded payloads while
// leaving room for future snippet additions without touching this gate.
return {
ok: false,
error: 'Caddyfile contains forbidden `import` directive (absolute path, encoded, or non-allowlisted snippet)'
};
}
return { ok: true };
}
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
const router = express.Router();
@@ -119,15 +44,6 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
let lastBackupStatus = { timestamp: null, status: null, size: null };
let lastRestoreStatus = { timestamp: null, status: null };
// DC-079: Staging dir for the candidate Caddyfile. The disaster-recovery
// restore endpoint stages here instead of writing directly to the live
// Caddyfile path. The operator must run `caddy-apply` (or its equivalent)
// to validate + reload + git-commit the staged file. This keeps the live
// Caddyfile under the same atomic-commit guard as every other edit.
function getStagedCaddyfileDir(dataDir) {
return path.join(dataDir, 'disaster-staged');
}
/**
* POST /api/v1/disaster/backup
* Creates a complete system snapshot as a downloadable JSON file.
@@ -259,64 +175,13 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
}
}
// DC-079: Stage the Caddyfile to a staging path inside dataDir
// instead of writing directly to caddyfilePath (which is the LIVE
// /etc/caddy/Caddyfile bind-mounted into the container as /caddyfile).
//
// Threat model (defense-in-depth, mirrors DC-070 / DC-074 / DC-076):
// the endpoint is TOTP-gated, but a compromised operator / phished
// session / pivot path could POST a snapshot with `caddyfile: <evil>`
// and the pre-fix code would call `fsp.writeFile(caddyfilePath, ...)`
// which writes the attacker-controlled string straight to the live
// Caddyfile. Caddy then reads that file on the next reload (which can
// be triggered by ACME renewals, health probes, or any admin API
// touch), executing whatever directives the attacker embedded:
// - `admin off` + arbitrary config write
// - `import /etc/caddy/<anything-caddy-can-read>` for content theft
// - `reverse_proxy` to attacker-controlled upstreams
// - `acme_ca` override to attacker CA
// - `log` directives to attacker-writable paths
//
// The Caddyfile is managed by the `caddy-apply` wrapper (validates +
// reloads + git-commits atomically — see CLAUDE.md hard rule). This
// endpoint previously bypassed that wrapper. The fix stages the
// candidate file under dataDir/disaster-staged/Caddyfile.candidate and
// returns the path so the operator can apply it via the normal flow.
const caddyfileStaged = [];
// DC-079: handle three cases for the caddyfile field:
// - absent/null/undefined: back-compat — no Caddyfile in snapshot
// - empty string "": explicit empty payload is suspicious — reject
// - non-string (object/array/number): type confusion attempt — reject
// - valid string: stage to dataDir/disaster-staged/Caddyfile.candidate
if (snapshot.caddyfile !== undefined && snapshot.caddyfile !== null) {
const validation = validateCaddyfileContent(snapshot.caddyfile);
if (!validation.ok) {
return errorResponse(res, 400, `Invalid Caddyfile in snapshot: ${validation.error}`, {
code: ErrorCodes.BACKUP.INVALID_CONFIG,
});
}
const stagedDir = getStagedCaddyfileDir(dataDir);
// Restore Caddyfile
if (snapshot.caddyfile) {
try {
await fsp.mkdir(stagedDir, { recursive: true });
const stagedPath = path.join(stagedDir, 'Caddyfile.candidate');
// Atomic write: write to .candidate.tmp then rename. The live
// Caddyfile is NEVER touched from this endpoint.
const tmpPath = stagedPath + '.tmp';
await fsp.writeFile(tmpPath, snapshot.caddyfile, { mode: 0o644 });
await fsp.rename(tmpPath, stagedPath);
caddyfileStaged.push({
file: 'Caddyfile',
stagedPath,
action: 'awaiting caddy-apply',
livePath: caddyfilePath,
});
if (log) log.info('disaster-recovery', 'Caddyfile staged (not applied)', {
stagedPath,
size: Buffer.byteLength(snapshot.caddyfile, 'utf8'),
});
await fsp.writeFile(caddyfilePath, snapshot.caddyfile);
restored.push('Caddyfile');
} catch (err) {
errors.push({ file: 'Caddyfile (staging)', error: err.message });
errors.push({ file: 'Caddyfile', error: err.message });
}
}
@@ -324,20 +189,8 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
try {
// DC-079: assets directory is the first attack surface that
// bypasses the Caddyfile-staging gate. `name` is a user-supplied
// JSON key; without validation, `path.join(assetsDir, name)` lets
// an attacker escape to /etc/caddy via path traversal.
assertSafeAssetKey(name);
const resolved = path.resolve(assetsDir, name);
// Defense-in-depth: even after charset checks, the resolved path
// MUST stay inside assetsDir. If it doesn't, refuse the write.
if (!resolved.startsWith(path.resolve(assetsDir) + path.sep) &&
resolved !== path.resolve(assetsDir)) {
throw new Error(`asset path resolves outside assets directory`);
}
await fsp.mkdir(assetsDir, { recursive: true });
await fsp.writeFile(resolved, Buffer.from(base64, 'base64'));
await fsp.writeFile(path.join(assetsDir, name), Buffer.from(base64, 'base64'));
restored.push(`assets/${name}`);
} catch (err) {
errors.push({ file: `assets/${name}`, error: err.message });
@@ -350,21 +203,8 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
try {
await fsp.mkdir(themesDir, { recursive: true });
for (const [name, content] of Object.entries(snapshot.themes)) {
// DC-079: same path-traversal vector as assets — keys are
// user-controlled JSON. Validate the name AND confirm the
// resolved path stays inside themesDir.
try {
assertSafeThemeName(name);
const resolved = path.resolve(themesDir, name);
if (!resolved.startsWith(path.resolve(themesDir) + path.sep) &&
resolved !== path.resolve(themesDir)) {
throw new Error(`theme path resolves outside themes directory`);
}
await fsp.writeFile(resolved, JSON.stringify(content, null, 2));
restored.push(`themes/${name}`);
} catch (err) {
errors.push({ file: `themes/${name}`, error: err.message });
}
await fsp.writeFile(path.join(themesDir, name), JSON.stringify(content, null, 2));
restored.push(`themes/${name}`);
}
} catch (err) {
errors.push({ file: 'themes', error: err.message });
@@ -375,33 +215,19 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
timestamp: new Date().toISOString(),
status: errors.length === 0 ? 'success' : 'partial',
restored: restored.length,
staged: caddyfileStaged.length,
errors: errors.length,
};
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
// DC-079: Surface the staged-Caddyfile warning in the response body so
// the UI / operator can see that the Caddyfile is NOT yet live. The
// restore endpoint stages under dataDir/disaster-staged/Caddyfile.candidate
// and the operator must run `caddy-apply` (or its equivalent) to
// validate + reload + git-commit the staged file. The live Caddyfile
// is owned by the caddy-apply wrapper per CLAUDE.md hard rule.
const responseBody = {
ok(res, {
status: errors.length === 0 ? 'success' : 'partial',
restored,
errors,
message: errors.length === 0
? `Successfully restored ${restored.length} files${caddyfileStaged.length > 0 ? ` (Caddyfile staged — ${caddyfileStaged[0].stagedPath}; run caddy-apply to apply)` : ''}. Restart DashCaddy to apply.`
? `Successfully restored ${restored.length} files. Restart DashCaddy to apply.`
: `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
};
if (caddyfileStaged.length > 0) {
responseBody.caddyfileStaged = caddyfileStaged;
responseBody.warning = '[DC-079] Caddyfile is STAGED, not applied. Live /etc/caddy/Caddyfile was NOT modified by this restore. Run `caddy-apply <reason>` (or equivalent) to validate + reload + git-commit the staged candidate.';
}
ok(res, responseBody);
});
}));
/**
+4 -17
View File
@@ -8,17 +8,12 @@
* 3. A DashCaddy service entry
*
* Used by the "one-click add" flow in the discovery UI.
*
* DC-064: Caddy admin API safety uses `fetchT` (with Origin + CSRF cookie
* plumbing via http.js) instead of raw `fetch`, and resolves the admin URL
* from the injected `caddy` context's `adminUrl` (which itself falls back to
* `process.env.CADDY_ADMIN_URL`) instead of a hardcoded `localhost:2019`.
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const { ErrorCodes } = require('../src/utilities/error-codes');
module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig, asyncHandler, fetchT }) {
module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig, asyncHandler }) {
const router = express.Router();
/**
@@ -70,15 +65,7 @@ module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig
const tld = siteConfig?.tld || '.sami';
const domain = `${serviceId}${tld}`;
const upstreamHost = protocol === 'https' ? 'https' : 'http';
// DC-064: resolve the Caddy admin URL from the caddy context (which
// itself defaults to process.env.CADDY_ADMIN_URL via context/caddy.js).
// Never hardcode localhost:2019 — non-loopback Caddy binds disable
// enforce_origin and the raw fetch below would 403. Using fetchT (when
// provided) includes the Origin header that satisfies enforce_origin;
// when fetchT is null we fall back to raw fetch but ONLY for tests that
// explicitly mock the admin URL.
const caddyAdminUrl = caddy?.adminUrl || process.env.CADDY_ADMIN_URL || 'http://localhost:2019';
const httpClient = typeof fetchT === 'function' ? fetchT : fetch;
const caddyAdminUrl = 'http://localhost:2019';
const result = {
service: null,
@@ -132,8 +119,8 @@ module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig
terminal: true,
};
// Add via Caddy admin API (via fetchT so Origin header is present)
const response = await httpClient(`${caddyAdminUrl}/config/apps/http/servers/srv0/routes`, {
// Add via Caddy admin API
const response = await fetch(`${caddyAdminUrl}/config/apps/http/servers/srv0/routes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(routeConfig),
+3 -81
View File
@@ -1,6 +1,5 @@
const express = require('express');
const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../src/utilities/errors');
/**
* Disk space management routes
@@ -11,76 +10,6 @@ const { ValidationError } = require('../src/utilities/errors');
* POST /disk/config update disk budget settings
* POST /disk/cleanup trigger manual cleanup (standard|aggressive|logs-only)
*/
// DC-059: monotonic-ordering invariant for the three threshold percentages.
// DiskSpaceMonitor._getBudgetStatus() walks them in order
// (cleanupAggressivePct → criticalThresholdPct → warningThresholdPct) and
// returns at the FIRST threshold the usage crosses. If a caller writes
// them out of order (e.g. warningThresholdPct=95, criticalThresholdPct=60),
// the higher-priority branches become unreachable and the monitor silently
// misclassifies budget state. Validate against the *effective* config
// (current value + incoming update for each field) so partial updates can
// be applied one field at a time without violating the invariant.
//
// Clamp values to the same ranges the previous inline Math.min/Math.max
// chains enforced (warning 50..99, critical 60..99, aggressive 70..99)
// so we don't loosen the original bounds while adding the new check.
const THRESHOLD_BOUNDS = Object.freeze({
warning: { min: 50, max: 99 },
critical: { min: 60, max: 99 },
aggressive: { min: 70, max: 99 },
});
function clampThreshold(name, value) {
const { min, max } = THRESHOLD_BOUNDS[name];
return Math.min(Math.max(value, min), max);
}
/**
* Apply a candidate update to a baseline config, then verify the three
* threshold percentages still satisfy
* warningThresholdPct < criticalThresholdPct < cleanupAggressivePct.
* The POST /config endpoint accepts partial updates (single field at a
* time), so we merge into the live diskSpaceMonitor config first, then test
* the merged value. Returns the merged candidate on success; throws
* ValidationError if the ordering invariant would be violated.
*
* @param {Object} baseline - current effective config from diskSpaceMonitor
* @param {Object} candidate - the partial update being applied this request
* @returns {Object} merged candidate with thresholds clamped to bounds
*/
function mergeAndCheckOrdering(baseline, candidate) {
const next = { ...baseline };
if (typeof candidate.warningThresholdPct === 'number') {
next.warningThresholdPct = clampThreshold('warning', candidate.warningThresholdPct);
}
if (typeof candidate.criticalThresholdPct === 'number') {
next.criticalThresholdPct = clampThreshold('critical', candidate.criticalThresholdPct);
}
if (typeof candidate.cleanupAggressivePct === 'number') {
next.cleanupAggressivePct = clampThreshold('aggressive', candidate.cleanupAggressivePct);
}
if (!(next.warningThresholdPct < next.criticalThresholdPct)) {
throw new ValidationError(
`warningThresholdPct (${next.warningThresholdPct}) must be strictly less than criticalThresholdPct (${next.criticalThresholdPct})`,
'warningThresholdPct'
);
}
if (!(next.criticalThresholdPct < next.cleanupAggressivePct)) {
throw new ValidationError(
`criticalThresholdPct (${next.criticalThresholdPct}) must be strictly less than cleanupAggressivePct (${next.cleanupAggressivePct})`,
'criticalThresholdPct'
);
}
// Return only the fields the caller asked to change (preserves partial-
// update semantics; diskSpaceMonitor.configure does its own merge).
const out = {};
if (typeof candidate.warningThresholdPct === 'number') out.warningThresholdPct = next.warningThresholdPct;
if (typeof candidate.criticalThresholdPct === 'number') out.criticalThresholdPct = next.criticalThresholdPct;
if (typeof candidate.cleanupAggressivePct === 'number') out.cleanupAggressivePct = next.cleanupAggressivePct;
return out;
}
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
const router = express.Router();
@@ -107,16 +36,9 @@ module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
const updates = {};
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
// DC-059: threshold percentages must satisfy a strict monotonic order
// (warning < critical < aggressive) so _getBudgetStatus() reaches the
// correct branch. mergeAndCheckOrdering() validates against the live
// baseline, so partial updates that violate the invariant are rejected
// BEFORE we mutate diskSpaceMonitor.diskConfig.
const thresholdUpdates = mergeAndCheckOrdering(
diskSpaceMonitor.getConfig(),
{ warningThresholdPct, criticalThresholdPct, cleanupAggressivePct }
);
Object.assign(updates, thresholdUpdates);
if (typeof warningThresholdPct === 'number') updates.warningThresholdPct = Math.min(Math.max(warningThresholdPct, 50), 99);
if (typeof criticalThresholdPct === 'number') updates.criticalThresholdPct = Math.min(Math.max(criticalThresholdPct, 60), 99);
if (typeof cleanupAggressivePct === 'number') updates.cleanupAggressivePct = Math.min(Math.max(cleanupAggressivePct, 70), 99);
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
if (typeof enabled === 'boolean') updates.enabled = enabled;
+124 -216
View File
@@ -1,29 +1,83 @@
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const { exists } = require('../src/utilities/fs-helpers');
const { success, error: errorResponse } = require('../src/utils/responses');
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
const { success } = require('../src/utils/responses');
const { ValidationError } = require('../src/utilities/errors');
// The unified error logger writes entries separated by a long horizontal-rule
// line made of U+2500 BOX DRAWINGS LIGHT HORIZONTAL (verified 2026-08-18
// against /opt/dashcaddy/dashcaddy-api/data/error.log on DNS2 — the previous
// implementation split on '='.repeat(80), which returned ONE block and
// produced ZERO entries for the modal). Anything else got dropped silently.
const ENTRY_SEPARATOR_RE = /\n\u2500{20,}\n?/;
const ENTRY_HEADER_RE = /^\[([^\]]+)\]\s+\[([A-Z]+)\]\s+(.*?):\s*(.*)$/;
const MAX_TAIL = 500;
const MAX_TAIL_BYTES = 2 * 1024 * 1024; // never read more than 2 MiB from disk
/**
* Parse the unified error-log format into structured entries.
* Each entry:
* [2026-08-16T23:13:14.123Z] [ERR] ctx: message
* <stack trace lines, if any>
* request: ... (optional)
* context: {...} (optional)
* (separator)
* @param {string} text
* @returns {Array<{timestamp:string,level:string,context:string,message:string,details:string|null}>}
*/
function parseEntries(text) {
if (!text) return [];
const blocks = text.split(ENTRY_SEPARATOR_RE);
const entries = [];
for (const block of blocks) {
const trimmed = block.replace(/^\n+|\n+$/g, '');
if (!trimmed) continue;
const firstLineEnd = trimmed.indexOf('\n');
const firstLine = firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd);
const rest = firstLineEnd === -1 ? '' : trimmed.slice(firstLineEnd + 1);
const m = firstLine.match(ENTRY_HEADER_RE);
if (!m) continue;
entries.push({
timestamp: m[1],
level: m[2],
context: m[3],
message: m[4],
details: rest ? rest.replace(/\n+$/g, '') : null,
});
}
return entries;
}
/**
* Read the last N bytes of a UTF-8 file safely (so the 4 MiB log doesn't
* blow up memory or block the event loop). Splits on the first complete
* line boundary after the cut.
*/
async function readTailBytes(filePath, byteLimit) {
const fh = await fsp.open(filePath, 'r');
try {
const stat = await fh.stat();
const start = Math.max(0, stat.size - byteLimit);
const length = stat.size - start;
const buf = Buffer.alloc(length);
await fh.read(buf, 0, length, start);
let text = buf.toString('utf8');
// If we cut into the middle of a UTF-8 sequence, drop the partial char
const partialLead = text.match(/[\uD800-\uDBFF]$/);
if (partialLead) text = text.slice(0, -1);
// Drop a half first line so we never start mid-entry
const nl = text.indexOf('\n');
if (start > 0 && nl !== -1) text = text.slice(nl + 1);
return { text, totalSize: stat.size, truncated: start > 0 };
} finally {
await fh.close();
}
}
/**
* Error logs routes factory
*
* DC-052: Enhanced the legacy `GET /error-logs` tail handler with:
* - Server-side filtering by level (ERR / WARN), context (substring),
* free-text search across error+message+stack, and time window (since/until).
* - Real pagination via limit/offset (the legacy handler returned only the
* last 50 entries, which made it impossible to inspect older entries
* once the file grew past 5MB the logging module rotates at 5MB).
* - Distinct-context endpoint for populating the frontend filter dropdown.
* - Confirm=CLEAR gating on DELETE so an accidental click can't wipe
* forensic context (matches the audit-log DC-050 hardening).
*
* The audit-log routes that previously lived here moved to
* `routes/audit-log.js` (DC-050). We keep thin proxy handlers so any
* client still talking to /api/v1/audit-logs gets the new behaviour
* without an extra hop the actual route module is preferred when
* mounted, but this defensive duplicate means a partial deploy
* (apiRouter only loads this file) still serves correct answers.
*
* @param {Object} deps - Explicit dependencies
* @param {string} deps.ERROR_LOG_FILE - Path to error log file
* @param {Object} deps.auditLogger - Audit logger instance
@@ -33,216 +87,70 @@ const { success, error: errorResponse } = require('../src/utils/responses');
module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
const router = express.Router();
// ── DC-052: Robust entry parser ────────────────────────────────────────
// The error log format produced by src/utils/logging.js is:
// [ISO_TIMESTAMP] [LEVEL] ctx: message
// <stack frames...>
// request: ... | ip: ... | ua: ... | id: ...
// context: {...}
// ──── (80 equal-signs) ────
// Anything between two 80-equal lines is one entry. The legacy parser
// assumed `[ts] ctx: msg` with no LEVEL field; we now extract LEVEL and
// collapse multi-line context/request blocks into structured fields so the
// frontend can filter/search on them.
const ENTRY_SEP = '='.repeat(80);
const HEADER_RE = /^\[([^\]]+)\] \[([^\]]+)\] ([^:]+): (.*)$/;
const REQUEST_RE = /request:\s*(.*?)\s*\|\s*ip:\s*(\S+)\s*\|\s*ua:\s*(.*?)\s*\|\s*id:\s*(\S+)/;
const CONTEXT_RE = /context:\s*(\{[^\n]*\})\s*$/m;
function parseEntries(logContent) {
const raw = logContent.split(ENTRY_SEP);
const entries = [];
for (const block of raw) {
const trimmed = block.trim();
if (!trimmed) continue;
const lines = trimmed.split('\n');
const headerLine = lines[0];
const m = headerLine.match(HEADER_RE);
if (!m) {
// Unknown shape — keep it as a "raw" entry so nothing gets silently
// dropped from the operator's view.
entries.push({
timestamp: null,
level: null,
context: null,
error: trimmed,
request: null,
contextJson: null,
raw: trimmed,
_rawTimestamp: 0,
});
continue;
}
const [, timestamp, level, context, message] = m;
const bodyLines = lines.slice(1);
const bodyText = bodyLines.join('\n');
const reqMatch = bodyText.match(REQUEST_RE);
const ctxMatch = bodyText.match(CONTEXT_RE);
let contextJson = null;
if (ctxMatch) {
try { contextJson = JSON.parse(ctxMatch[1]); } catch { /* leave as null */ }
}
entries.push({
timestamp,
level,
context,
error: message,
request: reqMatch ? {
method_path: reqMatch[1] || '',
ip: reqMatch[2] || '',
ua: reqMatch[3] || '',
id: reqMatch[4] || '',
} : null,
contextJson,
// The full multi-line block (header + stack + request + context) for
// the "click to expand" detail view in the UI.
detail: trimmed,
_rawTimestamp: timestamp ? Date.parse(timestamp) || 0 : 0,
});
}
return entries;
}
// Validate ISO timestamp strings (since/until) — accept anything
// Date.parse() understands so we don't reject a bare "2026-08-17".
function parseTimestamp(raw, fieldName) {
if (!raw) return null;
const t = Date.parse(raw);
if (Number.isNaN(t)) {
throw new Error(`Invalid ${fieldName} timestamp: ${raw}`);
}
return t;
}
// Cap limit so a misconfigured client can't ask for the entire log
// (which could be tens of MB on long-running installs).
const MAX_LIMIT = 500;
const DEFAULT_LIMIT = 50;
// ── DC-052: Distinct contexts endpoint ─────────────────────────────────
// The frontend uses this to populate the "Context" dropdown so operators
// can drill into one subsystem (e.g. all "updater" or "http" errors).
router.get('/error-logs/contexts', asyncHandler(async (req, res) => {
if (!await exists(ERROR_LOG_FILE)) {
return success(res, { contexts: [] });
}
const logContent = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
const entries = parseEntries(logContent);
const counts = new Map();
for (const e of entries) {
if (!e.context) continue;
counts.set(e.context, (counts.get(e.context) || 0) + 1);
}
const contexts = Array.from(counts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
success(res, { contexts });
}, 'error-logs-contexts'));
// ── DC-052: Enhanced GET /error-logs ───────────────────────────────────
// Get error logs
// GET /api/v1/error-logs?tail=100&level=ERR
// - tail: cap on returned entries (default 100, max 500)
// - level: filter by level (ERR/WARN/INFO/DBG) — case-insensitive
router.get('/error-logs', asyncHandler(async (req, res) => {
const level = (req.query.level || '').toString().trim();
const context = (req.query.context || '').toString().trim();
const search = (req.query.search || '').toString().trim();
let since, until;
try {
since = parseTimestamp(req.query.since, 'since');
until = parseTimestamp(req.query.until, 'until');
} catch (e) {
return errorResponse(res, e.message, 400);
}
if (level && !['ERR', 'WARN', 'INFO', 'DEBUG'].includes(level)) {
return errorResponse(res, `Unknown level: ${level}`, 400);
}
const limit = Math.min(
Math.max(parseInt(req.query.limit, 10) || DEFAULT_LIMIT, 1),
MAX_LIMIT
);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
if (!await exists(ERROR_LOG_FILE)) {
return success(res, {
logs: [],
total: 0,
hasMore: false,
filters: { level: level || null, context: context || null, search: search || null, since: null, until: null },
});
return success(res, { logs: [], totalSize: 0, truncated: false });
}
const logContent = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
let entries = parseEntries(logContent);
let tailRaw = parseInt(req.query.tail, 10);
if (!Number.isFinite(tailRaw) || tailRaw <= 0) tailRaw = 100;
const tail = Math.min(tailRaw, MAX_TAIL);
// Filter chain — order matters: the cheapest predicate runs first so we
// skip work on entries the others would also reject.
if (level) entries = entries.filter((e) => e.level === level);
if (context) entries = entries.filter((e) => (e.context || '').includes(context));
if (since != null) entries = entries.filter((e) => e._rawTimestamp >= since);
if (until != null) entries = entries.filter((e) => e._rawTimestamp <= until);
if (search) {
const needle = search.toLowerCase();
entries = entries.filter((e) => {
if ((e.error || '').toLowerCase().includes(needle)) return true;
if ((e.context || '').toLowerCase().includes(needle)) return true;
if (e.detail && e.detail.toLowerCase().includes(needle)) return true;
if (e.request && e.request.ip && e.request.ip.toLowerCase().includes(needle)) return true;
return false;
});
const levelFilter = req.query.level ? String(req.query.level).toUpperCase() : null;
const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES);
let logs = parseEntries(text);
if (levelFilter) {
logs = logs.filter(e => e.level === levelFilter);
}
// Sort newest first; entries without a parseable timestamp sink to the
// bottom (Date.parse returns NaN → _rawTimestamp=0).
entries.sort((a, b) => b._rawTimestamp - a._rawTimestamp);
// Newest first; bounded by `tail`
logs = logs.slice(-tail).reverse();
const total = entries.length;
const page = entries.slice(offset, offset + limit);
// Strip the internal field so it doesn't leak into the wire response.
const logs = page.map(({ _rawTimestamp, ...rest }) => rest);
success(res, {
logs,
total,
hasMore: offset + logs.length < total,
filters: {
level: level || null,
context: context || null,
search: search || null,
since: req.query.since || null,
until: req.query.until || null,
},
});
success(res, { logs, totalSize, truncated, returned: logs.length });
}, 'error-logs-get'));
// Clear error logs (gated by confirm=CLEAR — DC-052)
// Clear error logs
router.delete('/error-logs', asyncHandler(async (req, res) => {
const confirm = (req.body && req.body.confirm) || '';
if (confirm !== 'CLEAR') {
return errorResponse(res, 'Body must include { confirm: "CLEAR" }', 400);
if (!await exists(ERROR_LOG_FILE)) {
return success(res, { message: 'Error logs cleared', cleared: 0 });
}
if (await exists(ERROR_LOG_FILE)) {
await fsp.writeFile(ERROR_LOG_FILE, '');
}
// Audit the clear BEFORE returning so the wipe itself is recorded.
try {
if (auditLogger && typeof auditLogger.log === 'function') {
await auditLogger.log({
action: 'error-log.clear',
resource: 'all',
outcome: 'success',
details: { source: 'error-logs/DELETE' },
});
}
} catch { /* don't fail the clear on audit failure */ }
success(res, { message: 'Error logs cleared' });
const before = await fsp.stat(ERROR_LOG_FILE).then(s => s.size).catch(() => 0);
await fsp.writeFile(ERROR_LOG_FILE, '');
success(res, { message: 'Error logs cleared', clearedBytes: before });
}, 'error-logs-clear'));
// DC-052 fix: removed the legacy /audit-logs GET/DELETE proxies that lived
// here before DC-050. GLM judge round-1 flagged this as HIGH-severity:
// because errorLogsRoutes is mounted in src/app.js (line 733) BEFORE
// auditLogRoutes (line 789), these legacy handlers shadowed DC-050's
// hardened versions — DELETE without confirm=CLEAR would silently wipe the
// audit log, GET filters (action whitelist, ISO since/until, outcome) were
// never invoked, and /audit-logs/actions was unreachable. The hardened
// handlers in routes/audit-log.js are the single source of truth now.
// Audit log
router.get('/audit-logs', asyncHandler(async (req, res) => {
const paginationParams = parsePaginationParams(req.query);
const action = req.query.action || '';
if (paginationParams) {
const entries = await auditLogger.query({ limit: Number.MAX_SAFE_INTEGER, offset: 0, action });
const result = paginate(entries, paginationParams);
success(res, { entries: result.data, pagination: result.pagination });
} else {
const limit = parseInt(req.query.limit) || 50;
const offset = parseInt(req.query.offset) || 0;
const entries = await auditLogger.query({ limit, offset, action });
success(res, { entries });
}
}, 'audit-log'));
router.delete('/audit-logs', asyncHandler(async (req, res) => {
await auditLogger.clear();
success(res, { message: 'Audit log cleared' });
}, 'audit-log-clear'));
return router;
};
// Exported for unit testing
module.exports.parseEntries = parseEntries;
module.exports.readTailBytes = readTailBytes;
module.exports.MAX_TAIL = MAX_TAIL;
module.exports.MAX_TAIL_BYTES = MAX_TAIL_BYTES;
+3 -114
View File
@@ -4,50 +4,6 @@ const url = require('url');
const docker = new Docker();
/**
* DC-072: WebSocket scope authorization admin-only by default.
*
* Container exec is full root-equivalent access inside the target
* container. Granting it to a key whose scope is `['read']` violates
* least privilege. The validScopes list (`['read','write','admin']`)
* is defined in routes/auth/keys.js; exec requires `admin`.
*
* Defensive: the scope field is coerced via `Array.isArray(...) ? ... : []`
* so a malformed payload (string, object, null, undefined) cannot reach
* `.includes('admin')` and accidentally grant access. Every malformed
* shape falls into the rejection branch with the same 403 envelope.
*
* Tests should call `__test.assertExecScope(auth)` directly rather
* than spinning up a WebSocket server.
*/
function assertExecScope(auth) {
const scope = Array.isArray(auth && auth.scope) ? auth.scope : [];
if (!scope.includes('admin')) {
const err = new Error('Container exec requires admin scope');
err.code = 'DC-072_INSUFFICIENT_SCOPE';
err.statusCode = 403;
err.requiredScope = 'admin';
err.actualScope = scope;
throw err;
}
}
/**
* DC-072: Tighten containerId validation.
*
* Docker container IDs are exactly 64 lowercase hex chars (or 12-char
* short form). The pre-fix regex accepted `_`, `-`, `.`, mixed case,
* and up to 128 chars Docker would then 404 the inspect call and
* the rejection would surface as a generic 500 in the WS error
* envelope. Pre-validate at the upgrade layer so the rejection is
* fast and the log line discriminates "malformed" from "unknown".
*/
function isValidContainerId(id) {
if (typeof id !== 'string') return false;
// Full 64-char hex, or 12-char short hex
return /^[0-9a-f]{64}$/.test(id) || /^[0-9a-f]{12}$/.test(id);
}
/**
* Attach WebSocket server for container exec/shell
* Route: ws://host/ws/exec/:containerId
@@ -65,8 +21,8 @@ module.exports = function attachExecWS(server, log, authManager) {
const containerId = decodeURIComponent(match[1]);
// DC-072: Tighten containerId charset (64-char / 12-char lowercase hex)
if (!isValidContainerId(containerId)) {
// Validate container ID format to prevent injection
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerId)) {
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
@@ -99,35 +55,6 @@ module.exports = function attachExecWS(server, log, authManager) {
return;
}
// DC-072: Container exec is root-equivalent — require admin scope.
// Pre-fix, a key issued with scope `['read']` (e.g., for monitoring)
// would get a full PTY shell inside any running container. The
// `auth.scope` was captured at lines 39/46 but never checked.
try {
assertExecScope(auth);
} catch (err) {
log.warn('exec', 'Insufficient scope for exec attempt', {
containerId,
authType: auth.type,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
actualScope: err.actualScope,
requiredScope: err.requiredScope,
ip: req.socket.remoteAddress,
});
// 403 with a JSON error envelope over the upgrade socket so the
// dashboard can display "admin required" instead of guessing.
socket.write('HTTP/1.1 403 Forbidden\r\n');
socket.write('Content-Type: application/json\r\n');
socket.write('\r\n');
socket.end(JSON.stringify({
error: err.message,
code: err.code,
requiredScope: err.requiredScope,
actualScope: err.actualScope,
}));
return;
}
// Auth passed — proceed with WebSocket upgrade
wss.handleUpgrade(req, socket, head, (ws) => {
handleExec(ws, containerId, log, auth);
@@ -140,7 +67,6 @@ module.exports = function attachExecWS(server, log, authManager) {
async function handleExec(ws, containerId, log, auth) {
let execStream = null;
let execInstance = null;
const sessionStart = Date.now();
try {
const container = docker.getContainer(containerId);
@@ -152,13 +78,10 @@ async function handleExec(ws, containerId, log, auth) {
return;
}
// DC-072: Audit-log the exec session start. Pairs with the end-log
// below so the operator can correlate who opened which shell.
log.info('exec', 'Authenticated exec session started', {
containerId,
authType: auth.type,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
containerName: info.Name,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId
});
// Detect available shell
@@ -197,28 +120,7 @@ async function handleExec(ws, containerId, log, auth) {
}
});
// DC-072: Track whether the end-log has fired so we don't double-log
// when both execStream 'end' and ws 'close' fire (Docker stream end
// closes the WS, which then fires 'close' too — without the flag
// we'd emit the same audit line twice with the same durationMs).
let ended = false;
const logSessionEnd = (reason) => {
if (ended) return;
ended = true;
log.info('exec', 'Exec session ended', {
containerId,
authType: auth.type,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
durationMs: Date.now() - sessionStart,
reason,
});
};
execStream.on('end', () => {
// DC-072: Audit-log the session end (duration + container) so a
// long-running session is observable in the error log. Normal
// shutdown path: Docker exec stream closes → log + tell client.
logSessionEnd('exec-stream-end');
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'exit' }));
ws.close();
@@ -246,11 +148,6 @@ async function handleExec(ws, containerId, log, auth) {
});
ws.on('close', () => {
// DC-072: Fallback audit-log for abnormal close (browser tab
// closed, network drop, container killed mid-session) where the
// execStream 'end' event never fires. The ended-flag guard makes
// this idempotent with the normal path above.
logSessionEnd('ws-close');
if (execStream) {
try { execStream.destroy(); } catch (_) {
// Ignore stream teardown errors on socket close
@@ -275,11 +172,3 @@ async function handleExec(ws, containerId, log, auth) {
}
}
}
// Internal-only export for unit tests. Stripped from the public
// surface; tests import this via the destructure form
// `const { __test } = require('./routes/exec')`.
module.exports.__test = {
assertExecScope,
isValidContainerId,
};
+54 -224
View File
@@ -12,29 +12,6 @@
* POST /api/v1/fleet/deploy deploy to multiple hosts
*
* Host state is persisted in {dataDir}/fleet-hosts.json
*
* Security (SSRF hardening, DC-068):
* `POST /fleet/hosts` previously accepted any string as `hostname`, which
* the subsequent `GET /fleet/status` flow composed verbatim into
* `http://${hostname}:${port}/api/v1/system/health`. An authenticated
* dashboard operator could register `hostname: "127.0.0.1"` or
* `hostname: "169.254.169.254"` (cloud metadata service) and have the
* container reach that internal endpoint on their behalf. The
* `validateFleetHost()` + `resolveAndCheckAddress()` helpers in
* `src/utilities/fleet-validation.js` close that hole:
* - hostname syntax + port bounds + tag bounds (cheap, sync)
* - literal IPv4/IPv6 private-range check (sync)
* - DNS resolution + resolved-IP private-range check (async)
* - Probe URL built from the RESOLVED IP, not the user-supplied
* hostname, defeating DNS-rebinding attacks
* - Probe concurrency capped at MAX_PROBE_CONCURRENCY so a malicious or
* hung fleet can't stall the dashboard
* - `FLEET_ALLOW_PRIVATE_HOSTS=true` opt-in for Tailscale / RFC1918
* deployments where private hosts are intentional
*
* Hosts that violate validation are still surfaced in `GET /fleet/hosts`
* (operator visibility), but `GET /fleet/status` skips them and tags them
* `validation_failed` instead of probing.
*/
const express = require('express');
const fs = require('fs');
@@ -43,79 +20,13 @@ const path = require('path');
const crypto = require('crypto');
const { ok, errorResponse } = require('../src/utils/responses');
const { ErrorCodes } = require('../src/utilities/error-codes');
const {
validateFleetHost,
resolveAndCheckAddress,
} = require('../src/utilities/fleet-validation');
const HOSTS_FILE = process.env.FLEET_HOSTS_FILE || path.join(process.cwd(), 'data', 'fleet-hosts.json');
// Read lazily (per-request) so a test or operator script can flip the
// opt-in at runtime without re-requiring the module.
const ALLOW_PRIVATE_HOSTS = () => process.env.FLEET_ALLOW_PRIVATE_HOSTS === 'true';
// Cap concurrent probes in /fleet/status — a malicious fleet with N hosts
// would otherwise stall the dashboard with up to N parallel 3s timeouts.
const MAX_PROBE_CONCURRENCY = 5;
// Per-host probe timeout for /fleet/status.
const PROBE_TIMEOUT_MS = 3000;
module.exports = function({ log, asyncHandler }) {
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
const router = express.Router();
/**
* Re-validate every stored host's hostname+port (defense-in-depth against
* a hand-edited fleet-hosts.json or an environment where validation
* loosened since the entry was written). Returns the host with a
* `validation` field describing current policy compliance.
*/
async function revalidateStoredHost(host, opts = {}) {
const allowPrivate = !!opts.allowPrivate;
const v = validateFleetHost({
name: host.name,
hostname: host.hostname,
port: host.port,
tags: host.tags,
});
if (!v.ok) {
return { host, validation: { valid: false, code: v.code, message: v.message } };
}
// For DNS names, also resolve + check the resolved IP. Literal IPs are
// already validated inside validateFleetHost(). Use `net.isIP` rather
// than colon-presence heuristics so a real IPv6 with no dot is treated
// as a literal (not as a DNS name), while URL-shaped strings like
// `http://evil.com` (which contain both `:` and `/`) fall through to
// the DNS-name path and get rejected by validateFleetHost()'s hostname
// syntax check.
const net = require('net');
if (net.isIP(host.hostname) === 0) {
const r = await resolveAndCheckAddress(host.hostname, { allowPrivate });
if (!r.ok) {
return { host, validation: { valid: false, code: r.code, message: r.message } };
}
return { host, validation: { valid: true, resolvedIp: r.ip, family: r.family } };
}
return { host, validation: { valid: true } };
}
/**
* Run `worker(host)` over `hosts` with at most `MAX_PROBE_CONCURRENCY`
* concurrent workers. Preserves order in the returned array so the
* operator sees hosts in the same order they registered them.
*/
async function runWithConcurrency(hosts, worker, limit = MAX_PROBE_CONCURRENCY) {
const out = new Array(hosts.length);
let next = 0;
const runners = Array.from({ length: Math.min(limit, hosts.length) }, () => (async () => {
while (true) {
const i = next++;
if (i >= hosts.length) return;
out[i] = await worker(hosts[i], i);
}
})());
await Promise.all(runners);
return out;
}
async function loadHosts() {
const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
try {
@@ -139,85 +50,45 @@ module.exports = function({ log, asyncHandler }) {
}));
// POST /api/v1/fleet/hosts — register a new host
router.post('/fleet/hosts', wrap(async (req, res) => {
const body = req.body || {};
const { apiKey, ...rest } = body;
router.post('/fleet/hosts', wrap(async (req, res) => {
const { name, hostname, apiKey, port = 3001, tags = [] } = req.body || {};
// DC-068 SSRF hardening: synchronous structural validation first
// (hostname syntax, port bounds, tag bounds, literal-IPv4 private range).
// DNS rebinding protection runs after this via resolveAndCheckAddress().
const v = validateFleetHost(rest);
if (!v.ok) {
const logDetail = { code: v.code, message: v.message };
// Redact any user-supplied hostname in the audit log; only keep the
// error code + length, never the raw value (it may be attacker-supplied
// junk that has nothing to do with the real fleet).
if (typeof body.hostname === 'string') logDetail.hostnameLen = body.hostname.length;
if (log) log.warn('fleet', 'Host registration rejected by validation', logDetail);
return errorResponse(res, 400, v.message, { code: v.code });
}
const { name, hostname, port, tags } = v.normalized;
if (!name || !hostname) {
return errorResponse(res, 400, 'name and hostname are required', {
code: ErrorCodes.GENERAL.INVALID_INPUT,
});
}
// DC-068 DNS rebinding protection: if `hostname` is a DNS name (not a
// literal IP), resolve it now and reject the registration if the resolved
// address is private/reserved. The resolved IP is stored alongside the
// hostname so /fleet/status probes it by IP, not by re-resolving the
// name (closing the rebinding window). `net.isIP` distinguishes a real
// IPv4 dotted-quad OR IPv6 from URL-shaped junk like `http://evil.com`
// (which would otherwise be misclassified as IPv6 by a naive
// colon-presence check).
let resolvedIp = hostname;
let dnsFamily = null;
if (require('net').isIP(hostname) === 0) {
const r = await resolveAndCheckAddress(hostname, { allowPrivate: ALLOW_PRIVATE_HOSTS() });
if (!r.ok) {
if (log) log.warn('fleet', 'Host registration rejected by DNS resolution', { code: r.code, message: r.message });
return errorResponse(res, 400, r.message, { code: r.code });
}
resolvedIp = r.ip;
dnsFamily = r.family;
} else {
// Literal IP — capture the IP family so /fleet/status and
// /fleet/deploy can bracket-wrap IPv6 correctly when probes/URLs
// are built from the resolved IP. resolvedIp stays equal to the
// literal hostname so the existing test invariant still holds.
dnsFamily = require('net').isIP(hostname);
}
const hosts = await loadHosts();
const hosts = await loadHosts();
// Check for duplicate
if (hosts.some(h => h.hostname === hostname)) {
return errorResponse(res, 409, `Host ${hostname} already registered`, {
code: ErrorCodes.GENERAL.CONFLICT,
});
}
// Check for duplicate (compare on the original hostname string, not the
// resolved IP — operators know their hosts by name).
if (hosts.some(h => h.hostname === hostname)) {
return errorResponse(res, 409, `Host ${hostname} already registered`, {
code: ErrorCodes.GENERAL.CONFLICT,
});
}
const host = {
id: crypto.randomUUID(),
name,
hostname,
port,
apiKey: apiKey ? '***' : null, // Never store the actual key
apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null,
tags,
status: 'unknown',
registeredAt: new Date().toISOString(),
lastSeen: null,
containerCount: null,
};
const host = {
id: crypto.randomUUID(),
name,
hostname,
port,
tags,
status: 'unknown',
registeredAt: new Date().toISOString(),
lastSeen: null,
containerCount: null,
// DNS rebinding protection — probe by this IP, not by re-resolving.
resolvedIp,
dnsFamily,
apiKey: apiKey ? '***' : null, // Never store the actual key
apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null,
};
hosts.push(host);
await saveHosts(hosts);
hosts.push(host);
await saveHosts(hosts);
if (log) log.info('fleet', 'Host registered', { name, hostname });
if (log) log.info('fleet', 'Host registered', { name, hostname, resolvedIp, dnsFamily });
ok(res, { host }, 201);
}));
ok(res, { host }, 201);
}));
// DELETE /api/v1/fleet/hosts/:hostId
router.delete('/fleet/hosts/:hostId', wrap(async (req, res) => {
@@ -234,42 +105,20 @@ module.exports = function({ log, asyncHandler }) {
}));
// GET /api/v1/fleet/status — aggregate fleet status
//
// DC-068 SSRF hardening: every stored host is re-validated before probing
// (defense-in-depth against a hand-edited fleet-hosts.json or a config
// file written before this policy was enabled). Probes use the
// `resolvedIp` captured at registration time — never re-resolve the
// hostname, since DNS-rebinding attackers could flip the A record
// between registration and probe. Probe concurrency is capped at
// MAX_PROBE_CONCURRENCY so a malicious fleet with N hung hosts can't
// stall the dashboard with up to N parallel timeouts.
router.get('/fleet/status', wrap(async (req, res) => {
const hosts = await loadHosts();
// Validate all hosts (in parallel) and split into "probeable" vs
// "validation_failed". Both lists are returned for operator visibility.
const validated = await runWithConcurrency(
hosts,
(host) => revalidateStoredHost(host, { allowPrivate: ALLOW_PRIVATE_HOSTS() }),
Math.max(MAX_PROBE_CONCURRENCY, hosts.length || 1)
);
const probeTargets = validated.filter((v) => v.validation.valid);
const skipped = validated
.filter((v) => !v.validation.valid)
.map((v) => ({ ...v.host, status: 'validation_failed', validationError: v.validation.message }));
const probeResults = await runWithConcurrency(probeTargets, async ({ host, validation }) => {
const probeIp = validation.resolvedIp || host.hostname;
const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp;
const url = `http://${probeHost}:${host.port}/api/v1/system/health`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
// Try to reach each host and get its health
const statusPromises = hosts.map(async (host) => {
try {
const url = `http://${host.hostname}:${host.port}/api/v1/system/health`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(url, {
signal: controller.signal,
headers: host.apiKeyHash ? { 'x-api-key': host.apiKeyHash } : {},
});
}).finally(() => clearTimeout(timeout));
if (response.ok) {
const data = await response.json();
host.status = data.status || 'healthy';
@@ -280,34 +129,25 @@ module.exports = function({ log, asyncHandler }) {
}
} catch {
host.status = 'offline';
} finally {
clearTimeout(timeout);
}
return host;
}, MAX_PROBE_CONCURRENCY);
});
const updatedHosts = [...probeResults, ...skipped];
const updatedHosts = await Promise.all(statusPromises);
await saveHosts(updatedHosts);
const summary = {
total: updatedHosts.length,
healthy: updatedHosts.filter((h) => h.status === 'healthy').length,
degraded: updatedHosts.filter((h) => h.status === 'degraded').length,
unhealthy: updatedHosts.filter((h) => h.status === 'unhealthy').length,
offline: updatedHosts.filter((h) => h.status === 'offline' || h.status === 'unreachable').length,
validation_failed: updatedHosts.filter((h) => h.status === 'validation_failed').length,
healthy: updatedHosts.filter(h => h.status === 'healthy').length,
degraded: updatedHosts.filter(h => h.status === 'degraded').length,
unhealthy: updatedHosts.filter(h => h.status === 'unhealthy').length,
offline: updatedHosts.filter(h => h.status === 'offline' || h.status === 'unreachable').length,
};
ok(res, { summary, hosts: updatedHosts });
}));
// POST /api/v1/fleet/deploy — deploy a template to multiple hosts
//
// DC-068 SSRF hardening: returns plan entries whose `deployUrl` is built
// from `resolvedIp` (the address captured at registration time) — never
// from the raw hostname. Operators copy-and-paste these URLs into the
// forwarding tool of their choice; routing them through a literal IP
// prevents a DNS-rebinding rename from pivoting the deploy call.
router.post('/fleet/deploy', wrap(async (req, res) => {
const { templateId, hostIds = [], config = {} } = req.body || {};
@@ -324,25 +164,15 @@ module.exports = function({ log, asyncHandler }) {
return errorResponse(res, 400, 'No valid hosts to deploy to');
}
// Build the plan. Each entry's `deployUrl` is built from the host's
// resolved IP (or the literal hostname for literal-IP hosts) — never
// from a re-resolution of the raw hostname. IPv6 literals must be
// wrapped in `[...]` so the URL parser preserves them as a single
// authority. Use `net.isIP` against the resolved IP rather than the
// stored `dnsFamily` so legacy entries (those registered before
// dnsFamily was captured) still get correct bracket wrapping.
const plan = targetHosts.map(host => {
const probeIp = host.resolvedIp || host.hostname;
const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp;
return {
hostId: host.id,
hostname: host.hostname,
templateId,
config,
status: 'pending',
deployUrl: `http://${probeHost}:${host.port}/api/v1/apps/deploy`,
};
});
// Generate deployment plan
const plan = targetHosts.map(host => ({
hostId: host.id,
hostname: host.hostname,
templateId,
config,
status: 'pending',
deployUrl: `http://${host.hostname}:${host.port}/api/v1/apps/deploy`,
}));
ok(res, {
templateId,
+14 -170
View File
@@ -1,103 +1,8 @@
/**
* DC-081: Plain-English log insights + dispose endpoint
*
* GET /api/v1/log-insights Plain English summary of who's doing what
* POST /api/v1/log-insights/dispose Preview then confirm cleanup
*
* DC-081 hardening (paired with the deploy path fix):
* - AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE were HARDCODED to
* `/opt/dashcaddy/dashcaddy-api/data/...` which DOES NOT EXIST in the
* production container files live at `/app/data/...`. The dispose
* endpoint silently no-op'd (read empty arrays, wrote empty arrays
* back) and the GET endpoint dropped the storage-size block. Both
* paths now use the same canonical resolution as the audit-logger
* itself: `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`.
* - keepDays was unbounded `parseInt(req.body.keepDays) || 30` accepted
* negative numbers (e.g. -1000 cutoff = +3 years in the future,
* deleting 100% of forensic context) and non-integers (Infinity,
* floats). Now validated to an integer in [1, 3650] (1 day .. 10 years)
* before any file read.
* - confirm gate added: must send { confirm: true, keepDays: N } the
* preview pass is read-only, the confirm pass writes. Matches the
* audit-logs/DELETE confirm=CLEAR pattern.
* - The dispose handler now uses a single shared `_resolvePaths()` helper
* to keep GET and POST in lockstep (and so a future path-config change
* touches one site, not four).
*
* Pre-DC-081 verification: from inside the running container, both
* `/app/data/audit-log.json` (318 KB) and `/app/data/security-events.jsonl`
* (15 MB) exist, but the old hardcoded `/opt/dashcaddy/dashcaddy-api/data/...`
* paths resolve to ENOENT. The dispose endpoint therefore did nothing;
* this fix wires it back to the actual files.
*/
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const platformPaths = require('../platform-paths');
/**
* Resolve the canonical paths for the audit log + security event log.
*
* Both store the file path in their own module-level constants, so any
* environment override (e.g. AUDIT_LOG_FILE=...) is honoured here too
* exactly the same behaviour as src/security/audit-logger.js and
* src/security/event-store.js. Without this, a container with
* AUDIT_LOG_FILE set would see the dispose handler read from one file
* and the audit-logger write to a different one.
*
* @returns {{auditPath: string, secPath: string, auditPathFrom: string, secPathFrom: string}}
* paths + the source ("env" or "default") so tests can verify.
*/
function _resolvePaths() {
const auditPath = process.env.AUDIT_LOG_FILE
|| path.join(platformPaths.dataDir, 'audit-log.json');
const secPath = process.env.SECURITY_EVENT_LOG_FILE
|| path.join(platformPaths.dataDir, 'security-events.jsonl');
return {
auditPath,
secPath,
auditPathFrom: process.env.AUDIT_LOG_FILE ? 'env' : 'default',
secPathFrom: process.env.SECURITY_EVENT_LOG_FILE ? 'env' : 'default',
};
}
/**
* Validate the keepDays input. Coerces + bounds-checks BEFORE any file
* read so a malicious or mistyped client can't:
* - pass a negative number (cutoff = far future wipe 100%)
* - pass Infinity (parseInt(Infinity, 10) === NaN, currently falls
* through `|| 30` fixed to fail-fast instead)
* - pass a non-integer (e.g. 1.5 cutoff mid-day, off-by-half-day)
* - pass 0 (no-op-but-lies) or 10000 (way past retention policy)
*
* @param {unknown} raw - value from req.body.keepDays
* @returns {number} validated integer in [1, 3650]
* @throws {Error} when out of range / wrong type
*/
function _validateKeepDays(raw) {
if (raw === undefined || raw === null) {
throw new Error('keepDays is required (integer in [1, 3650])');
}
const n = Number(raw);
if (!Number.isFinite(n)) {
throw new Error(`keepDays must be a finite number (received ${JSON.stringify(raw)})`);
}
if (!Number.isInteger(n)) {
throw new Error(`keepDays must be an integer (received ${raw})`);
}
if (n < 1 || n > 3650) {
throw new Error(`keepDays must be between 1 and 3650 (received ${n})`);
}
return n;
}
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
const router = express.Router();
// Resolve once at module init so GET + POST both use the same files.
// If the env vars change at runtime (rare — start.sh wires them at
// container start), operators re-deploy rather than mutate env mid-flight.
const { auditPath, secPath } = _resolvePaths();
// GET /api/v1/log-insights — Plain English summary of who's doing what
router.get('/log-insights', asyncHandler(async (req, res) => {
@@ -169,18 +74,16 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
}
// --- Storage info ---
// DC-081: read from the canonical resolved paths (NOT the hardcoded
// /opt/... paths that don't exist in the container). Empty-object
// fallback on ENOENT — the file may legitimately be absent on a
// fresh install where the audit-logger hasn't written yet.
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
let storage = {};
try {
const a = await fs.stat(auditPath);
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length, path: auditPath };
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
} catch {}
try {
const s = await fs.stat(secPath);
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length, path: secPath };
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
} catch {}
ok(res, {
@@ -205,44 +108,16 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
}));
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
//
// Two-call pattern:
// 1. { keepDays: 30 } → preview, no writes
// 2. { keepDays: 30, confirm: true } → actually delete
//
// DC-081 hardening:
// - keepDays is validated to integer [1, 3650] BEFORE any file read.
// A negative keepDays (e.g. -1000) would previously compute a
// cutoff +3 years in the future, then delete every entry older
// than that — i.e. 100% of the audit log. Now rejected at the gate.
// - auditPath / secPath come from the canonical _resolvePaths() helper
// so the container's actual /app/data files are read (the pre-fix
// hardcoded /opt/dashcaddy/dashcaddy-api/data/... paths resolved to
// ENOENT inside the container, so the endpoint silently did nothing).
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
// Validate keepDays first — fail-fast before any file IO so a bad
// client never touches disk.
let keepDays;
try {
keepDays = _validateKeepDays(req.body?.keepDays);
} catch (e) {
return res.status(400).json({ success: false, error: e.message, code: 'DC-081_INVALID_KEEP_DAYS' });
}
const keepDays = parseInt(req.body.keepDays) || 30;
const confirm = req.body.confirm === true;
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
// Read both files via the canonical resolved paths (NOT the hardcoded
// /opt/... paths from before — those don't exist in the container).
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
let auditData;
try {
auditData = JSON.parse(auditRaw);
} catch (e) {
return res.status(500).json({ success: false, error: `audit-log file is corrupt (${auditPath}): ${e.message}`, code: 'DC-081_AUDIT_PARSE_FAILED' });
}
if (!Array.isArray(auditData)) {
return res.status(500).json({ success: false, error: `audit-log file is not an array (${auditPath})`, code: 'DC-081_AUDIT_SHAPE_INVALID' });
}
const auditData = JSON.parse(auditRaw);
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
@@ -252,40 +127,16 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
if (!confirm) {
ok(res, {
preview: true,
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true, keepDays: ' + keepDays + '} to proceed.',
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
cutoffDate: cutoff,
paths: { auditPath, secPath },
cutoffDate: cutoff
});
return;
}
// Execute cleanup. Audit the wipe FIRST via the audit-logger so the
// fact that a delete happened is itself preserved (matches the
// audit-logs/DELETE + error-logs/DELETE pattern).
try {
if (auditLogger && typeof auditLogger.log === 'function') {
await auditLogger.log({
action: 'log-insights.dispose',
resource: 'audit-log,security-events',
outcome: 'success',
details: {
keepDays,
cutoff,
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
},
});
}
} catch { /* don't fail the dispose on audit-side errors */ }
// Rewrite audit-log.json atomically — write to tmp + rename so a
// crash mid-write can't leave the file half-empty (the file is read
// by state-manager on every container start; a corrupt file would
// block the whole API).
// Execute cleanup
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
const tmpAudit = auditPath + '.tmp';
await fs.writeFile(tmpAudit, JSON.stringify(keptAudit, null, 2));
await fs.rename(tmpAudit, auditPath);
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
@@ -294,16 +145,9 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
disposed: true,
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
cutoffDate: cutoff,
cutoffDate: cutoff
});
}));
return router;
};
// DC-081: export helpers for direct unit testing (the route handlers are
// otherwise unreachable from outside the factory closure).
module.exports.__test = {
_resolvePaths,
_validateKeepDays,
};
-102
View File
@@ -6,15 +6,6 @@ const { exists } = require('../src/utilities/fs-helpers');
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
const { NotFoundError, ValidationError, ForbiddenError } = require('../src/utilities/errors');
const { ok } = require('../src/utils/responses');
const journald = require('../src/monitoring/journald-reader');
const journaldAvailable = (() => {
try {
return fs.existsSync('/var/log/journal') && fs.existsSync('/usr/bin/journalctl');
} catch (_) {
return false;
}
})();
/**
* Logs route factory
@@ -227,99 +218,6 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
ok(res, { result });
}, 'logs-docker-maintenance'));
// ===== DC-055: Host journald log viewer =====
// Reads from the host's /var/log/journal via bind-mount in start.sh.
// Returns 503 if the bind-mount isn't present (dev containers, Windows).
// Allow-list of units the dashboard can stream. Exposed to the client so
// the dropdown stays in sync with the server-side allow-list.
router.get('/logs/journal/units', asyncHandler(async (req, res) => {
if (!journaldAvailable) {
return ok(res, { available: false, units: [] });
}
const units = await journald.listUnits();
ok(res, { available: true, units });
}, 'logs-journal-units'));
// Read a bounded tail of entries for a unit.
router.get('/logs/journal', asyncHandler(async (req, res) => {
if (!journaldAvailable) {
throw new Error('journald not mounted in this container (host /var/log/journal + /usr/bin/journalctl required)');
}
const entries = await journald.readEntries({
unit: req.query.unit,
tail: req.query.tail,
since: req.query.since,
until: req.query.until,
search: req.query.search,
});
ok(res, { entries, count: entries.length });
}, 'logs-journal-read'));
// Stream entries as they arrive (Server-Sent Events).
router.get('/logs/journal/stream', asyncHandler(async (req, res) => {
if (!journaldAvailable) {
res.statusCode = 503;
res.setHeader('Content-Type', 'text/event-stream');
res.write(`data: ${JSON.stringify({ error: 'journald not mounted in this container' })}\n\n`);
res.end();
return;
}
// Validate BEFORE writing SSE headers — once headers go out we
// can't change statusCode. The reader does the same validation but
// we want to short-circuit here so the response status reflects the
// right category (400 for validation, 503 for bind-mount missing).
try {
journald.assertUnitAllowed(req.query.unit);
if (req.query.since) journald.parseTimestamp(req.query.since, 'since');
} catch (err) {
// Pass through the global error middleware so the response status
// + shape matches every other validation error in the API.
throw err;
}
// SSE headers — same convention as /logs/stream/:id.
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
let settled = false;
const cleanup = (handle) => {
if (settled) return;
settled = true;
try { handle && handle.kill(); } catch (_) { /* already dead */ }
try { res.end(); } catch (_) { /* already closed */ }
};
let handle;
try {
handle = journald.streamEntries(
{ unit: req.query.unit, since: req.query.since, search: req.query.search },
{
onData(entry) {
if (settled) return;
res.write(`data: ${JSON.stringify(entry)}\n\n`);
},
onError(err) {
if (settled) return;
res.write(`data: ${JSON.stringify({ error: err.message || String(err) })}\n\n`);
cleanup(handle);
},
}
);
} catch (err) {
res.write(`data: ${JSON.stringify({ error: (err && err.message) || 'stream failed' })}\n\n`);
try { res.end(); } catch (_) { /* ignore */ }
return;
}
// Modern Node fires 'close' for both clean disconnects and aborts;
// the separate 'aborted' listener is deprecated as of Node 18.
req.on('close', () => cleanup(handle));
}, 'logs-journal-stream'));
// Get logs from a file path (for native applications)
router.get('/logs/file', asyncHandler(async (req, res) => {
const { path: logPath, tail = 100 } = req.query;
+17 -239
View File
@@ -76,261 +76,39 @@ module.exports = function openClawRoutes(ctx) {
});
}
/**
* DC-065: OpenClaw proxy hardening.
*
* Three attack vectors were previously open:
* (a) Unbounded response passthrough proxyRes.on('data') wrote every
* byte to the client without a cap, allowing a compromised/buggy
* OpenClaw container to push arbitrarily large payloads (DoS,
* log-spam, memory pressure on the API container).
* (b) Hop-by-hop / response-shaping headers forwarded verbatim Node's
* `res.set(proxyRes.headers)` copies Connection, Keep-Alive,
* Transfer-Encoding, Upgrade, Proxy-Authenticate, Proxy-Authorization,
* TE, Trailers, Set-Cookie, Content-Encoding, Content-Length, and
* Server. Per RFC 7230 §6.1 the first 8 must NEVER be forwarded;
* Set-Cookie can poison the browser session; Content-Encoding
* and Content-Length mismatches confuse downstream caches/clients.
* (c) `proxyRes.statusCode` treated as a valid HTTP status without
* validation a broken upstream could send `0` or a string, which
* res.status() would either accept (silent corruption) or throw
* RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express default
* error handler returns HTML).
* (d) `path` taken from req.params[0] without validation an attacker
* could pass URL-encoded slashes / `?` / `#` chars / absolute URLs
* to redirect the proxy elsewhere on localhost.
*
* The five fixes below close (a)-(d) without changing the on-the-wire
* shape of the proxy from a same-origin browser's perspective.
*/
// RFC 7230 §6.1 hop-by-hop headers that must NEVER be forwarded by a proxy.
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade',
]);
// Headers we deliberately strip from proxied responses for client-safety /
// cache-correctness reasons (NOT hop-by-hop, but dangerous to forward).
// DC-065 round-1 GLM-5.3 finding: `location` MUST be stripped — a
// 3xx response with `Location: http://evil.com/x` would be honored by
// the same-origin browser because the proxy response is on
// /openclaw/proxy/* (same-origin from the dashboard's perspective) and
// the proxy didn't downgrade the status. This is a classic open-redirect
// through proxy. We strip Location and let the browser stay put (or,
// for clients that depend on redirect-following, they can retry the
// upstream directly without our proxy in the path).
// DC-065 round-2 GLM-5.3 finding: `refresh` and `www-authenticate` are
// in the same class and were also leaking. `Refresh: 0; url=...` is
// honored by a meaningful subset of browsers (older Chrome, Firefox,
// Safari, mobile WebViews) as an open-redirect primitive. `WWW-
// Authenticate: Basic realm=...` pops a native browser auth dialog on
// the dashboard's origin (phishing/UX attack). Both stripped.
const STRIPPED_RESPONSE_HEADERS = new Set([
'set-cookie', // upstream browser poisoning
'location', // round-1 GLM finding — open-redirect through proxy
'refresh', // round-2 GLM finding — same-class open-redirect primitive
'www-authenticate', // round-2 GLM finding — phishing via browser auth prompt
'content-encoding', // we send raw bytes; mismatched encoding breaks clients
'content-length', // node auto-computes; forwarding can desync with body
'server', // upstream fingerprinting
'x-powered-by', // upstream fingerprinting
]);
// 5 MiB is a generous cap for a chat / gateway UI; anything larger is
// either a misconfigured upstream or an attack. Picked to match the
// express.json({ limit }) default in src/utilities/middleware.js.
const MAX_PROXY_RESPONSE_BYTES = 5 * 1024 * 1024;
// Allowed chars in the downstream `path` segment: alphanumerics, `-`, `_`,
// `.`, `~`, `/`, `?`, `&`, `=`, `:`, `@`, `+`, `,`, `;` (RFC 3986 pchar +
// query/fragment separators). Anything else → 400.
const ALLOWED_PATH_RE = /^[a-zA-Z0-9._~/?&=:@+,;%\-]*$/;
// Maximum total `path` length (reasonable for a gateway UI endpoint).
const MAX_PATH_LEN = 1024;
function sanitizeForwardedHeaders(rawHeaders) {
const out = {};
for (const name of Object.keys(rawHeaders || {})) {
const lower = name.toLowerCase();
if (HOP_BY_HOP.has(lower)) continue;
if (STRIPPED_RESPONSE_HEADERS.has(lower)) continue;
out[name] = rawHeaders[name];
}
return out;
}
function coerceUpstreamStatus(rawStatus) {
// Status must be an integer in 100..599. Anything else → 502 (the proxy
// failed to interpret the upstream response, which is exactly what 502
// semantically means: bad gateway).
if (
typeof rawStatus !== 'number'
|| !Number.isInteger(rawStatus)
|| rawStatus < 100
|| rawStatus > 599
) {
return 502;
}
return rawStatus;
}
function validatePath(path) {
if (typeof path !== 'string') return { ok: false, code: 400, msg: 'path must be a string' };
if (path.length === 0) return { ok: false, code: 400, msg: 'path is empty' };
if (path.length > MAX_PATH_LEN) return { ok: false, code: 414, msg: 'path too long' };
// Reject absolute-URL injection (`://`), backslashes (Windows path-style
// smuggling), CRLF (header injection on rare downstream), and any char
// outside the RFC 3986 pchar/query/fragment set.
if (/[\s\\]|:\/\//.test(path)) return { ok: false, code: 400, msg: 'path contains forbidden characters' };
if (!ALLOWED_PATH_RE.test(path)) return { ok: false, code: 400, msg: 'path contains disallowed characters' };
// Strip a single leading slash so we can rebuild as `${targetBase}/${path}`
// idempotently (targetBase already has a trailing `:PORT` form).
return { ok: true, normalized: path.replace(/^\/+/, '') };
}
// DC-065: expose helpers via the router for direct unit testing. The
// router is an Express Router; any property we add here stays private
// to the module and is read by __tests__/routes/openclaw.proxy-hardening
// .test.js without going through Express.
router._dc065 = {
HOP_BY_HOP,
STRIPPED_RESPONSE_HEADERS,
MAX_PROXY_RESPONSE_BYTES,
ALLOWED_PATH_RE,
MAX_PATH_LEN,
sanitizeForwardedHeaders,
coerceUpstreamStatus,
validatePath,
};
function proxyRequest(req, res, targetBase, path, token) {
const pathCheck = validatePath(path);
if (!pathCheck.ok) {
return errorResponse(res, pathCheck.code, pathCheck.msg);
}
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
headers['X-Forwarded-For'] = req.ip;
headers['X-Forwarded-Proto'] = req.protocol;
const url = targetBase + '/' + pathCheck.normalized;
const url = targetBase + '/' + path;
const method = req.method;
// Stream the upstream response through `res` with a byte-size cap. On
// overrun we abort the proxyReq and reply with 502 Bad Gateway. The
// accumulated bytes are tracked per-call; if MAX_PROXY_RESPONSE_BYTES
// is exceeded, we close the upstream and tear down the client response.
function pipeUpstream(proxyReq) {
// Buffer-first response proxy: collect chunks in memory until either
// the upstream finishes or MAX_PROXY_RESPONSE_BYTES is exceeded. Then
// emit a single Express response with sanitized headers + the
// buffered body, or a 502 if the cap fired. Two reasons for the
// buffer-first approach:
//
// 1. Once res.status() is called and headers are flushed (which
// happens on the first res.write), the status code is locked.
// Streaming the body through res.write lets a malicious
// upstream send 1 byte of 200 OK + N bytes of garbage; we can't
// retroactively downgrade to 502. Buffering lets us inspect
// the full response before committing to a status.
//
// 2. Synchronous status/header/body emission is cheaper than
// backpressure-aware chunked writes for a proxy that
// specifically serves JSON-RPC + small payloads (OpenClaw's
// gateway chat API is not a streaming use case).
//
// Memory cost: MAX_PROXY_RESPONSE_BYTES per concurrent proxy
// request. At 5 MiB and Node's default 1000 concurrent connections
// (server.maxConnections defaults to Infinity), worst-case is ~5
// GiB. We cap concurrency in start.sh via Node CLI flags; see
// ulimit + --max-old-space-size settings.
const chunks = [];
let totalBytes = 0;
let capped = false;
let finishedEarly = false;
proxyReq.on('response', function(proxyRes) {
// Pre-check: if upstream claimed a Content-Length above the cap,
// reject before consuming any body bytes. This is the common case
// — most well-behaved upstreams declare length up-front.
const declaredLength = parseInt(proxyRes.headers['content-length'], 10);
if (Number.isFinite(declaredLength) && declaredLength > MAX_PROXY_RESPONSE_BYTES) {
capped = true;
proxyReq.destroy();
return errorResponse(res, 502, '[DC-065] upstream Content-Length ' + declaredLength + ' exceeds ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
}
proxyRes.on('data', function(chunk) {
if (capped || finishedEarly) return;
totalBytes += chunk.length;
if (totalBytes > MAX_PROXY_RESPONSE_BYTES) {
capped = true;
proxyReq.destroy();
if (!finishedEarly) {
finishedEarly = true;
if (!res.headersSent && !res.writableEnded) {
errorResponse(res, 502, '[DC-065] upstream response exceeded ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
}
}
return;
}
chunks.push(chunk);
});
proxyRes.on('end', function() {
if (capped) return;
finishedEarly = true;
const body = Buffer.concat(chunks);
const safeHeaders = sanitizeForwardedHeaders(proxyRes.headers);
try { res.set(safeHeaders); } catch (_) { /* noop if socket closed */ }
const safeStatus = coerceUpstreamStatus(proxyRes.statusCode);
try {
res.status(safeStatus);
res.end(body);
} catch (_) { /* socket may be closed */ }
});
proxyRes.on('error', function() {
if (!finishedEarly) {
finishedEarly = true;
try {
if (!res.headersSent) res.status(502).end();
else res.end();
} catch (_) { /* socket may be closed */ }
}
});
});
proxyReq.on('error', function(e) {
if (!finishedEarly) {
finishedEarly = true;
if (!res.headersSent && !res.writableEnded) {
errorResponse(res, 502, e.message);
}
}
});
proxyReq.setTimeout(15000, function() {
proxyReq.destroy();
if (!finishedEarly && !res.headersSent && !res.writableEnded) {
finishedEarly = true;
errorResponse(res, 504, 'gateway timeout');
}
});
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const body = JSON.stringify(req.body);
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(body);
const proxyReq = http.request(url, { method: method, headers: headers });
pipeUpstream(proxyReq);
proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
proxyReq.write(body);
proxyReq.end();
} else {
const proxyReq = http.get(url, { headers: headers });
pipeUpstream(proxyReq);
proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
}
}
+1 -5
View File
@@ -30,11 +30,7 @@
*/
const express = require('express');
// DC-063: use the canonical `errorResponse(res, statusCode, message, extras)`
// shape — alias `error: errorResponse` used here previously was message-first
// which silently mis-called every callsite (15 endpoints surfaced as 500 HTML
// panics instead of the intended 4xx JSON).
const { ok, errorResponse } = require('../src/utils/responses');
const { ok, error: errorResponse } = require('../src/utils/responses');
const { getStore } = require('../src/security/event-store');
const { getRegistry } = require('../src/security/host-registry');
const platformPaths = require('../platform-paths');
+6 -15
View File
@@ -10,11 +10,7 @@ const { exists } = require('../src/utilities/fs-helpers');
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
// DC-063: use the canonical `errorResponse(res, statusCode, message, extras)`
// shape — alias `error: errorResponse` used here previously was message-first
// which silently mis-called 3 credential-store callsites (returned 500 HTML
// panics for invalid serviceId instead of the intended 400 JSON).
const { success, errorResponse } = require('../src/utils/responses');
const { success, error: errorResponse } = require('../src/utils/responses');
const platformPaths = require('../platform-paths');
/**
@@ -263,10 +259,9 @@ module.exports = function({
const arrKey = await credentialManager.retrieve(`arr.${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 password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
success(res, {
hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username && !!password,
hasBasicAuth: !!username,
username: username || null
});
}, 'service-creds'));
@@ -403,8 +398,7 @@ module.exports = function({
try {
validateServiceConfig({ id, name });
} catch (validationErr) {
// DC-063: canonical shape (res, statusCode, message, extras) per responses.js:76.
return errorResponse(res, 400, validationErr.message, { errors: validationErr.errors });
return errorResponse(res, validationErr.message, 400, { errors: validationErr.errors });
}
await servicesStateManager.update(services => {
@@ -429,8 +423,7 @@ module.exports = function({
} catch (error) {
log.error('deploy', error, null, { note: 'Error adding service' });
if (error.message.includes('already exists')) {
// DC-063: canonical shape per responses.js:76.
errorResponse(res, 409, safeErrorMessage(error));
errorResponse(res, safeErrorMessage(error), 409);
} else {
// Error handled by middleware
}
@@ -452,8 +445,7 @@ module.exports = function({
try {
validateServiceConfig(service);
} catch (validationErr) {
// DC-063: canonical shape per responses.js:76.
return errorResponse(res, 400, `Invalid service "${service.id}": ${validationErr.message}`, { errors: validationErr.errors });
return errorResponse(res, `Invalid service "${service.id}": ${validationErr.message}`, 400, { errors: validationErr.errors });
}
}
@@ -483,8 +475,7 @@ module.exports = function({
});
if (!found) {
// DC-063: canonical shape per responses.js:76.
return errorResponse(res, 404, `Service "${id}" not found`);
return errorResponse(res, `Service "${id}" not found`, 404);
}
resyncHealthChecker?.().catch(() => {});
+9 -61
View File
@@ -37,10 +37,6 @@
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
const { PaymentRequiredError } = require('../src/utilities/errors');
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
// DC-083: route-layer validators for the public CSRF-exempt endpoints. These
// are imported from share-store so the route and store stay in lockstep
// (drift risk if one set is updated and the other is forgotten).
const { validatePublicEmail, validatePublicDeviceId } = require('../src/security/share-store');
const PUBLIC_TTL_OPTIONS = new Set([
60 * 60 * 1000,
@@ -297,35 +293,7 @@ module.exports = function shareRoutesFactory({
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
// DC-083: rate-limit the two CSRF-exempt public endpoints. The general
// limiter (1000/15min) is mounted globally in app.js and is too generous
// for unauthenticated state-mutating endpoints. 30/15min per IP is
// enough for a legitimate user clicking "subscribe" once or twice; anything
// beyond is abuse. Skipped in test envs via the standard isTest guard.
// Lazy-loaded so test environments without the dep installed don't blow up;
// a missing-dep in production logs a warning and falls back to no-op (still
// safe — the route+store validators are the primary defense).
const { RATE_LIMITS } = require('../src/utilities/constants');
const isTest = process.env.NODE_ENV === 'test';
let _sharePublicLimiter = (req, _res, next) => next(); // no-op default
try {
const rateLimit = require('express-rate-limit'); // eslint-disable-line global-require
_sharePublicLimiter = rateLimit({
...RATE_LIMITS.SHARE_PUBLIC,
standardHeaders: true,
legacyHeaders: false,
skip: () => isTest,
message: { success: false, error: 'Too many share requests, please try again later' },
});
} catch (e) {
// Don't crash on missing dep in a bare-bones env — but log so it's not
// invisible if production misconfigured.
if (log && typeof log.warn === 'function') {
log.warn({ ctx: 'share-routes', err: e.message }, 'express-rate-limit unavailable; share public endpoints have NO rate limit');
}
}
router.get('/share/:token/preview', _sharePublicLimiter, asyncHandler(async (req, res) => {
router.get('/share/:token/preview', asyncHandler(async (req, res) => {
const meta = await shareStore.peek(req.params.token);
if (!meta) {
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
@@ -342,21 +310,12 @@ module.exports = function shareRoutesFactory({
});
}, 'share-preview'));
router.post('/share/:token/subscribe', _sharePublicLimiter, asyncHandler(async (req, res) => {
// DC-083: replace the primitive `email.includes('@')` check with a
// charset/length/control-char-bounded validator. The pre-fix code
// accepted `@`, `a@`, `<script>@x.c`, and 10MB strings as "valid email".
// The subscribe body's `email` is now also captured to the share record
// (capped to last 8 entries, see share-store recordPublicSubscribe) so
// the operator can see who subscribed.
router.post('/share/:token/subscribe', asyncHandler(async (req, res) => {
const { email } = req.body || {};
let normalizedEmail = null;
if (email !== undefined && email !== null) {
const v = validatePublicEmail(email);
if (!v.ok) throw new ValidationError(v.reason, 'email');
normalizedEmail = v.email;
if (!email || typeof email !== 'string' || !email.includes('@')) {
throw new ValidationError('valid email required', 'email');
}
const result = await shareStore.recordPublicSubscribe(req.params.token, { email: normalizedEmail });
const result = await shareStore.recordPublicSubscribe(req.params.token);
if (!result.ok) {
if (result.reason === 'not_found') throw new NotFoundError('share not found');
throw new ValidationError(result.reason, 'share');
@@ -364,23 +323,12 @@ module.exports = function shareRoutesFactory({
res.json({ success: true, data: { count: result.count, cap: result.cap } });
}, 'share-subscribe'));
router.post('/share/:token/redeem-tailscale', _sharePublicLimiter, asyncHandler(async (req, res) => {
// DC-083: replace the bare `typeof deviceId === 'string'` check with a
// charset/length/control-char-bounded validator. The pre-fix code
// accepted arbitrary strings of any length — including CR/LF/NUL,
// which flow into the Tailscale auth-key description string in
// POST /share/tailscale (routes/share.js:213 in the issue path).
// The redeem-tailscale path receives the deviceId from Caddy's
// forward_auth (a Tailscale machine ID), which is base64url +
// hyphens — well within the validator's charset.
router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => {
const { deviceId } = req.body || {};
let normalizedDeviceId = null;
if (deviceId !== undefined && deviceId !== null) {
const v = validatePublicDeviceId(deviceId);
if (!v.ok) throw new ValidationError(v.reason, 'deviceId');
normalizedDeviceId = v.deviceId;
if (!deviceId || typeof deviceId !== 'string') {
throw new ValidationError('deviceId required', 'deviceId');
}
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId: normalizedDeviceId });
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId });
if (!result.ok) {
if (result.reason === 'not_found') throw new NotFoundError('share not found');
throw new ValidationError(result.reason, 'share');
+2 -50
View File
@@ -4,9 +4,6 @@ const { CADDY, REGEX, LIMITS } = require('../src/utilities/constants');
const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors');
const { validateURL } = require('../src/security/input-validator');
const { ok, successMessage } = require('../src/utils/responses');
// DC-074: SSRF defense — reject upstream hosts that resolve to
// private/reserved ranges before they reach the Caddyfile.
const { validateUpstream } = require('../src/utilities/fleet-validation');
/**
* Sites route factory
@@ -169,25 +166,8 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
if (!domain || !upstream) throw new ValidationError('Domain and upstream are required');
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
// DC-074: SSRF defense — reject upstreams that resolve to private/
// reserved ranges BEFORE we write them into the Caddyfile. Without
// this, an authenticated dashboard operator can call POST /api/v1/site
// with `upstream: '10.0.0.1:80'` and end up with a Caddy site block
// that proxies public traffic to an internal host. Caddy runs on
// DNS2 (same network as the targets), so the SSRF lands.
//
// The existing upstreamRegex /^[a-z0-9.-]+:\d{1,5}$/i only checks
// charset — it happily accepts 192.168.1.1:80 and 169.254.169.254:80
// (the AWS metadata IP). validateUpstream() also does a DNS lookup
// for hostnames so a malicious operator can't sneak a public-looking
// domain past the gate and have it resolve to a private IP later.
const upstreamCheck = await validateUpstream(upstream);
if (!upstreamCheck.ok) {
// Don't echo attacker-supplied hostnames in the audit log; keep the
// canonical code + message but never write the raw value.
log?.warn?.('site', 'POST /site rejected by SSRF gate', { code: upstreamCheck.code });
throw new ValidationError(`[DC-074] ${upstreamCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`);
}
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port');
const content = await caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -219,40 +199,12 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
throw new ValidationError('[DC-301] Invalid subdomain format');
}
// DC-074: SSRF defense — validate the URL syntax via validateURL() (catches
// non-http(s) schemes, malformed URLs) AND validateUpstream() (catches
// every private/reserved range including CGNAT, multicast, TEST-NET
// ranges that validateURL's isPrivateIP() regex misses).
//
// We intentionally do NOT pass `blockPrivate: true` to validateURL()
// here — that's handled by validateUpstream() below, which honors the
// SITES_ALLOW_PRIVATE_UPSTREAMS opt-in. validateURL's blockPrivate path
// is a hard reject with no escape hatch, which would force operators
// who intentionally proxy to a private target to remove validation
// entirely.
try {
validateURL(externalUrl);
} catch (validationErr) {
throw new ValidationError(validationErr.message);
}
// DC-074: validateUpstream() does the same rigorous private-IP check
// fleet-validation shipped for DC-068, with full CGNAT / multicast /
// broadcast / 0.0.0.0 / TEST-NET / benchmark range coverage and a DNS
// resolution step for hostnames (rebinding defense).
let parsedExternalUrl;
try {
parsedExternalUrl = new URL(externalUrl);
} catch (_) {
// validateURL() above already gates URL syntax — unreachable.
throw new ValidationError('Invalid external URL');
}
const externalCheck = await validateUpstream(`${parsedExternalUrl.hostname}:${parsedExternalUrl.port || (parsedExternalUrl.protocol === 'https:' ? '443' : '80')}`);
if (!externalCheck.ok) {
log?.warn?.('site', 'POST /site/external rejected by SSRF gate', { code: externalCheck.code });
throw new ValidationError(`[DC-074] ${externalCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`);
}
const domain = buildDomain(subdomain);
let dnsWarning = null;
+8 -146
View File
@@ -41,124 +41,12 @@
*
* DELETE /api/v1/tailscale/admin/devices/:id
* Revokes a device from the tailnet.
*
* # DC-080 input validation
*
* Three coupled gaps in the route layer pre-fix:
*
* (a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but had
* no length cap body-parser limit was the only ceiling. A 1 MB
* string starting with `tskey-api-` would be `.trim()`-ed, sent to
* Tailscale's /devices endpoint, and waste server-side CPU on a
* request that will always 401.
* (b) POST /settings/test accepted `apiToken` from the body with NO
* validation at all. The PUT route's prefix check is bypassed on
* the test path an operator could submit any string and have the
* container ping Tailscale's API with it (low impact, but inconsistent
* with PUT and surfaces fingerprinting via the 401 timing).
* (c) POST /admin/keys validated `tags` as Array but NOT per-element
* type `tags: ['tag:guest', null, 123, {injection: true}]` would be
* forwarded to Tailscale verbatim. Tailscale's API is JSON-strict
* and would 400 the request, but the bad shape reached the wire.
* Similarly `description` had no length cap (Tailscale caps at 120
* chars per their docs).
*
* All three are gated by TOTP this is a logged-in-operator / phished-
* session threat surface, not anonymous-unauth. The fix is defense-in-
* depth: a bug in the auth path (TOTP bypass, session theft, future
* route handler trust-boundary drift) should not turn these endpoints
* into a "submit anything and forward to Tailscale" relay.
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
// DC-080: shared validation helpers for the Tailscale admin surface.
// Tailscale API tokens follow the form `tskey-<kind>-<opaque>` where
// `<kind>` is one of a small set of values (`api`, `auth`, `partner`,
// `cli`). Real tokens observed in the wild are 40..80 chars; we cap at
// 256 to leave headroom for future Tailscale key formats without giving
// an unbounded buffer to validate+forward.
const TAILSCALE_TOKEN_PREFIX = 'tskey-api-';
const TAILSCALE_TOKEN_MAX_LEN = 256;
const TAG_KEY_MAX_LEN = 64;
const TAGS_MAX_LEN = 32;
const DESCRIPTION_MAX_LEN = 120;
// Tailscale tags are lowercased identifiers with optional colons
// (e.g. `tag:server`, `tag:guest-plex`). Reject whitespace, CR/LF,
// control chars, JSON metacharacters, and any character that could
// enable header-injection through the Tailscale coord client.
//
// DC-080 round-2 polish: Tailscale's tag spec requires `tag:` followed by
// ≥1 identifier char — bare `tag:` (empty name) is rejected by their API.
// We split the pattern in two so the error message names which form failed
// instead of dumping a generic regex.
const TAG_KEY_RE = /^tag:[a-z0-9][a-z0-9_-]{0,62}$/;
function _validateApiToken(token, fieldName = 'apiToken') {
if (typeof token !== 'string' || !token) {
return `${fieldName} is required and must be a string`;
}
if (!token.startsWith(TAILSCALE_TOKEN_PREFIX)) {
return `${fieldName} must start with ${TAILSCALE_TOKEN_PREFIX}`;
}
if (token.length > TAILSCALE_TOKEN_MAX_LEN) {
return `${fieldName} exceeds maximum length of ${TAILSCALE_TOKEN_MAX_LEN} characters`;
}
return null;
}
function _validateTags(tags) {
if (tags === undefined || tags === null) return null;
if (!Array.isArray(tags)) {
return 'tags must be an array of strings';
}
if (tags.length > TAGS_MAX_LEN) {
return `tags exceeds maximum length of ${TAGS_MAX_LEN} entries`;
}
for (let i = 0; i < tags.length; i += 1) {
const t = tags[i];
if (typeof t !== 'string' || !t) {
return `tags[${i}] must be a non-empty string`;
}
if (t.length > TAG_KEY_MAX_LEN) {
return `tags[${i}] exceeds maximum length of ${TAG_KEY_MAX_LEN} characters`;
}
if (!TAG_KEY_RE.test(t)) {
return `tags[${i}] must match ${TAG_KEY_RE} (lowercase alnum + :_-)`;
}
}
return null;
}
function _validateDescription(description) {
if (description === undefined || description === null) return null;
if (typeof description !== 'string') {
return 'description must be a string';
}
if (description.length > DESCRIPTION_MAX_LEN) {
return `description exceeds maximum length of ${DESCRIPTION_MAX_LEN} characters`;
}
return null;
}
// Exported for direct unit testing in __tests__/routes/tailscale-admin.test.js
// (the validator functions are otherwise unreachable from outside the factory
// closure; direct tests assert edge cases without supertest overhead).
const _validators = {
validateApiToken: _validateApiToken,
validateTags: _validateTags,
validateDescription: _validateDescription,
TAILSCALE_TOKEN_PREFIX,
TAILSCALE_TOKEN_MAX_LEN,
TAG_KEY_MAX_LEN,
TAGS_MAX_LEN,
DESCRIPTION_MAX_LEN,
TAG_KEY_RE,
};
module.exports = function({
tailscaleCoord,
asyncHandler,
@@ -187,12 +75,9 @@ module.exports = function({
router.put('/settings', asyncHandler(async (req, res) => {
const token = req.body && req.body.apiToken;
// DC-080: validate prefix + length cap. The pre-fix code only checked
// the prefix — a 1 MB string starting with `tskey-api-` would have been
// sent to Tailscale's /devices endpoint and wasted server-side CPU
// before the inevitable 401.
const tokenErr = _validateApiToken(token);
if (tokenErr) return errorResponse(res, 400, tokenErr);
if (!token || typeof token !== 'string' || !token.startsWith('tskey-api-')) {
return errorResponse(res, 400, 'Invalid API token (must start with tskey-api-)');
}
// Validate before storing
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
@@ -245,17 +130,6 @@ module.exports = function({
router.post('/settings/test', asyncHandler(async (req, res) => {
const token = (req.body && req.body.apiToken) || null;
// DC-080: validate any caller-provided token before it reaches the
// Tailscale API. Pre-fix the test endpoint accepted any string — the
// PUT route's prefix check did NOT extend to this path. An operator
// could submit arbitrary junk and the container would still call
// /devices on the Tailscale API with it (DoS-reflection + fingerprint
// timing for a future attacker probing whether this API token format
// is accepted at all).
if (token !== null && token !== undefined) {
const tokenErr = _validateApiToken(token);
if (tokenErr) return errorResponse(res, 400, tokenErr);
}
const client = await tailscaleCoord.getClient();
if (token) {
// Caller provided a fresh token to test — don't save it
@@ -340,16 +214,10 @@ module.exports = function({
return errorResponse(res, 503, 'Tailscale API token not configured');
}
const opts = req.body || {};
// Reject obviously-bad input early.
// DC-080: pre-fix the route only checked `Array.isArray(opts.tags)`.
// A `tags: ['tag:guest', null, 123, {injection: true}]` payload would
// be forwarded to Tailscale verbatim — Tailscale's API is JSON-strict
// and would 400 the request, but the bad shape reached the wire and
// would silently pass through the dashboard's JSON.stringify() flow.
const tagsErr = _validateTags(opts.tags);
if (tagsErr) return errorResponse(res, 400, tagsErr);
const descErr = _validateDescription(opts.description);
if (descErr) return errorResponse(res, 400, descErr);
// Reject obviously-bad input early
if (opts.tags && !Array.isArray(opts.tags)) {
return errorResponse(res, 400, 'tags must be an array of strings');
}
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
}
@@ -386,10 +254,4 @@ module.exports = function({
}));
return router;
};
// DC-080: validators exported for direct unit testing in
// __tests__/routes/tailscale-admin.test.js — the route factory closes
// over the same functions, so the validators are exercised end-to-end via
// supertest AND in isolation here.
module.exports._validators = _validators;
};
+1 -11
View File
@@ -75,16 +75,7 @@ process.on('uncaughtException', (error) => {
// .on() on a class threw on every boot and silently killed the WS).
try {
const { ctx } = app.locals;
const createDashboardWS = require('./src/websocket/dashboard-ws').createDashboardWS;
// DC-061: WS upgrade bypasses Express middleware, so inject the
// real session verifier from the shared context. Without this
// the WS would fall back to a presence-only cookie check that
// any attacker can satisfy by setting a cookie named
// `dashcaddy_session` (verified HMAC required, not just name).
const authVerifier = (ctx.session && typeof ctx.session.isValid === 'function')
? ctx.session.isValid
: null;
const createDashboardWS = require('./src/websocket/dashboard-ws');
createDashboardWS(server, {
resourceMonitor: ctx.resourceMonitor,
@@ -95,7 +86,6 @@ process.on('uncaughtException', (error) => {
driftDetector: ctx.driftDetector,
sslMonitor: ctx.sslMonitor,
dnsPropagationChecker: ctx.dnsPropagationChecker,
authVerifier,
log,
});
log.info('server', 'Dashboard WebSocket attached at /api/v1/ws');
-1
View File
@@ -634,7 +634,6 @@ async function createApp() {
caddy: ctx.caddy,
dns: ctx.dns,
siteConfig: ctx.config,
fetchT: ctx.fetchT,
asyncHandler: ctx.asyncHandler,
}));
-11
View File
@@ -31,17 +31,6 @@ module.exports = {
CADDY_ADMIN_URL,
SERVICES_FILE,
SERVICES_DIR,
// Re-export the resolved data directory so other modules (notably
// src/utilities/nesting-guard.js) can locate `/app/data` without having to
// also require('../../platform-paths') — keeps a single source of truth for
// the data dir on the src/config/paths surface. Without this, `dataDir`
// resolves to `undefined`, and `path.join(undefined, 'data')` throws
// `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type
// string. Received undefined` at startup (DC-077 fingerprint). Fall back to
// platformPaths.dataDir if SERVICES_DIR is somehow not a string (defensive —
// SERVICES_DIR is computed from a path.dirname() of a string so it always
// is, but the cost of guarding is one branch).
dataDir: typeof SERVICES_DIR === 'string' && SERVICES_DIR ? SERVICES_DIR : platformPaths.dataDir,
CONFIG_FILE,
DNS_CREDENTIALS_FILE,
TAILSCALE_CONFIG_FILE,
@@ -406,7 +406,7 @@ class AutoRestartManager extends EventEmitter {
// Transition: healthy → unhealthy
if (previousStatus === 'up' && currentStatus === 'down') {
// Find the containerId from the health checker config or status details
const containerId = await this._resolveContainerId(serviceId, status);
const containerId = this._resolveContainerId(serviceId, status);
if (containerId) {
try {
await this.handleContainerDown(serviceId, containerId);
@@ -429,19 +429,12 @@ class AutoRestartManager extends EventEmitter {
/**
* Attempt to find the containerId for a service from various sources.
*
* DC-060: the previous implementation fired the async lookup via `.then(...)`
* but discarded the returned containerId, returning `undefined` from the
* function. Callers (`_handleStatusCheck`) gate on the return value, so
* every auto-restart whose containerId came from servicesStateManager
* silently no-op'd. Now awaits the read() promise so the containerId
* actually propagates.
*
* @param {string} serviceId
* @param {Object} status - The status-check event data
* @returns {Promise<string|null>}
* @returns {string|null}
* @private
*/
async _resolveContainerId(serviceId, status) {
_resolveContainerId(serviceId, status) {
// Check if it's in the status details (some health checks embed it)
if (status.details?.containerId) return status.details.containerId;
@@ -449,20 +442,23 @@ class AutoRestartManager extends EventEmitter {
const hcService = this.healthChecker?.config?.services?.[serviceId];
if (hcService?.containerId) return hcService.containerId;
// Try to look it up from the services state manager. StateManager.read()
// is async (returns a Promise) — must await, not fire-and-forget.
// Try to look it up from the services state manager
try {
const servicesStateManager = this.ctx.servicesStateManager;
if (!servicesStateManager) return null;
const list = await servicesStateManager.read();
const found = (list || []).find(s => s.id === serviceId);
if (found?.containerId) return found.containerId;
} catch (err) {
// Best-effort: a state-manager read failure must not break the bridge.
// Surface at debug level so an operator hunting "why didn't auto-restart
// fire?" can find it without polluting the info-level event stream.
this.log?.debug?.('auto-restart', 'containerId resolve failed', { serviceId, error: err?.message });
}
if (servicesStateManager) {
const readResult = servicesStateManager.read();
if (readResult && typeof readResult.then === 'function') {
// It returns a promise — fire-and-forget lookup
readResult.then(list => {
const found = (list || []).find(s => s.id === serviceId);
return found?.containerId || null;
}).catch(() => null);
} else {
const found = (readResult || []).find(s => s.id === serviceId);
if (found?.containerId) return found.containerId;
}
}
} catch (_) { /* best effort */ }
return null;
}
+108 -310
View File
@@ -18,30 +18,6 @@ const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(platformP
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json');
const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour
// DC-078: registry probe reliability knobs. The container's /etc/resolv.conf points
// at Technitium (100.121.150.22) which sometimes returns a mix of A and AAAA
// records even when the host's IPv6 path to public registries (Docker Hub,
// ghcr.io) is broken or slow. Without `family: 4` Node defaults to dual-stack,
// every `https.request` to a registry races dual-stack DNS and stalls 30+ seconds
// per ENETUNREACH on the unreachable family. Without an explicit request timeout
// the entire `checkForUpdates()` loop (5+ containers) blocks for minutes per
// tick — visible in error.log as AggregateError [ETIMEDOUT] with a stack like
// `at internalConnectMultiple (node:net:1114:18)`.
//
// TUNABLES — keep conservative; the digest check is a background poll, not
// user-facing. Worst-case latency per query:
// 1st attempt: REGISTRY_REQUEST_TIMEOUT_MS (10s)
// 1st retry : REGISTRY_RETRY_BACKOFF_MS + REGISTRY_REQUEST_TIMEOUT_MS (10.5s)
// ─────────────────────────────────────────────────────────────────────
// per-container ceiling: 20.5s (REGISTRY_MAX_RETRIES=1)
const REGISTRY_REQUEST_TIMEOUT_MS = 10000; // hard per-request socket timeout
const REGISTRY_MAX_RETRIES = 1; // extra attempts after first failure
const REGISTRY_RETRY_BACKOFF_MS = 500; // delay before retry (transient blips)
const REGISTRY_TRANSIENT_ERROR_CODES = new Set([
'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN',
'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH',
]);
class UpdateManager extends EventEmitter {
constructor() {
super();
@@ -168,39 +144,12 @@ class UpdateManager extends EventEmitter {
/**
* Get latest image digest from registry
*
* DC-082: when the image name is a docker-compose prefixed name like
* `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash),
* the existing code normalized it to `library/dashcaddy-dashcaddy-api`
* before probing Docker Hub. The actual upstream namespace for a
* compose-prefixed image is `<project>/<service>` (with slash) Docker
* Compose hyphenates the project name and service name when tagging
* locally. The pre-fix code probed the wrong repo, Docker Hub returned
* HTTP 401 (the repo doesn't exist), and the error log showed
* `Docker Hub registry returned HTTP 401 after auth` on every restart
* for the local dashcaddy-api image. The fix: split on the FIRST hyphen
* for compose-prefixed names so the lookup targets the correct
* namespace.
*
* Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash,
* lowercase, both halves non-empty). Examples:
* dashcaddy-dashcaddy-api -> dashcaddy/dashcaddy-api
* myproject-myservice -> myproject/myservice
* nginx -> library/nginx (official, unchanged)
* library/nginx -> library/nginx (official, unchanged)
* dashcaddy/some-image -> dashcaddy/some-image (already has slash)
* ghcr.io/x/y -> ghcr.io/x/y (handled below)
*/
async getLatestImageDigest(imageName) {
// DC-082: declare `remainder` at the function scope so the catch block
// can classify the error against the image-name shape (compose-prefixed
// local images produce a steady-state 401 that should log as info, not
// error).
let remainder = imageName;
try {
// Parse image name — strip any leading registry host first
let imageTag = 'latest';
remainder = imageName;
let remainder = imageName;
const lastColon = imageName.lastIndexOf(':');
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
const lastSlash = imageName.lastIndexOf('/');
@@ -214,19 +163,8 @@ class UpdateManager extends EventEmitter {
return await this.getGhcrDigest(remainder, imageTag);
}
// Docker Hub images (library/nginx OR org/image with single slash).
// Special-case docker-compose prefixed names (single hyphen, no slash,
// lowercase) — split on the FIRST hyphen to recover the original
// `<project>/<service>` namespace. See DC-082.
if (!remainder.includes('/')) {
const composeRepo = this._composeProjectToRepo(remainder);
if (composeRepo) {
return await this.getDockerHubDigest(composeRepo, imageTag);
}
// Not a compose-prefixed name — fall through to the library/ default
return await this.getDockerHubDigest(remainder, imageTag);
}
if (remainder.split('/').length === 2) {
// Docker Hub images (library/nginx OR org/image with single slash)
if (!remainder.includes('/') || remainder.split('/').length === 2) {
return await this.getDockerHubDigest(remainder, imageTag);
}
@@ -234,278 +172,96 @@ class UpdateManager extends EventEmitter {
log.warn('update', 'Custom registry not yet supported', { remainder });
return null;
} catch (error) {
// DC-082: a "registry returned HTTP 401 after auth" against a
// compose-prefixed local image is the steady-state when the image
// is built locally and the upstream namespace on Docker Hub
// doesn't exist (or is private). The token endpoint returns 200
// with an empty-access JWT, and the authed manifest GET 401s.
// Log these as a clean info not-found line instead of an error
// so dashboards and PagerDuty don't fire on every restart.
if (this._isNotPublishedError(error, remainder)) {
log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder });
return null;
}
log.error('update', error, null, { imageName });
return null;
}
}
/**
* DC-082: split a docker-compose prefixed image name on the FIRST hyphen
* to recover the original `<project>/<service>` namespace. Returns null
* for names that don't match the compose-prefixed shape callers fall
* through to the standard library/-prefixed official-image path.
*
* Compose-prefixed shape:
* - Contains exactly one or more hyphens
* - No slash
* - Lowercase letters / digits / hyphens / underscores only
* - Both halves (before first hyphen, after first hyphen) are non-empty
* - First char is a letter or digit (not a hyphen)
*/
_composeProjectToRepo(remainder) {
if (typeof remainder !== 'string' || remainder.length === 0) return null;
if (remainder.includes('/')) return null; // already namespaced
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
// Not a compose-prefixed name — let the library/ path handle it
// (this is the official-image path: e.g. `nginx`, `alpine`).
return null;
}
const firstHyphen = remainder.indexOf('-');
// Defensive: indexOf must find a hyphen (regex requires it), but guard
// against any future regex drift.
if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null;
const project = remainder.substring(0, firstHyphen);
const service = remainder.substring(firstHyphen + 1);
if (!project || !service) return null;
return `${project}/${service}`;
}
/**
* DC-082: detect the "registry returned 401 after auth" pattern that
* signals "this image has no public upstream on Docker Hub" (as opposed
* to a genuine auth failure or transient network error). Steady-state
* for compose-prefixed local images that aren't published.
*/
_isNotPublishedError(error, remainder) {
if (!error || typeof error.message !== 'string') return false;
if (!error.message.includes('HTTP 401')) return false;
// Constrain to the compose-prefixed path — a real auth failure on a
// legitimate `library/foo` or `namespace/foo` probe should still log
// as an error (it never auto-heals).
if (typeof remainder !== 'string' || remainder.includes('/')) return false;
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
return false;
}
return true;
}
/**
* Get image digest from GitHub Container Registry (ghcr.io)
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
* but using ghcr.io's own auth endpoint.
*
* DC-078: hardened `family: 4` to avoid the dual-stack DNS race when the
* host's IPv6 path is unreachable (was producing AggregateError [ETIMEDOUT] in
* error.log every check cycle). Hard request timeout caps each attempt.
*/
async getGhcrDigest(repository, tag) {
// ghcr.io uses the same OCI distribution spec as Docker Hub
const imageRepo = repository.replace(/^ghcr\.io\//, '');
const res = await this.fetchWithReliability({
hostname: 'ghcr.io',
path: `/v2/${imageRepo}/manifests/${tag}`,
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
},
});
return res.headers['docker-content-digest'] || null;
}
/**
* Get image digest from Docker Hub
*
* DC-078: hardened see getGhcrDigest comment. Resolves a 401 token via
* `fetchAuthToken`, which itself is wrapped in the same retry + IPv4-only +
* timeout policy via `fetchWithReliability`.
*/
async getDockerHubDigest(repository, tag) {
// Normalize repository name
const repo = repository.includes('/') ? repository : `library/${repository}`;
const firstAttempt = await this.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: `/v2/${repo}/manifests/${tag}`,
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
},
});
if (firstAttempt.statusCode !== 401) {
if (firstAttempt.statusCode < 200 || firstAttempt.statusCode >= 300) {
throw new Error(`Docker Hub registry returned HTTP ${firstAttempt.statusCode}`);
}
return firstAttempt.headers['docker-content-digest'] || null;
}
// 401 → acquire a Bearer token via the WWW-Authenticate realm, then retry once.
const authHeader = firstAttempt.headers['www-authenticate'];
const authUrl = this.parseAuthHeader(authHeader);
if (!authUrl) {
throw new Error('Authentication required but no auth URL found');
}
const token = await this.fetchAuthToken(authUrl);
const authed = await this.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: `/v2/${repo}/manifests/${tag}`,
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
'Authorization': `Bearer ${token}`,
},
});
if (authed.statusCode < 200 || authed.statusCode >= 300) {
throw new Error(`Docker Hub registry returned HTTP ${authed.statusCode} after auth`);
}
return authed.headers['docker-content-digest'] || null;
}
/**
* Single hardened HTTPS probe DC-078.
*
* Reliability properties:
* 1. `family: 4` IPv4-only DNS lookup. Avoids dual-stack races where a
* single unreachable IPv6 destination consumes the default 30-second
* connect timeout before the IPv4 fallback succeeds (manifested in
* error.log as AggregateError [ETIMEDOUT] with `at internalConnectMultiple`).
* 2. Hard per-request timeout (REGISTRY_REQUEST_TIMEOUT_MS) caps total
* latency for any single probe attempt.
* 3. Retry on transient network errors (REGISTRY_TRANSIENT_ERROR_CODES)
* with REGISTRY_RETRY_BACKOFF_MS delay between attempts. Does NOT
* retry on HTTP 4xx/5xx those are real responses we should surface.
*
* Returns {statusCode, headers, body} so callers can read whichever response
* header or body bytes they need. For digest probes the body is drained and
* discarded; for auth-token fetches the JSON body is parsed.
*
* @param {object} opts
* @param {string} opts.hostname
* @param {string} opts.path
* @param {object} [opts.headers]
* @param {number} [opts.maxBodyBytes=65536] protect against runaway bodies
*/
async fetchWithReliability(opts) {
const maxBodyBytes = opts.maxBodyBytes || 65536;
let attempt = 0;
while (attempt <= REGISTRY_MAX_RETRIES) {
try {
const result = await this._httpsRequestOnce({
hostname: opts.hostname,
path: opts.path,
headers: opts.headers || {},
maxBodyBytes,
});
return result;
} catch (error) {
// Drain retryable transient errors; non-transient (HTTP status) errors
// and code-less errors are surfaced directly to the caller.
if (!REGISTRY_TRANSIENT_ERROR_CODES.has(error && error.code)) {
throw error;
}
if (attempt >= REGISTRY_MAX_RETRIES) {
throw error;
}
attempt += 1;
// Brief backoff before retry to let transient blips settle.
await new Promise((resolve) => setTimeout(resolve, REGISTRY_RETRY_BACKOFF_MS));
}
}
// Defensive — should not reach here because the loop either throws or returns.
throw new Error('fetchWithReliability exhausted retries');
}
/**
* One-shot HTTPS request helper for fetchWithReliability DC-078.
* Returns {statusCode, headers, body} on 2xx and most non-2xx responses
* (the caller decides what to do with non-2xx). Throws on transient
* network errors so the retry policy catches them.
*/
_httpsRequestOnce({ hostname, path: urlPath, headers, maxBodyBytes }) {
return new Promise((resolve, reject) => {
const options = {
hostname,
path: urlPath,
hostname: 'ghcr.io',
path: `/v2/${imageRepo}/manifests/${tag}`,
method: 'GET',
family: 4, // DC-078: IPv4-only — see top-of-file comment
headers,
timeout: REGISTRY_REQUEST_TIMEOUT_MS, // DC-078: hard per-request cap
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
}
};
const req = https.request(options, (res) => {
let body = '';
let size = 0;
let aborted = false;
res.on('data', (chunk) => {
if (aborted) return;
size += chunk.length;
if (size > maxBodyBytes) {
aborted = true;
res.destroy();
const err = new Error(`response from ${hostname}${urlPath} exceeded ${maxBodyBytes} bytes`);
err.code = 'ERR_RESPONSE_TOO_LARGE';
reject(err);
return;
if (res.statusCode === 401) {
const authHeader = res.headers['www-authenticate'];
const authUrl = this.parseAuthHeader(authHeader);
if (authUrl) {
// ghcr.io auth endpoint accepts scope=repository:owner/name:pull
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
} else {
reject(new Error('Authentication required but no auth URL found'));
}
body += chunk;
});
res.on('end', () => {
if (aborted) return;
resolve({
statusCode: res.statusCode,
headers: res.headers,
body,
});
});
});
// Node 22 emits 'timeout' on the request, not the socket, when socket.setTimeout
// is hit — make it an explicit error so fetchWithReliability's retry policy catches it.
req.on('timeout', () => {
req.destroy(new Error('request timeout'));
const err = new Error(`registry request to ${hostname}${urlPath} timed out after ${REGISTRY_REQUEST_TIMEOUT_MS}ms`);
err.code = 'ETIMEDOUT';
reject(err);
});
req.on('error', (err) => {
// Tag errors missing .code so the retry policy recognizes transient ones.
if (!err.code && /timeout/i.test(err.message)) err.code = 'ETIMEDOUT';
reject(err);
return;
}
if (res.statusCode !== 200) {
// Drain body to avoid socket leak
res.resume();
reject(new Error(`ghcr.io returned HTTP ${res.statusCode}`));
return;
}
const digest = res.headers['docker-content-digest'];
resolve(digest || null);
});
req.on('error', reject);
req.end();
});
}
/**
* Fetch an auth token from a registry's WWW-Authenticate realm URL DC-078.
* Uses fetchWithReliability for IPv4-only + timeout + retry. Parses the
* JSON body and returns the `token` or `access_token` field.
* Get image digest from Docker Hub
*/
async fetchAuthToken(authUrl) {
const url = new URL(authUrl);
const result = await this.fetchWithReliability({
hostname: url.hostname,
path: url.pathname + url.search,
maxBodyBytes: 16384, // auth tokens are <2 KB; cap to a small bound
async getDockerHubDigest(repository, tag) {
return new Promise((resolve, reject) => {
// Normalize repository name
const repo = repository.includes('/') ? repository : `library/${repository}`;
const options = {
hostname: 'registry-1.docker.io',
path: `/v2/${repo}/manifests/${tag}`,
method: 'GET',
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
}
};
const req = https.request(options, (res) => {
if (res.statusCode === 401) {
// Need to authenticate
const authHeader = res.headers['www-authenticate'];
const authUrl = this.parseAuthHeader(authHeader);
if (authUrl) {
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
} else {
reject(new Error('Authentication required but no auth URL found'));
}
return;
}
const digest = res.headers['docker-content-digest'];
resolve(digest || null);
});
req.on('error', reject);
req.end();
});
if (result.statusCode !== 200) {
throw new Error(`auth token endpoint ${authUrl} returned HTTP ${result.statusCode}`);
}
let auth;
try {
auth = JSON.parse(result.body);
} catch (parseErr) {
// Surface a clean error — otherwise a malformed token response throws
// SyntaxError with the raw body snippet, which is hard to diagnose
// against the offending realm URL in a log line.
throw new Error(`auth token response from ${authUrl} was not valid JSON: ${parseErr.message}`);
}
const token = auth.token || auth.access_token;
if (!token) throw new Error(`No token in auth response from ${authUrl}`);
return token;
}
/**
@@ -527,6 +283,48 @@ class UpdateManager extends EventEmitter {
return url.toString();
}
/**
* Authenticate and get digest
*/
async authenticateAndGetDigest(authUrl, originalOptions) {
return new Promise((resolve, reject) => {
https.get(authUrl, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const auth = JSON.parse(data);
const token = auth.token || auth.access_token;
if (!token) {
reject(new Error('No token in auth response'));
return;
}
// Retry original request with token
const options = {
...originalOptions,
headers: {
...originalOptions.headers,
'Authorization': `Bearer ${token}`
}
};
const req = https.request(options, (res) => {
const digest = res.headers['docker-content-digest'];
resolve(digest || null);
});
req.on('error', reject);
req.end();
} catch (error) {
reject(error);
}
});
}).on('error', reject);
});
}
/**
* Extract tag from image name
*/
@@ -52,34 +52,6 @@ const STATE_FILE = process.env.CADDY_UPSTREAMS_STATE_FILE
const SITES_DIR = process.env.CADDY_SITES_DIR || '/etc/caddy/sites';
/**
* Hostname the probe uses instead of a loopback address.
*
* CRITICAL: this watcher runs INSIDE the dashcaddy-api container. Caddy runs
* on the HOST. A site config's `reverse_proxy localhost:8088` means "the
* host's loopback" from Caddy's point of view but from inside the container
* `localhost`/`127.0.0.1` is the container's OWN loopback, where nothing
* listens. Probing loopback verbatim makes every healthy host-side upstream
* report ECONNREFUSED (live prod bug 2026-08-18: 9 of 14 tracked upstreams
* showed 278 consecutive phantom failures and opened bogus `caddy-upstream-dead`
* incidents).
*
* Fix: remap loopback probe targets to `host.docker.internal`, which start.sh
* pins to the host's bridge IP via `--add-host=host.docker.internal:host-gateway`
* (Docker 20.10). The upstream's display key stays `localhost:PORT` so
* existing mute lists and UI labels are unaffected only the probe target
* changes. Set IN_CONTAINER=false (e.g. a bare-metal deployment where the API
* runs beside Caddy) to disable the remap.
*/
const HOST_GATEWAY_NAME = process.env.CADDY_UPSTREAM_HOST_GATEWAY_NAME || 'host.docker.internal';
const IN_CONTAINER = process.env.IN_CONTAINER !== 'false';
const HOST_GATEWAY_PROBE = IN_CONTAINER ? HOST_GATEWAY_NAME : null;
/** True when the address is IPv4 loopback (127.0.0.0/8) or the `localhost` name. */
function isLoopbackHost(host) {
return host === 'localhost' || /^127(\.\d{1,3}){3}$/.test(host);
}
class CaddyUpstreamWatcher extends EventEmitter {
constructor(opts = {}) {
super();
@@ -223,12 +195,7 @@ class CaddyUpstreamWatcher extends EventEmitter {
/** Probe a single upstream and update state. */
async _probeOne(u) {
// Loopback upstreams (see HOST_GATEWAY_PROBE header comment): the Caddyfile
// `localhost`/`127.x` is host-relative, so probe the host gateway instead of
// the container's own loopback. Display key and persisted `ip` are unchanged.
const loopbackRemap = !!(HOST_GATEWAY_PROBE && isLoopbackHost(u.ip));
const probeHost = loopbackRemap ? HOST_GATEWAY_PROBE : u.ip;
const result = await this._doProbe(probeHost, u.port);
const result = await this._doProbe(u.ip, u.port);
u.lastCheckedAt = new Date().toISOString();
if (result.healthy) {
@@ -242,41 +209,6 @@ class CaddyUpstreamWatcher extends EventEmitter {
// to be stable. After one full successful check we mark 'up' but the
// incident resolution waits for RESOLVED_AFTER_MS.
u.status = 'up';
// A successful host-gateway probe PROVES the bridge can reach the
// host. If a later probe then fails, we have strong evidence the
// upstream itself went dead — not that bridge connectivity broke.
// Mark verifiedViaBridge so the unverifiable path can short-circuit
// and treat it like a non-loopback upstream.
if (loopbackRemap) u.verifiedViaBridge = true;
} else if (loopbackRemap && !u.verifiedViaBridge) {
// The host-gateway probe comes from the docker bridge IP. A service
// bound to 0.0.0.0 on the host answers; a service bound to the host's
// 127.0.0.1 ONLY refuses — indistinguishable, from this vantage point,
// from a truly dead service. Caddy (on the host) reaches both fine, so
// a failed probe here is NOT evidence the upstream is dead. Mark it
// unverifiable: no failure counters, no incident, keep lastError for
// visibility. (A successful probe IS conclusive — see above.)
u.consecutiveFailures = 0;
u.status = 'unverifiable';
u.lastError = `host-loopback upstream not verifiable from container (${result.error || `HTTP ${result.statusCode || 'unknown'}`})`;
// Clear the success anchor: a 10-minute-old success is not evidence of
// anything for an upstream we cannot observe from this vantage point,
// and leaving it would make snapshot() compute a bogus failingForMs
// and flag `dead`.
u.lastSuccessAt = null;
this._maybeResolve(u);
} else if (loopbackRemap && u.verifiedViaBridge) {
// The bridge previously reached this upstream successfully — so a
// failed probe here is near-conclusive evidence the upstream itself
// went dead (the bridge path itself doesn't change between probes).
// Treat it like a non-loopback upstream failure: count it, open an
// incident after DEAD_AFTER_MS. This restores dead-detection for the
// subset of loopback upstreams that prove themselves reachable.
u.consecutiveFailures += 1;
u.lastFailureAt = u.lastCheckedAt;
u.lastError = result.error || `HTTP ${result.statusCode || 'unknown'}`;
u.status = 'down';
this._maybeOpenIncident(u);
} else {
u.consecutiveFailures += 1;
u.lastFailureAt = u.lastCheckedAt;
@@ -373,25 +305,7 @@ class CaddyUpstreamWatcher extends EventEmitter {
});
}
/**
* Public snapshot for the API/UI.
*
* Each upstream record includes:
* - host / site / siteFile: identity
* - status: 'up' | 'down' | 'unverifiable' | 'unknown' (or 'muted' here)
* - consecutiveFailures / failingForMs: dead-detection counters
* - lastCheckedAt / lastSuccessAt / lastFailureAt / lastError: probe history
* - muted: true if user silenced this upstream
* - dead: true if failingForMs >= DEAD_AFTER_MS (5 min default)
* - verifiedViaBridge (loopback upstreams only): true iff this upstream
* has ever answered a host-gateway probe with success. A later failed
* probe is then near-conclusive evidence of upstream death rather
* than bridge/UFW refusal. UI consumers should label `unverifiable`
* rows as "no prior observation" and `down` rows with
* verifiedViaBridge=true as "previously-verified, now down".
*
* @returns {{ upstreams: Array<object>, config: object }}
*/
/** Public snapshot for the API/UI. */
snapshot() {
const list = [];
for (const u of this.upstreams.values()) {
@@ -420,18 +334,12 @@ class CaddyUpstreamWatcher extends EventEmitter {
lastError: u.lastError,
failingForMs: failingFor,
muted,
dead: !muted && failingFor >= DEAD_AFTER_MS,
// True iff this loopback upstream has ever answered a host-gateway
// probe with success — meaning we have at least one prior positive
// observation of bridge connectivity, so a later failure is
// evidence of upstream death rather than bridge/UFW refusal.
verifiedViaBridge: !!u.verifiedViaBridge
dead: !muted && failingFor >= DEAD_AFTER_MS
});
}
// Sort: dead first, then down, then muted, then unverifiable (informational),
// then up, then unknown. Within each, by host.
// Sort: dead first, then down, then up, then unknown. Within each, by host.
list.sort((a, b) => {
const order = { dead: 0, down: 1, muted: 2, unverifiable: 3, up: 4, unknown: 5 };
const order = { dead: 0, down: 1, muted: 2, up: 3, unknown: 4 };
const oa = order[a.dead ? 'dead' : a.status] ?? 9;
const ob = order[b.dead ? 'dead' : b.status] ?? 9;
if (oa !== ob) return oa - ob;
@@ -498,16 +406,6 @@ class CaddyUpstreamWatcher extends EventEmitter {
lastSuccessAt: st.lastSuccessAt || null,
lastError: st.lastError || null,
lastCheckedAt: st.lastCheckedAt || null,
// Persist verifiedViaBridge so a loopback upstream that proved itself
// reachable once doesn't have to re-prove it after every container
// restart. A 1-tick blip is acceptable here because:
// (a) the field is only used as a labelling gate for the
// unverifiable-vs-down decision — a falsy restart value means
// we re-mark unverifiable for one cycle, the safer direction;
// (b) the bridge IP doesn't change between restarts of the same
// container, so a previously-positive observation is still
// good evidence.
verifiedViaBridge: !!st.verifiedViaBridge,
status: 'unknown'
});
}
@@ -528,8 +426,7 @@ class CaddyUpstreamWatcher extends EventEmitter {
lastFailureAt: v.lastFailureAt,
lastSuccessAt: v.lastSuccessAt,
lastError: v.lastError,
lastCheckedAt: v.lastCheckedAt,
verifiedViaBridge: !!v.verifiedViaBridge
lastCheckedAt: v.lastCheckedAt
};
}
const tmp = STATE_FILE + '.tmp';
+15 -153
View File
@@ -33,45 +33,17 @@ const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '30
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
// DC-086: hysteresis thresholds for badge display.
// The raw probe result can flap on a single transient blip (Caddy reload,
// container CPU steal, network hiccup, mid-flight TLS handshake). Showing
// every probe result as-is to the dashboard creates the "perpetual flicker"
// UX. Asymmetric thresholds: going red is slow (don't false-alarm), going
// green is fast (don't keep showing red after recovery).
// - DOWN_THRESHOLD = N consecutive "down" probes before the badge flips to red
// - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green
// Single probe flips to green on purpose — false-positive-green is much less
// painful than perpetual-red (operators notice red, ignore green).
function readPositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
const value = Number(raw);
return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
}
const DOWN_THRESHOLD = readPositiveIntEnv('HEALTH_DOWN_THRESHOLD', 2);
const UP_THRESHOLD = readPositiveIntEnv('HEALTH_UP_THRESHOLD', 1);
class HealthChecker extends EventEmitter {
constructor() {
super();
this.config = this.loadConfig();
this.history = this.loadHistory();
this.currentStatus = new Map();
// DC-086: the status the dashboard SHOULD display (post-hysteresis).
// Distinct from currentStatus, which is the latest raw probe result.
this.displayedStatus = new Map();
// DC-086: counter of consecutive healthy/unhealthy probes since the
// last displayed-status change. Reset to 0 whenever displayed status flips.
this.consecutiveSinceChange = new Map();
this.incidents = [];
this.checking = false;
this.checkInterval = null;
this.consecutiveFailures = new Map(); // serviceId -> failure count
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
}
/**
@@ -144,7 +116,6 @@ class HealthChecker extends EventEmitter {
*/
async checkService(serviceId, config) {
const startTime = Date.now();
const generation = this.serviceGenerations.get(serviceId) || 0;
try {
const result = await this.performHealthCheck(config);
@@ -160,10 +131,6 @@ class HealthChecker extends EventEmitter {
details: result.details
};
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
return status;
}
// Track consecutive failures for exponential backoff
if (result.healthy) {
this.consecutiveFailures.delete(serviceId);
@@ -171,9 +138,8 @@ class HealthChecker extends EventEmitter {
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
}
const previousStatus = this.currentStatus.get(serviceId);
this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config, previousStatus);
this.checkForIncidents(serviceId, status, config);
return status;
} catch (error) {
@@ -190,13 +156,8 @@ class HealthChecker extends EventEmitter {
error: error.message
};
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
return status;
}
const previousStatus = this.currentStatus.get(serviceId);
this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config, previousStatus);
this.checkForIncidents(serviceId, status, config);
return status;
}
@@ -312,109 +273,27 @@ class HealthChecker extends EventEmitter {
return true;
}
/**
* Compute the displayed status for a service given the latest raw probe
* result. Applies asymmetric hysteresis:
* - Going DOWN: requires DOWN_THRESHOLD (default 2) consecutive "down"
* probes since the last display-state change. A single blip keeps the
* badge green.
* - Going UP: requires UP_THRESHOLD (default 1) consecutive "up" probes.
* Any single "up" after a down streak flips back to green so the badge
* doesn't linger red after the service has recovered.
*
* Returns the displayed status object (same shape as the raw status) so
* recordStatus can use it both for the displayed map and as the broadcast
* payload when the displayed status actually changes.
*/
_computeDisplayedStatus(serviceId, rawStatus) {
const currentDisplayed = this.displayedStatus.get(serviceId);
const previousStatus = currentDisplayed ? currentDisplayed.status : null;
// If no prior state, accept the raw probe as-is (first-check bootstrap).
if (!previousStatus) {
return rawStatus;
}
// Probe agrees with current displayed → no change, reset the counter so
// a brief blip doesn't accumulate against the displayed state.
if (rawStatus.status === previousStatus) {
this.consecutiveSinceChange.set(serviceId, 0);
return rawStatus;
}
// Probe disagrees with displayed. Bump the streak counter — this counts
// CONSECUTIVE probes that disagree with what's shown, regardless of
// whether the raw value itself changed between probes. That's what
// makes "down, down" flip after threshold but "down, up, down" not flip.
const prev = this.consecutiveSinceChange.get(serviceId) || 0;
const next = prev + 1;
if (rawStatus.status === 'down') {
// Going DOWN: need DOWN_THRESHOLD consecutive probes that disagree
// with the displayed "up" state.
if (previousStatus === 'up' && next < DOWN_THRESHOLD) {
this.consecutiveSinceChange.set(serviceId, next);
// Keep the last internally-consistent displayed snapshot. Mixing the
// raw failure metadata with status="up" would expose contradictory
// API data (for example statusCode=500 on an "up" service).
return currentDisplayed;
}
// Threshold met (or already down) — flip to red.
this.consecutiveSinceChange.set(serviceId, 0);
return rawStatus;
}
// rawStatus.status === 'up' (must be — the equal-to-displayed case above
// already returned). Going UP after a down streak: need UP_THRESHOLD.
if (previousStatus === 'down' && next < UP_THRESHOLD) {
this.consecutiveSinceChange.set(serviceId, next);
return currentDisplayed;
}
this.consecutiveSinceChange.set(serviceId, 0);
return rawStatus;
}
/**
* Record service status
*
* DC-086: history + consecutiveFailures are updated for EVERY probe
* (operators want full probe history for postmortems). The dashboard's
* `status-check` event is only emitted when the DISPLAYED status changes,
* so the badge stops re-rendering on every probe.
*/
recordStatus(serviceId, status) {
// Update current (raw) status — used by checkForIncidents and history.
// Update current status
this.currentStatus.set(serviceId, status);
// Add raw probe to history (full fidelity — operators rely on this).
// Add to history
if (!this.history[serviceId]) {
this.history[serviceId] = [];
}
this.history[serviceId].push(status);
// Cap entries to prevent unbounded growth (disk explosion fix)
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
}
// Compute the post-hysteresis displayed status; only emit when it changes.
// _computeDisplayedStatus compares the raw probe against the DISPLAYED
// status (not the previous raw status), so the "consecutive since
// change" counter doesn't depend on the order of writes here.
const displayed = this._computeDisplayedStatus(serviceId, status);
const previousDisplayed = this.displayedStatus.get(serviceId);
const displayChanged =
!previousDisplayed || previousDisplayed.status !== displayed.status;
this.displayedStatus.set(serviceId, displayed);
if (displayChanged) {
// Emit with the displayed status so the dashboard renders the same
// state the hysteresis just decided. The raw probe result is still
// in `history` and `currentStatus` for anyone who wants it.
this.emit('status-check', displayed);
}
// Emit status event
this.emit('status-check', status);
// Save history periodically
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
@@ -425,7 +304,8 @@ class HealthChecker extends EventEmitter {
/**
* Check for incidents (downtime, slow response, etc.)
*/
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId)) {
checkForIncidents(serviceId, status, config) {
const previous = this.currentStatus.get(serviceId);
// Check for status change (up -> down or down -> up)
if (previous && previous.status !== status.status) {
@@ -565,29 +445,19 @@ class HealthChecker extends EventEmitter {
}
/**
* Get current status for all services.
*
* DC-086: returns the DISPLAYED status (post-hysteresis), not the latest
* raw probe. A page reload should show the same badge state the live
* SSE stream is currently showing otherwise an operator who reloads
* the page after a single blip sees red even though the hysteresis kept
* the badge green for them.
* Get current status for all services
*/
getCurrentStatus() {
const result = {};
for (const [serviceId, rawStatus] of this.currentStatus.entries()) {
for (const [serviceId, status] of this.currentStatus.entries()) {
const config = this.config.services[serviceId];
const uptime24h = this.calculateUptime(serviceId, 24);
const uptime7d = this.calculateUptime(serviceId, 168);
const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24);
// Prefer the displayed status if we've already computed one; fall back
// to the raw probe on the very first call (before recordStatus has run).
const displayed = this.displayedStatus.get(serviceId) || rawStatus;
result[serviceId] = {
...displayed,
...status,
name: config?.name || serviceId,
uptime: {
'24h': uptime24h,
@@ -597,7 +467,7 @@ class HealthChecker extends EventEmitter {
sla: config?.sla
};
}
return result;
}
@@ -660,7 +530,6 @@ class HealthChecker extends EventEmitter {
this.config.services = {};
}
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
this.config.services[serviceId] = {
enabled: config.enabled !== false,
name: config.name || serviceId,
@@ -683,19 +552,12 @@ class HealthChecker extends EventEmitter {
* Remove service configuration
*/
removeService(serviceId) {
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
if (this.config.services) {
delete this.config.services[serviceId];
this.saveConfig();
}
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];
}
@@ -1,417 +0,0 @@
/**
* DC-055: Host journald reader
*
* Wraps the host's `journalctl` binary so the API can stream host service
* logs (caddy, dashcaddy-api, docker, ...) without exposing the binary
* directly to the web layer. The CLI is invoked with --directory pointed at
* the bind-mounted /var/log/journal from start.sh so we don't need the
* systemd-journal remote protocol or a privileged socket.
*
* Security contract:
* - `unit` MUST be in the allow-list `ALLOWED_UNITS`. We never accept a
* raw unit name from the caller and pass it to the shell, even with
* shell:false because an attacker who can set unit=caddy.service;
* touch /tmp/x could use the CLI itself as a confused-deputy vector.
* - All journalctl invocations use `spawn` (not `exec`) and pass arguments
* as an array (`shell:false`). No shell metacharacters can be smuggled
* in through any field the unit, since/until, search, tail numbers
* are validated separately before being added to argv.
* - Streams (SSE) cap to MAX_STREAM_BYTES and kill the child on overflow
* so a `tail=999999999999` request can't OOM the process.
*
* Failure modes that surface to the route layer:
* - journalctl missing in the container (DN container, dev container):
* every call throws Error('journalctl unavailable'). Route 503s.
* - unit not in allow-list: throws ValidationError. Route 400s.
* - non-zero exit code: child stderr is captured and surfaced verbatim
* up to LOG_PREVIEW_BYTES so the operator can see "Failed to open
* directory" instead of a generic 500.
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const JOURNAL_DIR = '/var/log/journal';
const ALLOWED_UNITS = Object.freeze([
// Core reverse proxy + DNS host services
'caddy',
'dashcaddy-api',
'docker',
'systemd-journald',
'networkd-dispatcher',
'tailscaled',
'ssh',
// Permit the unit with and without the .service suffix. The CLI accepts
// both; we store the bare name and append nothing — journalctl treats
// "caddy" and "caddy.service" identically.
]);
// Cap how much a single request can read — prevents `tail=999999999` from
// piping half the journal into memory. The dashboard doesn't have a UI for
// "load 100MB of logs" and journalctl itself caps at 2GB anyway.
const MAX_TAIL_LINES = 5000;
// Streaming cap: how many journal entries we hand to the SSE consumer
// before killing the child. The dashboard shouldn't accumulate more than
// this in memory — pair with MAX_OUTPUT_BUFFER for a defense-in-depth
// bound on what the route layer will hold.
const MAX_STREAM_LINES = 5000;
const MAX_OUTPUT_BUFFER = 2 * 1024 * 1024; // 2MB hard cap on total stdout
const LOG_PREVIEW_BYTES = 4096;
const UNIT_PATTERN = /^[a-zA-Z0-9_.@-]+$/;
const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
/**
* Validate a unit name against the allow-list. Returns the canonical name
* or throws ValidationError.
*/
function assertUnitAllowed(unit) {
if (typeof unit !== 'string' || !unit) {
const err = new Error('unit is required');
err.name = 'ValidationError';
throw err;
}
// Strip the .service suffix defensively so callers don't have to remember
// which form journalctl prefers for a given unit.
const normalised = unit.endsWith('.service') ? unit.slice(0, -8) : unit;
if (!UNIT_PATTERN.test(normalised)) {
const err = new Error(`unit contains invalid characters: ${unit}`);
err.name = 'ValidationError';
throw err;
}
if (!ALLOWED_UNITS.includes(normalised)) {
const err = new Error(`unit not in allow-list: ${normalised}`);
err.name = 'ValidationError';
throw err;
}
return normalised;
}
/**
* Parse tail to a bounded positive integer.
*/
function parseTail(raw, fallback = 200) {
if (raw === undefined || raw === null || raw === '') return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
const err = new Error(`tail must be a positive integer (got ${raw})`);
err.name = 'ValidationError';
throw err;
}
return Math.min(n, MAX_TAIL_LINES);
}
/**
* Parse since/until accept either an ISO timestamp, a unix epoch in ms, or
* journalctl's relative syntax ("30 min ago", "today", "yesterday"). The
* dashboard uses ISO timestamps from `<input type="datetime-local">`; the
* relative syntax is for power users typing into the search bar.
*/
function parseTimestamp(raw, fieldName) {
if (raw === undefined || raw === null || raw === '') return null;
if (typeof raw !== 'string') {
const err = new Error(`${fieldName} must be a string`);
err.name = 'ValidationError';
throw err;
}
// ISO 8601
if (ISO_PATTERN.test(raw)) {
const ms = Date.parse(raw);
if (!Number.isFinite(ms)) {
const err = new Error(`${fieldName} is not a valid ISO timestamp: ${raw}`);
err.name = 'ValidationError';
throw err;
}
return new Date(ms).toISOString();
}
// Numeric (unix epoch seconds OR ms — journalctl accepts seconds)
if (/^-?\d+$/.test(raw)) {
const n = Number(raw);
const ms = n > 1e12 ? n : n * 1000;
if (!Number.isFinite(ms)) {
const err = new Error(`${fieldName} is not a valid epoch: ${raw}`);
err.name = 'ValidationError';
throw err;
}
return new Date(ms).toISOString();
}
// Relative syntax: pass through to journalctl, but cap to 1024 chars and
// disallow shell metacharacters.
if (raw.length > 1024 || /[`$;&|><\\\n\r]/.test(raw)) {
const err = new Error(`${fieldName} contains forbidden characters: ${raw}`);
err.name = 'ValidationError';
throw err;
}
return raw;
}
/**
* Detect whether journalctl is reachable. Cheap probe (no-op flag) so we
* don't shell out on every request when the binary is missing (dev
* container, Windows host, etc.).
*/
function isAvailable({ journalDir = JOURNAL_DIR, exec = spawn } = {}) {
if (!fs.existsSync(journalDir)) return false;
return new Promise((resolve) => {
const child = exec('journalctl', ['--no-pager', '--version'], { stdio: 'ignore' });
child.on('error', () => resolve(false));
child.on('exit', (code) => resolve(code === 0));
});
}
/**
* Build argv for journalctl. Exposed so tests can assert exactly what we
* shell out never build the arg array inline anywhere else.
*/
function buildArgv({ unit, since, until, tail, search, follow = false }) {
const argv = [
'--directory', JOURNAL_DIR,
'--no-pager',
'--output=short',
'-u', unit,
];
if (since) argv.push('--since', since);
if (until) argv.push('--until', until);
if (typeof tail === 'number') argv.push('-n', String(tail));
if (search) {
// journalctl -S matches the searchable text fields (MESSAGE + others).
// Quote-enforcing isn't needed because spawn argv doesn't touch a shell.
argv.push('-S', search);
}
if (follow) argv.push('--follow');
return argv;
}
/**
* Read a bounded tail of journal entries for a unit. Resolves to an array
* of {timestamp, text} lines, oldest first. Throws ValidationError on bad
* input, Error('journalctl unavailable') if the binary or journal dir is
* missing, and Error('journalctl exited N: <stderr>') for CLI failures.
*/
/**
* Spawn journalctl with the given argv and collect stdout/stderr up to
* the configured caps. Resolves to a Buffer of stdout on success, rejects
* with Error('journalctl unavailable') on ENOENT or
* Error('journalctl exited N: <stderr>') on non-zero exit. Exceeding the
* output cap rejects with an explicit overflow message.
*
* Kept as a free function (not inside `readEntries`) so the same plumbing
* can be reused for streaming without code duplication.
*/
function runJournalctl({ exec, argv }) {
return new Promise((resolve, reject) => {
const child = exec('journalctl', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = Buffer.alloc(0);
let stderr = '';
child.stdout.on('data', (chunk) => {
if (stdout.length + chunk.length > MAX_OUTPUT_BUFFER) {
child.kill('SIGKILL');
reject(new Error(`output exceeded ${MAX_OUTPUT_BUFFER} bytes`));
return;
}
stdout = Buffer.concat([stdout, chunk]);
});
child.stderr.on('data', (chunk) => {
if (stderr.length < LOG_PREVIEW_BYTES) {
stderr += chunk.toString('utf8');
if (stderr.length > LOG_PREVIEW_BYTES) {
stderr = stderr.slice(0, LOG_PREVIEW_BYTES) + '…';
}
}
});
child.on('error', (err) => {
if (err.code === 'ENOENT') {
reject(new Error('journalctl unavailable'));
} else {
reject(err);
}
});
child.on('exit', (code, signal) => {
if (signal === 'SIGKILL' && stdout.length >= MAX_OUTPUT_BUFFER) return; // already rejected
if (code !== 0) {
reject(new Error(`journalctl exited ${code}${stderr ? ': ' + stderr.trim() : ''}`));
return;
}
resolve({ stdout, stderr });
});
});
}
/**
* Parse a journalctl --output=short line into a structured entry.
* Lines look like: "Aug 18 00:42:46 vmi3080415 caddy[3620580]: {...}"
*/
function parseShortLine(line, fallbackUnit) {
const tsMatch = line.match(/^([A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}) (\S+) (.+?)\[\d+\]: (.*)$/);
if (tsMatch) {
return {
timestamp: tsMatch[1],
hostname: tsMatch[2],
unit: tsMatch[3],
text: tsMatch[4],
};
}
return { timestamp: null, hostname: null, unit: fallbackUnit, text: line };
}
function readEntries(opts, { exec = spawn } = {}) {
return Promise.resolve().then(async () => {
const unit = assertUnitAllowed(opts.unit);
const tail = parseTail(opts.tail);
const since = parseTimestamp(opts.since, 'since');
const until = parseTimestamp(opts.until, 'until');
const search = typeof opts.search === 'string' && opts.search.length > 0
? opts.search.slice(0, 1024)
: null;
const argv = buildArgv({ unit, tail, since, until, search, follow: false });
const { stdout } = await runJournalctl({ exec, argv });
const lines = stdout.toString('utf8').split('\n').filter(Boolean);
return lines.map((line) => parseShortLine(line, unit));
});
}
/**
* Stream journal entries as they arrive. Returns { child, onData, onError,
* kill } the route wires `onData`/`onError` to the SSE socket and calls
* `kill()` on disconnect.
*
* The child is spawned with --follow and we cap total bytes received; on
* overflow we kill the child and emit a synthetic 'overflow' message so the
* client knows to reconnect with a narrower window.
*/
function streamEntries(opts, { exec = spawn, onData, onError } = {}) {
const unit = assertUnitAllowed(opts.unit);
const since = parseTimestamp(opts.since, 'since');
const search = typeof opts.search === 'string' && opts.search.length > 0
? opts.search.slice(0, 1024)
: null;
const argv = buildArgv({ unit, since, search, follow: true });
let child;
try {
child = exec('journalctl', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
} catch (err) {
if (err.code === 'ENOENT') {
const e = new Error('journalctl unavailable');
onError && onError(e);
return { kill: () => {}, child: null };
}
throw err;
}
// Closure-scoped stream bookkeeping: the previous version attached a
// counter to the onData function itself, which made the 5000-line cap
// unreachable (a function has its own properties — the count was never
// incremented). Closure scope is the right place.
let stdout = Buffer.alloc(0);
let lineCount = 0;
let overflowEmitted = false;
child.stdout.on('data', (chunk) => {
if (stdout.length + chunk.length > MAX_OUTPUT_BUFFER) {
child.kill('SIGKILL');
onError && onError(new Error(`stream exceeded ${MAX_OUTPUT_BUFFER} bytes`));
return;
}
stdout = Buffer.concat([stdout, chunk]);
if (onData) {
const text = stdout.toString('utf8');
const lines = text.split('\n');
// Hold back the last partial line; flush on the next chunk or exit.
stdout = Buffer.from(lines.pop(), 'utf8');
for (const line of lines) {
if (!line) continue;
lineCount++;
if (lineCount > MAX_STREAM_LINES && !overflowEmitted) {
overflowEmitted = true;
child.kill('SIGKILL');
onError && onError(new Error(`stream exceeded ${MAX_STREAM_LINES} lines`));
return;
}
const tsMatch = line.match(/^([A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}) (\S+) (.+?)\[\d+\]: (.*)$/);
onData({
timestamp: tsMatch ? tsMatch[1] : null,
hostname: tsMatch ? tsMatch[2] : null,
unit: tsMatch ? tsMatch[3] : unit,
text: tsMatch ? tsMatch[4] : line,
});
}
}
});
let stderr = '';
child.stderr.on('data', (chunk) => {
if (stderr.length < LOG_PREVIEW_BYTES) {
stderr += chunk.toString('utf8');
if (stderr.length > LOG_PREVIEW_BYTES) {
stderr = stderr.slice(0, LOG_PREVIEW_BYTES) + '…';
}
}
});
child.on('error', (err) => {
onError && onError(err);
});
child.on('exit', (code) => {
if (code !== 0 && stderr) {
onError && onError(new Error(`journalctl exited ${code}: ${stderr.trim()}`));
}
});
return {
child,
kill() {
try { child.kill('SIGTERM'); } catch (_) { /* already dead */ }
},
};
}
/**
* List units that currently have journal entries (for the dashboard
* dropdown). Walks the allow-list and asks journalctl for the most recent
* entry per unit. Units with no entries are omitted.
*/
async function listUnits({ exec = spawn } = {}) {
if (!fs.existsSync(JOURNAL_DIR)) return [];
const out = [];
for (const unit of ALLOWED_UNITS) {
const lines = await new Promise((resolve) => {
const child = exec('journalctl', [
'--directory', JOURNAL_DIR,
'--no-pager', '-q',
'-u', unit,
'-n', '1',
'--output=short',
], { stdio: ['ignore', 'pipe', 'ignore'] });
let buf = '';
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
child.on('error', () => resolve(''));
child.on('exit', () => resolve(buf));
});
if (lines.trim()) {
out.push({ unit, hasEntries: true });
}
}
return out;
}
module.exports = {
ALLOWED_UNITS,
MAX_TAIL_LINES,
MAX_OUTPUT_BUFFER,
isAvailable,
readEntries,
streamEntries,
listUnits,
assertUnitAllowed,
parseTail,
parseTimestamp,
parseShortLine,
buildArgv,
};
+2 -18
View File
@@ -214,30 +214,14 @@ function csrfValidationMiddleware(req, res, next) {
return next();
}
// DC-058: differentiate "browser auto-retry" from "real probe" using the
// X-CSRF-Token header as a signal. The dashboard JS in status/js/globals.js
// secureFetch() pre-fetches /api/v1/csrf-token (which sets the CSRF cookie
// via csrfCookieMiddleware) before posting; if the GET raced with container
// restart OR the user cleared cookies mid-session, the POST can arrive with
// a header but no cookie. secureFetch catches the 403 and auto-retries
// with a fresh token (lines 225-238 of globals.js). For these "has header
// but no cookie" misses, tag the log line [CSRF-debug] — operators can
// grep them out as expected noise. A request with NEITHER cookie NOR
// header (curl probe, exploit scanner, broken client) keeps the louder
// [CSRF] tag.
// Validate both values exist
if (!cookieNonce) {
const isLikelyBrowserAutoRetry = !!headerToken;
const tag = isLikelyBrowserAutoRetry ? '[CSRF-debug]' : '[CSRF]';
process.stderr.write(`${tag} Missing CSRF cookie: ${method} ${req.path} from ${req.ip}` +
(isLikelyBrowserAutoRetry ? ' (browser auto-retry — header present, expect self-heal)' : '') + '\n');
process.stderr.write(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}\n`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.'
});
}
// Cookie present but no header — a real browser POST always sends both, so
// header-less is suspicious (curl probe with manual cookie, misconfigured
// client). Keep WARN level.
if (!headerToken) {
process.stderr.write(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}\n`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
+3 -82
View File
@@ -47,53 +47,6 @@ const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24
const TAILSCALE_MAX_USES = 1;
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
// DC-083: Public share endpoint input bounds. The two CSRF-exempt public
// endpoints accept untrusted body fields — bound shape, length, charset so
// an attacker can't bloat data/shares.json, inject CRLF into fields that
// flow into Tailscale auth-key descriptions, or smuggle control chars into
// the on-disk store. See routes/share.js for the route-layer validation;
// these helpers are the defense-in-depth belt under the route's suspenders.
const PUBLIC_EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const PUBLIC_EMAIL_MAX_LENGTH = 254; // RFC 5321 §4.5.3.1.3
const PUBLIC_DEVICE_ID_REGEX = /^[a-zA-Z0-9._:-]+$/;
const PUBLIC_DEVICE_ID_MIN_LENGTH = 1;
const PUBLIC_DEVICE_ID_MAX_LENGTH = 128;
function validatePublicEmail(raw) {
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_email' };
// Reject control chars / NUL / CR / LF before they can corrupt the on-disk
// JSON or be embedded in subsequent log lines. RFC 5321 forbids these in
// SMTP addresses; we mirror that at the API layer.
if (raw.length === 0 || raw.length > PUBLIC_EMAIL_MAX_LENGTH) {
return { ok: false, reason: 'invalid_email' };
}
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_email' };
// The local-part can technically contain `+`, `.`, `_`, `%`, `-`; the
// domain part must have at least one dot and a 2+ letter TLD. Reject
// quote-bracket forms (RFC 5321 obs-quote-text) — we don't accept them.
if (!PUBLIC_EMAIL_REGEX.test(raw)) return { ok: false, reason: 'invalid_email' };
// Block obvious shell-attachment characters that the regex doesn't catch.
if (/[<>{}|\\^`\s]/.test(raw)) return { ok: false, reason: 'invalid_email' };
return { ok: true, email: raw.toLowerCase() };
}
function validatePublicDeviceId(raw) {
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_device_id' };
if (raw.length < PUBLIC_DEVICE_ID_MIN_LENGTH || raw.length > PUBLIC_DEVICE_ID_MAX_LENGTH) {
return { ok: false, reason: 'invalid_device_id' };
}
// Tailscale machine IDs are base64url-with-hyphens; we accept a slightly
// broader charset (`._:-`) to also accommodate hostname-style IDs and
// Caddy's `forward_auth` device headers. Reject CR/LF/NUL/TAB explicitly
// so a smuggled control char can't break out of the Tailscale auth-key
// description string in routes/share.js:213.
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_device_id' };
if (!PUBLIC_DEVICE_ID_REGEX.test(raw)) return { ok: false, reason: 'invalid_device_id' };
return { ok: true, deviceId: raw };
}
function _nowMs() { return Date.now(); }
function _nowIso() { return new Date().toISOString(); }
@@ -374,18 +327,8 @@ function createShareStore(opts = {}) {
});
}
function recordPublicSubscribe(token, { email } = {}) {
function recordPublicSubscribe(token) {
return _enqueue(() => {
// DC-083: validate the optional subscriber email at the store layer too.
// The route layer validates first; this is the defense-in-depth catch
// for direct callers (cron sweepers, internal jobs, future endpoints).
// `email` is OPT-IN — callers omitting it get the original behavior.
let normalizedEmail = null;
if (email !== undefined && email !== null) {
const v = validatePublicEmail(email);
if (!v.ok) return { ok: false, reason: v.reason };
normalizedEmail = v.email;
}
const data = _load();
const hash = _sha256(token);
const s = _findByHash(data, hash);
@@ -398,16 +341,6 @@ function createShareStore(opts = {}) {
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
s.subscribeCount += 1;
// DC-083: record the last submitting email (capped to 8 entries to
// bound the on-disk size). PII minimization — we keep only the hash
// + last 8 emails; full email log would grow unbounded.
if (normalizedEmail) {
if (!Array.isArray(s.subscriberEmails)) s.subscriberEmails = [];
s.subscriberEmails.push(normalizedEmail);
if (s.subscriberEmails.length > 8) {
s.subscriberEmails.splice(0, s.subscriberEmails.length - 8);
}
}
_save(data);
return { ok: true, count: s.subscribeCount, cap };
});
@@ -415,18 +348,6 @@ function createShareStore(opts = {}) {
function recordTailscaleUse(token, { deviceId } = {}) {
return _enqueue(() => {
// DC-083: validate deviceId at the store layer. The pre-fix code
// accepted ANY string of any length, including control chars and
// CR/LF — which would flow into the Tailscale auth-key description
// (routes/share.js:213) and into the on-disk shares.json. Reject
// early so an attacker can't bloat the store or smuggle characters
// out of the Tailscale description field.
let normalizedDeviceId = 'unknown';
if (deviceId !== undefined && deviceId !== null) {
const v = validatePublicDeviceId(deviceId);
if (!v.ok) return { ok: false, reason: v.reason };
normalizedDeviceId = v.deviceId;
}
const data = _load();
const hash = _sha256(token);
const s = _findByHash(data, hash);
@@ -438,7 +359,7 @@ function createShareStore(opts = {}) {
return { ok: false, reason: 'expired' };
}
s.usedAt = _nowIso();
s.usedBy = normalizedDeviceId;
s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown';
_save(data);
return { ok: true, share: _publicView(s) };
});
@@ -490,4 +411,4 @@ function createShareStore(opts = {}) {
};
}
module.exports = { createShareStore, validatePublicEmail, validatePublicDeviceId };
module.exports = { createShareStore };
-11
View File
@@ -79,17 +79,6 @@ const RATE_LIMITS = {
windowMs: 15 * 60 * 1000,
max: 10,
},
// DC-083: Public share endpoint limiter. The two CSRF-exempt public
// endpoints (POST /share/:token/subscribe + POST /share/:token/redeem-tailscale)
// mutate on-disk state (data/shares.json). Bound them tighter than the
// general limiter (1000/15min) so a single attacker can't bloat the
// store or saturate the tmp+rename writer. 30/15min is enough for a
// legitimate user clicking "subscribe" once or twice — anything beyond
// is abuse.
SHARE_PUBLIC: {
windowMs: 15 * 60 * 1000,
max: 30,
},
};
// ── Caddy ─────────────────────────────────────────────────────
@@ -1,505 +0,0 @@
/**
* Fleet-host input validation defends against SSRF on /api/v1/fleet/*.
*
* Why this lives in its own module instead of inline in routes/fleet.js:
* The fleet endpoints compose a user-supplied hostname + port into a URL
* that is then fetched from inside the dashcaddy-api container
* (DC-108, GET /fleet/status probes `http://${hostname}:${port}/api/v1/system/health`;
* POST /fleet/deploy returns `http://${hostname}:${port}/api/v1/apps/deploy`
* for the operator to call). Without validation, an authenticated dashboard
* operator could register a host with `hostname: "127.0.0.1"` or
* `hostname: "169.254.169.254"` (cloud metadata service) and have the
* container reach that internal endpoint on the operator's behalf. Worse:
* a hostname like `attacker.example.com` could exploit DNS rebinding
* (public IP at registration time loopback IP at fetch time).
*
* By extracting `validateFleetHost()`, `isPrivateOrReservedIPv4()`, and
* `isPrivateOrReservedIPv6()` here, the policy is unit-testable without
* booting Express + auth + CSRF, and a future route that wants the same
* guard can reuse it.
*
* Default-deny posture:
* - Reject IPv4 loopback (127.0.0.0/8), link-local (169.254.0.0/16
* including the AWS/GCP/Azure metadata address 169.254.169.254), RFC 1918
* private (10/8, 172.16/12, 192.168/16), CGNAT (100.64.0.0/10,
* which Tailscale uses), multicast (224.0.0.0/4), broadcast
* (255.255.255.255), and the reserved/documentation ranges (0.0.0.0/8,
* 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24,
* 203.0.113.0/24, 240.0.0.0/4).
* - Reject IPv6 loopback (::1), link-local (fe80::/10), ULA (fc00::/7),
* multicast (ff00::/8), and the IPv4-mapped loopback (::ffff:127.0.0.1).
* - Allow public DNS hostnames (e.g. `fleet.example.com`) and public IPs.
* - To opt in to private-network hosts (a real fleet of homelab DashCaddy
* instances behind Tailscale or RFC1918), set FLEET_ALLOW_PRIVATE_HOSTS=true
* in the operator's environment. Even then, DNS-rebinding protection still
* resolves the hostname once before probing and rejects private results.
*
* Public API:
* validateFleetHost({ name, hostname, port, tags })
* -> { ok: true, normalized: {...} } | { ok: false, code, message }
* resolveAndCheckAddress(hostname)
* -> { ok: true, ip } | { ok: false, code, message }
* Resolves a DNS hostname to its first A/AAAA record and validates the
* resolved IP is also non-private (defends against DNS rebinding).
* isPrivateOrReservedIPv4(ip)
* isPrivateOrReservedIPv6(ip)
*/
'use strict';
const dns = require('dns').promises;
// IPv4 ranges that should NEVER be probed from the fleet container unless
// the operator has explicitly opted in via FLEET_ALLOW_PRIVATE_HOSTS.
// Order matters: most specific (longest prefix) first so a `192.168.x.y`
// check happens before a generic `192.*` swallow-all.
const PRIVATE_OR_RESERVED_IPV4 = [
// ── Broadcast — checked first because 255.255.255.255 matches the
// `240.0.0.0/4 reserved` range and would otherwise be mislabeled.
{ cidr: '255.255.255.255/32', label: 'broadcast' },
// ── Loopback (RFC 1122) ──
// 127.0.0.0/8 — covers 127.0.0.1 and the rest of the loopback block.
{ cidr: '127.0.0.0/8', label: 'loopback (RFC 1122)' },
// ── Link-local (RFC 3927) + cloud metadata ──
// 169.254.0.0/16 covers AWS / GCP / Azure metadata at 169.254.169.254
// (the canonical IMDS endpoint) and any other link-local address.
{ cidr: '169.254.0.0/16', label: 'link-local / cloud-metadata (RFC 3927, IMDS)' },
// ── RFC 1918 private ──
{ cidr: '10.0.0.0/8', label: 'RFC 1918 private' },
{ cidr: '172.16.0.0/12', label: 'RFC 1918 private' },
{ cidr: '192.168.0.0/16', label: 'RFC 1918 private' },
// ── CGNAT (RFC 6598) — Tailscale uses this range ──
{ cidr: '100.64.0.0/10', label: 'CGNAT / Tailscale (RFC 6598)' },
// ── Multicast (RFC 5771) ──
{ cidr: '224.0.0.0/4', label: 'multicast (RFC 5771)' },
// ── Reserved / documentation / benchmarks ──
{ cidr: '0.0.0.0/8', label: 'reserved "this network" (RFC 1122)' },
{ cidr: '192.0.0.0/24', label: 'IETF protocol assignments (RFC 6890)' },
{ cidr: '192.0.2.0/24', label: 'TEST-NET-1 documentation (RFC 5737)' },
{ cidr: '198.18.0.0/15', label: 'benchmark testing (RFC 2544)' },
{ cidr: '198.51.100.0/24', label: 'TEST-NET-2 documentation (RFC 5737)' },
{ cidr: '203.0.113.0/24', label: 'TEST-NET-3 documentation (RFC 5737)' },
{ cidr: '240.0.0.0/4', label: 'reserved for future use (RFC 1112)' },
];
/**
* IPv4 reserved-range check. Returns { isPrivate, label } where label names
* the matched range (loopback / RFC 1918 / etc.) for human-readable errors.
*/
function isPrivateOrReservedIPv4(ip) {
if (typeof ip !== 'string') return { isPrivate: false, label: null };
const parts = ip.split('.');
if (parts.length !== 4) return { isPrivate: false, label: null };
const nums = parts.map((p) => parseInt(p, 10));
if (nums.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) {
return { isPrivate: false, label: null };
}
// Decode the IP to a 32-bit unsigned integer for prefix matching.
const asInt = ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
for (const { cidr, label } of PRIVATE_OR_RESERVED_IPV4) {
const [base, bits] = cidr.split('/');
const prefix = parseInt(bits, 10);
const baseParts = base.split('.').map((p) => parseInt(p, 10));
const baseInt = ((baseParts[0] << 24) | (baseParts[1] << 16) | (baseParts[2] << 8) | baseParts[3]) >>> 0;
// Build a mask by shifting prefix bits down from the top.
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
if ((asInt & mask) === (baseInt & mask)) {
return { isPrivate: true, label };
}
}
// Broadcast is now handled by the cidr list (255.255.255.255/32 entry),
// checked first to win over the 240.0.0.0/4 reserved-for-future-use range.
return { isPrivate: false, label: null };
}
/**
* IPv6 reserved-range check. Returns { isPrivate, label }.
*/
function isPrivateOrReservedIPv6(ip) {
if (typeof ip !== 'string') return { isPrivate: false, label: null };
// Normalize IPv4-mapped IPv6 (::ffff:127.0.0.1) -> delegate to v4 check.
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (mapped) {
const v4Check = isPrivateOrReservedIPv4(mapped[1]);
return v4Check.isPrivate
? { isPrivate: true, label: `IPv4-mapped (${mapped[1]})` }
: { isPrivate: false, label: null };
}
const lc = ip.toLowerCase();
// ::1 loopback
if (lc === '::1') return { isPrivate: true, label: 'IPv6 loopback (RFC 4291)' };
// :: unspecified
if (lc === '::') return { isPrivate: true, label: 'IPv6 unspecified (RFC 4291)' };
// fe80::/10 link-local
if (/^fe[89ab][0-9a-f]:/i.test(lc) || /^fe80::/i.test(lc)) {
return { isPrivate: true, label: 'IPv6 link-local (RFC 4291)' };
}
// fc00::/7 unique-local (ULA)
if (/^[fF][cdCE]/.test(lc)) {
return { isPrivate: true, label: 'IPv6 unique-local (RFC 4193)' };
}
// ff00::/8 multicast
if (/^ff[0-9a-fA-F]?[0-9a-fA-F]?:/.test(lc)) {
return { isPrivate: true, label: 'IPv6 multicast (RFC 4291)' };
}
return { isPrivate: false, label: null };
}
/**
* Lightweight hostname syntax check (RFC 1123-style DNS names + literal IPs).
* `net.isIP` would also work for IP literals, but we accept IPv6 with
* a leading colon here and delegate that branch separately.
*/
const RFC1123_LABEL = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
function isValidHostnameSyntax(hostname) {
if (typeof hostname !== 'string') return false;
if (hostname.length === 0 || hostname.length > 253) return false;
// Trailing dot is legal (signals root); strip for label parsing.
let h = hostname;
if (h.endsWith('.')) h = h.slice(0, -1);
if (h.length === 0) return false;
const labels = h.split('.');
if (labels.length === 0) return false;
for (const label of labels) {
if (!RFC1123_LABEL.test(label)) return false;
}
return true;
}
/**
* Async DNS-resolve the hostname to its first A and AAAA records, run the
* private-range check on each, and return the first non-private match. If
* all resolved addresses are private (or the name doesn't resolve), report
* the failure mode so the caller can return a meaningful 400.
*
* DNS-rebinding protection: by resolving ONCE at validation time and returning
* the IP, a follow-up probe URL built from the resolved IP can't be pointed
* at a different IP via a fast-flipping DNS record. For maximum robustness
* the caller should pass the resolved IP back as the host's `resolvedIp` so
* future `fetch()` calls use `http://<resolvedIp>:<port>`, not
* `http://<hostname>:<port>`.
*/
async function resolveAndCheckAddress(hostname, opts = {}) {
const allowPrivate = !!opts.allowPrivate;
if (typeof hostname !== 'string' || hostname.length === 0) {
return { ok: false, code: 'INVALID_HOSTNAME', message: 'hostname is required' };
}
// Literal IPv4 -- skip the DNS round-trip.
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
const v4Check = isPrivateOrReservedIPv4(hostname);
if (v4Check.isPrivate && !allowPrivate) {
return {
ok: false,
code: 'PRIVATE_IPV4',
message: `hostname "${hostname}" resolves to a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
};
}
return { ok: true, ip: hostname, family: 4 };
}
// Literal IPv6 -- detect by containing a colon AND no `/` or `://`
// substrings (URL-like strings contain colons but aren't IPv6). Use
// Node's built-in `net.isIP` for the authoritative check; the
// colon-presence check is a fast-path to skip the DNS call for obvious
// IPv6 inputs.
const net = require('net');
const isLikelyIPv6 = hostname.includes(':') && net.isIP(hostname) === 6;
if (isLikelyIPv6) {
const v6Check = isPrivateOrReservedIPv6(hostname);
if (v6Check.isPrivate && !allowPrivate) {
return {
ok: false,
code: 'PRIVATE_IPV6',
message: `hostname "${hostname}" resolves to a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
};
}
return { ok: true, ip: hostname, family: 6 };
}
// Hostname syntax guard before DNS call -- saves an OS query for obvious junk.
if (!isValidHostnameSyntax(hostname)) {
return {
ok: false,
code: 'INVALID_HOSTNAME',
message: `hostname "${hostname}" is not a valid DNS name or IP address`,
};
}
// DNS resolve.
let results;
try {
results = await dns.lookup(hostname, { all: true });
} catch (err) {
return {
ok: false,
code: 'DNS_RESOLUTION_FAILED',
message: `hostname "${hostname}" did not resolve: ${err.code || err.message}`,
};
}
if (!results || results.length === 0) {
return {
ok: false,
code: 'DNS_NO_RECORDS',
message: `hostname "${hostname}" has no A or AAAA records`,
};
}
for (const r of results) {
if (r.family === 4) {
const v4Check = isPrivateOrReservedIPv4(r.address);
if (v4Check.isPrivate && !allowPrivate) {
return {
ok: false,
code: 'PRIVATE_IPV4',
message: `hostname "${hostname}" resolves to ${r.address}, a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
};
}
return { ok: true, ip: r.address, family: 4 };
} else if (r.family === 6) {
const v6Check = isPrivateOrReservedIPv6(r.address);
if (v6Check.isPrivate && !allowPrivate) {
return {
ok: false,
code: 'PRIVATE_IPV6',
message: `hostname "${hostname}" resolves to ${r.address}, a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
};
}
return { ok: true, ip: r.address, family: 6 };
}
}
return {
ok: false,
code: 'DNS_NO_RECORDS',
message: `hostname "${hostname}" has no usable A or AAAA records`,
};
}
/**
* Validate the full input shape of POST /fleet/hosts and POST /fleet/deploy.
* On success, returns the normalized payload (with `port` coerced to int and
* `hostname` lowercased). On failure, returns { ok: false, code, message } for
* the caller to surface as a 400 errorResponse.
*
* Validates in this order (cheapest predicate first):
* 1. name: string, 1..100 chars, no control chars
* 2. hostname: syntax (IP or RFC 1123 DNS name); literal IPv4/v6 also runs
* the private-range check synchronously here
* 3. port: integer 1..65535; port 22 explicitly rejected (SSH, not HTTP)
* 4. tags: array of strings, max 20 items, each 1..50 chars, no control chars
*
* Note: DNS-rebinding check is async (resolveAndCheckAddress) and runs
* separately, because this function is kept synchronous for testability.
* Callers MUST invoke resolveAndCheckAddress after validateFleetHost
* for DNS-named hosts.
*/
function validateFleetHost(input) {
const { name, hostname, port, tags } = input || {};
if (typeof name !== 'string' || name.length === 0 || name.length > 100) {
return {
ok: false,
code: 'INVALID_NAME',
message: 'name is required and must be 1..100 characters',
};
}
// Disallow control chars in name (newlines would let a stored name break
// log-file formats and could enable log injection if not properly escaped).
if (/[\x00-\x1f]/.test(name)) {
return {
ok: false,
code: 'INVALID_NAME',
message: 'name must not contain control characters',
};
}
if (typeof hostname !== 'string' || hostname.length === 0) {
return {
ok: false,
code: 'INVALID_HOSTNAME',
message: 'hostname is required',
};
}
// Hard syntax check (catches obvious junk before any DNS call). Use
// `net.isIP` to detect literal IPv4/IPv6 (handles both pure-v6 AND the
// IPv4-mapped v6 `::ffff:x.y.z.w` correctly), then fall back to the
// RFC 1123 DNS-name check.
const syntaxIpFamily = require('net').isIP(hostname);
if (syntaxIpFamily === 0 && !isValidHostnameSyntax(hostname)) {
return {
ok: false,
code: 'INVALID_HOSTNAME',
message: 'hostname must be a valid IPv4 address, IPv6 address, or DNS name',
};
}
// If it's a literal IP, run the private-range check synchronously here.
// Use `net.isIP` to distinguish a real IPv4 dotted-quad or IPv6 from
// URL-shaped junk like `http://evil.com` (which contains both `:` and `.`
// but is not a valid IP literal).
const net = require('net');
const ipFamily = net.isIP(hostname);
if (ipFamily === 4) {
const v4Check = isPrivateOrReservedIPv4(hostname);
if (v4Check.isPrivate) {
return {
ok: false,
code: 'PRIVATE_IPV4',
message: `IPv4 address "${hostname}" is a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
};
}
} else if (ipFamily === 6) {
const v6Check = isPrivateOrReservedIPv6(hostname);
if (v6Check.isPrivate) {
return {
ok: false,
code: 'PRIVATE_IPV6',
message: `IPv6 address "${hostname}" is a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
};
}
}
// Port bounds + SSH sentinel.
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
return {
ok: false,
code: 'INVALID_PORT',
message: 'port must be an integer in 1..65535',
};
}
if (portNum === 22) {
return {
ok: false,
code: 'INVALID_PORT',
message: 'port 22 is reserved (SSH); the fleet API probe is HTTP, not SSH',
};
}
// Tags — array of short strings.
if (tags !== undefined) {
if (!Array.isArray(tags)) {
return {
ok: false,
code: 'INVALID_TAGS',
message: 'tags must be an array of strings',
};
}
if (tags.length > 20) {
return {
ok: false,
code: 'INVALID_TAGS',
message: 'tags may contain at most 20 entries',
};
}
for (const t of tags) {
if (typeof t !== 'string' || t.length === 0 || t.length > 50) {
return {
ok: false,
code: 'INVALID_TAGS',
message: 'each tag must be a string of 1..50 characters',
};
}
if (/[\x00-\x1f]/.test(t)) {
return {
ok: false,
code: 'INVALID_TAGS',
message: 'tags must not contain control characters',
};
}
}
}
return {
ok: true,
normalized: {
name: name.trim(),
hostname: hostname.toLowerCase(),
port: portNum,
tags: tags || [],
},
};
}
/**
* Validate a `host:port` upstream string for use in Caddy's `reverse_proxy`.
*
* DC-074 SSRF hardening: an authenticated dashboard operator can call
* POST /api/v1/site with `upstream: '10.0.0.1:80'` and end up with a
* Caddyfile entry that proxies public traffic (https://attacker.example.com)
* to an INTERNAL host (10.0.0.1:80). Caddy runs on DNS2 same network
* as the targets so the proxy lands the request on the private host.
* The operator doesn't even need DNS-rebinding tricks: a literal IPv4
* like 192.168.1.1 is accepted by the existing `[a-z0-9.-]+:\d{1,5}`
* upstream regex.
*
* Reuses `resolveAndCheckAddress()` to:
* - reject literal private IPv4 / IPv6
* - resolve DNS names and reject any private-IP answer
* (rebinding defense the actual address Caddy connects to is
* the resolved IP at registration time; Caddy itself resolves
* the name per-request, so a malicious operator could flip the
* A record between registration and connection. Acceptable
* residual risk the registration check is the main gate.)
* - cap port to 1..65535 (defense vs. `host:99999999` integer
* overflow / Caddy parser-bomb)
*
* Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true for operators who
* intentionally proxy to private targets (faster than a public DNS
* round-trip + central control plane).
*
* @param {string} upstream - "host:port" string (e.g. "10.0.0.1:80")
* @param {object} [opts]
* @param {boolean} [opts.allowPrivate] - override the env-var default
* @returns {Promise<{ok: true, host: string, port: number, resolvedIp?: string, family?: number} | {ok: false, code: string, message: string}>}
*/
async function validateUpstream(upstream, opts = {}) {
if (typeof upstream !== 'string' || upstream.length === 0) {
return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream is required' };
}
// Split on the LAST colon so IPv6 literals like `[::1]:80` parse
// correctly (and a malformed `[::1]` without port is rejected with
// a clean code, not a confusing TypeError from Number()).
const lastColon = upstream.lastIndexOf(':');
if (lastColon < 0) {
return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream must be host:port' };
}
const host = upstream.slice(0, lastColon);
const portStr = upstream.slice(lastColon + 1);
const portNum = Number(portStr);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
return { ok: false, code: 'INVALID_PORT', message: 'upstream port must be an integer 1..65535' };
}
// Allow-list the host charset BEFORE the DNS lookup so attacker
// payloads can't make the resolver do work. Matches the fleet
// isValidHostnameSyntax check; sites.js's own `[a-z0-9.-]+` regex
// is more restrictive (only letters/digits/dots/hyphens) so
// we widen here to also accept bracketed IPv6. Anything else gets
// rejected pre-DNS.
const isBracketedIPv6 = host.startsWith('[') && host.endsWith(']');
const hostToCheck = isBracketedIPv6 ? host.slice(1, -1) : host;
if (!isValidHostnameSyntax(hostToCheck) && require('net').isIP(hostToCheck) === 0) {
return { ok: false, code: 'INVALID_HOST', message: `upstream host "${host}" is not a valid DNS name or IP address` };
}
const allowPrivate = typeof opts.allowPrivate === 'boolean'
? opts.allowPrivate
: process.env.SITES_ALLOW_PRIVATE_UPSTREAMS === 'true';
const r = await resolveAndCheckAddress(hostToCheck, { allowPrivate });
if (!r.ok) return r; // bubbles up PRIVATE_IPV4 / PRIVATE_IPV6 / INVALID_HOSTNAME / DNS_*
return {
ok: true,
host,
port: portNum,
resolvedIp: r.ip,
family: r.family,
};
}
module.exports = {
validateFleetHost,
resolveAndCheckAddress,
isPrivateOrReservedIPv4,
isPrivateOrReservedIPv6,
isValidHostnameSyntax,
validateUpstream,
};
+6 -24
View File
@@ -330,22 +330,17 @@ module.exports = function configureMiddleware(app, {
const ssoHandoffTokens = new Map();
const SSO_HANDOFF_TTL_MS = 60 * 1000;
function createHandoffToken(expectedHost = null) {
function createHandoffToken() {
const token = crypto.randomBytes(24).toString('base64url');
ssoHandoffTokens.set(token, {
exp: Date.now() + SSO_HANDOFF_TTL_MS,
expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null,
});
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
return token;
}
function redeemHandoffToken(token, actualHost = null) {
function redeemHandoffToken(token) {
if (!token) return false;
const entry = ssoHandoffTokens.get(token);
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
if (!entry || entry.exp <= Date.now()) return false;
if (!entry.expectedHost) return true;
return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase();
return !!entry && entry.exp > Date.now();
}
function setHostOnlySessionCookie(res, durationKey) {
@@ -431,21 +426,8 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
{ path: '/api/v1/ca/install-script', exact: true, method: 'GET' },
{ path: '/api/v1/health/ca', exact: true, method: 'GET' },
// DC-076: /api/v1/ca/cert/<domain> and /api/v1/ca/certs MUST stay gated
// by TOTP/session. The /cert/<domain> endpoint returns the private key
// (format=key and format=pem both embed `server.key`; format=pfx wraps
// the same key in a PKCS#12 envelope). If an operator disables TOTP at
// any point in the future (ops command, fresh install with TOTP off
// during setup, .disabled-* rename of totp-config.json), an unauthenticated
// attacker reaching `https://ca.sami/api/ca/cert/<any-domain>?format=key`
// would receive the per-service RSA private key for every service whose
// cert Caddy has ever signed — that's a per-service key disclosure, not
// just a CA fingerprint leak. The `/api/v1/ca/info`, `/root.crt`, and
// `/install-script` paths above stay public (the root CA cert is public
// by design — devices need it to trust *.sami TLS); only the per-service
// private key and per-service cert list go behind auth. See DC-076 for
// the corresponding rate-limit + admin-scope + password-required
// hardening in routes/ca.js.
{ path: '/api/v1/ca/cert/', prefix: true, method: 'GET' },
{ path: '/api/v1/ca/certs', exact: true, method: 'GET' },
{ path: '/api/v1/csrf-token', exact: true, method: 'GET' },
{ path: '/api/v1/logo', exact: true, method: 'GET' },
{ path: '/api/v1/favicon', exact: true, method: 'GET' },
+2 -13
View File
@@ -14,19 +14,8 @@ const path = require('path');
module.exports = function nestingGuard() {
try {
const paths = require('../config/paths');
const dataDir = paths && paths.dataDir;
// Defensive: if paths.dataDir is undefined (older callers or a future
// export-shape drift), fall back to platformPaths.dataDir directly so the
// guard can still execute. Pre-fix this branch was swallowed silently by
// the outer try/catch, leaving the entire nesting-guard a no-op (DC-077).
const effectiveDataDir = typeof dataDir === 'string' && dataDir
? dataDir
: require('../../platform-paths').dataDir;
if (typeof effectiveDataDir !== 'string' || !effectiveDataDir) {
console.warn('[nesting-guard] Skipped: dataDir unavailable from src/config/paths and platform-paths');
return;
}
const dataDataPath = path.join(effectiveDataDir, 'data');
const dataDir = paths.dataDir;
const dataDataPath = path.join(dataDir, 'data');
// If data/data exists, it's a recursive duplicate — remove it
if (fs.existsSync(dataDataPath)) {
+2 -41
View File
@@ -118,55 +118,16 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
/**
* Raw http.request wrapper for Caddy admin API
*
* Auto-injects `Origin: http://<host>:<port>` because Caddy's admin API on a
* non-loopback bind (e.g. `admin 0.0.0.0:2019` so the DashCaddy docker
* container can probe it from 172.17.0.1) enables `enforce_origin` and
* rejects every request whose Origin isn't in the admin's `origins` allowlist
* OR is empty. Node's undici fetch sets `Sec-Fetch-Mode: cors` which triggers
* the check; raw http.request sets no Origin at all, which fails the empty
* check. Setting Origin to the admin endpoint's own origin satisfies
* gorilla/csrf same-origin and is the documented override.
* (See: https://caddyserver.com/docs/caddyfile/options — `origins` directive.)
*
* Caller-provided `Origin` header (via opts.headers) wins so tests / future
* proxies can override; default matches the parsed admin URL.
*
* IMPORTANT IPv6 path (DC-069): on Linux, `dns.lookup('localhost')` returns
* `::1` FIRST (per RFC 3484, because /etc/hosts has `::1 localhost`). When the
* caller passes `http://localhost:2019/...`, `parsed.hostname` is `::1` AND
* the auto-injected Origin is `http://[::1]:2019` which means the Caddy
* `origins` allowlist MUST contain `http://[::1]:2019` (and ideally
* `http://ip6-localhost:2019` for the glibc alias), otherwise every on-host
* Node probe via `localhost` gets a 403 with empty-Origin-looking error.
* The corresponding `origins` entries live in `/etc/caddy/Caddyfile` on DNS2
* (committed via `caddy-apply`) and are documented in
* `dashcaddy-installer/templates/Caddyfile.template`.
*/
function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const defaultOrigin = `${parsed.protocol}//${parsed.hostname}:${parsed.port || 2019}`;
// Node 22's WHATWG URL parser preserves the brackets around IPv6
// literals in `parsed.hostname` (e.g. '[::1]'), but `http.request({hostname})`
// expects the BRACKETLESS form for actual connection — passing '[::1]'
// triggers `getaddrinfo ENOTFOUND [::1]` and the request fails before
// any Origin matching happens. Caddy's `enforce_origin` allowlist
// matches by exact Origin string (which DOES include the brackets),
// so we keep `defaultOrigin` bracket-form for the header but strip them
// for the transport-layer hostname. (DC-069 — IPv6 admin probe path.)
const transportHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
? parsed.hostname.slice(1, -1)
: parsed.hostname;
const options = {
hostname: transportHostname,
hostname: parsed.hostname,
port: parsed.port || 2019,
path: parsed.pathname + parsed.search,
method: (opts.method || 'GET').toUpperCase(),
headers: {
Origin: defaultOrigin,
...opts.headers,
},
headers: { ...opts.headers },
timeout: timeoutMs,
};
+2 -68
View File
@@ -112,78 +112,12 @@ async function appendErrorLog(line) {
}
}
// Flatten an error chain into readable lines so error.log records why a
// request failed, not just that it did. Handle AggregateError (`.errors[]`,
// common from lookups/DNS-fetch timeouts) and the modern `.cause` chain —
// both common in Node 18+ networking. Always returns at least one line
// (a head line with `name [code]: message`), and appends cause lines for
// any `.errors` / `.cause` chains present.
//
// Defensive against:
// - Circular `.cause` references (a pathological error payload pointing
// `err.cause = err` would otherwise infinite-recurse and crash the
// error-path). Visited set carries forward via parameter.
// - Excessively deep chains (> MAX_CHAIN_DEPTH): truncated with a marker
// so the operator can see something IS coming from underneath.
const MAX_CHAIN_DEPTH = 16;
function describeErrorChain(err, depth = 0, seen = new WeakSet()) {
const out = [];
if (depth > MAX_CHAIN_DEPTH) {
out.push(`${' '.repeat(depth)} ... (chain truncated at depth ${MAX_CHAIN_DEPTH})`);
return out;
}
if (!(err instanceof Error)) {
out.push(`${' '.repeat(depth)}${String(err)}`);
return out;
}
// Cycle guard — same Error instance already on the chain.
if (seen.has(err)) {
out.push(`${' '.repeat(depth)} ... (cycle: same Error instance seen earlier)`);
return out;
}
seen.add(err);
const indent = ' '.repeat(depth);
const code = err.code ? ` [${err.code}]` : '';
const msg = err.message ? `: ${err.message}` : '';
// For every error (including AggregateError), render the head line; an
// empty `.message` simply produces `Name [code]:` which is still useful.
out.push(`${indent}${err.name || 'Error'}${code}${msg}`);
if (Array.isArray(err.errors) && err.errors.length) {
err.errors.forEach((sub, i) => {
out.push(`${indent} cause #${i + 1}:`);
out.push(...describeErrorChain(sub, depth + 2, seen));
});
}
if (err.cause instanceof Error) {
out.push(`${indent} cause:`);
out.push(...describeErrorChain(err.cause, depth + 2, seen));
}
return out;
}
async function writeErrorLog(ctx, error, req, extra) {
const ts = new Date().toISOString();
const errMsg = error instanceof Error ? error.message : String(error);
const errStack = error instanceof Error ? error.stack : '';
// Build the head line AND a tail diagnostic from the same describeErrorChain,
// so plain errors with .code get `[CODE]` formatted into the head (regression)
// and AggregateError with empty `.message` gets a diagnostic block listing
// every cause (the actual bug fix).
let headLine;
let diagLines = [];
if (error instanceof Error) {
const chain = describeErrorChain(error);
// The chain head is always the error itself (now including AggregateError),
// so chain[0] is what we want in the headline and chain[1..] is the rest.
headLine = chain[0] || `${error.name || 'Error'}`;
diagLines = chain.slice(1);
} else {
headLine = String(error);
}
// Preserve the historical `ctx: <head>` shape so log scrapers don't break.
// The head now carries `name [code]: message` instead of bare `.message`.
const parts = [`[${ts}] [ERR] ${ctx}: ${headLine.replace(/^\s+/, '')}`];
const parts = [`[${ts}] [ERR] ${ctx}: ${errMsg}`];
if (errStack) parts.push(errStack);
if (diagLines.length) parts.push(' diagnostic: ' + diagLines.join('\n diagnostic: '));
if (req) {
const ip = req.ip || req.socket?.remoteAddress || '';
const ua = req.get ? req.get('user-agent') : '';
-26
View File
@@ -62,34 +62,8 @@ function noContent(res) {
*
* DC-086: If extras.code is set, it's treated as a machine-readable error code
* (e.g. 'DC-CONT-002'). If message looks like a DC code, it's auto-extracted.
*
* DC-062: Validate that `statusCode` is a valid HTTP status (integer in
* 100..599) BEFORE calling res.status(). Without this guard, a caller who
* passes (res, message, statusCode) instead of (res, statusCode, message)
* ends up with res.status(<string>), which throws
* RangeError [ERR_HTTP_INVALID_STATUS_CODE] Express catches that and
* writes a 500 with an HTML stack trace to the client, which is the worst
* possible failure mode (looks like a server crash, breaks CSRF and
* content-type expectations, leaks the stack). Failing fast with a clear
* TypeError names the call site early in the request lifecycle.
*/
function errorResponse(res, statusCode, message, extras = {}) {
if (
typeof statusCode !== 'number'
|| !Number.isFinite(statusCode)
|| !Number.isInteger(statusCode)
|| statusCode < 100
|| statusCode > 599
) {
throw new TypeError(
`errorResponse(res, statusCode, message, extras): statusCode must be an integer HTTP status (100..599); received ${JSON.stringify(statusCode)} (message=${JSON.stringify(message)})`
);
}
if (typeof message !== 'string') {
throw new TypeError(
`errorResponse(res, statusCode, message, extras): message must be a string; received ${typeof message} ${JSON.stringify(message)}`
);
}
const body = { success: false, error: message, ...extras };
// DC-086: surface machine-readable code at top level for client handling
if (extras.code) {
+61 -138
View File
@@ -13,31 +13,6 @@
*/
const { WebSocketServer } = require('ws');
/**
* Parse the `Cookie` header into a plain `{name: value}` map.
* WS upgrade requests don't go through Express's cookie-parser, so we
* do it by hand here. We deliberately do NOT decode the values the
* session-cookie HMAC verifier reads the raw cookie string verbatim
* (`payloadB64.sig` shape), so any decoding (e.g. url-decode) would
* corrupt the signature. Single cookie-pair per call, no nesting.
*
* @param {string|undefined} header - Raw Cookie header value
* @returns {Object<string, string>} name value map (empty string for blanks)
*/
function parseCookieHeader(header) {
const out = {};
if (!header) return out;
for (const part of header.split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
const name = part.slice(0, idx).trim();
if (!name) continue;
const value = part.slice(idx + 1).trim();
out[name] = value;
}
return out;
}
function createDashboardWS(server, deps = {}) {
const wss = new WebSocketServer({ noServer: true });
@@ -55,52 +30,9 @@ function createDashboardWS(server, deps = {}) {
log,
} = deps;
// ── Auth verifier (injected by server.js from app.locals.ctx.session) ──
// The WS upgrade path bypasses Express middleware, so the global
// `totpAuthMiddleware` (which calls `isSessionValid(req)`) never runs.
// We accept the SAME verifier here so a valid browser session cookie
// grants access and nothing else does.
//
// The injected verifier receives the raw HTTP upgrade request (an
// IncomingMessage with `.headers.cookie`). Production wires
// `app.locals.ctx.session.isValid` directly — it accepts the same
// shape, parses the Cookie header internally, and runs the HMAC
// check. Tests inject a stub.
//
// DC-061 hardening: prior code only checked that the `dashcaddy_session`
// SUBSTRING appeared in the Cookie header. That let an attacker set any
// cookie named `dashcaddy_session=garbage` (or include the literal text
// in another cookie's value) and bypass auth. The injected verifier
// runs HMAC validation, so a present-but-invalid cookie now 401s.
const authVerifier = typeof deps.authVerifier === 'function'
? deps.authVerifier
: (req) => {
// Last-resort fallback: presence-only check on a non-empty
// session-cookie value. Used only when the caller didn't inject
// a real verifier (e.g. tests, unusual boot paths). Production
// wires the real one from app.locals.ctx.session.isValid.
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
const raw = parsed.dashcaddy_session || parsed.sid;
return typeof raw === 'string' && raw.length > 0;
};
// Track connected clients and their subscriptions
const wsClients = new Set();
// Track the listener functions we attach to shared EventEmitters so we
// can detach exactly OUR listeners on close() — without disturbing the
// SSE route's listeners on the same emitters. DC-061 critical fix:
// the previous code called `resourceMonitor.removeAllListeners()` which
// silently killed the SSE route's `alert`/`status-check`/etc subscribers
// whenever close() ran (hot reload, graceful restart).
const emitterListeners = [];
function attachListener(emitter, event, handler) {
if (!emitter || typeof emitter.on !== 'function') return;
emitter.on(event, handler);
emitterListeners.push({ emitter, event, handler });
}
function broadcast(event, data) {
const msg = JSON.stringify({ type: 'event', event, data });
for (const client of wsClients) {
@@ -117,46 +49,62 @@ function createDashboardWS(server, deps = {}) {
// ── Wire up EventEmitter listeners (same events as SSE) ──
attachListener(resourceMonitor, 'alert', (data) => broadcast('resource-alert', data));
attachListener(resourceMonitor, 'auto-restart', (data) => broadcast('auto-restart', data));
if (resourceMonitor) {
resourceMonitor.on('alert', (data) => broadcast('resource-alert', data));
resourceMonitor.on('auto-restart', (data) => broadcast('auto-restart', data));
}
attachListener(healthChecker, 'status-check', (data) => {
broadcast('status-change', {
serviceId: data.serviceId,
name: data.name,
status: data.status,
responseTime: data.responseTime,
timestamp: data.timestamp,
if (healthChecker) {
healthChecker.on('status-check', (data) => {
broadcast('status-change', {
serviceId: data.serviceId,
name: data.name,
status: data.status,
responseTime: data.responseTime,
timestamp: data.timestamp,
});
});
});
attachListener(healthChecker, 'incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
attachListener(healthChecker, 'incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
healthChecker.on('incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
healthChecker.on('incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
}
attachListener(updateManager, 'update-available', (data) => broadcast('update-available', data));
attachListener(updateManager, 'update-start', (data) => broadcast('update-start', data));
attachListener(updateManager, 'update-complete', (data) => broadcast('update-complete', data));
attachListener(updateManager, 'update-failed', (data) => broadcast('update-failed', data));
attachListener(updateManager, 'auto-update-start', (data) => broadcast('auto-update-start', data));
attachListener(updateManager, 'auto-update-complete', (data) => broadcast('auto-update-complete', data));
if (updateManager) {
updateManager.on('update-available', (data) => broadcast('update-available', data));
updateManager.on('update-start', (data) => broadcast('update-start', data));
updateManager.on('update-complete', (data) => broadcast('update-complete', data));
updateManager.on('update-failed', (data) => broadcast('update-failed', data));
updateManager.on('auto-update-start', (data) => broadcast('auto-update-start', data));
updateManager.on('auto-update-complete', (data) => broadcast('auto-update-complete', data));
}
attachListener(dependencyManager, 'dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
attachListener(dependencyManager, 'dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
attachListener(dependencyManager, 'dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
attachListener(dependencyManager, 'dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
if (dependencyManager) {
dependencyManager.on('dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
dependencyManager.on('dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
dependencyManager.on('dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
dependencyManager.on('dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
}
attachListener(autoRestartManager, 'auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
attachListener(autoRestartManager, 'auto-restart-success', (data) => broadcast('auto-restart-success', data));
attachListener(autoRestartManager, 'auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
attachListener(autoRestartManager, 'auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
if (autoRestartManager) {
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
}
attachListener(driftDetector, 'drift-detected', (data) => broadcast('drift-detected', data));
if (driftDetector) {
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
}
attachListener(sslMonitor, 'cert-expiring', (data) => broadcast('cert-expiring', data));
attachListener(sslMonitor, 'cert-critical', (data) => broadcast('cert-critical', data));
if (sslMonitor) {
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
}
attachListener(dnsPropagationChecker, 'propagation-check', (data) => broadcast('dns-propagation-check', data));
attachListener(dnsPropagationChecker, 'propagation-complete', (data) => broadcast('dns-propagation-complete', data));
attachListener(dnsPropagationChecker, 'propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
if (dnsPropagationChecker) {
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
}
// ── Handle upgrade requests at /api/v1/ws ──
@@ -168,21 +116,16 @@ function createDashboardWS(server, deps = {}) {
return; // Let other upgrade handlers deal with it
}
// DC-061 auth gate: WS upgrade bypasses Express middleware, so we
// must validate the session here. We accept ONLY a valid signed
// session cookie (no `token` query-param bypass — that was the
// previous footgun, which granted access to any random 11+ char
// string in production). The verifier is injected from
// app.locals.ctx.session.isValid in production.
const ok = authVerifier(request);
// DC-076: Auth check — extract session/token from query params or cookies
// The SSE endpoint is behind auth middleware; WS needs the same gate.
// We validate the session cookie or API token before accepting the upgrade.
const cookies = (request.headers.cookie || '');
const hasSession = cookies.includes('dashcaddy_session') || cookies.includes('sid');
const token = url.searchParams.get('token');
const hasToken = token && token.length > 10;
if (!ok) {
const ip = (request.socket && request.socket.remoteAddress) || 'unknown';
if (log && log.warn) {
log.warn('websocket', 'WS upgrade rejected — no valid session', { ip, path: url.pathname });
}
// 401 + Connection: close so the client doesn't retry.
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
if (!hasSession && !hasToken && process.env.NODE_ENV === 'production') {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
@@ -227,17 +170,6 @@ function createDashboardWS(server, deps = {}) {
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (raw) => {
// Cap message size at 16 KB — defense-in-depth against a malicious
// peer that exploits ws's message framing to flood our parser.
// The `ws` library already enforces this via its constructor option,
// but a second guard at the handler level catches any future
// regressions (e.g. someone passing `maxPayload` differently).
if (raw.length > 16 * 1024) {
ws.send(JSON.stringify({ type: 'error', error: 'Message too large' }));
try { ws.close(1009, 'Message too large'); } catch { /* already closed */ }
return;
}
let msg;
try {
msg = JSON.parse(raw.toString());
@@ -316,21 +248,12 @@ function createDashboardWS(server, deps = {}) {
}
wsClients.clear();
wss.close();
// DC-061: detach ONLY the listeners we attached. Previously the
// module called `resourceMonitor.removeAllListeners()` (and same
// for healthChecker / updateManager), which silently wiped the
// SSE route's listeners on the same shared emitters — the SSE
// stream went dead the moment close() ran (hot reload, restart).
for (const { emitter, event, handler } of emitterListeners) {
if (emitter && typeof emitter.removeListener === 'function') {
emitter.removeListener(event, handler);
}
}
emitterListeners.length = 0;
// Remove all listeners from the event emitters to prevent leaks on restart
if (resourceMonitor) resourceMonitor.removeAllListeners();
if (healthChecker) healthChecker.removeAllListeners();
if (updateManager) updateManager.removeAllListeners();
},
};
}
module.exports = createDashboardWS;
module.exports.createDashboardWS = createDashboardWS;
module.exports.parseCookieHeader = parseCookieHeader;
+1 -6
View File
@@ -630,15 +630,10 @@ generate_caddyfile() {
SNIP
local auth_snippet="(dashcaddy_auth) {
@needsAuth not path /dashcaddy-sso
forward_auth @needsAuth localhost:${API_PORT} {
forward_auth localhost:${API_PORT} {
uri /api/v1/auth/gate/{args[0]}
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}
@@ -51,15 +51,10 @@ class CaddyfileGenerator {
_authSnippet(apiPort) {
return `# DashCaddy SSO auth snippet
(dashcaddy_auth) {
@needsAuth not path /dashcaddy-sso
forward_auth @needsAuth localhost:${apiPort} {
forward_auth localhost:${apiPort} {
uri /api/v1/auth/gate/{args[0]}
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}
}
}
`;
}
@@ -1,35 +0,0 @@
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');
});
});
@@ -3,21 +3,6 @@
# Global options
{
# The default `admin localhost:2019` binds to the loopback interface, so
# Caddy's `enforce_origin` CSRF guard is never engaged and no `origins`
# directive is required. (Note: glibc resolves `localhost` to `::1`
# first per RFC 3484, so `admin localhost:2019` typically binds BOTH
# IPv4 and IPv6 loopback the actionable point is that any loopback
# bind skips enforce_origin, not the exact IPv4/IPv6 split.)
#
# If a non-loopback bind is adopted later (e.g. `admin 0.0.0.0:2019 { ... }`
# so a docker container on the host's bridge can reach admin via
# 172.17.0.1:2019), the admin block MUST include an `origins` allowlist.
# On Linux, `localhost` resolves to `::1` FIRST per glibc RFC 3484 (because
# /etc/hosts has `::1 localhost`), so allowlist entries must include the
# IPv6 literal form `http://[::1]:2019` AND `http://ip6-localhost:2019`
# (the glibc alias) `http://localhost:2019` alone will 403 every probe
# that resolves localhost to `::1`. See DC-051 + DC-069 in repo history.
admin localhost:2019
auto_https off
}
-16
View File
@@ -86,20 +86,8 @@ run_image_layer_migration
# dns1.sami → DNS1 (SAMI-CLOUD-U32)
# dc-contabo-de → DashCaddy Contabo test instance
# git.dashcaddy.net → DashCaddy upstream git
# git.sami → DNS2 (NOT DNS3 — see warning above). Resolves an
# intermittent ENOTFOUND in the ssl-monitor's TLS
# handshake check (~2/h) by pinning the name in the
# container's /etc/hosts to the Caddy listener.
# ca.sami → local CA (DN2 + DN3 both have their own)
ADD_HOST_FLAGS=(
# host.docker.internal → host bridge IP (Docker host-gateway). The caddy
# upstream watcher probes Caddy site upstreams from INSIDE this container;
# `reverse_proxy localhost:PORT` in a site file means the HOST's loopback,
# so the watcher remaps loopback probe targets to this name (see
# dashcaddy-api/src/monitoring/caddy-upstream-watcher.js). Without this
# entry the probes would hit the container's own loopback and report every
# host-side upstream as dead.
--add-host=host.docker.internal:host-gateway
--add-host=dns3.sami:100.81.59.99
--add-host=gitea:100.81.59.99
--add-host=dns3-wan.sami:74.208.167.19
@@ -107,7 +95,6 @@ ADD_HOST_FLAGS=(
--add-host=dns1.sami:100.71.97.12
--add-host=dc-contabo-de:100.98.123.59
--add-host=git.dashcaddy.net:100.98.123.59
--add-host=git.sami:100.121.150.22
# ca.sami resolves via DNS to 100.121.150.22 (Caddy on DNS2). Don't pin
# to 127.0.0.1 — nothing listens on 443 inside the container, so the
# health checker would fail with ECONNREFUSED. The CA itself is a
@@ -159,7 +146,6 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
-v ${DATA_DIR}:/app/data \
-v ${BACKUPS_DIR}:/app/backups \
-v ${CADDYFILE}:/caddyfile \
-v /etc/caddy/sites:/etc/caddy/sites:ro \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${ASSETS_DIR}:/app/assets \
-v ${UPDATES_DIR}:/app/updates \
@@ -167,8 +153,6 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
-v /usr/bin/tailscale:/usr/bin/tailscale:ro \
-v /var/run/tailscale:/var/run/tailscale:ro \
-v /etc/ssl/sami-ca:/etc/ssl/sami-ca:ro \
-v /var/log/journal:/var/log/journal:ro \
-v /usr/bin/journalctl:/usr/bin/journalctl:ro \
-e NODE_ENV=production \
-e SERVICES_FILE=/app/data/services.json \
-e CONFIG_FILE=/app/data/config.json \
-5
View File
@@ -28,7 +28,6 @@ const bundles = {
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
// calls from showTotpOverlay(). Must come after totp-auth.js.
JS('totp-recovery.js'),
JS('credential-vault-handoff.js'),
JS('service-credentials.js'),
JS('totp-settings.js'),
// DC-048 admin panel — modal-overlay UI for user/invite management.
@@ -55,10 +54,6 @@ const bundles = {
JS('import-export.js'),
JS('error-logs.js'),
JS('container-logs.js'),
// DC-055: Host journald log viewer — reads /var/log/journal via the
// bind-mount added in start.sh. Self-contained modal with SSE stream
// + bounded tail read. Exposes window.openJournaldModal().
JS('journald.js'),
JS('snapshot.js'),
JS('smart-arr-connect.js'),
JS('notification-settings.js'),
+108 -108
View File
File diff suppressed because one or more lines are too long
+222 -314
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
+1 -1
View File
@@ -203,7 +203,7 @@
<div class="tools-section-items">
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
<button id="view-journald-logs" aria-label="Host journald logs">🛰️ Host Logs</button>
<a id="view-logs-page" aria-label="Dedicated logs page" href="/logs.html" target="_blank" rel="noopener" style="text-decoration:none;color:inherit">📄 Logs Page</a>
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
+4 -65
View File
@@ -216,8 +216,8 @@
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
));
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
_el('span', { text: 'Also send via email (optional)' }),
_el('input', { name: 'sendEmail', type: 'checkbox', checked: true }),
_el('span', { text: 'Send email' }),
));
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
container.appendChild(form);
@@ -297,77 +297,16 @@
},
});
banner.appendChild(copyBtn);
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
// copy this as a sentence instead of dealing with the raw URL.
if (invite.shareText) {
const shareBlock = _el('div', { style: 'margin-top:12px' });
shareBlock.appendChild(_el('div', {
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
text: 'Share this message:',
}));
shareBlock.appendChild(_el('div', {
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
text: invite.shareText,
}));
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
const copyTextBtn = _el('button', {
class: 'btn-sm', style: 'padding:4px 10px',
text: 'Copy message',
onclick: async () => {
try {
await navigator.clipboard.writeText(invite.shareText);
copyTextBtn.textContent = 'Copied!';
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
} catch (e) {
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
}
},
});
shareActions.appendChild(copyTextBtn);
// Native share sheet on mobile / supported browsers. Falls back silently
// (the copy buttons cover the same intent).
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
const nativeShareBtn = _el('button', {
class: 'btn-sm', style: 'padding:4px 10px',
text: 'Share via…',
onclick: async () => {
try {
await navigator.share({
title: 'DashCaddy invite',
text: invite.shareText,
url: invite.acceptUrl,
});
} catch (e) {
// User-cancelled throws AbortError — that's fine, just stay quiet.
if (e && e.name && e.name !== 'AbortError') {
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
}
}
},
});
shareActions.appendChild(nativeShareBtn);
}
shareBlock.appendChild(shareActions);
banner.appendChild(shareBlock);
}
if (invite.deliveredVia === 'failed') {
if (invite.deliveredVia === 'dev-console') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
text: 'SMTP not configured the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
}));
} else if (invite.deliveredVia === 'email') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
text: 'Email sent to ' + invite.email + '.',
}));
} else if (invite.deliveredVia === 'manual') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
text: 'Share the link above via text, chat, or any messenger.',
}));
}
parent.appendChild(banner);
}
+2 -46
View File
@@ -267,46 +267,6 @@
}
}
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);
if (urlParams.get('auth') === 'required') {
// Preserve the gated service destination so submitTotpCode() can append
@@ -317,12 +277,8 @@
}
// Clean URL — happens after we've captured the redirect
window.history.replaceState({}, '', window.location.pathname);
// Reuse the valid status.sami session first. Only show the TOTP/provider
// challenge when that session is genuinely absent or expired.
setTimeout(async () => {
if (await resumeExistingSession(returnUrl)) return;
await show();
}, 0);
// Show on next tick so the DOM (the .totp-card) is ready
setTimeout(show, 0);
}
// Expose for hot-trigger from other modules (e.g. logout)
+52 -55
View File
@@ -33,20 +33,6 @@
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 */
function buildCredentialSections() {
const container = document.getElementById('dns-cred-sections');
@@ -272,6 +258,14 @@
document.getElementById('token-save')?.addEventListener('click', async () => {
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
const servers = {};
let hasAnyCreds = false;
@@ -310,36 +304,45 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
const data = await res.json();
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);
}
dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results[dnsId];
if (result?.success) {
statusEl.textContent = '\u2713 Verified & saved';
statusEl.className = 'token-status success';
} else if (result?.partial) {
statusEl.textContent = '\u2713 ' + result.partial;
statusEl.className = 'token-status success';
} else {
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
statusEl.className = 'token-status error';
}
});
} else if (data.success) {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
}
});
} else {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
}
// Cache locally only after the encrypted server vault confirms success.
dnsIds.forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results?.[dnsId];
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
statusEl.className = 'token-status success';
});
} catch (e) {
console.error('Failed to sync DNS credentials to backend:', e);
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
}
@@ -365,24 +368,18 @@
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
try {
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
} catch (e) {
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
});
}
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
} catch (_) {}
}
});
-3
View File
@@ -61,9 +61,6 @@
await window.loadServices();
await loadTemplateCategories();
window.buildGrid();
if (typeof window.openRequestedCredentialForm === 'function') {
window.openRequestedCredentialForm();
}
animateTopCards();
window.refreshAll();
setInterval(() => {
-52
View File
@@ -1,52 +0,0 @@
// ===== 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 };
})();
+116 -264
View File
@@ -1,292 +1,144 @@
// ========== ERROR LOG VIEWER (DC-052) ==========
// DC-052: Adds Level / Context / Search / Time-range filters, server-side
// pagination with Load More, click-to-expand stack frames, and a distinct
// contexts dropdown backed by /api/v1/error-logs/contexts. Mirrors the
// audit-log UX (DC-050) so operators can drill into a subsystem as easily
// as they can audit who-did-what.
// ========== ERROR LOG VIEWER ==========
// DC-051: The /api/v1/error-logs route now parses the unified-logger
// ── (U+2500) separator (verified 2026-08-18 — previous '=' splitter
// returned ZERO entries and the modal always rendered "No errors logged").
// The modal now renders the captured `details` (stack trace + req context)
// and offers a Level filter + tail cap mirroring the audit-log viewer
// (DC-050). Mirrors the audit-log-viewer shape (5f95fdc).
(function() {
// Inject modal HTML. Same weather-modal shell as audit-log so styles
// are shared; wider min-width because error stacks need room to breathe.
injectModal('error-log-modal', `<div id="error-log-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>📋 Error Logs</h3>
<p class="modal-subtitle">
Errors and warnings from the DashCaddy API. Click a row to see the full stack trace.
</p>
const MAX_LEVELS = ['ERR', 'WRN', 'INF', 'DBG'];
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
<label class="text-muted-sm">Level:</label>
<select id="error-log-level" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
<option value="">All</option>
<option value="ERR">Errors</option>
<option value="WARN">Warnings</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
</select>
<label class="text-muted-sm">Context:</label>
<select id="error-log-context" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem; max-width: 220px;">
<option value="">All</option>
</select>
<label class="text-muted-sm" style="margin-left: 8px;">Search:</label>
<input id="error-log-search" type="search" placeholder="message / stack / ip" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem; min-width: 180px;">
<label class="text-muted-sm" style="margin-left: 8px;">Since:</label>
<input id="error-log-since" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<label class="text-muted-sm">Until:</label>
<input id="error-log-until" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<button id="error-log-refresh" class="btn-sm">🔄 Refresh</button>
<span style="flex: 1;"></span>
<button id="error-log-clear" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑 Clear Log</button>
</div>
<div id="error-log-container" class="scroll-container">
<div class="panel-empty"><span class="brand-spinner"></span> Loading error logs...</div>
</div>
<div style="margin-top: 12px; text-align: center;">
<button id="error-log-load-more" style="display: none; padding: 6px 16px; font-size: 0.8rem;">Load More</button>
</div>
<div style="margin-top: 8px; font-size: 0.78rem; color: var(--muted); text-align: right;">
<span id="error-log-total"></span>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="error-log-close">Close</button>
</div>
</div>
</div>`);
injectModal('error-log-modal', [
'<div id="error-log-modal" class="logs-modal">',
' <div class="logs-modal-content">',
' <div class="logs-header">',
' <h3>📋 Error Logs</h3>',
' <div class="logs-controls">',
' <select id="error-log-level" aria-label="Filter by level" style="padding:4px 8px!important;font-size:.85rem!important">',
' <option value="">All levels</option>',
' <option value="ERR">Errors</option>',
' <option value="WRN">Warnings</option>',
' <option value="INF">Info</option>',
' <option value="DBG">Debug</option>',
' </select>',
' <select id="error-log-tail" aria-label="Tail length" style="padding:4px 8px!important;font-size:.85rem!important">',
' <option value="50">Last 50</option>',
' <option value="100" selected>Last 100</option>',
' <option value="200">Last 200</option>',
' <option value="500">Last 500</option>',
' </select>',
' <button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">🔄 Refresh</button>',
' <button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">🗑️ Clear</button>',
' <button id="error-log-close" class="close-btn">✕</button>',
' </div>',
' </div>',
' <div class="logs-container">',
' <div id="error-log-meta" class="logs-meta" style="padding:6px 12px;color:var(--muted);font-size:.8rem;border-bottom:1px solid var(--border)"></div>',
' <div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div>',
' </div>',
' </div>',
'</div>',
].join(''));
const modal = document.getElementById('error-log-modal');
const content = document.getElementById('error-log-content');
const meta = document.getElementById('error-log-meta');
const viewBtn = document.getElementById('view-error-logs');
const refreshBtn = document.getElementById('error-log-refresh');
const clearBtn = document.getElementById('error-log-clear');
const closeBtn = document.getElementById('error-log-close');
const levelSel = document.getElementById('error-log-level');
const contextSel = document.getElementById('error-log-context');
const searchInput = document.getElementById('error-log-search');
const sinceInput = document.getElementById('error-log-since');
const untilInput = document.getElementById('error-log-until');
const container = document.getElementById('error-log-container');
const loadMoreBtn = document.getElementById('error-log-load-more');
const totalSpan = document.getElementById('error-log-total');
const levelSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-level'));
const tailSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-tail'));
const PAGE_SIZE = 50;
let currentOffset = 0;
let inflight = null;
let filterNonce = 0;
// Cached distinct contexts so the dropdown is populated once per open and
// re-populated after a clear (which removes all contexts) or a refresh
// that surfaces a new subsystem for the first time.
let knownContexts = [];
// datetime-local fields are naive local time — convert to UTC ISO so the
// server compares correctly. Same shape as audit-log.js so the operator
// sees consistent behaviour between the two modals.
function toIso(localDtValue) {
if (!localDtValue) return null;
const d = new Date(localDtValue);
if (isNaN(d.getTime())) return null;
return d.toISOString();
function levelClass(level) {
const L = (level || '').toUpperCase();
if (L === 'ERR') return 'log-entry error';
if (L === 'WRN') return 'log-entry warn';
if (L === 'INF') return 'log-entry info';
if (L === 'DBG') return 'log-entry debug';
return 'log-entry';
}
// Pull the distinct contexts list once per open. Failures are silent
// (the dropdown will just show "All" only) so a transient backend hiccup
// doesn't block the operator from seeing the actual error rows.
async function refreshContexts() {
function formatBytes(n) {
if (!Number.isFinite(n) || n <= 0) return '0 B';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
return (n / 1024 / 1024).toFixed(2) + ' MiB';
}
async function loadErrorLogs() {
content.innerHTML = '<div class="logs-loading">Loading error logs...</div>';
meta.textContent = '';
const tail = encodeURIComponent(tailSelect.value || '100');
const level = levelSelect.value || '';
const qs = `tail=${tail}` + (level ? `&level=${encodeURIComponent(level)}` : '');
try {
const res = await fetch('/api/v1/error-logs/contexts');
if (!res.ok) return;
const data = await res.json();
if (!data.success || !Array.isArray(data.contexts)) return;
knownContexts = data.contexts;
const currentValue = contextSel.value;
contextSel.innerHTML = '<option value="">All</option>';
for (const c of data.contexts) {
const opt = document.createElement('option');
opt.value = c.name;
opt.textContent = `${c.name} (${c.count})`;
contextSel.appendChild(opt);
}
// Restore previous selection if still present.
if (currentValue && data.contexts.some((c) => c.name === currentValue)) {
contextSel.value = currentValue;
}
} catch { /* ignore */ }
}
const response = await fetch('/api/v1/error-logs?' + qs);
const data = await response.json();
function buildQuery() {
const params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(currentOffset));
if (levelSel.value) params.set('level', levelSel.value);
if (contextSel.value) params.set('context', contextSel.value);
const since = toIso(sinceInput.value);
const until = toIso(untilInput.value);
if (since) params.set('since', since);
if (until) params.set('until', until);
const search = (searchInput.value || '').trim();
if (search) params.set('search', search);
return params;
}
async function loadLogs(append) {
try {
if (!append) {
if (inflight) inflight.abort();
inflight = new AbortController();
currentOffset = 0;
filterNonce++;
container.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
} else {
if (inflight) inflight.abort();
inflight = new AbortController();
}
const myNonce = filterNonce;
const params = buildQuery();
const res = await fetch('/api/v1/error-logs?' + params.toString(), {
signal: inflight.signal,
});
// Mirror audit-log: surface 4xx/5xx explicitly instead of falling
// through to a misleading "no entries yet" empty state.
if (!res.ok) {
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${res.status}</div>`;
loadMoreBtn.style.display = 'none';
totalSpan.textContent = '';
return;
}
const data = await res.json();
if (!data.success) {
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(data.error || 'unknown')}</div>`;
loadMoreBtn.style.display = 'none';
totalSpan.textContent = '';
return;
}
// Stale-response guard: a non-append load happened after this fetch,
// discard so we don't splice into the wrong DOM.
if (!append && myNonce !== filterNonce) return;
const logs = Array.isArray(data.logs) ? data.logs : [];
if (logs.length === 0 && !append) {
const reason = (data.filters && (data.filters.level || data.filters.context || data.filters.search || data.filters.since || data.filters.until))
? 'No error log entries match your filters.'
: '✅ No errors logged! Everything is working smoothly.';
container.innerHTML = `<div class="panel-empty"><span class="empty-icon">📋</span>${escapeHtml(reason)}</div>`;
loadMoreBtn.style.display = 'none';
totalSpan.textContent = data.total ? `${data.total} total` : '';
return;
}
let html = '';
if (!append) {
html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">';
html += '<th style="padding: 6px; text-align: left; width: 160px;">When</th>';
html += '<th style="padding: 6px; text-align: left; width: 80px;">Level</th>';
html += '<th style="padding: 6px; text-align: left; width: 140px;">Context</th>';
html += '<th style="padding: 6px; text-align: left;">Message</th>';
html += '<th style="padding: 6px; text-align: left; width: 110px;">IP</th>';
html += '</tr>';
}
for (const log of logs) {
const level = (log.level || '?').toUpperCase();
const levelColor = level === 'ERR' ? 'var(--bad-fg)' : (level === 'WARN' ? 'var(--warn-fg, #f0c674)' : 'var(--muted)');
const ts = log.timestamp ? new Date(log.timestamp).toLocaleString() : '—';
const ctx = log.context || '—';
const msg = (log.error || '').split('\n')[0];
const ip = (log.request && log.request.ip) || '';
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">`;
html += `<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(log.timestamp || '')}">${escapeHtml(ts)}</td>`;
html += `<td style="padding: 6px;"><span style="color: ${levelColor}; font-weight: 600;">${escapeHtml(level)}</span></td>`;
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(ctx)}</td>`;
html += `<td style="padding: 6px;">${escapeHtml(msg)}</td>`;
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(ip)}</td>`;
html += '</tr>';
if (log.detail) {
html += `<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(log.detail)}</pre></td></tr>`;
if (data.success && data.logs) {
if (data.logs.length === 0) {
content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--muted);">✅ No errors logged! Everything is working smoothly.</div>';
} else {
content.innerHTML = data.logs.map((log, idx) => {
const date = new Date(log.timestamp).toLocaleString();
const lvl = (log.level || 'ERR').toUpperCase();
const detailsId = `error-log-details-${idx}`;
const details = log.details ? escapeHtml(log.details) : null;
const ctx = log.context ? `<strong>${escapeHtml(log.context)}</strong>: ` : '';
const msg = escapeHtml(log.message || '');
return `
<div class="${levelClass(log.level)}">
<span class="log-timestamp">${escapeHtml(date)}</span>
<span class="log-level">${escapeHtml(lvl)}</span>
<div class="log-message">
${ctx}${msg}
${details ? `<br><details id="${detailsId}"><summary style="cursor:pointer;opacity:.7">stack + request context</summary><pre style="margin:6px 0 0;font-size:.75rem;background:var(--card-bg);padding:8px;border-radius:4px;overflow-x:auto">${details}</pre></details>` : ''}
</div>
</div>
`;
}).join('');
}
}
if (!append) {
html += '</table>';
container.innerHTML = html;
const sizeStr = formatBytes(data.totalSize);
const truncStr = data.truncated ? ' (showing last 2 MiB)' : '';
const returnedStr = `${data.returned ?? data.logs.length}`;
meta.textContent = `${returnedStr} entries · ${sizeStr}${truncStr}`;
} else {
const table = container.querySelector('table');
if (table) table.insertAdjacentHTML('beforeend', html);
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
}
currentOffset += logs.length;
loadMoreBtn.style.display = data.hasMore ? '' : 'none';
totalSpan.textContent = `${data.total} total${data.hasMore ? ' (showing ' + currentOffset + ')' : ''}`;
// Toggle detail rows on click — same pattern as audit-log.js
container.querySelectorAll('.error-log-row').forEach((row) => {
if (row.dataset.wired) return;
row.dataset.wired = 'true';
row.addEventListener('click', () => {
const detail = row.nextElementSibling;
if (detail && detail.classList.contains('error-log-detail')) {
detail.style.display = detail.style.display === 'none' ? '' : 'none';
}
});
});
} catch (e) {
if (e && e.name === 'AbortError') return;
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
totalSpan.textContent = '';
} catch (error) {
content.innerHTML = `<div style="padding: 20px; color: var(--bad-fg);">❌ Error loading logs: ${escapeHtml(error.message)}</div>`;
}
}
async function clearLogs() {
if (!confirm('Clear the entire error log? This cannot be undone.')) return;
async function clearErrorLogs() {
if (!confirm('Clear all error logs?')) return;
try {
const res = await secureFetch('/api/v1/error-logs', {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ confirm: 'CLEAR' }),
});
const data = await res.json();
const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' });
const data = await response.json();
if (data.success) {
// After a clear, the contexts list will be empty — re-fetch so the
// dropdown reflects reality. Load the now-empty page in parallel.
await refreshContexts();
loadLogs(false);
showNotification('✅ Error logs cleared', 'success', 3000);
loadErrorLogs();
} else {
showNotification('❌ ' + (data.error || 'Clear failed'), 'error', 4000);
showNotification('❌ Failed to clear logs', 'error', 3000);
}
} catch (e) {
showNotification('❌ ' + e.message, 'error', 4000);
} catch (error) {
showNotification(`❌ Error: ${error.message}`, 'error', 3000);
}
}
// Debounce text-input changes so we don't refetch on every keystroke.
let searchDebounce;
function wireFilters() {
levelSel?.addEventListener('change', () => loadLogs(false));
contextSel?.addEventListener('change', () => loadLogs(false));
searchInput?.addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => loadLogs(false), 250);
});
let dateDebounce;
[sinceInput, untilInput].forEach((el) => {
el?.addEventListener('change', () => {
clearTimeout(dateDebounce);
dateDebounce = setTimeout(() => loadLogs(false), 250);
});
});
refreshBtn?.addEventListener('click', () => loadLogs(false));
loadMoreBtn?.addEventListener('click', () => loadLogs(true));
clearBtn?.addEventListener('click', clearLogs);
wireModal(modal, closeBtn);
}
viewBtn?.addEventListener('click', async () => {
modal?.classList.add('show');
await refreshContexts();
loadLogs(false);
viewBtn?.addEventListener('click', () => {
modal.classList.add('show');
loadErrorLogs();
});
wireFilters();
})();
refreshBtn?.addEventListener('click', loadErrorLogs);
clearBtn?.addEventListener('click', clearErrorLogs);
levelSelect?.addEventListener('change', loadErrorLogs);
tailSelect?.addEventListener('change', loadErrorLogs);
wireModal(modal, closeBtn);
})();
-282
View File
@@ -1,282 +0,0 @@
// ========== DC-055: HOST JOURNALD LOG VIEWER ==========
// Streams host service logs (caddy, dashcaddy-api, docker, ssh, …) via the
// journalctl bind-mount added in start.sh. Server-Sent Events for live
// tailing; bounded non-streaming read for historical views.
(function() {
'use strict';
// Allow-list mirrors the backend's ALLOWED_UNITS so the dropdown stays
// honest when the bind-mount isn't available. The server is still the
// source of truth — anything not in its allow-list returns 400.
const UNIT_PRESETS = [
{ unit: 'caddy', label: 'Caddy (reverse proxy)' },
{ unit: 'dashcaddy-api', label: 'DashCaddy API (host systemd unit, not this container)' },
{ unit: 'docker', label: 'Docker daemon' },
{ unit: 'ssh', label: 'SSH server' },
{ unit: 'systemd-journald', label: 'systemd-journald' },
{ unit: 'tailscaled', label: 'Tailscale' },
{ unit: 'networkd-dispatcher', label: 'Networkd dispatcher' },
];
injectModal('journald-modal', `
<div id="journald-modal" class="weather-modal" style="z-index: 1002;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
<h3 style="margin: 0;">🛰 Host Logs (journald)</h3>
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">
Stream host service logs from <code>journalctl</code> (read-only mount). Docker container logs are still in the <em>Container Logs</em> modal.
</p>
</div>
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end;">
<select id="jd-unit-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 220px;"></select>
<input type="text" id="jd-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 160px;" />
<input type="number" id="jd-tail" min="1" max="5000" value="200" title="Lines to load (historical view)" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 90px;" />
<button id="jd-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">🔄 Load tail</button>
<button id="jd-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;"> Stream</button>
<button id="jd-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search"></button>
<button id="jd-close" class="close-btn" style="padding: 6px 10px;"></button>
</div>
</div>
<div id="jd-meta" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem; flex-wrap: wrap;">
<span><strong>Source:</strong> <span id="jd-source">journald</span></span>
<span><strong>Unit:</strong> <span id="jd-unit-display">-</span></span>
<span><strong>Stream:</strong> <span id="jd-stream-state">disconnected</span></span>
</div>
<div id="jd-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a unit and click <em>Load tail</em> or <em>Stream</em>.</div>
</div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
<span id="jd-line-count">0 lines</span>
<span>|</span>
<span id="jd-filter-count">0 shown</span>
<span>|</span>
<span id="jd-overflow" style="display: none; color: var(--warn-fg, #fbbf24);"> stream overflow re-load with narrower window</span>
</div>
<button id="jd-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>
`);
const modal = document.getElementById('journald-modal');
const unitSelect = document.getElementById('jd-unit-select');
const searchInput = document.getElementById('jd-search');
const tailInput = document.getElementById('jd-tail');
const refreshBtn = document.getElementById('jd-refresh');
const streamBtn = document.getElementById('jd-stream');
const clearSearch = document.getElementById('jd-clear-search');
const closeBtn = document.getElementById('jd-close');
const closeBtn2 = document.getElementById('jd-close-btn');
const content = document.getElementById('jd-content');
const lineCount = document.getElementById('jd-line-count');
const filterCount = document.getElementById('jd-filter-count');
const overflowHint = document.getElementById('jd-overflow');
const unitDisplay = document.getElementById('jd-unit-display');
const streamState = document.getElementById('jd-stream-state');
let available = false; // /var/log/journal mounted?
let lines = []; // current buffer (array of {timestamp, unit, text})
let streaming = false;
let eventSource = null;
let searchTimer = null;
function escapeHtml(s) {
// Local re-declaration so we don't depend on a global; same semantics
// as the helper used by container-logs.js and error-logs.js.
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
function setAvailable(isAvailable) {
available = isAvailable;
unitSelect.innerHTML = '';
UNIT_PRESETS.forEach(p => {
const opt = document.createElement('option');
opt.value = p.unit;
opt.textContent = p.label + ' (' + p.unit + ')';
unitSelect.appendChild(opt);
});
unitSelect.disabled = !isAvailable;
if (!isAvailable) {
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>';
refreshBtn.disabled = true;
streamBtn.disabled = true;
} else {
refreshBtn.disabled = false;
streamBtn.disabled = false;
}
}
async function probeAvailable() {
try {
const resp = await fetch('/api/v1/logs/journal/units');
if (!resp.ok) { setAvailable(false); return; }
const data = await resp.json();
setAvailable(!!data.available);
} catch (e) {
setAvailable(false);
}
}
function renderLines() {
const term = (searchInput.value || '').trim().toLowerCase();
const filtered = term ? lines.filter(l => (l.textContent || '').toLowerCase().includes(term)) : lines;
if (filtered.length === 0) {
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries' + (term ? ` matching &quot;${escapeHtml(term)}&quot;` : '') + '</div>';
} else {
const html = filtered.map(line => {
const ts = line.timestamp ? escapeHtml(line.timestamp) : '—';
const t = escapeHtml(line.textContent);
return `<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${ts}</span>${t}</div>`;
}).join('');
content.innerHTML = html;
// Auto-scroll only if user is already at the bottom (don't fight them).
const nearBottom = content.scrollHeight - content.scrollTop - content.clientHeight < 80;
if (nearBottom) content.scrollTop = content.scrollHeight;
}
lineCount.textContent = `${lines.length} entries`;
filterCount.textContent = term ? `${filtered.length} of ${lines.length} shown` : `${lines.length} shown`;
}
async function loadTail() {
if (!available) return;
stopStream();
const unit = unitSelect.value;
if (!unit) return;
const tail = Math.max(1, Math.min(5000, Number(tailInput.value) || 200));
const term = (searchInput.value || '').trim();
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading…</div>';
try {
const url = new URL('/api/v1/logs/journal', window.location.origin);
url.searchParams.set('unit', unit);
url.searchParams.set('tail', String(tail));
if (term) url.searchParams.set('search', term);
const resp = await fetch(url.toString());
const data = await resp.json();
if (!resp.ok || !data.success) {
content.innerHTML = '<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: ' + escapeHtml((data && data.error) || ('HTTP ' + resp.status)) + '</div>';
return;
}
unitDisplay.textContent = unit;
lines = (data.entries || []).map(e => ({
timestamp: e.timestamp,
unit: e.unit,
textContent: e.text || '',
}));
overflowHint.style.display = 'none';
renderLines();
} catch (e) {
content.innerHTML = '<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: ' + escapeHtml(e.message) + '</div>';
}
}
function startStream() {
if (!available) return;
stopStream();
const unit = unitSelect.value;
if (!unit) return;
const term = (searchInput.value || '').trim();
unitDisplay.textContent = unit;
streamBtn.textContent = '⏸ Stop';
streamBtn.classList.add('streaming');
streamState.textContent = 'streaming';
streamState.style.color = 'var(--ok-fg, #4ade80)';
lines = [];
renderLines();
overflowHint.style.display = 'none';
const url = new URL('/api/v1/logs/journal/stream', window.location.origin);
url.searchParams.set('unit', unit);
if (term) url.searchParams.set('search', term);
eventSource = new EventSource(url.toString());
eventSource.onmessage = (ev) => {
try {
const entry = JSON.parse(ev.data);
if (entry.error) {
// Overflow / validation / bind-mount errors
if (/stream (exceeded|line cap)/.test(entry.error)) {
overflowHint.style.display = '';
stopStream();
}
content.innerHTML += '<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">⚠ ' + escapeHtml(entry.error) + '</div>';
content.scrollTop = content.scrollHeight;
return;
}
lines.push({
timestamp: entry.timestamp,
unit: entry.unit || unit,
textContent: entry.text || '',
});
// Hard cap to keep memory bounded if operator streams forever.
if (lines.length > 5000) {
lines = lines.slice(lines.length - 5000);
overflowHint.style.display = '';
}
renderLines();
} catch (_) {
// Ignore malformed events; the server is authoritative.
}
};
eventSource.onerror = () => {
// EventSource auto-reconnects; mark transient if we were expecting
// more, otherwise we closed it deliberately.
if (!streaming) return;
};
streaming = true;
}
function stopStream() {
streaming = false;
if (eventSource) {
try { eventSource.close(); } catch (_) { /* ignore */ }
eventSource = null;
}
streamBtn.textContent = '▶ Stream';
streamBtn.classList.remove('streaming');
streamState.textContent = 'disconnected';
streamState.style.color = 'var(--muted)';
}
function close() {
stopStream();
modal.classList.remove('show');
}
// Wire events
refreshBtn.addEventListener('click', loadTail);
streamBtn.addEventListener('click', () => streaming ? stopStream() : startStream());
clearSearch.addEventListener('click', () => { searchInput.value = ''; renderLines(); });
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(renderLines, 200);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { searchInput.value = ''; renderLines(); }
});
closeBtn.addEventListener('click', close);
closeBtn2.addEventListener('click', close);
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('show')) close();
});
// Reload tail automatically when the unit dropdown changes (if we have
// data already — saves a click).
unitSelect.addEventListener('change', () => {
if (lines.length > 0) loadTail();
});
// Hook into the existing "Container Logs" modal button so operators get a
// separate entry point; mirror the openContainerLogsModal pattern.
function openJournaldModal() {
modal.classList.add('show');
probeAvailable();
}
window.openJournaldModal = openJournaldModal;
document.getElementById('view-journald-logs')?.addEventListener('click', openJournaldModal);
})();
+172
View File
@@ -0,0 +1,172 @@
// Logs page (status/logs.html) — dedicated admin log viewer.
// Two tabs: Error log (calls /api/v1/error-logs) + Container (calls
// /api/v1/logs/containers + /api/v1/logs/container/:id). Mirrors the
// /api/v1/error-logs parser fix from DC-051 — U+2500 separator, capped
// tail, level filter.
(function() {
'use strict';
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function formatBytes(n) {
if (!Number.isFinite(n) || n <= 0) return '0 B';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
return (n / 1024 / 1024).toFixed(2) + ' MiB';
}
const out = document.getElementById('output');
const meta = document.getElementById('meta');
const tabError = document.getElementById('tab-error');
const tabContainer = document.getElementById('tab-container');
const errorCtrls = document.getElementById('error-controls');
const containerCtrls = document.getElementById('container-controls');
let activeTab = 'error';
let containers = [];
function switchTab(tab) {
activeTab = tab;
tabError.classList.toggle('active', tab === 'error');
tabContainer.classList.toggle('active', tab === 'container');
errorCtrls.style.display = tab === 'error' ? 'flex' : 'none';
containerCtrls.style.display = tab === 'container' ? 'flex' : 'none';
if (tab === 'error') loadErrorLog();
else loadContainerList();
}
async function fetchJson(url, opts) {
const r = await fetch(url, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok || (data && data.success === false)) {
throw new Error((data && (data.error || data.message)) || `HTTP ${r.status}`);
}
return data;
}
async function loadErrorLog() {
const tailEl = /** @type {HTMLSelectElement} */ (document.getElementById('tail'));
const levelEl = /** @type {HTMLSelectElement} */ (document.getElementById('level'));
const tail = tailEl.value;
const level = levelEl.value;
let qs = `tail=${encodeURIComponent(tail)}`;
if (level) qs += '&level=' + encodeURIComponent(level);
out.innerHTML = '<div class="logs-loading">Loading error log…</div>';
meta.textContent = '';
try {
const data = await fetchJson('/api/v1/error-logs?' + qs);
const logs = data.logs || [];
if (logs.length === 0) {
out.innerHTML = '<div class="empty">✅ No errors logged</div>';
} else {
out.innerHTML = logs.map((log, idx) => {
const date = new Date(log.timestamp).toLocaleString();
const lvl = (log.level || 'ERR').toUpperCase();
const cls = ['ERR','WRN','INF','DBG'].includes(lvl) ? lvl.toLowerCase() : 'error';
const details = log.details ? escapeHtml(log.details) : null;
return `
<div class="log-entry ${cls}">
<span class="log-timestamp">${escapeHtml(date)}</span>
<span class="log-level">${escapeHtml(lvl)}</span>
<div class="log-message">
<strong>${escapeHtml(log.context || '')}</strong>: ${escapeHtml(log.message || '')}
${details ? `<details><summary style="cursor:pointer;opacity:.7">stack + request context</summary><pre>${details}</pre></details>` : ''}
</div>
</div>
`;
}).join('');
}
const sizeStr = formatBytes(data.totalSize);
const truncStr = data.truncated ? ' (last 2 MiB)' : '';
const returnedStr = data.returned ?? logs.length;
meta.textContent = `${returnedStr} entries · ${sizeStr}${truncStr}`;
} catch (err) {
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
}
}
async function clearErrorLog() {
if (!confirm('Clear all error logs?')) return;
try {
await fetchJson('/api/v1/error-logs', { method: 'DELETE' });
meta.textContent = '✅ cleared';
loadErrorLog();
} catch (err) {
meta.textContent = '❌ ' + err.message;
}
}
async function loadContainerList() {
const sel = document.getElementById('container-select');
out.innerHTML = '<div class="logs-loading">Loading containers…</div>';
document.getElementById('container-meta').textContent = '';
try {
const data = await fetchJson('/api/v1/logs/containers');
containers = data.containers || [];
sel.innerHTML = containers.map(c => {
const name = c.name || c.id;
const state = (c.status || 'unknown');
return `<option value="${escapeHtml(c.id)}">${escapeHtml(name)} (${escapeHtml(state)})</option>`;
}).join('');
if (containers.length === 0) {
out.innerHTML = '<div class="empty">No containers running</div>';
return;
}
loadContainerLog();
} catch (err) {
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
}
}
async function loadContainerLog() {
const sel = /** @type {HTMLSelectElement} */ (document.getElementById('container-select'));
const tailEl = /** @type {HTMLSelectElement} */ (document.getElementById('container-tail'));
const tail = tailEl.value;
const id = sel.value;
if (!id) {
out.innerHTML = '<div class="empty">Select a container</div>';
return;
}
out.innerHTML = '<div class="logs-loading">Loading container logs…</div>';
document.getElementById('container-meta').textContent = '';
try {
const data = await fetchJson(`/api/v1/logs/container/${encodeURIComponent(id)}?tail=${encodeURIComponent(tail)}&timestamps=true`);
const logs = data.logs || [];
if (logs.length === 0) {
out.innerHTML = '<div class="empty">No log lines</div>';
} else {
out.innerHTML = logs.map(l => {
const cls = l.stream === 'stderr' ? 'error' : 'info';
const ts = l.timestamp || (data.logs.length ? '' : '');
return `
<div class="log-entry ${cls}">
${ts ? `<span class="log-timestamp">${escapeHtml(new Date(ts).toLocaleString())}</span>` : ''}
<span class="log-level">${l.stream === 'stderr' ? 'ERR' : 'OUT'}</span>
<div class="log-message"><pre style="margin:0;white-space:pre-wrap">${escapeHtml(l.text)}</pre></div>
</div>
`;
}).join('');
}
const containerName = data.containerName || '';
document.getElementById('container-meta').textContent = `${escapeHtml(containerName)} · ${logs.length} lines`;
} catch (err) {
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
}
}
tabError.addEventListener('click', () => switchTab('error'));
tabContainer.addEventListener('click', () => switchTab('container'));
document.getElementById('refresh').addEventListener('click', loadErrorLog);
document.getElementById('clear').addEventListener('click', clearErrorLog);
document.getElementById('level').addEventListener('change', loadErrorLog);
document.getElementById('tail').addEventListener('change', loadErrorLog);
document.getElementById('container-refresh').addEventListener('click', loadContainerLog);
document.getElementById('container-select').addEventListener('change', loadContainerLog);
document.getElementById('container-tail').addEventListener('change', loadContainerLog);
switchTab('error');
})();
+20 -88
View File
@@ -32,8 +32,8 @@
injectModal('service-creds-modal', `<div id="service-creds-modal">
<div class="service-creds-content">
<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;">Passwords are encrypted at rest and used automatically when you open this service.</p>
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</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>
<!-- Status indicator -->
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
@@ -91,7 +91,7 @@
<!-- Buttons -->
<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;">
Save to encrypted vault
Save
</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;">
Clear
@@ -105,8 +105,6 @@
const modal = document.getElementById('service-creds-modal');
let currentService = null;
let credentialReturnUrl = null;
let currentServiceHadCreds = false;
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
const qualityProfileServices = ['sonarr', 'radarr'];
@@ -126,28 +124,8 @@
el.style.display = 'none';
}
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 = {}) {
window.openServiceCredsModal = async function(service) {
currentService = service;
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
currentServiceHadCreds = false;
hideError();
const title = document.getElementById('svc-creds-title');
const desc = document.getElementById('svc-creds-desc');
@@ -156,10 +134,7 @@
const basicSection = document.getElementById('svc-creds-basic');
const qualitySection = document.getElementById('svc-creds-quality');
title.textContent = service.name + ' — Encrypted Vault';
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
? 'Save to vault & open service'
: 'Save to encrypted vault';
title.textContent = service.name + ' Credentials';
// Determine which sections to show
const isExt = !!service.isExternal;
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
@@ -239,7 +214,6 @@
}
if (hasCreds) {
currentServiceHadCreds = true;
dot.style.background = 'var(--ok-fg, #74dfc4)';
status.style.color = 'var(--ok-fg, #74dfc4)';
status.textContent = 'Credentials stored';
@@ -378,35 +352,16 @@
const isArr = arrServices.includes(currentService.id) || arrServices.includes(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)
if (currentService.isExternal) {
const user = document.getElementById('svc-seedhost-user').value.trim();
const pass = document.getElementById('svc-seedhost-pass').value;
if (user) {
const response = await secureFetch('/api/v1/seedhost-creds', {
await secureFetch('/api/v1/seedhost-creds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
});
await requireSuccessfulWrite(response, 'Seedhost credential save');
}
}
@@ -432,18 +387,23 @@
qualityProfileName: qualityProfileName || undefined
})
});
const data = await requireSuccessfulWrite(res, 'ARR credential save');
const data = await res.json();
if (!data.success) {
showError(data.error || 'Failed to save API key');
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
return;
}
if (data.connectionTest && !data.connectionTest.success) {
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
}
} else {
// Non-arr services use the generic endpoint
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey })
});
await requireSuccessfulWrite(response, 'API key save');
}
} else if (isArr && qualityProfileServices.includes(svcId)) {
// API key unchanged but user may have changed quality profile — save profile only
@@ -451,12 +411,11 @@
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
if (qualityProfileId) {
const response = await secureFetch('/api/v1/arr/quality-profiles', {
await secureFetch('/api/v1/arr/quality-profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
});
await requireSuccessfulWrite(response, 'Quality profile save');
}
}
@@ -465,28 +424,20 @@
const user = document.getElementById('svc-basic-user').value.trim();
const pass = document.getElementById('svc-basic-pass').value;
if (user && pass) {
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
await requireSuccessfulWrite(response, 'Service credential save');
}
}
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) {
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
showError('Failed to save: ' + (e.message || 'Unknown error'));
}
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
});
@@ -499,15 +450,12 @@
const svcId = currentService.id || currentService.appTemplate;
const isArr = arrServices.includes(svcId);
if (currentService.isExternal) {
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
await requireSuccessfulWrite(response, 'Seedhost credential removal');
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
}
// Delete from both namespaces
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
await requireSuccessfulWrite(response, 'Service credential removal');
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
if (isArr) {
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
}
const btn = document.getElementById(`creds-btn-${currentService.id}`);
if (btn) btn.classList.remove('has-creds');
@@ -522,13 +470,11 @@
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
modal.classList.remove('show');
currentService = null;
credentialReturnUrl = null;
});
modal?.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('show');
currentService = null;
credentialReturnUrl = null;
}
});
@@ -555,18 +501,4 @@
}
} 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;
};
})();
+2 -12
View File
@@ -90,22 +90,11 @@
errorEl.textContent = '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 {
const res = await secureFetch('/api/v1/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, serviceId })
body: JSON.stringify({ code })
});
const data = await res.json();
@@ -117,6 +106,7 @@
}
hideTotpOverlay();
// Check if redirected here from another service
const redirect = safeSessionGet('totp_redirect');
if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
// .sami is an unregistered TLD, so browsers silently drop the

Some files were not shown because too many files have changed in this diff Show More