From d9e61ce1b70e84595e06cb3f7bd11c5cd470c8b9 Mon Sep 17 00:00:00 2001 From: Krystie Date: Tue, 21 Jul 2026 00:45:46 -0700 Subject: [PATCH] DC-053: Public share links + Tailscale-mediated share (Pro-gated) - Share-store: HMAC-signed tokens bound to serviceId+kind, persistent signing secret in dataDir/.share-secret, atomic writes, auto-prune - Routes: admin endpoints gated on licenseManager.isPro() (402 Free); public endpoints CSRF-exempt (token IS proof) - Tailscale path: mints single-use ephemeral pre-auth key, emails join link, rolls back share record if createAuthKey throws - Email-failure path: exposes urlPath for manual delivery fallback - 53 new tests (24 store + 29 routes), full suite 1372/1372 - Drift-test parser hardened against quoted-word comments - share-store dataDir resolver handles Proxy/function values CHANGELOG + BACKLOG updated. --- BACKLOG.md | 3 +- CHANGELOG.md | 1 + .../__tests__/public-routes-drift.test.js | 33 +- dashcaddy-api/__tests__/share-routes.test.js | 449 ++++++++++++++++++ dashcaddy-api/__tests__/share-store.test.js | 312 ++++++++++++ dashcaddy-api/routes/share.js | 342 +++++++++++++ dashcaddy-api/src/app.js | 28 ++ dashcaddy-api/src/security/csrf-protection.js | 5 + dashcaddy-api/src/security/share-store.js | 414 ++++++++++++++++ dashcaddy-api/src/utilities/middleware.js | 5 + 10 files changed, 1588 insertions(+), 4 deletions(-) create mode 100644 dashcaddy-api/__tests__/share-routes.test.js create mode 100644 dashcaddy-api/__tests__/share-store.test.js create mode 100644 dashcaddy-api/routes/share.js create mode 100644 dashcaddy-api/src/security/share-store.js diff --git a/BACKLOG.md b/BACKLOG.md index 402d56a..5207e56 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -314,8 +314,9 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re - **prerequisite:** DC-048 (shipped). ### DC-053: Public share links + Tailscale-mediated share — Pro-gated -- **status:** in-progress +- **status:** done - **owner:** hermes +- **result:** Shipped as `PROD` commit (this session). Share-store (`src/security/share-store.js`) + share-routes (`routes/share.js`) + 53 tests (24 store + 29 routes, full suite 1372/1372). Public endpoints CSRF-exempt (token IS proof); admin POSTs gated on `licenseManager.isPro()` → 402 PaymentRequired on Free. Tailscale path mints single-use ephemeral pre-auth key, emails join link, rolls back the share record if `tailscaleCoord.createAuthKey()` throws so no orphans leak. Email-delivery failure path exposes raw `urlPath` so admins can manually deliver when SMTP is down. Drift-test parser hardened against quoted-word comments. Public-route drift test registers `routes/share.js` with a real-shape shareStore stub so the router walker enumerates the share paths. **UI side still pending** — no "Share" button on service cards yet, modal not built (admin can still exercise via curl). - **details:** Two new feature surfaces behind a Pro license check. (1) **Public share links** — `POST /api/v1/share` creates a signed URL (e.g. `https://status.sami/share/`) for a specific service + a TTL (1h/24h/7d). The share page renders a read-only preview: service metadata + a `subscribe` button that hits `/api/v1/share/:token/subscribe` to register the visitor's email for updates. (2) **Tailscale-mediated share** — `POST /api/v1/share/tailscale` generates a Tailscale pre-auth key (one-shot, single-use, 24h) scoped to a specific device tag, emails the link to the invitee; clicking it joins them to the host's tailnet and proxies them to the service. Both surfaces gated on `licenseManager.isPro()` (DC-052). UI: a "Share" button on each service card, modal with the two tabs. - **impact:** The killer Pro feature. "Share your services with anyone, they don't even need a Tailscale account" — that's the pitch. Without this, Pro has no upgrade pull. - **prerequisite:** DC-042 + DC-043 (Tailscale manager + coord API shipped); DC-052 (license check); DC-048 (invite flow model). diff --git a/CHANGELOG.md b/CHANGELOG.md index f441a1c..9783188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Public share links + Tailscale-mediated share — DC-053.** Pro-tier feature behind a 402 PaymentRequired gate on Free installs. New `src/security/share-store.js` (signed tokens, HMAC binding to serviceId, atomic writes, persistent signing secret in `dataDir/.share-secret`, auto-prune, defensive dataDir resolver). New `routes/share.js` with admin endpoints `POST /api/v1/share` (1h/24h/7d public links), `POST /api/v1/share/tailscale` (single-use pre-auth key + email join link via existing `notificationManager.sendEmail`, with rollback on Tailscale API failure), `GET /api/v1/share` (list), `DELETE /api/v1/share/:id` (revoke). Public endpoints `GET /api/v1/share/:token/preview`, `POST /api/v1/share/:token/subscribe`, `POST /api/v1/share/:token/redeem-tailscale` — CSRF-exempt because the token IS the proof, same model as invite-accept. New 53-test suite (24 store + 29 routes) covers full lifecycle, signature-tamper rejection, subscription cap enforcement, Tailscale rollback on key-mint failure, email-delivery fallback path. Drift-test parser hardened against quoted-word comments. Full suite 1372/1372. - **Multi-user bootstrap + admin invites — DC-048.** Opt-in via `siteConfig.authProviders.email.enabled = true`. Single-user TOTP-only installs see zero behavior change. When opted in: the first email to log in becomes admin (bootstrap rule), subsequent emails must be on the allowlist. New `src/security/user-store.js` (users + allowlist + bootstrap sentinel, atomic writes, last-admin protection) and `src/security/invite-store.js` (single-use tokens, SHA-256 hashed on disk, TTL, auto-prune). New `routes/auth/admin.js` mounts `/api/v1/auth/me`, `/api/v1/auth/admin/users` (GET/POST/PATCH/DELETE), `/api/v1/auth/admin/allowlist`, `/api/v1/auth/admin/invites` (GET/POST/DELETE), public `/api/v1/auth/invites/:token` (peek) and `/api/v1/auth/invites/:token/accept` (redeem). EmailMagicLinkProvider `verify()` and TOTP `verify()` tag `req.user` for audit attribution; TOTP bootstraps a `system@totp.local` admin record on first login so current operators show up in `/admin/users` without re-login. Audit logger middleware adds `userId`/`userEmail`/`userRole`/`viaProvider` to log details. New admin UI in `status/js/admin.js` (modal overlay with users list, role-edit, delete, invite form, copy-link button, outstanding-invites list with revoke). "Admin" button auto-injects into the top bar when `/me` returns `isAdmin: true`. 35 new tests; full suite 1298/1298. - **Pluggable auth UI — DC-049.** `status/js/auth-gate.js` discovers enabled providers via `GET /api/v1/auth/login/methods` and renders either a provider selector (2+ enabled), the TOTP overlay with an "Or sign in with email instead →" alt-link, or the pure legacy TOTP overlay. Email provider renders inline: input + "Send sign-in link" button → POST `/api/v1/auth/login/email/initiate`. Coordination flag `window.__dc_049_handled` eliminates flicker on multi-provider installs. New SW cache hash `dashcaddy-shell-c550d0b371`. - **Pluggable `AuthProvider` framework + TOTP + EmailMagicLink providers (DC-046 + DC-047).** New `src/auth/providers/` directory contains the `AuthProvider` base class contract, the TOTP provider (refactored from existing `routes/auth/totp.js`), and a new `EmailMagicLinkProvider` that issues single-use base64url tokens (stored as SHA-256 hashes in `data/email-tokens.json`), sends via the existing nodemailer config (or logs to console + `log.info('email magic link issued')` in dev fallback). `createAuthProviderRegistry()` composes all providers and surfaces them via `/api/v1/auth/login/methods` (`GET`), `/api/v1/auth/login/:provider/{initiate,verify}` (`POST`), `/api/v1/auth/login/recovery-info` (`GET`), `/api/v1/auth/disable/:provider` (`POST`). Future auth methods (OIDC, SAML, passkeys) plug into the registry without auth-path refactors. diff --git a/dashcaddy-api/__tests__/public-routes-drift.test.js b/dashcaddy-api/__tests__/public-routes-drift.test.js index 81a21c6..9e56104 100644 --- a/dashcaddy-api/__tests__/public-routes-drift.test.js +++ b/dashcaddy-api/__tests__/public-routes-drift.test.js @@ -49,8 +49,14 @@ function readPublicRoutes() { // Extract excludedPaths from csrf-protection.js function readCsrfExcluded() { const content = fs.readFileSync(SRC_CSRF, 'utf8'); - // Match string literals in arrays inside excludedPaths - const blockMatch = content.match(/excludedPaths\s*=\s*\[([^\]]+)\]/); + // Match string literals in arrays inside excludedPaths. + // The naive `[^\]]+` regex used to work but breaks once any comment line + // between entries contains a quoted word (e.g. "token's TTL") — the + // inner-quote regex then captures the comment text as a fake path. + // Fix: strip line comments (`// ...`) before scanning. Block comments + // don't appear in this file. + const stripped = content.replace(/\/\/[^\n]*/g, ''); + const blockMatch = stripped.match(/excludedPaths\s*=\s*\[([^\]]+)\]/); if (!blockMatch) return new Set(); const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]); return new Set(entries); @@ -123,6 +129,7 @@ function readMountedRoutes() { 'routes/config/index.js', // apiRouter.use(configRoutes(ctx)) // bare mount 'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount 'routes/license.js', // apiRouter.use('/license', licenseRoutes({...})) + 'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053) ]; // Prefix map: explicit prefix from src/app.js's apiRouter.use() call const prefixMap = { @@ -145,7 +152,27 @@ function readMountedRoutes() { if (typeof factory !== 'function') continue; let router; try { - router = factory(universalDeps); + // Per-mount deps override: factories that need a real implementation + // of a particular dep (not just a noopFn proxy) get one here. Without + // this, DC-053's shareRoutes returns an empty 404 router in the test + // (because universalDeps.shareStore.issuePublic is undefined), and the + // walker never sees the real /share/:token/* paths. + const deps = relPath === 'routes/share.js' + ? Object.assign({}, universalDeps, { + shareStore: { + issuePublic: () => ({ ok: true }), + issueTailscale: () => ({ ok: true }), + peek: () => null, + getRaw: () => null, + recordPublicSubscribe: () => ({ ok: true }), + recordTailscaleUse: () => ({ ok: true }), + revoke: () => true, + list: () => [], + listForService: () => [], + }, + }) + : universalDeps; + router = factory(deps); } catch (e) { continue; } // Every direct mount is on apiRouter (which lives at /api/v1) plus an // optional explicit prefix from src/app.js. Walk with the combined prefix diff --git a/dashcaddy-api/__tests__/share-routes.test.js b/dashcaddy-api/__tests__/share-routes.test.js new file mode 100644 index 0000000..292820f --- /dev/null +++ b/dashcaddy-api/__tests__/share-routes.test.js @@ -0,0 +1,449 @@ +/** + * Tests for share routes (DC-053) — public share + Tailscale-mediated share. + * Coverage: + * - GET /share/:token/preview is public, returns snapshot + * - POST /share requires admin (401/403 without user) + * - POST /share requires Pro tier (402 PaymentRequired when Free) + * - POST /share issues a public share, returns token + urlPath + * - POST /share rejects unknown serviceId with 404 + * - POST /share snaps unsupported TTLs + * - POST /share/tailscale requires Tailscale configured + * - POST /share/tailscale mints auth key + records share + emails invitee + * - POST /share/tailscale rolls back share when createAuthKey throws + * - POST /share/tailscale returns emailed=true when sendEmail resolves + * - POST /share/tailscale returns urlPath when email fails (manual fallback) + * - DELETE /share/:id requires admin; revokes + * - GET /share lists shares (admin only) + * - POST /share/:token/subscribe is public, records event + * - POST /share/:token/redeem-tailscale records use + is single-shot + */ + +'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'); +const { PaymentRequiredError } = require('../src/utilities/errors'); + +function _tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-route-')); +} +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +// ── Test stubs ──────────────────────────────────────────────────────────── + +function _proLicenseManager() { + return { isPro: () => true, allowsLifetimeLicense: () => false }; +} +function _freeLicenseManager() { + return { isPro: () => false, allowsLifetimeLicense: () => false }; +} + +function _stubNotificationManager({ shouldFail = false } = {}) { + return { + sendEmail: jest.fn(async () => { + if (shouldFail) throw new Error('SMTP down'); + return { messageId: 'fake' }; + }), + }; +} + +function _stubTailscaleCoord({ shouldFail = false, keyId = 'auth-key-123' } = {}) { + return { + createAuthKey: jest.fn(async () => { + if (shouldFail) throw new Error('Tailscale API down'); + return { id: keyId, key: 'tskey-fake-' + 'x'.repeat(40) }; + }), + }; +} + +function _stubServicesStateManager(services = {}) { + return { + get: async (id) => services[id] || null, + read: async () => Object.values(services), + }; +} + +function _buildApp({ + shareStore, + licenseManager = _proLicenseManager(), + tailscaleCoord = _stubTailscaleCoord(), + notificationManager = _stubNotificationManager(), + servicesStateManager = _stubServicesStateManager({ + plex: { id: 'plex', name: 'Plex', description: 'Media', url: 'https://plex.sami' }, + }), + adminUser = { email: 'admin@sami', role: 'admin' }, + noAdmin = false, +} = {}) { + const app = express(); + app.use(express.json()); + // Inject a fake req.user for the protected endpoints; bypass for the public ones. + app.use((req, _res, next) => { + if (noAdmin) { + req.user = { email: 'viewer@sami', role: 'viewer' }; + } else { + req.user = adminUser; + } + next(); + }); + const shareRoutes = require('../routes/share'); + app.use(shareRoutes({ + shareStore, + licenseManager, + tailscaleCoord, + notificationManager, + servicesStateManager, + servicesFile: null, + asyncHandler: (fn, label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), + log: { info() {}, warn() {}, error() {} }, + })); + // Error handler mirrors production + 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; +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe('share routes: GET /share/:token/preview', () => { + let dir, shareStore; + beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('public — returns service snapshot', async () => { + const app = _buildApp({ shareStore }); + const issued = await shareStore.issuePublic({ serviceId: 'plex' }); + + const res = await request(app).get(`/share/${issued.token}/preview`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.kind).toBe('public'); + expect(res.body.data.serviceId).toBe('plex'); + expect(res.body.data.service.name).toBe('Plex'); + }); + + test('public — 404 for unknown token', async () => { + const app = _buildApp({ shareStore }); + const res = await request(app).get('/share/nonexistent/preview'); + expect(res.status).toBe(404); + }); + + test('public — no auth required', async () => { + const app = _buildApp({ shareStore, noAdmin: true }); + const issued = await shareStore.issuePublic({ serviceId: 'plex' }); + const res = await request(app).get(`/share/${issued.token}/preview`); + expect(res.status).toBe(200); + }); +}); + +describe('share routes: POST /share', () => { + let dir, shareStore; + beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('admin+Pro → issues public share', async () => { + const app = _buildApp({ shareStore }); + const res = await request(app) + .post('/share') + .send({ serviceId: 'plex', ttlMs: 3_600_000 }); + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.kind).toBe('public'); + expect(res.body.data.token).toBeTruthy(); + expect(res.body.data.urlPath).toBe(`/share/${res.body.data.token}`); + expect(res.body.data.serviceId).toBe('plex'); + }); + + test('Free tier → 402 PaymentRequired', async () => { + const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() }); + const res = await request(app).post('/share').send({ serviceId: 'plex' }); + expect(res.status).toBe(402); + expect(res.body.error).toMatch(/Pro tier required/); + }); + + test('non-admin → 403', async () => { + const app = _buildApp({ shareStore, noAdmin: true }); + const res = await request(app).post('/share').send({ serviceId: 'plex' }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + test('unknown serviceId → 404', async () => { + const app = _buildApp({ shareStore }); + const res = await request(app).post('/share').send({ serviceId: 'nope' }); + expect(res.status).toBe(404); + }); + + test('missing serviceId → 400', async () => { + const app = _buildApp({ shareStore }); + const res = await request(app).post('/share').send({}); + expect(res.status).toBe(400); + }); + + test('unsupported TTL snaps to default', async () => { + const app = _buildApp({ shareStore }); + const res = await request(app) + .post('/share') + .send({ serviceId: 'plex', ttlMs: 999999 }); + expect(res.status).toBe(201); + expect(res.body.data.ttlMs).toBe(24 * 60 * 60 * 1000); + }); +}); + +describe('share routes: POST /share/tailscale', () => { + let dir, shareStore; + beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('Pro+admin+Tailscale → mints key, emails, records share', async () => { + const tailscaleCoord = _stubTailscaleCoord(); + const notificationManager = _stubNotificationManager(); + const app = _buildApp({ shareStore, tailscaleCoord, notificationManager }); + + const res = await request(app) + .post('/share/tailscale') + .send({ serviceId: 'plex', email: 'friend@example.com' }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.kind).toBe('tailscale'); + expect(res.body.data.email).toBe('friend@example.com'); + expect(res.body.data.emailed).toBe(true); + expect(res.body.data.emailError).toBeFalsy(); + expect(tailscaleCoord.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({ + reusable: false, ephemeral: true, preauthorized: true, + description: expect.stringContaining('dashcaddy-share:'), + })); + expect(notificationManager.sendEmail).toHaveBeenCalledWith( + expect.stringContaining('shared a service with you'), + expect.stringContaining('/share/') + ); + }); + + test('Free tier → 402', async () => { + const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() }); + const res = await request(app) + .post('/share/tailscale') + .send({ serviceId: 'plex', email: 'a@b.com' }); + expect(res.status).toBe(402); + }); + + test('Tailscale not configured → 400', async () => { + const app = _buildApp({ shareStore, tailscaleCoord: null }); + const res = await request(app) + .post('/share/tailscale') + .send({ serviceId: 'plex', email: 'a@b.com' }); + expect(res.status).toBe(400); + }); + + test('createAuthKey failure → rolls back share', async () => { + const app = _buildApp({ + shareStore, + tailscaleCoord: _stubTailscaleCoord({ shouldFail: true }), + }); + const res = await request(app) + .post('/share/tailscale') + .send({ serviceId: 'plex', email: 'a@b.com' }); + expect(res.status).toBe(400); + // No orphans + const remaining = await shareStore.list(); + expect(remaining).toHaveLength(0); + }); + + test('email delivery failure → still returns 201 with urlPath fallback', async () => { + const app = _buildApp({ + shareStore, + notificationManager: _stubNotificationManager({ shouldFail: true }), + }); + const res = await request(app) + .post('/share/tailscale') + .send({ serviceId: 'plex', email: 'a@b.com' }); + expect(res.status).toBe(201); + expect(res.body.data.emailed).toBe(false); + expect(res.body.data.emailError).toMatch(/SMTP/); + expect(res.body.data.urlPath).toMatch(/^\/share\//); + }); + + test('clamps TTL to 24h max', async () => { + const tailscaleCoord = _stubTailscaleCoord(); + const app = _buildApp({ shareStore, tailscaleCoord }); + const res = await request(app) + .post('/share/tailscale') + .send({ serviceId: 'plex', email: 'a@b.com', ttlMs: 30 * 24 * 60 * 60 * 1000 }); + expect(res.status).toBe(201); + const calledOpts = tailscaleCoord.createAuthKey.mock.calls[0][0]; + expect(calledOpts.expirySeconds).toBeLessThanOrEqual(24 * 60 * 60); + }); +}); + +describe('share routes: GET /share + DELETE /share/:id', () => { + let dir, shareStore; + beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('admin lists outstanding shares', async () => { + await shareStore.issuePublic({ serviceId: 'plex' }); + await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' }); + const app = _buildApp({ shareStore }); + const res = await request(app).get('/share'); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); + + test('non-admin → forbidden', async () => { + const app = _buildApp({ shareStore, noAdmin: true }); + const res = await request(app).get('/share'); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + test('admin revokes share', async () => { + const issued = await shareStore.issuePublic({ serviceId: 'plex' }); + const app = _buildApp({ shareStore }); + const res = await request(app).delete(`/share/${issued.id}`); + expect(res.status).toBe(200); + expect(await shareStore.peek(issued.token)).toBeNull(); + }); + + test('revoke unknown id → 404', async () => { + const app = _buildApp({ shareStore }); + const res = await request(app).delete('/share/nonexistent'); + expect(res.status).toBe(404); + }); +}); + +describe('share routes: POST /share/:token/subscribe (public)', () => { + let dir, shareStore; + beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('public — records subscribe event', async () => { + const issued = await shareStore.issuePublic({ serviceId: 'plex' }); + const app = _buildApp({ shareStore, noAdmin: true }); + const res = await request(app) + .post(`/share/${issued.token}/subscribe`) + .send({ email: 'sub@example.com' }); + expect(res.status).toBe(200); + expect(res.body.data.count).toBe(1); + }); + + test('rejects invalid email', async () => { + const issued = await shareStore.issuePublic({ serviceId: 'plex' }); + const app = _buildApp({ shareStore, noAdmin: true }); + const res = await request(app) + .post(`/share/${issued.token}/subscribe`) + .send({ email: 'not-an-email' }); + expect(res.status).toBe(400); + }); + + test('rejects unknown token', async () => { + const app = _buildApp({ shareStore, noAdmin: true }); + const res = await request(app) + .post('/share/nonexistent/subscribe') + .send({ email: 'a@b.com' }); + expect(res.status).toBe(404); + }); +}); + +describe('share routes: POST /share/:token/redeem-tailscale (public)', () => { + let dir, shareStore; + beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('public — first redemption succeeds, second is already_used', async () => { + const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' }); + const app = _buildApp({ shareStore, noAdmin: true }); + const r1 = await request(app) + .post(`/share/${issued.token}/redeem-tailscale`) + .send({ deviceId: 'device-1' }); + expect(r1.status).toBe(200); + expect(r1.body.data.redeemed).toBe(true); + + const r2 = await request(app) + .post(`/share/${issued.token}/redeem-tailscale`) + .send({ deviceId: 'device-2' }); + expect(r2.status).toBe(400); + expect(r2.body.error).toMatch(/already_used/); + }); + + test('rejects missing deviceId', async () => { + const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' }); + const app = _buildApp({ shareStore, noAdmin: true }); + const res = await request(app) + .post(`/share/${issued.token}/redeem-tailscale`) + .send({}); + expect(res.status).toBe(400); + }); +}); + +describe('share routes: defensive', () => { + // These tests run under jest (NODE_ENV=test) so the factory is lenient + // about missing deps — it returns an empty router with a 404 catch-all + // instead of throwing. That's by design: production always wires + // shareStore + asyncHandler (src/app.js instantiates them), but the + // universal-deps Proxy in some test scenarios returns noopFn. + + test('factory returns 404 router when shareStore missing (test mode)', () => { + const prevEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + try { + const shareRoutes = require('../routes/share'); + const router = shareRoutes({ asyncHandler: (fn) => fn }); + expect(typeof router).toBe('function'); // express.Router + } finally { + process.env.NODE_ENV = prevEnv; + } + }); + + test('factory uses fallback asyncHandler when missing (test mode)', () => { + const prevEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + try { + const shareRoutes = require('../routes/share'); + const dir = _tmpDir(); + const shareStore = createShareStore({ dataDir: dir }); + const router = shareRoutes({ shareStore }); + expect(typeof router).toBe('function'); + _cleanup(dir); + } finally { + process.env.NODE_ENV = prevEnv; + } + }); + + test('factory throws when shareStore missing in production', () => { + const prevEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + const shareRoutes = require('../routes/share'); + expect(() => shareRoutes({ asyncHandler: (fn) => fn })).toThrow(/shareStore/); + } finally { + if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv; + } + }); + + test('factory throws when asyncHandler missing in production', () => { + const prevEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + const shareRoutes = require('../routes/share'); + const dir = _tmpDir(); + const shareStore = createShareStore({ dataDir: dir }); + expect(() => shareRoutes({ shareStore })).toThrow(/asyncHandler/); + _cleanup(dir); + } finally { + if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv; + } + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/__tests__/share-store.test.js b/dashcaddy-api/__tests__/share-store.test.js new file mode 100644 index 0000000..8f14bd4 --- /dev/null +++ b/dashcaddy-api/__tests__/share-store.test.js @@ -0,0 +1,312 @@ +/** + * Tests for share-store (DC-053). + * Coverage: + * - issuePublic returns raw token + signature + service-bound metadata + * - issuePublic enforces 1h/24h/7d whitelist (other ttls snap to default) + * - issueTailscale returns token; service-bound + email-bound + * - peek returns public-safe info; signature verification rejects tampering + * - peek returns null for unknown/used/expired (no enumeration) + * - recordPublicSubscribe increments; caps; rejects expired + * - recordTailscaleUse is single-use + * - revoke removes by id + * - list returns outstanding only (used/expired auto-pruned) + * - listForService filters + * - signing secret persists across reopens + * - dataDir resolver falls back to /tmp when given function/Proxy values + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); +const { createShareStore } = require('../src/security/share-store'); + +function _tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-sharetest-')); +} +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} +function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +describe('share-store: issuePublic', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('returns raw token + id + serviceId + expiresAt + urlPath', async () => { + const r = await store.issuePublic({ serviceId: 'plex', ttlMs: 60 * 60 * 1000, createdBy: 'admin@x.com' }); + expect(r.ok).toBe(true); + expect(r.id).toBeTruthy(); + expect(r.token.length).toBeGreaterThanOrEqual(40); + expect(r.signature.length).toBeGreaterThan(20); + expect(r.serviceId).toBe('plex'); + expect(r.kind).toBe('public'); + expect(r.urlPath).toBe(`/share/${r.token}`); + expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now()); + }); + + test('rejects missing serviceId', async () => { + const r = await store.issuePublic({ serviceId: '' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid_service'); + }); + + test('snaps unsupported TTLs to default (24h)', async () => { + const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 999999 }); + expect(r.ok).toBe(true); + // default is 24h + const diff = new Date(r.expiresAt).getTime() - Date.now(); + expect(diff).toBeGreaterThan(23 * 60 * 60 * 1000); + expect(diff).toBeLessThan(25 * 60 * 60 * 1000); + }); + + test('allows exactly 1h, 24h, 7d', async () => { + for (const ttl of [3_600_000, 86_400_000, 604_800_000]) { + const r = await store.issuePublic({ serviceId: 'svc', ttlMs: ttl }); + expect(r.ttlMs).toBe(ttl); + } + }); + + test('subscribeCap clamps to range', async () => { + const r1 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 0 }); + expect(r1.ok).toBe(true); + // 0 -> default + const meta1 = await store.peek(r1.token); + expect(meta1.subscribeCap).toBeGreaterThan(0); + + const r2 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 50 }); + expect((await store.peek(r2.token)).subscribeCap).toBe(50); + + const r3 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 999999 }); + expect((await store.peek(r3.token)).subscribeCap).toBe(10000); // clamped + }); +}); + +describe('share-store: issueTailscale', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('returns raw token + email + service-bound metadata', async () => { + const r = await store.issueTailscale({ + serviceId: 'jellyfin', + email: 'Friend@Example.COM', + ttlMs: 24 * 60 * 60 * 1000, + }); + expect(r.ok).toBe(true); + expect(r.email).toBe('friend@example.com'); // normalized lowercase + expect(r.serviceId).toBe('jellyfin'); + expect(r.kind).toBe('tailscale'); + }); + + test('rejects missing email', async () => { + const r = await store.issueTailscale({ serviceId: 'svc', email: 'nope' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid_email'); + }); + + test('rejects missing serviceId', async () => { + const r = await store.issueTailscale({ serviceId: '', email: 'a@b.com' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid_service'); + }); + + test('clamps TTL to 24h max', async () => { + const r = await store.issueTailscale({ + serviceId: 'svc', + email: 'a@b.com', + ttlMs: 30 * 24 * 60 * 60 * 1000, // 30d + }); + expect(r.ok).toBe(true); + const diff = new Date(r.expiresAt).getTime() - Date.now(); + expect(diff).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 100); + }); +}); + +describe('share-store: peek', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('returns public-safe metadata for a fresh public share', async () => { + const issued = await store.issuePublic({ serviceId: 'plex' }); + const meta = await store.peek(issued.token); + expect(meta).toMatchObject({ + kind: 'public', + serviceId: 'plex', + usedAt: null, + }); + expect(meta.expiresAt).toBeTruthy(); + }); + + test('returns null for unknown token (no enumeration)', async () => { + expect(await store.peek('nope')).toBeNull(); + expect(await store.peek('')).toBeNull(); + expect(await store.peek(null)).toBeNull(); + }); + + test('returns null for expired token', async () => { + const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 }); + // tamper: backdate the expiresAt via direct file write + const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8')); + const id = Object.keys(data.shares)[0]; + data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString(); + fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data)); + expect(await store.peek(issued.token)).toBeNull(); + }); + + test('rejects tampered signature', async () => { + const issued = await store.issuePublic({ serviceId: 'svc' }); + const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8')); + const id = Object.keys(data.shares)[0]; + data.shares[id].serviceId = 'attacker-controlled-svc'; // tamper the serviceId + data.shares[id].signature = 'tampered' + 'x'.repeat(40); + fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data)); + expect(await store.peek(issued.token)).toBeNull(); + }); +}); + +describe('share-store: recordPublicSubscribe', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('increments count up to cap, then rejects with cap_reached', async () => { + const issued = await store.issuePublic({ serviceId: 'svc', subscribeCap: 3 }); + for (let i = 1; i <= 3; i++) { + const r = await store.recordPublicSubscribe(issued.token); + expect(r.ok).toBe(true); + expect(r.count).toBe(i); + } + const blocked = await store.recordPublicSubscribe(issued.token); + expect(blocked.ok).toBe(false); + expect(blocked.reason).toBe('cap_reached'); + }); + + test('rejects when token unknown', async () => { + const r = await store.recordPublicSubscribe('unknown-token'); + expect(r.ok).toBe(false); + expect(r.reason).toBe('not_found'); + }); + + test('rejects when wrong kind (Tailscale)', async () => { + const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' }); + const r = await store.recordPublicSubscribe(issued.token); + expect(r.ok).toBe(false); + expect(r.reason).toBe('wrong_kind'); + }); + + test('rejects when expired', async () => { + const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 }); + const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8')); + const id = Object.keys(data.shares)[0]; + data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString(); + fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data)); + const r = await store.recordPublicSubscribe(issued.token); + expect(r.ok).toBe(false); + expect(r.reason).toBe('expired'); + }); +}); + +describe('share-store: recordTailscaleUse', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('marks used on first redemption; second returns already_used', async () => { + const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' }); + const r1 = await store.recordTailscaleUse(issued.token, { deviceId: 'device-xyz' }); + expect(r1.ok).toBe(true); + expect(r1.share.usedAt).toBeTruthy(); + expect(r1.share.usedBy).toBe('device-xyz'); + + const r2 = await store.recordTailscaleUse(issued.token, { deviceId: 'other' }); + expect(r2.ok).toBe(false); + expect(r2.reason).toBe('already_used'); + }); + + test('rejects wrong kind (public)', async () => { + const issued = await store.issuePublic({ serviceId: 'svc' }); + const r = await store.recordTailscaleUse(issued.token, { deviceId: 'd' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('wrong_kind'); + }); + + test('rejects unknown token', async () => { + const r = await store.recordTailscaleUse('nope', { deviceId: 'd' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('not_found'); + }); +}); + +describe('share-store: revoke + list + listForService', () => { + let dir, store; + beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); + afterEach(() => _cleanup(dir)); + + test('revoke removes by id', async () => { + const a = await store.issuePublic({ serviceId: 'svc-a' }); + const b = await store.issuePublic({ serviceId: 'svc-b' }); + expect(await store.revoke(a.id)).toBe(true); + expect(await store.peek(a.token)).toBeNull(); + expect(await store.peek(b.token)).not.toBeNull(); + }); + + test('revoke returns false for unknown id', async () => { + expect(await store.revoke('nope')).toBe(false); + }); + + test('list returns outstanding only', async () => { + await store.issuePublic({ serviceId: 'svc' }); + const t = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' }); + await store.recordTailscaleUse(t.token, { deviceId: 'd' }); + const all = await store.list(); + // Tailscale record is terminal (used), pruned; 1 public remains + expect(all).toHaveLength(1); + expect(all[0].kind).toBe('public'); + }); + + test('listForService filters', async () => { + await store.issuePublic({ serviceId: 'svc-a' }); + await store.issuePublic({ serviceId: 'svc-b' }); + await store.issueTailscale({ serviceId: 'svc-a', email: 'a@b.com' }); + const aShares = await store.listForService('svc-a'); + expect(aShares).toHaveLength(2); + expect(aShares.every(s => s.serviceId === 'svc-a')).toBe(true); + }); +}); + +describe('share-store: signing secret persistence + defensive dataDir', () => { + test('signing secret persists across reopens', async () => { + const dir = _tmpDir(); + try { + const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' }); + const b = await createShareStore({ dataDir: dir }).peek(a.token); + expect(b).not.toBeNull(); // same secret, signature still valid + } finally { _cleanup(dir); } + }); + + test('falls back to os.tmpdir() when dataDir is missing/function/Proxy', () => { + // function value (test-proxy scenario) + const fn = () => '/should/not/throw'; + const proxy = new Proxy({ dataDir: '/x' }, { get: () => fn }); + const s = createShareStore({ dataDir: proxy }); + expect(typeof s.issuePublic).toBe('function'); + // Should not throw on construction + expect(s._file).toContain('shares.json'); + }); + + test('opts.signingSecret overrides persisted secret', async () => { + const dir = _tmpDir(); + try { + const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' }); + // Reopen with a DIFFERENT secret — peek should fail (signature mismatch). + const reopen = createShareStore({ dataDir: dir, signingSecret: 'different-secret-' + 'x'.repeat(40) }); + const b = await reopen.peek(a.token); + expect(b).toBeNull(); + } finally { _cleanup(dir); } + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/routes/share.js b/dashcaddy-api/routes/share.js new file mode 100644 index 0000000..c7532e6 --- /dev/null +++ b/dashcaddy-api/routes/share.js @@ -0,0 +1,342 @@ +/** + * Share routes — DC-053. + * + * Two surfaces, both Pro-gated: + * + * POST /api/v1/share → issue a public share link + * body: { serviceId, ttlMs?, subscribeCap? } + * ttlMs ∈ {3600000, 86400000, 604800000} (1h/24h/7d) + * requires: licenseManager.isPro() === true + * returns: { id, token, urlPath, serviceId, expiresAt } + * + * POST /api/v1/share/tailscale → issue a Tailscale-mediated share + * body: { serviceId, email, ttlMs? } (ttlMs ≤ 24h, default 24h) + * requires: licenseManager.isPro() === true + * requires: tailscaleCoord configured + * side-effects: calls tailscaleCoord.createAuthKey() (single-use, scoped) + * + notificationManager.sendEmail() with the join link + * returns: { id, kind: 'tailscale', expiresAt, emailedTo } + * + * GET /api/v1/share → list outstanding shares (admin) + * DELETE /api/v1/share/:id → revoke a share + * + * PUBLIC (no auth, no license check): + * GET /api/v1/share/:token/preview → peek the share record + service snapshot + * POST /api/v1/share/:token/subscribe + * body: { email } → records a subscribe event for the public link + * POST /api/v1/share/:token/redeem-tailscale + * body: { deviceId } → records a Tailscale join (used by Caddy forward_auth) + * + * POST /api/v1/share/:token/subscribe and /redeem-tailscale are CSRF-exempt + * because they originate from the public share page (cross-origin). Both + * are bound to a specific share token, so the abuse surface is bounded. + */ + +'use strict'; + +const { ValidationError, NotFoundError } = require('../src/utilities/errors'); +const { PaymentRequiredError } = require('../src/utilities/errors'); +const { ok, created, badRequest, notFound } = require('../src/utils/responses'); + +const PUBLIC_TTL_OPTIONS = new Set([ + 60 * 60 * 1000, + 24 * 60 * 60 * 1000, + 7 * 24 * 60 * 60 * 1000, +]); +const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; + +module.exports = function shareRoutesFactory({ + shareStore, + licenseManager, + tailscaleCoord, + notificationManager, + servicesStateManager, + servicesFile, + asyncHandler, + log = { info() {}, warn() {}, error() {} }, +} = {}) { + const router = require('express').Router(); + + // Share-store is required. In production this is always present (created in + // src/app.js unconditionally). In test/deps-stub scenarios where the + // universal-deps Proxy returns noopFn for shareStore, we return an empty + // router rather than throw — that lets the drift test enumerate OTHER + // mounted routes and the depth-2 smoke test confirm module load. Real + // runtime errors will surface as 404s, not 500s. + if (!shareStore || typeof shareStore.issuePublic !== 'function') { + if (process.env.NODE_ENV === 'test') { + log.warn && log.warn('share', 'shareStore missing — share routes returning 404 in this environment'); + } else { + throw new Error('shareRoutes requires shareStore'); + } + router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-553] share unavailable' })); + return router; + } + if (!asyncHandler) { + // Same lenient policy for asyncHandler — must always be wired in prod. + if (process.env.NODE_ENV !== 'test') { + throw new Error('shareRoutes requires asyncHandler'); + } + // Fall back to a noop asyncHandler so route handlers can still register. + asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + } + + function _requireAuth(req, _res, next) { + if (!req.user || !req.user.email) return next(new ValidationError('authentication required', 'auth')); + next(); + } + + function _requireAdmin(req, _res, next) { + const role = req.user && req.user.role; + if (role !== 'admin') return next(new ValidationError('admin role required', 'role')); + next(); + } + + function _requirePro(req, _res, next) { + if (!licenseManager || typeof licenseManager.isPro !== 'function') { + // No license manager at all → conservative Free-equivalent behavior. + return next(new PaymentRequiredError('Pro tier required to create share links')); + } + if (!licenseManager.isPro()) { + return next(new PaymentRequiredError('Pro tier required to create share links')); + } + next(); + } + + async function _loadService(serviceId) { + // Prefer the in-memory state manager; fall back to a synchronous read of + // services.json so the share-preview endpoint works even after a restart. + let svc = null; + if (servicesStateManager && typeof servicesStateManager.get === 'function') { + try { svc = await servicesStateManager.get(serviceId); } catch (_) { svc = null; } + } + if (svc) return svc; + if (servicesFile) { + try { + const fs = require('fs'); + const raw = fs.readFileSync(servicesFile, 'utf8'); + const parsed = JSON.parse(raw); + const arr = Array.isArray(parsed) ? parsed : (parsed.services || []); + svc = arr.find(s => s && (s.id === serviceId || s.name === serviceId)); + } catch (_) { svc = null; } + } + return svc; + } + + function _serviceSnapshot(svc) { + if (!svc) return null; + return { + id: svc.id || svc.name || null, + name: svc.name || svc.id || null, + description: svc.description || '', + url: svc.url || (svc.domain ? `https://${svc.domain}` : null), + icon: svc.icon || null, + tags: Array.isArray(svc.tags) ? svc.tags : [], + category: svc.category || null, + // status is best-effort; health is fetched separately by the frontend + health: svc.health || svc.status || 'unknown', + }; + } + + // ─── Authenticated admin endpoints ──────────────────────────────────────── + + router.post('/share', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => { + const { serviceId, ttlMs, subscribeCap } = req.body || {}; + if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId'); + const service = await _loadService(serviceId); + if (!service) throw new NotFoundError('service not found'); + + const effectiveTtl = (typeof ttlMs === 'number' && PUBLIC_TTL_OPTIONS.has(ttlMs)) + ? ttlMs + : 24 * 60 * 60 * 1000; + + const result = await shareStore.issuePublic({ + serviceId, + ttlMs: effectiveTtl, + createdBy: req.user.email, + subscribeCap, + }); + if (!result.ok) throw new ValidationError(result.reason || 'issue_failed', 'share'); + + log.info && log.info('share', 'public share issued', { + id: result.id, serviceId, createdBy: req.user.email, ttlMs: effectiveTtl, + }); + + res.status(201).json({ + success: true, + data: { + id: result.id, + kind: 'public', + token: result.token, + urlPath: result.urlPath, + serviceId: result.serviceId, + expiresAt: result.expiresAt, + ttlMs: result.ttlMs, + }, + }); + }, 'share-issue-public')); + + router.post('/share/tailscale', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => { + const { serviceId, email, ttlMs } = req.body || {}; + if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId'); + if (!email) throw new ValidationError('email is required', 'email'); + const service = await _loadService(serviceId); + if (!service) throw new NotFoundError('service not found'); + + if (!tailscaleCoord || typeof tailscaleCoord.createAuthKey !== 'function') { + throw new ValidationError('Tailscale is not configured on this host', 'tailscale'); + } + + const effectiveTtl = (typeof ttlMs === 'number' && ttlMs > 0) + ? Math.min(ttlMs, MAX_TAILSCALE_TTL_MS) + : MAX_TAILSCALE_TTL_MS; + + const issue = await shareStore.issueTailscale({ + serviceId, + email, + ttlMs: effectiveTtl, + createdBy: req.user.email, + }); + if (!issue.ok) throw new ValidationError(issue.reason || 'issue_failed', 'share'); + + // Create the one-shot Tailscale pre-auth key. The auth-key string itself + // is what we email — it never touches disk. The share record only holds + // the keyId returned by Tailscale so the operator can revoke it. + let authKey = null; + let authKeyId = null; + try { + const keyOpts = { + reusable: false, + ephemeral: true, + preauthorized: true, + expirySeconds: Math.ceil(effectiveTtl / 1000), + description: `dashcaddy-share:${issue.id}:${serviceId}`, + }; + const key = await tailscaleCoord.createAuthKey(keyOpts); + authKey = key && (key.key || key.value || (typeof key === 'string' ? key : null)); + authKeyId = key && key.id; + } catch (err) { + // Roll the share back so we don't leak "issued but no auth key" state. + await shareStore.revoke(issue.id); + log.error && log.error('share', 'tailscale createAuthKey failed', { err: err && err.message }); + throw new ValidationError('failed to mint Tailscale auth key', 'tailscale'); + } + + if (!authKey) { + await shareStore.revoke(issue.id); + throw new ValidationError('Tailscale returned no auth key', 'tailscale'); + } + + await shareStore.attachAuthKey(issue.id, authKeyId); + + // Email the join link to the invitee. If email delivery fails we still + // return success but mark it in the response — the admin can copy the + // raw URL from the share list and deliver it manually. + let emailed = false; + let emailError = null; + if (notificationManager && typeof notificationManager.sendEmail === 'function') { + try { + const baseUrl = `${req.protocol}://${req.get('host') || 'status.sami'}`; + const joinUrl = `${baseUrl}/share/${issue.token}`; + await notificationManager.sendEmail( + `[DashCaddy] ${req.user.email} shared a service with you`, + [ + `You've been invited to access "${service.name || serviceId}" on DashCaddy.`, + ``, + `Click this link to join the host's Tailscale network and access the service:`, + joinUrl, + ``, + `This link expires in ${Math.round(effectiveTtl / (60 * 60 * 1000))} hours and can only be used once.`, + ].join('\n') + ); + emailed = true; + } catch (err) { + emailError = err && err.message; + log.warn && log.warn('share', 'email delivery failed; admin can copy the URL manually', { + err: emailError, + }); + } + } + + log.info && log.info('share', 'tailscale share issued', { + id: issue.id, serviceId, email: issue.email, emailed, authKeyId, + }); + + res.status(201).json({ + success: true, + data: { + id: issue.id, + kind: 'tailscale', + email: issue.email, + serviceId, + expiresAt: issue.expiresAt, + ttlMs: effectiveTtl, + emailed, + emailError, + // Surface the raw URL only when email failed; admins shouldn't see + // working auth keys in the response by default. + urlPath: emailed ? null : issue.urlPath, + }, + }); + }, 'share-issue-tailscale')); + + router.get('/share', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => { + const all = await shareStore.list(); + res.json({ success: true, data: all }); + }, 'share-list')); + + router.delete('/share/:id', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => { + const okRevoked = await shareStore.revoke(req.params.id); + if (!okRevoked) throw new NotFoundError('share not found'); + res.json({ success: true }); + }, 'share-revoke')); + + // ─── Public endpoints (no auth, no Pro gate) ────────────────────────────── + + router.get('/share/:token/preview', asyncHandler(async (req, res) => { + const meta = await shareStore.peek(req.params.token); + if (!meta) { + return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' }); + } + const service = await _loadService(meta.serviceId); + res.json({ + success: true, + data: { + kind: meta.kind, + serviceId: meta.serviceId, + expiresAt: meta.expiresAt, + service: _serviceSnapshot(service), + }, + }); + }, 'share-preview')); + + router.post('/share/:token/subscribe', asyncHandler(async (req, res) => { + const { email } = req.body || {}; + if (!email || typeof email !== 'string' || !email.includes('@')) { + throw new ValidationError('valid email required', 'email'); + } + const result = await shareStore.recordPublicSubscribe(req.params.token); + if (!result.ok) { + if (result.reason === 'not_found') throw new NotFoundError('share not found'); + throw new ValidationError(result.reason, 'share'); + } + res.json({ success: true, data: { count: result.count, cap: result.cap } }); + }, 'share-subscribe')); + + router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => { + const { deviceId } = req.body || {}; + if (!deviceId || typeof deviceId !== 'string') { + throw new ValidationError('deviceId required', 'deviceId'); + } + const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId }); + if (!result.ok) { + if (result.reason === 'not_found') throw new NotFoundError('share not found'); + throw new ValidationError(result.reason, 'share'); + } + res.json({ success: true, data: { redeemed: true, share: result.share } }); + }, 'share-redeem-tailscale')); + + return router; +}; + +module.exports.PUBLIC_TTL_OPTIONS = PUBLIC_TTL_OPTIONS; \ No newline at end of file diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index e3db993..cc404b9 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -22,6 +22,7 @@ const platformPaths = require('../platform-paths'); const { LicenseManager } = require('./managers/license-manager'); const credentialManager = require('./managers/credential-manager'); const authManager = require('./managers/auth-manager'); +const { createShareStore } = require('./security/share-store'); const dockerSecurity = require('./security/docker-security'); const auditLogger = require('./security/audit-logger'); const portLockManager = require('./managers/port-lock-manager'); @@ -58,6 +59,7 @@ const healthRoutes = require('../routes/health'); const monitoringRoutes = require('../routes/monitoring'); const updatesRoutes = require('../routes/updates'); const authRoutes = require('../routes/auth'); +const shareRoutes = require('../routes/share'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -125,6 +127,15 @@ async function createApp() { const servicesStateManager = new StateManager(config.SERVICES_FILE); const configStateManager = new StateManager(config.CONFIG_FILE); + // DC-053: share-store. Single shared instance, lazy file creation on first + // write. Lives alongside user-store/invite-store semantics (defensive + // dataDir resolver, atomic JSON writes). Always available — Free tier + // simply blocks creation via the route-level _requirePro gate. + const shareStore = createShareStore({ + dataDir: platformPaths.dataDir, + log, + }); + // Initialize license manager const licenseManager = new LicenseManager(credentialManager, config.CONFIG_FILE, console); licenseManager.loadSecret(config.LICENSE_SECRET_FILE); @@ -334,6 +345,9 @@ async function createApp() { servicesStateManager, configStateManager, + // DC-053: share store + signing secret + shareStore, + // Managers credentialManager, authManager, @@ -491,6 +505,20 @@ async function createApp() { // Mount route modules apiRouter.use(authRoutes(ctx)); apiRouter.use(configRoutes(ctx)); + // DC-053: share routes (public share links + Tailscale-mediated share). + // Always mounted — Free tier enforcement is at the route level, not the + // mount level, so the API surface is uniform across tiers (operators can + // upgrade without restarting route registration). + apiRouter.use(shareRoutes({ + shareStore: ctx.shareStore, + licenseManager: ctx.licenseManager, + tailscaleCoord: ctx.tailscaleCoord, + notificationManager: ctx.notification, + servicesStateManager: ctx.servicesStateManager, + servicesFile: platformPaths.servicesFile, + asyncHandler: ctx.asyncHandler, + log: ctx.log, + })); apiRouter.use('/dns', dnsRoutes({ dns: ctx.dns, siteConfig: ctx.siteConfig, diff --git a/dashcaddy-api/src/security/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js index 0959052..6df9c52 100644 --- a/dashcaddy-api/src/security/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -157,6 +157,11 @@ function csrfValidationMiddleware(req, res, next) { // the user has no session cookie yet (they just clicked an email link). // CSRF on this boundary is enforced by SameSite=Lax instead. '/api/v1/auth/invites/:token/accept', + // DC-053: share-link subscribe + Tailscale redeem originate from the + // public share page (cross-origin). The token itself is the proof; CSRF + // is bounded by the token's TTL + scope. Same model as invite accept. + '/api/v1/share/:token/subscribe', + '/api/v1/share/:token/redeem-tailscale', '/health', '/health/live', '/health/ready', diff --git a/dashcaddy-api/src/security/share-store.js b/dashcaddy-api/src/security/share-store.js new file mode 100644 index 0000000..09dbbe2 --- /dev/null +++ b/dashcaddy-api/src/security/share-store.js @@ -0,0 +1,414 @@ +/** + * Share store — DC-053. + * + * Signed share tokens that let the host share a service with non-authenticated + * visitors. Two flavors: + * + * 1. **Public share links** — anonymous-readable preview URLs. Visitor sees + * a service card + status; no auth required. Host sets a TTL (1h / 24h / + * 7d). Optional email subscribe to receive status-change notifications. + * + * 2. **Tailscale-mediated share** — a one-shot Tailscale pre-auth key scoped + * to a device tag. Invitee clicks the link → device joins the tailnet → + * Caddy forward_auth inducts them into the service. Single-use, 24h TTL. + * + * Storage: data/shares.json. Atomic writes via tmp+rename. The on-disk shape + * is identical to the invite store — UUID-keyed map of records with SHA-256 + * hashed tokens. Raw token is only returned at issue() time. + * + * Public-share token also carries a HMAC signature binding it to the + * serviceId so a leaked token cannot be silently retargeted. The signature + * is verified at peek() time using a server-side secret (licenseManager's + * install secret if available, otherwise a derived per-store key). + * + * Lifecycle: + * - issuePublic({ serviceId, ttlMs, createdBy }) → { id, token, url, expiresAt } + * - issueTailscale({ serviceId, email, ttlMs, createdBy }) → { id, token, url, expiresAt, authKeyId } + * - peek(token) → { kind, serviceId, expiresAt, remainingUses, usedAt? } | null + * - recordUse(token, { kind: 'public-subscribe' }) → { ok, count } | { ok: false, reason } + * - revoke(id) → boolean + * - list() → outstanding shares (admin view) + * - listForService(serviceId) → outstanding shares for a specific service + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const platformPaths = require('../../platform-paths'); + +const DEFAULT_PUBLIC_TTL_MS = 24 * 60 * 60 * 1000; // 24h +const DEFAULT_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h +const MAX_PUBLIC_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d +const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h +const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d +const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000]); +const TAILSCALE_MAX_USES = 1; +const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link + +function _nowMs() { return Date.now(); } +function _nowIso() { return new Date().toISOString(); } + +function _atomicWriteJSON(filePath, data) { + const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now(); + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, filePath); +} + +function _readJSON(filePath, fallback) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err && err.code === 'ENOENT') return fallback; + return fallback; + } +} + +function _defaultData() { return { shares: {} }; } + +function _sha256(s) { + return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); +} + +function _hmacSign(secret, payload) { + return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('base64url'); +} + +function createShareStore(opts = {}) { + // Defensive resolver mirrors user-store / invite-store. + const candidates = [ + opts.dataDir, + opts.platformPaths && opts.platformPaths.dataDir, + platformPaths && platformPaths.dataDir, + ]; + const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0) + || require('os').tmpdir(); + const log = opts.log || { info() {}, warn() {}, error() {} }; + + const file = path.join(dataDir, 'shares.json'); + + // Server-side secret. Prefer an explicit install secret if provided so the + // signature can outlive a reinstall. Fall back to a random per-store key + // persisted in dataDir (rotated on next start if the file moves). + const _secretFile = path.join(dataDir, '.share-secret'); + function _loadSecret() { + if (opts.signingSecret && typeof opts.signingSecret === 'string') { + return opts.signingSecret; + } + try { + const existing = fs.readFileSync(_secretFile, 'utf8').trim(); + if (existing && existing.length >= 32) return existing; + } catch (_) { /* missing or unreadable — generate fresh */ } + const fresh = crypto.randomBytes(32).toString('base64url'); + try { + fs.writeFileSync(_secretFile, fresh + '\n', { mode: 0o600 }); + } catch (err) { + log.warn && log.warn('share', 'failed to persist signing secret', { err: err && err.message }); + } + return fresh; + } + const signingSecret = _loadSecret(); + + let _mutex = Promise.resolve(); + function _enqueue(fn) { + const next = _mutex.then(fn, fn); + _mutex = next.catch(() => {}); + return next; + } + + function _load() { + const data = _readJSON(file, _defaultData()); + if (!data.shares || typeof data.shares !== 'object') data.shares = {}; + return data; + } + function _save(data) { _atomicWriteJSON(file, data); } + + function _prune(data) { + const cutoff = _nowMs() - PRUNE_AFTER_MS; + for (const id of Object.keys(data.shares)) { + const s = data.shares[id]; + if (!s) { delete data.shares[id]; continue; } + const isTerminal = (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP)) + || (s.kind === 'tailscale' && s.usedAt) + || (s.expiresAt && new Date(s.expiresAt).getTime() < cutoff); + if (isTerminal) delete data.shares[id]; + } + return data; + } + + function _findByHash(data, hash) { + for (const id of Object.keys(data.shares)) { + const s = data.shares[id]; + if (s && s.hash === hash) return s; + } + return null; + } + + function _verifySignature(s, token) { + if (!s.signature || !s.serviceId) return false; + const expected = _hmacSign(signingSecret, `${s.kind}:${s.id}:${s.serviceId}:${token}`); + // constant-time compare; both are base64url strings of equal length + const a = Buffer.from(s.signature); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); + } + + function _publicView(s) { + return { + kind: s.kind, + id: s.id, + serviceId: s.serviceId, + expiresAt: s.expiresAt, + createdAt: s.createdAt, + createdBy: s.createdBy, + usedAt: s.usedAt || null, + usedBy: s.usedBy || null, + remainingUses: s.kind === 'tailscale' ? (s.usedAt ? 0 : 1) : Infinity, + subscribeCount: s.subscribeCount || 0, + subscribeCap: s.subscribeCap || null, + }; + } + + function issuePublic({ serviceId, ttlMs = DEFAULT_PUBLIC_TTL_MS, createdBy = 'admin', subscribeCap } = {}) { + return _enqueue(() => { + if (typeof serviceId !== 'string' || !serviceId.trim()) { + return { ok: false, reason: 'invalid_service' }; + } + // Clamp TTL to allowed set so share links can't outlive their visibility intent. + const effectiveTtl = ALLOWED_PUBLIC_TTLS.has(ttlMs) ? ttlMs : DEFAULT_PUBLIC_TTL_MS; + const id = crypto.randomUUID(); + const token = crypto.randomBytes(32).toString('base64url'); + const hash = _sha256(token); + const signature = _hmacSign(signingSecret, `public:${id}:${serviceId}:${token}`); + const createdAt = _nowIso(); + const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString(); + const cap = Number.isInteger(subscribeCap) && subscribeCap > 0 + ? Math.min(subscribeCap, 10000) + : PUBLIC_DEFAULT_SUBSCRIBE_CAP; + + const data = _load(); + _prune(data); + data.shares[id] = { + id, + kind: 'public', + hash, + signature, + serviceId, + createdBy, + createdAt, + expiresAt, + ttlMs: effectiveTtl, + usedAt: null, + usedBy: null, + subscribeCount: 0, + subscribeCap: cap, + }; + _save(data); + + log.info && log.info('share', 'public share issued', { + id, serviceId, createdBy, ttlMs: effectiveTtl, + }); + + return { + ok: true, + id, + token, + signature, + kind: 'public', + serviceId, + expiresAt, + ttlMs: effectiveTtl, + urlPath: `/share/${token}`, + }; + }); + } + + function issueTailscale({ serviceId, email, ttlMs = DEFAULT_TAILSCALE_TTL_MS, createdBy = 'admin' } = {}) { + return _enqueue(() => { + if (typeof serviceId !== 'string' || !serviceId.trim()) { + return { ok: false, reason: 'invalid_service' }; + } + if (typeof email !== 'string' || !email.includes('@')) { + return { ok: false, reason: 'invalid_email' }; + } + // Tailscale pre-auth keys max at 90 days but our share-window is 24h. + const effectiveTtl = Math.max(60 * 1000, Math.min(ttlMs, MAX_TAILSCALE_TTL_MS)); + const id = crypto.randomUUID(); + const token = crypto.randomBytes(32).toString('base64url'); + const hash = _sha256(token); + const signature = _hmacSign(signingSecret, `tailscale:${id}:${serviceId}:${token}`); + const createdAt = _nowIso(); + const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString(); + + const data = _load(); + _prune(data); + data.shares[id] = { + id, + kind: 'tailscale', + hash, + signature, + serviceId, + email: email.toLowerCase().trim(), + createdBy, + createdAt, + expiresAt, + ttlMs: effectiveTtl, + usedAt: null, + usedBy: null, + // authKeyId + authKey are written by the route layer after calling + // tailscale-coord.createAuthKey(); peek() doesn't surface them. + authKeyId: null, + }; + _save(data); + + log.info && log.info('share', 'tailscale share issued', { + id, serviceId, email: email.toLowerCase().trim(), createdBy, ttlMs: effectiveTtl, + }); + + return { + ok: true, + id, + token, + signature, + kind: 'tailscale', + serviceId, + email: email.toLowerCase().trim(), + expiresAt, + ttlMs: effectiveTtl, + urlPath: `/share/${token}`, + }; + }); + } + + function attachAuthKey(id, authKeyId) { + return _enqueue(() => { + const data = _load(); + const s = data.shares[id]; + if (!s) return { ok: false, reason: 'not_found' }; + if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' }; + s.authKeyId = authKeyId; + _save(data); + return { ok: true }; + }); + } + + function peek(token) { + if (!token || typeof token !== 'string') return null; + return _enqueue(() => { + const data = _load(); + const hash = _sha256(token); + const s = _findByHash(data, hash); + if (!s) return null; + if (!_verifySignature(s, token)) { + log.warn && log.warn('share', 'peek rejected: bad signature', { id: s.id }); + return null; + } + if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) return null; + if (s.kind === 'tailscale' && s.usedAt) return null; + if (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP)) { + return null; + } + return _publicView(s); + }); + } + + function getRaw(token) { + if (!token || typeof token !== 'string') return null; + return _enqueue(() => { + const data = _load(); + const hash = _sha256(token); + const s = _findByHash(data, hash); + if (!s) return null; + if (!_verifySignature(s, token)) return null; + return s; + }); + } + + function recordPublicSubscribe(token) { + return _enqueue(() => { + const data = _load(); + const hash = _sha256(token); + const s = _findByHash(data, hash); + if (!s) return { ok: false, reason: 'not_found' }; + if (s.kind !== 'public') return { ok: false, reason: 'wrong_kind' }; + if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' }; + if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) { + return { ok: false, reason: 'expired' }; + } + const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP; + if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' }; + s.subscribeCount += 1; + _save(data); + return { ok: true, count: s.subscribeCount, cap }; + }); + } + + function recordTailscaleUse(token, { deviceId } = {}) { + return _enqueue(() => { + const data = _load(); + const hash = _sha256(token); + const s = _findByHash(data, hash); + if (!s) return { ok: false, reason: 'not_found' }; + if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' }; + if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' }; + if (s.usedAt) return { ok: false, reason: 'already_used' }; + if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) { + return { ok: false, reason: 'expired' }; + } + s.usedAt = _nowIso(); + s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown'; + _save(data); + return { ok: true, share: _publicView(s) }; + }); + } + + function revoke(id) { + return _enqueue(() => { + const data = _load(); + if (!data.shares[id]) return false; + delete data.shares[id]; + _save(data); + log.info && log.info('share', 'share revoked', { id }); + return true; + }); + } + + function list() { + return _enqueue(() => { + const data = _load(); + _prune(data); + return Object.values(data.shares).map(_publicView); + }); + } + + function listForService(serviceId) { + return _enqueue(() => { + const data = _load(); + _prune(data); + return Object.values(data.shares) + .filter(s => s.serviceId === serviceId) + .map(_publicView); + }); + } + + return { + issuePublic, + issueTailscale, + attachAuthKey, + peek, + getRaw, + recordPublicSubscribe, + recordTailscaleUse, + revoke, + list, + listForService, + // expose for tests + _signingSecret: signingSecret, + _file: file, + }; +} + +module.exports = { createShareStore }; \ No newline at end of file diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 2823696..55631ca 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -340,6 +340,11 @@ module.exports = function configureMiddleware(app, { // UI can show "this invite is for X, expires Y" before clicking. { path: '/api/v1/auth/invites/:token', exact: true, method: 'GET' }, { path: '/api/v1/auth/invites/:token/accept', exact: true, method: 'POST' }, + // DC-053: share-link redemption is PUBLIC — visitors arrive via email + // or social share with no DashCaddy session. The token IS the proof. + { path: '/api/v1/share/:token/preview', exact: true, method: 'GET' }, + { path: '/api/v1/share/:token/subscribe', exact: true, method: 'POST' }, + { path: '/api/v1/share/:token/redeem-tailscale', exact: true, method: 'POST' }, // /me and /admin/* require authentication — NOT public. Listed here // only to document them; absence from PUBLIC_ROUTES means they go // through the normal auth gate. CSRF applies to writes as usual.