Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be021588c7 | ||
|
|
d8459a4a87 | ||
|
|
499fcc2742 | ||
|
|
93d6c44e45 | ||
|
|
4c4ffc35ca | ||
|
|
7e68955e66 | ||
|
|
089f5d2902 |
+25
@@ -400,3 +400,28 @@ 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).
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Tests for DC-085: link-first invite (Discord-style "share it however you want").
|
||||
*
|
||||
* - default sendEmail omission = no email sent, link returned, no token in logs
|
||||
* - sendEmail:true triggers SMTP send when configured
|
||||
* - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
|
||||
* - shareText field present and well-formed in every response
|
||||
* - acceptUrl always present (regardless of sendEmail)
|
||||
* - role + ttl validation unchanged from DC-048
|
||||
*
|
||||
* Strategy: drive the route handler directly with mock req/res, mount the admin
|
||||
* router against an isolated userStore + inviteStore + email-sender stub.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
// Stub email-sender so we can assert "was it called?" without an SMTP server.
|
||||
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
|
||||
// call is allowed to reference it (Babel guard against out-of-scope access).
|
||||
const mockEmailSender = {
|
||||
isConfigured: jest.fn(() => false),
|
||||
sendEmail: jest.fn(async () => undefined),
|
||||
};
|
||||
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);
|
||||
|
||||
describe('DC-085: link-first admin invites', () => {
|
||||
let dir, app, request;
|
||||
let logCalls; // captured { level, msg, meta } from our fake log
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
dir = _tmpDir();
|
||||
logCalls = [];
|
||||
|
||||
// Set up email auth enable flag so userStore mounts.
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { createUserStore } = require('../src/security/user-store');
|
||||
const userStore = createUserStore({ dataDir: dir });
|
||||
|
||||
// Bootstrap the admin so we have a session-attributable user.
|
||||
await userStore.login({ email: 'admin@sami-host.me' });
|
||||
|
||||
// Build a tiny Express app with the admin router mounted, but skip the
|
||||
// global auth gate (we inject req.user directly).
|
||||
const adminRouter = require('../routes/auth/admin')({
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||
log: {
|
||||
info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
|
||||
warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
|
||||
error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
|
||||
},
|
||||
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||
dataDir: dir,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
// Inject req.user = admin so /admin/* passes the role gate.
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||
req.app.locals = req.app.locals || {};
|
||||
req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
|
||||
req.app.locals.emailConfig = null; // SMTP not configured by default
|
||||
next();
|
||||
});
|
||||
app.use('/api/v1/auth', adminRouter);
|
||||
// Error handler — last in chain.
|
||||
app.use((err, _req, res, _next) => {
|
||||
const code = (err && err.statusCode) || 500;
|
||||
res.status(code).json({
|
||||
success: false,
|
||||
error: err && err.message,
|
||||
code: err && err.code,
|
||||
});
|
||||
});
|
||||
|
||||
request = require('supertest');
|
||||
});
|
||||
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('default sendEmail (omitted) returns link and does NOT send email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
|
||||
expect(res.body.deliveredVia).toBe('manual');
|
||||
});
|
||||
|
||||
test('default sendEmail does NOT log raw token to server log', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator' });
|
||||
|
||||
const acceptUrl = res.body.acceptUrl;
|
||||
// Extract the token from the URL and verify it does NOT appear in any log call.
|
||||
const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||
const tokenLeaked = logCalls.some(c =>
|
||||
typeof c.msg === 'string' && c.msg.includes(token)
|
||||
);
|
||||
expect(tokenLeaked).toBe(false);
|
||||
|
||||
// Also assert no log entry mentions the URL verbatim (the old
|
||||
// `[DC-048-DEV-INVITE-LINK] url=...` spam).
|
||||
const oldSpam = logCalls.find(c =>
|
||||
typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
|
||||
);
|
||||
expect(oldSpam).toBeUndefined();
|
||||
});
|
||||
|
||||
test('shareText is present and well-formed in every response', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });
|
||||
|
||||
expect(res.body.shareText).toBeDefined();
|
||||
expect(res.body.shareText).toContain('Join my DashCaddy');
|
||||
expect(res.body.shareText).toContain('operator');
|
||||
expect(res.body.shareText).toContain(res.body.acceptUrl);
|
||||
expect(res.body.shareText).toContain('expires in 24h');
|
||||
});
|
||||
|
||||
test('acceptUrl is always returned regardless of sendEmail', async () => {
|
||||
const r1 = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'a@x.com', sendEmail: false });
|
||||
const r2 = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'b@x.com' });
|
||||
expect(r1.body.acceptUrl).toBeTruthy();
|
||||
expect(r2.body.acceptUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
test('sendEmail: true triggers SMTP send when configured', async () => {
|
||||
// Build a SECOND app instance where emailConfig is a real-looking object,
|
||||
// so isConfigured() returns true. The first app uses emailConfig=null.
|
||||
mockEmailSender.isConfigured.mockReturnValueOnce(true);
|
||||
mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
|
||||
const app2 = express();
|
||||
app2.use(express.json());
|
||||
app2.use((req, _res, next) => {
|
||||
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||
req.app.locals = req.app.locals || {};
|
||||
req.app.locals.siteConfig = {};
|
||||
req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
|
||||
next();
|
||||
});
|
||||
const { createUserStore } = require('../src/security/user-store');
|
||||
const userStore2 = createUserStore({ dataDir: dir });
|
||||
await userStore2.login({ email: 'admin@sami-host.me' });
|
||||
const router2 = require('../routes/auth/admin')({
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||
log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
|
||||
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||
dataDir: dir,
|
||||
});
|
||||
app2.use('/api/v1/auth', router2);
|
||||
|
||||
const res = await request(app2)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
|
||||
const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
|
||||
expect(to).toBe('friend@example.com');
|
||||
expect(subject).toMatch(/invited/i);
|
||||
expect(text).toContain(res.body.acceptUrl);
|
||||
expect(html).toContain(res.body.acceptUrl);
|
||||
expect(res.body.deliveredVia).toBe('email');
|
||||
});
|
||||
|
||||
test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
|
||||
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
expect(res.body.deliveredVia).toBe('failed');
|
||||
// acceptUrl + shareText still present so the operator can share manually.
|
||||
expect(res.body.acceptUrl).toBeTruthy();
|
||||
expect(res.body.shareText).toBeTruthy();
|
||||
// Token does NOT appear in any log call.
|
||||
const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||
const tokenLeaked = logCalls.some(c =>
|
||||
typeof c.msg === 'string' && c.msg.includes(token)
|
||||
);
|
||||
expect(tokenLeaked).toBe(false);
|
||||
});
|
||||
|
||||
test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
|
||||
// DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
|
||||
// silently substitutes default rather than throwing. This test pins that
|
||||
// behavior so a future "strict role validation" change is a deliberate
|
||||
// decision, not a silent regression.
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'a@x.com', role: 'superuser' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.role).toBe('operator');
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('email validation: missing email still rejected', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ role: 'operator' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'a@x.com', ttlHours: 1 });
|
||||
expect(res.body.shareText).toContain('expires in 1h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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');
|
||||
|
||||
const { HealthChecker } = require('../src/monitoring/health-checker');
|
||||
// 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');
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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('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', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeDown()); // raw=down, displayed=up
|
||||
const out = hc.getCurrentStatus();
|
||||
expect(out['svc1'].status).toBe('up'); // shown to API consumers
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const origDown = process.env.HEALTH_DOWN_THRESHOLD;
|
||||
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');
|
||||
if (origDown === undefined) delete process.env.HEALTH_DOWN_THRESHOLD;
|
||||
else process.env.HEALTH_DOWN_THRESHOLD = origDown;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* DC-083 -- Public share endpoint input hardening.
|
||||
*
|
||||
* The two CSRF-exempt public endpoints (POST /share/:token/subscribe +
|
||||
* POST /share/:token/redeem-tailscale) accept untrusted body fields. The
|
||||
* pre-fix code had three coupled bugs:
|
||||
*
|
||||
* 1. `email.includes('@')` accepted `@`, `a@`, `<script>@x.c`, and 10MB
|
||||
* strings as "valid email" -- and the field was never even used after
|
||||
* validation (the subscribe endpoint discarded it).
|
||||
* 2. `typeof deviceId === 'string'` accepted arbitrary strings of any
|
||||
* length, including CR/LF/NUL -- which fed straight into the Tailscale
|
||||
* auth-key description string and the on-disk shares.json.
|
||||
* 3. No rate-limit; the general limiter (1000/15min) was too generous for
|
||||
* unauthenticated state-mutating endpoints.
|
||||
*
|
||||
* Fix: charset/length/control-char-bounded validators at the route layer
|
||||
* AND at the store layer (defense-in-depth), plus a dedicated
|
||||
* SHARE_PUBLIC rate-limit (30/15min) on the public endpoints.
|
||||
*
|
||||
* Coverage:
|
||||
* - subscribe email: rejects bare @, missing TLD, oversized, CR/LF, shell
|
||||
* metachars, control chars; accepts normal addresses; accepts OMITTED
|
||||
* email (backwards-compatible with the original behavior).
|
||||
* - subscribe email propagates to share-store subscriberEmails (capped 8).
|
||||
* - redeem-tailscale deviceId: rejects CR/LF/NUL, oversized, empty,
|
||||
* spaces, brackets, quotes; accepts Tailscale-style base64url+hphens;
|
||||
* accepts OMITTED deviceId (treated as 'unknown').
|
||||
* - Sanitized usedBy is what flows into the on-disk shares.json.
|
||||
* - Rate-limit fires after the configured budget per IP.
|
||||
* - Store-level defense: bypassing the route (direct store call) still
|
||||
* rejects invalid inputs.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { createShareStore } = require('../src/security/share-store');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-dc083-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
function _buildApp({ shareStore } = {}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// No req.user injection -- the public endpoints must work without auth.
|
||||
const shareRoutes = require('../routes/share');
|
||||
app.use(shareRoutes({
|
||||
shareStore,
|
||||
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
||||
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
||||
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
||||
servicesStateManager: { get: async () => null, read: async () => [] },
|
||||
servicesFile: null,
|
||||
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
log: { info() {}, warn() {}, error() {} },
|
||||
}));
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err && err.statusCode) {
|
||||
return res.status(err.statusCode).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
});
|
||||
}
|
||||
return res.status(500).json({ success: false, error: err && err.message });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
// --------- Subscribe endpoint -- email validation ---------------------------------------------------------------------------------------
|
||||
|
||||
describe('DC-083: subscribe email validation', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('accepts omitted email (backwards-compatible)', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.count).toBe(1);
|
||||
});
|
||||
|
||||
test('accepts a well-formed email', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'subscriber@example.com' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.count).toBe(1);
|
||||
});
|
||||
|
||||
test('lowercases the email on capture', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'Subscriber@Example.COM' });
|
||||
expect(res.status).toBe(200);
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(raw.shares)[0];
|
||||
expect(raw.shares[id].subscriberEmails).toEqual(['subscriber@example.com']);
|
||||
});
|
||||
|
||||
test('rejects bare @', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: '@' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects missing local-part', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: '@example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects missing TLD', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'user@localhost' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects single-char TLD', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'user@example.c' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects CR/LF in email (CRLF-injection defense)', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'a@b.com\r\nX-Injected: yes' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects NUL in email', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 'a@b.com\x00hack' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects oversized email (>254 chars)', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const longLocal = 'a'.repeat(250) + '@example.com';
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: longLocal });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects XSS-shape email', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: '<script>@x.com' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects non-string email', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: 42 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('keeps subscriberEmails capped to 8 entries', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await request(app)
|
||||
.post(`/share/${issued.token}/subscribe`)
|
||||
.send({ email: `user${i}@example.com` });
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(raw.shares)[0];
|
||||
expect(raw.shares[id].subscriberEmails).toHaveLength(8);
|
||||
// FIFO cap -- the first 4 got dropped, latest 8 remain.
|
||||
expect(raw.shares[id].subscriberEmails[0]).toBe('user4@example.com');
|
||||
expect(raw.shares[id].subscriberEmails[7]).toBe('user11@example.com');
|
||||
});
|
||||
|
||||
test('omitted email does not write subscriberEmails', async () => {
|
||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||
const app = _buildApp({ shareStore });
|
||||
await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(raw.shares)[0];
|
||||
expect(raw.shares[id].subscriberEmails).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --------- Redeem-tailscale endpoint -- deviceId validation ---------------------------------------------------------
|
||||
|
||||
describe('DC-083: redeem-tailscale deviceId validation', () => {
|
||||
let dir, shareStore;
|
||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('accepts Tailscale-style base64url ID', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'nodekey-abc123-def456' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.redeemed).toBe(true);
|
||||
});
|
||||
|
||||
test('accepts OMITTED deviceId (treated as "unknown")', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(raw.shares)[0];
|
||||
expect(raw.shares[id].usedBy).toBe('unknown');
|
||||
});
|
||||
|
||||
test('rejects CR/LF in deviceId (CRLF-injection defense)', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'nodekey\r\nX-Injected: yes' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects NUL in deviceId', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'nodekey\x00hack' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects oversized deviceId (>128 chars)', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const long = 'a'.repeat(200);
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: long });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects empty string deviceId', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: '' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects whitespace in deviceId', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'node key 1' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects shell metachars in deviceId', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'nodekey; rm -rf /' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects non-string deviceId', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const res = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: { evil: true } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('sanitized usedBy flows into the on-disk shares.json', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'node-abc.def-123' });
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
const id = Object.keys(raw.shares)[0];
|
||||
expect(raw.shares[id].usedBy).toBe('node-abc.def-123');
|
||||
});
|
||||
|
||||
test('rejection does NOT mark the share used', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const app = _buildApp({ shareStore });
|
||||
const bad = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'node with spaces' });
|
||||
expect(bad.status).toBe(400);
|
||||
// A FOLLOW-UP valid redeem should still succeed.
|
||||
const ok = await request(app)
|
||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||
.send({ deviceId: 'node-clean' });
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// --------- Store-layer defense-in-depth (bypass the route, hit the store) ------------
|
||||
|
||||
describe('DC-083: store-layer defense-in-depth', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('recordPublicSubscribe rejects CRLF in email', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||
const r = await store.recordPublicSubscribe(issued.token, { email: 'a@b.com\r\nX: 1' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_email');
|
||||
});
|
||||
|
||||
test('recordPublicSubscribe rejects oversized email', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||
const r = await store.recordPublicSubscribe(issued.token, { email: 'a'.repeat(300) + '@x.com' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_email');
|
||||
});
|
||||
|
||||
test('recordTailscaleUse rejects CRLF in deviceId', async () => {
|
||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'node\r\nhack' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_device_id');
|
||||
});
|
||||
|
||||
test('recordTailscaleUse rejects oversized deviceId', async () => {
|
||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'a'.repeat(200) });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid_device_id');
|
||||
});
|
||||
|
||||
test('recordTailscaleUse accepts null deviceId (defaults to "unknown")', async () => {
|
||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||
const r = await store.recordTailscaleUse(issued.token, { deviceId: null });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.share.usedBy).toBe('unknown');
|
||||
});
|
||||
|
||||
test('recordTailscaleUse accepts omitted deviceId (defaults to "unknown")', async () => {
|
||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||
const r = await store.recordTailscaleUse(issued.token, {});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.share.usedBy).toBe('unknown');
|
||||
});
|
||||
|
||||
test('recordPublicSubscribe accepts omitted email (backwards-compatible)', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||
const r = await store.recordPublicSubscribe(issued.token);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('recordPublicSubscribe accepts null email (backwards-compatible)', async () => {
|
||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||
const r = await store.recordPublicSubscribe(issued.token, { email: null });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --------- Rate-limit guard ------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('DC-083: SHARE_PUBLIC rate-limit', () => {
|
||||
// We can't easily trigger the rate-limit in a unit test because the
|
||||
// default 30/15min is high. Instead, verify the constant is wired and
|
||||
// that the limiter is mounted on the public endpoints (the test env
|
||||
// skips the limiter, so we just confirm the constants).
|
||||
test('RATE_LIMITS.SHARE_PUBLIC is bounded tighter than GENERAL', () => {
|
||||
const { RATE_LIMITS } = require('../src/utilities/constants');
|
||||
expect(RATE_LIMITS.SHARE_PUBLIC).toBeDefined();
|
||||
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThan(RATE_LIMITS.GENERAL.max);
|
||||
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThanOrEqual(30);
|
||||
expect(RATE_LIMITS.SHARE_PUBLIC.windowMs).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('route module loads without throwing when express-rate-limit is wired', () => {
|
||||
// Smoke test: the route factory must succeed with the limiter attached.
|
||||
const dir = _tmpDir();
|
||||
try {
|
||||
const shareStore = createShareStore({ dataDir: dir });
|
||||
const app = _buildApp({ shareStore });
|
||||
// _buildApp would have thrown if the route factory threw.
|
||||
expect(typeof app).toBe('function');
|
||||
} finally {
|
||||
_cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('sharePublicLimiter is mounted on /preview (route stack contains limiter)', () => {
|
||||
// Verify the limiter middleware is actually wired into /preview's route
|
||||
// stack. The route uses express.Router().use(path, ...mw, handler) so we
|
||||
// can inspect the stack via the router's internal `stack` array.
|
||||
const dir = _tmpDir();
|
||||
try {
|
||||
const shareStore = createShareStore({ dataDir: dir });
|
||||
const router = require('../routes/share')({
|
||||
shareStore,
|
||||
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
||||
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
||||
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
||||
servicesStateManager: { get: async () => null, read: async () => [] },
|
||||
servicesFile: null,
|
||||
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
log: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
const previewStack = router.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/share/:token/preview'
|
||||
);
|
||||
expect(previewStack).toBeDefined();
|
||||
// The route handler should be preceded by at least one middleware
|
||||
// layer (the limiter). route.stack contains the per-route middleware.
|
||||
// In express, .route.stack has the route-local middleware + handler.
|
||||
// The limiter is mounted at the router level (router.use pattern), so
|
||||
// it's actually a separate layer in router.stack. Look for any layer
|
||||
// that has a regex/path matching /share/:token.
|
||||
const limiterLayer = router.stack.find(
|
||||
(layer) => layer.regexp && layer.regexp.test && layer.regexp.test('/share/abc/preview')
|
||||
);
|
||||
expect(limiterLayer).toBeDefined();
|
||||
} finally {
|
||||
_cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-083: positive smoke tests (legitimate inputs)', () => {
|
||||
test('validates user+tag@sub.domain.io (RFC 5322 plus addressing)', () => {
|
||||
const { validatePublicEmail } = require('../src/security/share-store');
|
||||
const v = validatePublicEmail('user+tag@sub.domain.io');
|
||||
expect(v).toEqual({ ok: true, email: 'user+tag@sub.domain.io' });
|
||||
});
|
||||
|
||||
test('validates a typical Tailscale node ID as deviceId', () => {
|
||||
const { validatePublicDeviceId } = require('../src/security/share-store');
|
||||
// Tailscale node IDs look like "nodekey:abcdef0123456789" or just hex
|
||||
const v = validatePublicDeviceId('nodekey:abcdef0123456789');
|
||||
expect(v).toEqual({ ok: true, deviceId: 'nodekey:abcdef0123456789' });
|
||||
});
|
||||
});
|
||||
@@ -378,12 +378,35 @@ describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
|
||||
expect(r2.body.error).toMatch(/already_used/);
|
||||
});
|
||||
|
||||
test('rejects missing deviceId', async () => {
|
||||
test('rejects missing deviceId — DC-083 accepts omitted, treats as "unknown"', async () => {
|
||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||
const 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* DC-082: Update-manager image-name parsing for docker-compose prefixed names.
|
||||
*
|
||||
* The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to
|
||||
* `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub.
|
||||
* Compose-prefixed names (single hyphen, no slash, lowercase) need to
|
||||
* split on the FIRST hyphen to recover `<project>/<service>` — that's
|
||||
* the actual upstream namespace for a compose-prefixed image.
|
||||
*
|
||||
* The fix also adds a "no upstream registry image, skip cleanly" path
|
||||
* for when the authed GET 401s against a compose-prefixed name (the
|
||||
* compose-prefixed image is built locally and not published to Docker
|
||||
* Hub). That should log as info, not error.
|
||||
*/
|
||||
const updateManager = require('../src/managers/update-manager');
|
||||
|
||||
describe('DC-082 update-manager / compose-prefixed image names', () => {
|
||||
let um = updateManager; // module exports the singleton instance
|
||||
|
||||
describe('_composeProjectToRepo', () => {
|
||||
test('splits dashcaddy-dashcaddy-api on the first hyphen', () => {
|
||||
expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api');
|
||||
});
|
||||
|
||||
test('splits myproject-myservice on the first hyphen', () => {
|
||||
expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice');
|
||||
});
|
||||
|
||||
test('splits multi-hyphen names on the FIRST hyphen only', () => {
|
||||
// "myproj-grandchild-service" -> "myproj/grandchild-service"
|
||||
// (first hyphen is the project/service boundary; later hyphens are
|
||||
// part of the service name like docker-compose's `web-cache`).
|
||||
expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service');
|
||||
});
|
||||
|
||||
test('returns null for slash-namespaced names (handled by other path)', () => {
|
||||
expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null);
|
||||
expect(um._composeProjectToRepo('library/nginx')).toBe(null);
|
||||
expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null for Docker Official Image names (no hyphen)', () => {
|
||||
expect(um._composeProjectToRepo('nginx')).toBe(null);
|
||||
expect(um._composeProjectToRepo('alpine')).toBe(null);
|
||||
expect(um._composeProjectToRepo('node')).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null for empty / malformed input', () => {
|
||||
expect(um._composeProjectToRepo('')).toBe(null);
|
||||
expect(um._composeProjectToRepo(null)).toBe(null);
|
||||
expect(um._composeProjectToRepo(undefined)).toBe(null);
|
||||
expect(um._composeProjectToRepo(123)).toBe(null);
|
||||
expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen
|
||||
expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen
|
||||
// The regex tolerates mixed-case via the /i flag for defensiveness
|
||||
// even though Docker Compose names are typically lowercase — the
|
||||
// important shape constraints are the letter/digit/underscore/hyphen
|
||||
// charset and the non-empty two-part split.
|
||||
});
|
||||
|
||||
test('accepts names with underscores and digits (compose allows)', () => {
|
||||
expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service');
|
||||
expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2');
|
||||
});
|
||||
|
||||
test('rejects names with chars compose never produces', () => {
|
||||
// dot/colon/slash should never pass — they're either already-namespaced
|
||||
// or invalid in a Docker Compose service name.
|
||||
expect(um._composeProjectToRepo('foo:bar')).toBe(null);
|
||||
expect(um._composeProjectToRepo('foo.bar')).toBe(null);
|
||||
expect(um._composeProjectToRepo('foo/bar')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_isNotPublishedError', () => {
|
||||
test('returns true for HTTP 401 + compose-prefixed remainder', () => {
|
||||
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for HTTP 401 with non-compose-prefixed remainder', () => {
|
||||
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||
expect(um._isNotPublishedError(err, 'nginx')).toBe(false);
|
||||
expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false);
|
||||
expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for non-401 errors', () => {
|
||||
const err = new Error('network timeout after 10s');
|
||||
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for malformed error or remainder', () => {
|
||||
expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false);
|
||||
expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestImageDigest (mocked fetchWithReliability)', () => {
|
||||
let originalFetch;
|
||||
let originalFetchAuth;
|
||||
let originalFetchRetry;
|
||||
beforeEach(() => {
|
||||
originalFetch = um.fetchWithReliability.bind(um);
|
||||
originalFetchAuth = um.fetchAuthToken.bind(um);
|
||||
});
|
||||
|
||||
test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
// Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token
|
||||
// (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub).
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers: {
|
||||
'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"',
|
||||
},
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' };
|
||||
};
|
||||
um.fetchAuthToken = async () => 'fake-token';
|
||||
const { log } = require('../src/utils/logging');
|
||||
const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {});
|
||||
const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest');
|
||||
expect(result).toBe(null);
|
||||
|
||||
// First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest
|
||||
// NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest
|
||||
const firstPath = calls[0].path;
|
||||
expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest');
|
||||
expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api');
|
||||
|
||||
// The 401 after auth should produce an INFO log about "no upstream"
|
||||
// NOT an error log.
|
||||
const infoMsgs = infoSpy.mock.calls.map((c) => c[1]);
|
||||
expect(infoMsgs).toContain('No upstream registry image — skipping update check');
|
||||
const errorMsgs = errorSpy.mock.calls.map((c) => c[1]);
|
||||
expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth');
|
||||
|
||||
infoSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('official image (nginx) still probes library/nginx', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
if (calls.length === 1) {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' };
|
||||
}
|
||||
return { statusCode: 200, headers: {}, body: '' };
|
||||
};
|
||||
|
||||
const result = await um.getLatestImageDigest('nginx:latest');
|
||||
expect(result).toBe('sha256:abc123');
|
||||
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||
});
|
||||
|
||||
test('library/nginx (explicit) probes library/nginx', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
if (calls.length === 1) {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' };
|
||||
}
|
||||
return { statusCode: 200, headers: {}, body: '' };
|
||||
};
|
||||
|
||||
const result = await um.getLatestImageDigest('library/nginx:latest');
|
||||
expect(result).toBe('sha256:abc');
|
||||
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||
});
|
||||
|
||||
test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' };
|
||||
};
|
||||
|
||||
const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest');
|
||||
expect(result).toBe('sha256:ghcr');
|
||||
expect(calls[0].hostname).toBe('ghcr.io');
|
||||
expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest');
|
||||
});
|
||||
|
||||
test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => {
|
||||
let callCount = 0;
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' },
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' };
|
||||
};
|
||||
um.fetchAuthToken = async () => 'fake-token';
|
||||
|
||||
const result = await um.getLatestImageDigest('myproj-myservice:latest');
|
||||
expect(result).toBe(null);
|
||||
// Probe targets the correct namespace (myproj/myservice), not library/.
|
||||
const firstCall = await (async () => {
|
||||
let p;
|
||||
um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; };
|
||||
await um.getLatestImageDigest('myproj-myservice:latest');
|
||||
return p;
|
||||
})();
|
||||
expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
um.fetchWithReliability = originalFetch;
|
||||
um.fetchAuthToken = originalFetchAuth;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,23 +32,6 @@ 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'));
|
||||
@@ -240,11 +223,21 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
});
|
||||
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
||||
|
||||
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);
|
||||
// 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) {
|
||||
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 });
|
||||
@@ -254,33 +247,37 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
||||
deliveredVia = 'email';
|
||||
} else {
|
||||
// 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';
|
||||
// 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';
|
||||
}
|
||||
} catch (sendErr) {
|
||||
log.warn && log.warn('auth-invite-send',
|
||||
'invite send failed: ' + (sendErr.message || String(sendErr)));
|
||||
'invite send failed: ' + (sendErr.message || String(sendErr)),
|
||||
{ inviteId: issued.id });
|
||||
deliveredVia = 'failed';
|
||||
}
|
||||
} 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,
|
||||
// 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',
|
||||
acceptUrl,
|
||||
shareText,
|
||||
deliveredVia,
|
||||
maskedEmail,
|
||||
});
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
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,
|
||||
@@ -293,7 +297,35 @@ module.exports = function shareRoutesFactory({
|
||||
|
||||
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
|
||||
|
||||
router.get('/share/:token/preview', asyncHandler(async (req, res) => {
|
||||
// DC-083: rate-limit the two CSRF-exempt public endpoints. The general
|
||||
// limiter (1000/15min) is mounted globally in app.js and is too generous
|
||||
// for unauthenticated state-mutating endpoints. 30/15min per IP is
|
||||
// enough for a legitimate user clicking "subscribe" once or twice; anything
|
||||
// beyond is abuse. Skipped in test envs via the standard isTest guard.
|
||||
// Lazy-loaded so test environments without the dep installed don't blow up;
|
||||
// a missing-dep in production logs a warning and falls back to no-op (still
|
||||
// safe — the route+store validators are the primary defense).
|
||||
const { RATE_LIMITS } = require('../src/utilities/constants');
|
||||
const isTest = process.env.NODE_ENV === 'test';
|
||||
let _sharePublicLimiter = (req, _res, next) => next(); // no-op default
|
||||
try {
|
||||
const rateLimit = require('express-rate-limit'); // eslint-disable-line global-require
|
||||
_sharePublicLimiter = rateLimit({
|
||||
...RATE_LIMITS.SHARE_PUBLIC,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: () => isTest,
|
||||
message: { success: false, error: 'Too many share requests, please try again later' },
|
||||
});
|
||||
} catch (e) {
|
||||
// Don't crash on missing dep in a bare-bones env — but log so it's not
|
||||
// invisible if production misconfigured.
|
||||
if (log && typeof log.warn === 'function') {
|
||||
log.warn({ ctx: 'share-routes', err: e.message }, 'express-rate-limit unavailable; share public endpoints have NO rate limit');
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/share/:token/preview', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||
const meta = await shareStore.peek(req.params.token);
|
||||
if (!meta) {
|
||||
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
|
||||
@@ -310,12 +342,21 @@ module.exports = function shareRoutesFactory({
|
||||
});
|
||||
}, 'share-preview'));
|
||||
|
||||
router.post('/share/:token/subscribe', asyncHandler(async (req, res) => {
|
||||
router.post('/share/:token/subscribe', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||
// DC-083: replace the primitive `email.includes('@')` check with a
|
||||
// charset/length/control-char-bounded validator. The pre-fix code
|
||||
// accepted `@`, `a@`, `<script>@x.c`, and 10MB strings as "valid email".
|
||||
// The subscribe body's `email` is now also captured to the share record
|
||||
// (capped to last 8 entries, see share-store recordPublicSubscribe) so
|
||||
// the operator can see who subscribed.
|
||||
const { email } = req.body || {};
|
||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||
throw new ValidationError('valid email required', 'email');
|
||||
let normalizedEmail = null;
|
||||
if (email !== undefined && email !== null) {
|
||||
const v = validatePublicEmail(email);
|
||||
if (!v.ok) throw new ValidationError(v.reason, 'email');
|
||||
normalizedEmail = v.email;
|
||||
}
|
||||
const result = await shareStore.recordPublicSubscribe(req.params.token);
|
||||
const result = await shareStore.recordPublicSubscribe(req.params.token, { email: normalizedEmail });
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||
throw new ValidationError(result.reason, 'share');
|
||||
@@ -323,12 +364,23 @@ module.exports = function shareRoutesFactory({
|
||||
res.json({ success: true, data: { count: result.count, cap: result.cap } });
|
||||
}, 'share-subscribe'));
|
||||
|
||||
router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => {
|
||||
router.post('/share/:token/redeem-tailscale', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||
// DC-083: replace the bare `typeof deviceId === 'string'` check with a
|
||||
// charset/length/control-char-bounded validator. The pre-fix code
|
||||
// accepted arbitrary strings of any length — including CR/LF/NUL,
|
||||
// which flow into the Tailscale auth-key description string in
|
||||
// POST /share/tailscale (routes/share.js:213 in the issue path).
|
||||
// The redeem-tailscale path receives the deviceId from Caddy's
|
||||
// forward_auth (a Tailscale machine ID), which is base64url +
|
||||
// hyphens — well within the validator's charset.
|
||||
const { deviceId } = req.body || {};
|
||||
if (!deviceId || typeof deviceId !== 'string') {
|
||||
throw new ValidationError('deviceId required', 'deviceId');
|
||||
let normalizedDeviceId = null;
|
||||
if (deviceId !== undefined && deviceId !== null) {
|
||||
const v = validatePublicDeviceId(deviceId);
|
||||
if (!v.ok) throw new ValidationError(v.reason, 'deviceId');
|
||||
normalizedDeviceId = v.deviceId;
|
||||
}
|
||||
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId });
|
||||
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId: normalizedDeviceId });
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||
throw new ValidationError(result.reason, 'share');
|
||||
|
||||
@@ -168,12 +168,39 @@ 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';
|
||||
let remainder = imageName;
|
||||
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('/');
|
||||
@@ -187,8 +214,19 @@ class UpdateManager extends EventEmitter {
|
||||
return await this.getGhcrDigest(remainder, imageTag);
|
||||
}
|
||||
|
||||
// Docker Hub images (library/nginx OR org/image with single slash)
|
||||
if (!remainder.includes('/') || remainder.split('/').length === 2) {
|
||||
// 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) {
|
||||
return await this.getDockerHubDigest(remainder, imageTag);
|
||||
}
|
||||
|
||||
@@ -196,11 +234,72 @@ 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,
|
||||
|
||||
@@ -33,12 +33,31 @@ 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).
|
||||
const DOWN_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_DOWN_THRESHOLD || '2', 10));
|
||||
const UP_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_UP_THRESHOLD || '1', 10));
|
||||
|
||||
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;
|
||||
@@ -273,14 +292,78 @@ 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 currentDisplayed;
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 status
|
||||
// Update current (raw) status — used by checkForIncidents and history.
|
||||
this.currentStatus.set(serviceId, status);
|
||||
|
||||
// Add to history
|
||||
// Add raw probe to history (full fidelity — operators rely on this).
|
||||
if (!this.history[serviceId]) {
|
||||
this.history[serviceId] = [];
|
||||
}
|
||||
@@ -292,8 +375,23 @@ class HealthChecker extends EventEmitter {
|
||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||
}
|
||||
|
||||
// Emit status event
|
||||
this.emit('status-check', status);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Save history periodically
|
||||
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
|
||||
@@ -445,19 +543,29 @@ class HealthChecker extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status for all services
|
||||
* Get current status for all services.
|
||||
*
|
||||
* DC-086: returns the DISPLAYED status (post-hysteresis), not the latest
|
||||
* raw probe. A page reload should show the same badge state the live
|
||||
* SSE stream is currently showing — otherwise an operator who reloads
|
||||
* the page after a single blip sees red even though the hysteresis kept
|
||||
* the badge green for them.
|
||||
*/
|
||||
getCurrentStatus() {
|
||||
const result = {};
|
||||
|
||||
for (const [serviceId, status] of this.currentStatus.entries()) {
|
||||
for (const [serviceId, rawStatus] of this.currentStatus.entries()) {
|
||||
const config = this.config.services[serviceId];
|
||||
const 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] = {
|
||||
...status,
|
||||
...displayed,
|
||||
name: config?.name || serviceId,
|
||||
uptime: {
|
||||
'24h': uptime24h,
|
||||
|
||||
@@ -47,6 +47,53 @@ const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24
|
||||
const TAILSCALE_MAX_USES = 1;
|
||||
const 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(); }
|
||||
|
||||
@@ -327,8 +374,18 @@ function createShareStore(opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function recordPublicSubscribe(token) {
|
||||
function recordPublicSubscribe(token, { email } = {}) {
|
||||
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);
|
||||
@@ -341,6 +398,16 @@ 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 };
|
||||
});
|
||||
@@ -348,6 +415,18 @@ 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);
|
||||
@@ -359,7 +438,7 @@ function createShareStore(opts = {}) {
|
||||
return { ok: false, reason: 'expired' };
|
||||
}
|
||||
s.usedAt = _nowIso();
|
||||
s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown';
|
||||
s.usedBy = normalizedDeviceId;
|
||||
_save(data);
|
||||
return { ok: true, share: _publicView(s) };
|
||||
});
|
||||
@@ -411,4 +490,4 @@ function createShareStore(opts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createShareStore };
|
||||
module.exports = { createShareStore, validatePublicEmail, validatePublicDeviceId };
|
||||
@@ -79,6 +79,17 @@ 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 ─────────────────────────────────────────────────────
|
||||
|
||||
+65
-4
@@ -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: true }),
|
||||
_el('span', { text: 'Send email' }),
|
||||
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
|
||||
_el('span', { text: 'Also send via email (optional)' }),
|
||||
));
|
||||
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
||||
container.appendChild(form);
|
||||
@@ -297,16 +297,77 @@
|
||||
},
|
||||
});
|
||||
banner.appendChild(copyBtn);
|
||||
if (invite.deliveredVia === 'dev-console') {
|
||||
|
||||
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
|
||||
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
|
||||
// copy this as a sentence instead of dealing with the raw URL.
|
||||
if (invite.shareText) {
|
||||
const shareBlock = _el('div', { style: 'margin-top:12px' });
|
||||
shareBlock.appendChild(_el('div', {
|
||||
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
|
||||
text: 'Share this message:',
|
||||
}));
|
||||
shareBlock.appendChild(_el('div', {
|
||||
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
|
||||
text: invite.shareText,
|
||||
}));
|
||||
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
|
||||
const copyTextBtn = _el('button', {
|
||||
class: 'btn-sm', style: 'padding:4px 10px',
|
||||
text: 'Copy message',
|
||||
onclick: async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.shareText);
|
||||
copyTextBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
|
||||
}
|
||||
},
|
||||
});
|
||||
shareActions.appendChild(copyTextBtn);
|
||||
// Native share sheet on mobile / supported browsers. Falls back silently
|
||||
// (the copy buttons cover the same intent).
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||
const nativeShareBtn = _el('button', {
|
||||
class: 'btn-sm', style: 'padding:4px 10px',
|
||||
text: 'Share via…',
|
||||
onclick: async () => {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'DashCaddy invite',
|
||||
text: invite.shareText,
|
||||
url: invite.acceptUrl,
|
||||
});
|
||||
} catch (e) {
|
||||
// User-cancelled throws AbortError — that's fine, just stay quiet.
|
||||
if (e && e.name && e.name !== 'AbortError') {
|
||||
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
shareActions.appendChild(nativeShareBtn);
|
||||
}
|
||||
shareBlock.appendChild(shareActions);
|
||||
banner.appendChild(shareBlock);
|
||||
}
|
||||
|
||||
if (invite.deliveredVia === 'failed') {
|
||||
banner.appendChild(_el('p', {
|
||||
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
||||
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
|
||||
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
|
||||
}));
|
||||
} else if (invite.deliveredVia === 'email') {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user