DC-053: Public share links + Tailscale-mediated share (Pro-gated)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

- 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.
This commit is contained in:
Krystie
2026-07-21 00:45:46 -07:00
parent f0afc4358c
commit d9e61ce1b7
10 changed files with 1588 additions and 4 deletions
+2 -1
View File
@@ -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/<token>`) 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).
+1
View File
@@ -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.
@@ -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
@@ -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;
}
});
});
+312
View File
@@ -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); }
});
});
+342
View File
@@ -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;
+28
View File
@@ -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,
@@ -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',
+414
View File
@@ -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 };
@@ -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.