P7 Private Network [grade=A] (PR #1)
Judge A r18 urn:ump:rgxdivsp2xf6zy5lsdoclvdgb7cuqd3qpwlszt2p7fu7xwa3zkkq. 3,399/3,399 tests.
This commit was merged in pull request #1.
This commit is contained in:
commit
e04787477a
58 files changed
+16670
-9
No files matched your search
@@ -392,7 +392,70 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
||||
}))).filter(Boolean);
|
||||
|
||||
ok(res, { certificates });
|
||||
}, 'ca-certs'));
|
||||
}, 'ca-certs'));
|
||||
|
||||
return router;
|
||||
// DC-147: encrypted root-key backup. The root key never leaves the host
|
||||
// unencrypted: this route returns an aes-256-cbc (pbkdf2, 100k iters)
|
||||
// passphrase-wrapped copy plus a sha256 checksum, only after a passphrase
|
||||
// is supplied, only to admin scope, rate-limited like the other cert
|
||||
// endpoints (shares the /cert/* bucket — one fewer bypass to reason about).
|
||||
router.post('/backup', ctx.asyncHandler(async (req, res) => {
|
||||
if (!requireCaCertAdminScope(req, res)) return;
|
||||
|
||||
const clientIp = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
const rl = caCertRateLimit(clientIp);
|
||||
if (!rl.allowed) {
|
||||
res.setHeader('Retry-After', Math.ceil(rl.retryAfterMs / 1000));
|
||||
return ctx.errorResponse(res, 429,
|
||||
`Rate limit exceeded for /api/v1/ca/backup (${CA_CERT_RATE_LIMIT.max} req/${CA_CERT_RATE_LIMIT.windowMs / 1000}s per IP). Retry in ${Math.ceil(rl.retryAfterMs / 1000)}s.`,
|
||||
{ code: 'DC147_RATE_LIMITED', retryAfterMs: rl.retryAfterMs });
|
||||
}
|
||||
res.setHeader('X-RateLimit-Limit', String(CA_CERT_RATE_LIMIT.max));
|
||||
res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
|
||||
|
||||
const { backupRootKey } = require('../src/network/ca-setup');
|
||||
const passphrase = req.body && req.body.passphrase;
|
||||
if (typeof passphrase !== 'string' || passphrase.length < 8) {
|
||||
return ctx.errorResponse(res, 400,
|
||||
'A passphrase of at least 8 characters (body: { "passphrase": "..." }) is required to wrap the backup.',
|
||||
{ code: 'DC147_PASSPHRASE_REQUIRED' });
|
||||
}
|
||||
|
||||
// Root key lives in the CA cert dir (ca-setup.js flow) or the Caddy PKI
|
||||
// dir (existing pipeline). Probe both; refuse politely when absent.
|
||||
const path = require('path');
|
||||
const candidates = [
|
||||
path.join(platformPaths.caCertDir, 'root.key'),
|
||||
path.join(platformPaths.pkiDir, 'root.key')
|
||||
];
|
||||
const keyPath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!keyPath) {
|
||||
return ctx.errorResponse(res, 404, 'Root key not found on this host.', { code: 'DC147_ROOT_KEY_MISSING' });
|
||||
}
|
||||
|
||||
let backup;
|
||||
try {
|
||||
backup = backupRootKey({ dir: path.dirname(keyPath), passphrase });
|
||||
} catch (e) {
|
||||
return ctx.errorResponse(res, 500, `Backup generation failed: ${e.message}`, { code: 'DC147_BACKUP_FAILED' });
|
||||
}
|
||||
|
||||
// Defense-in-depth: never ship the raw key, even if a future refactor
|
||||
// breaks the cipher below us.
|
||||
if (typeof backup.ciphertext !== 'string' || backup.ciphertext.includes('PRIVATE KEY')) {
|
||||
return ctx.errorResponse(res, 500, 'Backup sanity check failed — refusing to emit.', { code: 'DC147_BACKUP_UNSAFE' });
|
||||
}
|
||||
|
||||
ok(res, {
|
||||
backup: {
|
||||
ciphertext: backup.ciphertext,
|
||||
sha256: backup.sha256,
|
||||
algorithm: backup.algorithm,
|
||||
filename: 'dashcaddy-root-key.backup.enc'
|
||||
},
|
||||
instructions: 'Decrypt with: openssl aes-256-cbc -d -pbkdf2 -iter 100000 -a -in dashcaddy-root-key.backup.enc -out root.key'
|
||||
});
|
||||
}, 'ca-backup'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Network access grid routes — DC-145.
|
||||
*
|
||||
* Mounted by app.js under the authed apiRouter as /network/access (the
|
||||
* factory receives the store; no auth middleware lives here).
|
||||
*
|
||||
* GET /network/access → whole access map {grants, updatedAt}
|
||||
* PUT /network/access → atomic whole-grid save (validates,
|
||||
* dedupes last-wins) → saved map
|
||||
* POST /network/access/grant → upsert one grant {principal,resource,allowed}
|
||||
* DELETE /network/access/grant → remove one grant {principal,resource}
|
||||
* GET /network/access/effective/:principalType/:principalId
|
||||
* → effectiveAccess({type,id,groups?})
|
||||
* groups from ?groups=a,b query param
|
||||
*
|
||||
* The access map file (not provider state) is the single source of truth —
|
||||
* writes here fire the store's change event which the ACL compiler (DC-148)
|
||||
* subscribes to.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
const { ok, created } = require('../src/utils/responses');
|
||||
|
||||
const PRINCIPAL_TYPES = ['person', 'group'];
|
||||
|
||||
module.exports = function networkAccessRoutesFactory({
|
||||
accessStore,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router();
|
||||
|
||||
// Store is required. Same lenient-404 policy as share routes: universal
|
||||
// deps stubs in test environments may not provide one; real runtime
|
||||
// misconfig must 404, not 500.
|
||||
if (!accessStore
|
||||
|| typeof accessStore.getAccessMap !== 'function'
|
||||
|| typeof accessStore.saveAccessMap !== 'function') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
log.warn && log.warn('network-access', 'accessStore missing — network access routes returning 404 in this environment');
|
||||
} else {
|
||||
throw new Error('networkAccessRoutes requires accessStore');
|
||||
}
|
||||
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-145] network access store unavailable' }));
|
||||
return router;
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkAccessRoutes requires asyncHandler');
|
||||
}
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function _parsePrincipalBody(body) {
|
||||
const principal = body && body.principal;
|
||||
if (!principal || typeof principal !== 'object' || Array.isArray(principal)) {
|
||||
throw new ValidationError('principal is required ({type, id})', 'principal');
|
||||
}
|
||||
if (!PRINCIPAL_TYPES.includes(principal.type)) {
|
||||
throw new ValidationError('principal.type must be "person" or "group"', 'principal.type');
|
||||
}
|
||||
if (typeof principal.id !== 'string' || principal.id.trim().length === 0) {
|
||||
throw new ValidationError('principal.id must be a non-empty string', 'principal.id');
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
function _parseResource(body) {
|
||||
const resource = body && body.resource;
|
||||
if (typeof resource !== 'string' || resource.trim().length === 0) {
|
||||
throw new ValidationError('resource must be a non-empty string', 'resource');
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
// ─── GET / — whole map ───────────────────────────────────────────────────
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const map = await accessStore.getAccessMap();
|
||||
ok(res, { grants: map.grants, updatedAt: map.updatedAt });
|
||||
}, 'network-access-get-map'));
|
||||
|
||||
// ─── PUT / — whole-grid atomic save ──────────────────────────────────────
|
||||
router.put('/', asyncHandler(async (req, res) => {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new ValidationError('body must be an object with a grants array', 'grants');
|
||||
}
|
||||
const result = await accessStore.saveAccessMap(body);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_access_map', 'grants');
|
||||
}
|
||||
log.info && log.info('network-access', 'whole-grid save', {
|
||||
count: result.map.grants.length, by: req.user && req.user.email,
|
||||
});
|
||||
ok(res, { grants: result.map.grants, updatedAt: result.map.updatedAt });
|
||||
}, 'network-access-put-map'));
|
||||
|
||||
// ─── POST /grant — upsert one grant ──────────────────────────────────────
|
||||
router.post('/grant', asyncHandler(async (req, res) => {
|
||||
const principal = _parsePrincipalBody(req.body);
|
||||
const resource = _parseResource(req.body);
|
||||
const { allowed } = req.body || {};
|
||||
if (typeof allowed !== 'boolean') {
|
||||
throw new ValidationError('allowed must be a boolean', 'allowed');
|
||||
}
|
||||
const result = await accessStore.setGrant(principal, resource, allowed);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_grant', 'grant');
|
||||
}
|
||||
created(res, { grant: { principal, resource, allowed }, updatedAt: result.map.updatedAt });
|
||||
}, 'network-access-set-grant'));
|
||||
|
||||
// ─── DELETE /grant — remove one grant ────────────────────────────────────
|
||||
router.delete('/grant', asyncHandler(async (req, res) => {
|
||||
// Accept the grant from the JSON body or from the query string so
|
||||
// curl-style deletes work without a body parser dependency.
|
||||
const body = { ...(req.body || {}), ...req.query };
|
||||
const principal = _parsePrincipalBody(body);
|
||||
const resource = _parseResource(body);
|
||||
const result = await accessStore.removeGrant(principal, resource);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_grant', 'grant');
|
||||
}
|
||||
if (!result.removed) {
|
||||
// Idempotent semantics could also be 200; we 404 so the admin UI can
|
||||
// distinguish a real remove from a no-op (stale grid).
|
||||
throw new NotFoundError('grant not found');
|
||||
}
|
||||
ok(res, { removed: true, updatedAt: result.map.updatedAt });
|
||||
}, 'network-access-remove-grant'));
|
||||
|
||||
// ─── GET /effective/:principalType/:principalId ──────────────────────────
|
||||
router.get('/effective/:principalType/:principalId', asyncHandler(async (req, res) => {
|
||||
const { principalType, principalId } = req.params;
|
||||
if (!PRINCIPAL_TYPES.includes(principalType)) {
|
||||
throw new ValidationError('principalType must be "person" or "group"', 'principalType');
|
||||
}
|
||||
const principal = { type: principalType, id: principalId };
|
||||
if (principalType === 'person' && typeof req.query.groups === 'string'
|
||||
&& req.query.groups.length > 0) {
|
||||
principal.groups = req.query.groups.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
const result = await accessStore.effectiveAccess(principal);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_principal', 'principal');
|
||||
}
|
||||
ok(res, { principal: { type: principalType, id: principalId }, resources: result.resources });
|
||||
}, 'network-access-effective'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* forward_auth enforcement routes — DC-154 (spec §4 Layer 2).
|
||||
*
|
||||
* GET|POST /network/forward-auth/ — the Caddy forward_auth target
|
||||
*
|
||||
* This route IS the auth check — deliberately NO auth middleware in front
|
||||
* of it and completely stateless. Caddy calls it on every protected
|
||||
* request; a 2xx lets the request through, anything else is the answer the
|
||||
* client sees. Mounted via the SAME lazy-provider pattern as the WG redeem
|
||||
* route (src/app.js p7WgRedeemLazy): no active provider → 503
|
||||
* { error: 'network provider not active' }.
|
||||
*
|
||||
* Caddy convention: the client IP arrives in X-Forwarded-For (set from the
|
||||
* remote host by the reverse proxy), a chained comma list whose FIRST
|
||||
* entry is the original client. parseForwardedFor() extracts it; fall back
|
||||
* to req.ip when the header is absent.
|
||||
*
|
||||
* Host: X-Forwarded-Host first (proxy-rewritten), then Host.
|
||||
*
|
||||
* TRUST BOUNDARY: forwarded headers are consulted only when the immediate
|
||||
* peer is in trustedProxies; every other request is identified by its
|
||||
* socket address alone.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse an X-Forwarded-For chain into IP strings.
|
||||
* Returns [] when the header is missing/empty.
|
||||
* '203.0.113.7' → ['203.0.113.7']
|
||||
* '203.0.113.7, 10.53.0.1, 10.53.0.2' → ['203.0.113.7', '10.53.0.1', '10.53.0.2']
|
||||
* Port-bearing entries are trimmed: '203.0.113.7:1234' → '203.0.113.7'.
|
||||
* IPv6 bracketed forms keep their brackets stripped: '[fd00::1]:443' → 'fd00::1'.
|
||||
*/
|
||||
function parseForwardedFor(header) {
|
||||
if (typeof header !== 'string') return [];
|
||||
return header
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => {
|
||||
// ipv6 "[addr]:port" or "[addr]"
|
||||
const v6 = part.match(/^\[([0-9a-fA-F:.]+)\](?::\d+)?$/);
|
||||
if (v6) return v6[1];
|
||||
// "addr:port" — only strip when the tail is all digits and the head
|
||||
// is not itself a bare ipv6 (no second colon).
|
||||
const colon = part.lastIndexOf(':');
|
||||
if (colon !== -1 && part.indexOf(':') === colon && /^\d+$/.test(part.slice(colon + 1))) {
|
||||
return part.slice(0, colon);
|
||||
}
|
||||
return part;
|
||||
})
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
/** Build the forward_auth router. */
|
||||
module.exports = function networkForwardAuthRoutesFactory({
|
||||
service,
|
||||
asyncHandler,
|
||||
trustedProxies = null, // e.g. ['127.0.0.1', '::1'] — the Caddy proxy addrs
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router(); // eslint-disable-line global-require
|
||||
|
||||
if (!service || typeof service.check !== 'function') {
|
||||
throw new Error('networkForwardAuthRoutes requires a forward-auth service (check)');
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkForwardAuthRoutes requires asyncHandler');
|
||||
}
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign
|
||||
}
|
||||
|
||||
// Trust boundary (r7 judge blocker): forwarded headers are honored ONLY
|
||||
// when the immediate peer (socket remote address) is an explicitly
|
||||
// configured trusted proxy (the Caddy reverse proxy on the host). A direct
|
||||
// request — or one arriving via any other hop — gets its headers IGNORED,
|
||||
// so a spoofed X-Forwarded-For cannot smuggle a tailnet identity.
|
||||
function isTrustedPeer(req) {
|
||||
if (trustedProxies === null) {
|
||||
// disabled trust: never honor forwarded headers
|
||||
return false;
|
||||
}
|
||||
const peer =
|
||||
(req.socket && req.socket.remoteAddress) ||
|
||||
req.connection?.remoteAddress || null;
|
||||
if (!peer) return false;
|
||||
const norm = peer.startsWith('::ffff:') ? peer.slice(7) : peer;
|
||||
return trustedProxies.includes(norm);
|
||||
}
|
||||
|
||||
function sourceIpFor(req) {
|
||||
if (isTrustedPeer(req)) {
|
||||
const chain = parseForwardedFor(req.headers['x-forwarded-for']);
|
||||
if (chain.length > 0) return chain[0]; // first entry = original client
|
||||
}
|
||||
// Untrusted/absent proxy info: the socket address is the only
|
||||
// non-spoofable source. (whois of a proxy LAN IP simply resolves to
|
||||
// unknown-device — safe default, never an inherited identity.)
|
||||
const sock = (req.socket && req.socket.remoteAddress) ||
|
||||
req.connection?.remoteAddress || req.ip || null;
|
||||
if (typeof sock === 'string' && sock.startsWith('::ffff:')) {
|
||||
return sock.slice(7);
|
||||
}
|
||||
return sock;
|
||||
}
|
||||
|
||||
function hostFor(req) {
|
||||
if (isTrustedPeer(req)) {
|
||||
const fwd = req.headers['x-forwarded-host'];
|
||||
if (typeof fwd === 'string' && fwd.trim().length > 0) {
|
||||
return fwd.split(',')[0].trim();
|
||||
}
|
||||
}
|
||||
return req.headers.host || req.hostname || null;
|
||||
}
|
||||
|
||||
const handle = asyncHandler(async (req, res) => {
|
||||
const sourceIp = sourceIpFor(req);
|
||||
const host = hostFor(req);
|
||||
const decision = await service.check({
|
||||
sourceIp,
|
||||
host,
|
||||
requestPath: req.originalUrl || req.url,
|
||||
req,
|
||||
});
|
||||
for (const [name, value] of Object.entries(decision.headers || {})) {
|
||||
res.set(name, value);
|
||||
}
|
||||
return res.status(decision.status).json(decision.body);
|
||||
}, 'network-forward-auth');
|
||||
|
||||
router.get('/', handle);
|
||||
router.post('/', handle);
|
||||
|
||||
log.info && log.info('network:forward-auth', 'forward_auth route mounted (stateless)');
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports.parseForwardedFor = parseForwardedFor;
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Network groups routes — DC-144.
|
||||
*
|
||||
* CRUD for DashCaddy-side groups (spec §2): a group is an id/name used in
|
||||
* person.groups[] and access grants. HARD RULE (spec §2): DashCaddy
|
||||
* groups are UI-side ONLY — they never map to Tailscale ACL groups (free
|
||||
* plans cap TS groups at 3; compiled ACLs are per-user). Group ids are
|
||||
* validated as DashCaddy-local slug strings, enforced in the service.
|
||||
*
|
||||
* GET /network/groups → list (derived from people store +
|
||||
* names map at network-groups.json)
|
||||
* POST /network/groups → create { id, name }
|
||||
* PATCH /network/groups/:id → rename ({ id?, name? }; re-points
|
||||
* person.groups[] + grant refs)
|
||||
* DELETE /network/groups/:id → delete + cascade removal from
|
||||
* people.groups[] and grants
|
||||
*
|
||||
* 'Admin' is the well-known group: cannot be renamed or deleted (409).
|
||||
*
|
||||
* Mounted on the authed apiRouter — auth middleware is the caller's job
|
||||
* (same as every other route module in this repo). Factory pattern.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
|
||||
const { ok, created } = require('../src/utils/responses');
|
||||
const { createGroupsService, GroupError } = require('../src/security/network-groups');
|
||||
|
||||
module.exports = function networkGroupsRoutesFactory({
|
||||
groupsService,
|
||||
peopleStore,
|
||||
accessStore,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router(); // eslint-disable-line global-require
|
||||
|
||||
const service = groupsService || createGroupsService({ peopleStore, accessStore, log });
|
||||
if (!service || typeof service.listGroups !== 'function') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
log.warn && log.warn('network-groups', 'groupsService missing — routes returning 404 in this environment');
|
||||
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-144] groups unavailable' }));
|
||||
return router;
|
||||
}
|
||||
throw new Error('networkGroupsRoutes requires groupsService (or peopleStore + accessStore)');
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
// Must always be wired in prod (see share.js); test fallback only.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkGroupsRoutes requires asyncHandler');
|
||||
}
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign
|
||||
}
|
||||
|
||||
// Map GroupError (statusCode-carrying, operational) onto the repo's
|
||||
// error classes so the shared error handler renders the right status.
|
||||
function _map(err) {
|
||||
if (err instanceof GroupError) {
|
||||
if (err.statusCode === 404) return new NotFoundError('group');
|
||||
if (err.statusCode === 409) return new ConflictError(err.message);
|
||||
return new ValidationError(err.message, 'group');
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const groups = await service.listGroups();
|
||||
ok(res, { data: groups, count: groups.length });
|
||||
}, 'network-groups-list'));
|
||||
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const group = await service.createGroup(req.body || {});
|
||||
created(res, { group });
|
||||
} catch (err) {
|
||||
throw _map(err);
|
||||
}
|
||||
}, 'network-groups-create'));
|
||||
|
||||
router.patch('/:id', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const group = await service.renameGroup(req.params.id, req.body || {});
|
||||
ok(res, { group });
|
||||
} catch (err) {
|
||||
throw _map(err);
|
||||
}
|
||||
}, 'network-groups-rename'));
|
||||
|
||||
router.delete('/:id', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const result = await service.deleteGroup(req.params.id);
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
throw _map(err);
|
||||
}
|
||||
}, 'network-groups-delete'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Network invite routes — DC-142 (link-first invites via network provider).
|
||||
*
|
||||
* GET /api/v1/network/invites → list active invites via provider
|
||||
* POST /api/v1/network/invites → create an invite (link-first)
|
||||
* DELETE /api/v1/network/invites/:id → revoke an invite (204)
|
||||
*
|
||||
* Link-first contract (DC-085 pattern, specs/PRIVATE-NETWORK-SPEC.md §5):
|
||||
* - The inviteUrl IS the deliverable. Email is an opt-in checkbox
|
||||
* (`sendEmail: true`, strict === true — default NO email).
|
||||
* - POST response ALWAYS includes { inviteUrl, shareText, deliveredVia }:
|
||||
* shareText — pre-formed human sentence (never a JSON blob), built
|
||||
* server-side so every client picks up the wording
|
||||
* deliveredVia — 'manual' (no email requested) | 'email' (provider
|
||||
* confirmed the send) | 'failed' (email was requested but
|
||||
* the send failed — the link is STILL returned so the
|
||||
* operator can share it manually)
|
||||
* - NEVER log raw inviteUrl/token values. Every log call goes through
|
||||
* redact() which replaces link-bearing fields with '[REDACTED]'. The DC-048
|
||||
* mistake (echoing invite links to the server console) must not return.
|
||||
*
|
||||
* Provider resolution goes exclusively through deps.networkRegistry
|
||||
* (src/network registry). When no provider is connected the registry throws
|
||||
* ProviderNotActiveError; this module maps it (and a missing registry) to a
|
||||
* 503 with code 'provider_not_active' — the setup wizard (DC-152) is the fix.
|
||||
*
|
||||
* Hermetic by construction: the factory takes an injectable registry, logger
|
||||
* and asyncHandler; tests pass a mocked provider, no network.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
const { ok, created, noContent } = require('../src/utils/responses');
|
||||
const { ProviderError, ProviderNotActiveError } = require('../src/network/errors');
|
||||
|
||||
/** Tailscale user-invite roles (provider contract, DC-140). */
|
||||
const VALID_ROLES = new Set(['member', 'admin']);
|
||||
|
||||
const REDACTED = '[REDACTED]';
|
||||
/** Fields whose values embed the raw invite link — never allowed into logs. */
|
||||
const REDACT_KEYS = new Set(['inviteUrl', 'acceptUrl', 'url', 'token', 'shareText']);
|
||||
|
||||
/**
|
||||
* Shallow-copy a log-fields object, replacing link/token-bearing values.
|
||||
* The admin already has the link in the API response; the console does not
|
||||
* need a copy (DC-085 rule 2).
|
||||
*/
|
||||
function redact(fields) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(fields || {})) {
|
||||
out[key] = REDACT_KEYS.has(key) ? REDACTED : value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 503 — no network provider connected (mapped from ProviderNotActiveError). */
|
||||
class ProviderInactiveError extends Error {
|
||||
constructor(message) {
|
||||
super(message || 'No active network provider configured');
|
||||
this.name = 'ProviderInactiveError';
|
||||
this.statusCode = 503;
|
||||
this.code = 'provider_not_active';
|
||||
this.isOperational = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the provider's createInvite result shape. */
|
||||
function assertInviteResult(invite) {
|
||||
if (!invite || typeof invite.inviteUrl !== 'string' || !invite.inviteUrl) {
|
||||
throw new Error('provider createInvite() must return { inviteUrl }');
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull the still-valid link off a failed email send, or null. */
|
||||
function linkFromEmailFailure(err) {
|
||||
if (!err || err.code !== 'email_send_failed') return null;
|
||||
const details = err.details;
|
||||
const url = err.inviteUrl || (details && details.inviteUrl);
|
||||
return url ? { url, id: (details && details.id) || null } : null;
|
||||
}
|
||||
|
||||
module.exports = function networkInvitesRoutesFactory({
|
||||
networkRegistry,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router();
|
||||
|
||||
const safeAsync = typeof asyncHandler === 'function'
|
||||
? asyncHandler
|
||||
: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
/** Resolve the active provider or throw the 503-mapped inactive error. */
|
||||
function requireProvider() {
|
||||
if (!networkRegistry || typeof networkRegistry.getActiveProvider !== 'function') {
|
||||
throw new ProviderInactiveError();
|
||||
}
|
||||
try {
|
||||
return networkRegistry.getActiveProvider();
|
||||
} catch (err) {
|
||||
if (err instanceof ProviderNotActiveError || (err && err.code === 'provider_not_active')) {
|
||||
throw new ProviderInactiveError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map ProviderError codes onto HTTP statuses for the error middleware. */
|
||||
function mapProviderError(err) {
|
||||
if (err instanceof ProviderInactiveError) return err;
|
||||
if (err && (err instanceof ProviderError || typeof err.code === 'string')) {
|
||||
if (err.code === 'not_found') return new NotFoundError(err.message || 'invite not found');
|
||||
if (err.code === 'bad_input') return new ValidationError(err.message || 'invalid input', 'invite');
|
||||
if (err.code === 'provider_not_active') return new ProviderInactiveError(err.message);
|
||||
const upstream = new Error(err.message || 'network provider error');
|
||||
upstream.statusCode = 502;
|
||||
upstream.code = err.code || 'provider_error';
|
||||
upstream.isOperational = true;
|
||||
return upstream;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email leg of the link-first flow (only entered when sendEmail === true).
|
||||
* Never a total failure when the link exists: a failed send downgrades to
|
||||
* deliveredVia 'failed' with the link still in the response (DC-085 rule 1).
|
||||
*/
|
||||
async function createInviteWithEmail(provider, { role, email }) {
|
||||
let invite;
|
||||
let deliveredVia;
|
||||
try {
|
||||
invite = await provider.createInvite({ role, email });
|
||||
assertInviteResult(invite);
|
||||
// Providers confirm the email leg with emailSent: true on the result.
|
||||
deliveredVia = invite.emailSent === true ? 'email' : 'failed';
|
||||
if (deliveredVia === 'failed') {
|
||||
log.warn && log.warn(
|
||||
'network-invites',
|
||||
'provider did not confirm email delivery — link returned for manual sharing',
|
||||
redact({ inviteId: invite.id || null, email, role })
|
||||
);
|
||||
}
|
||||
return { invite, deliveredVia };
|
||||
} catch (err) {
|
||||
const salvaged = linkFromEmailFailure(err);
|
||||
if (salvaged) {
|
||||
log.warn && log.warn(
|
||||
'network-invites',
|
||||
'provider email send failed — link returned for manual sharing',
|
||||
redact({ inviteId: salvaged.id, email, role })
|
||||
);
|
||||
return { invite: { inviteUrl: salvaged.url, id: salvaged.id }, deliveredVia: 'failed' };
|
||||
}
|
||||
throw mapProviderError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET / — list active invites ────────────────────────────────────────
|
||||
router.get('/', safeAsync(async (_req, res) => {
|
||||
const provider = requireProvider();
|
||||
// Capability degradation (spec §3): providers without listInvites (the
|
||||
// DC-140 tailscale provider does not expose it yet) get an honest empty
|
||||
// list instead of a 500.
|
||||
if (typeof provider.listInvites !== 'function') {
|
||||
log.info && log.info('network-invites', 'provider cannot list invites — returning empty list');
|
||||
ok(res, { data: [], count: 0, supported: false });
|
||||
return;
|
||||
}
|
||||
const invites = await provider.listInvites();
|
||||
if (!Array.isArray(invites)) {
|
||||
throw new ValidationError('provider listInvites() must return an array', 'provider');
|
||||
}
|
||||
ok(res, { data: invites, count: invites.length, supported: true });
|
||||
}));
|
||||
|
||||
// ─── POST / — create an invite (link-first, email opt-in) ───────────────
|
||||
router.post('/', safeAsync(async (req, res) => {
|
||||
const body = req.body || {};
|
||||
|
||||
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : '';
|
||||
if (!displayName || displayName.length > 80) {
|
||||
throw new ValidationError('displayName is required (1-80 characters)', 'displayName');
|
||||
}
|
||||
const role = body.role;
|
||||
if (!VALID_ROLES.has(role)) {
|
||||
throw new ValidationError('role must be one of: ' + [...VALID_ROLES].join(', '), 'role');
|
||||
}
|
||||
|
||||
// Strict opt-in: only sendEmail === true counts (never 'yes'/1/'on').
|
||||
const sendEmail = body.sendEmail === true;
|
||||
|
||||
let email;
|
||||
if (body.email !== undefined && body.email !== null && body.email !== '') {
|
||||
if (typeof body.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
||||
throw new ValidationError('email must be a valid address when provided', 'email');
|
||||
}
|
||||
email = body.email;
|
||||
}
|
||||
if (sendEmail && !email) {
|
||||
throw new ValidationError('sendEmail: true requires an email address', 'sendEmail');
|
||||
}
|
||||
|
||||
const provider = requireProvider();
|
||||
|
||||
let invite;
|
||||
let deliveredVia;
|
||||
if (sendEmail) {
|
||||
({ invite, deliveredVia } = await createInviteWithEmail(provider, { role, email }));
|
||||
} else {
|
||||
// Default path: no email key at all — the provider mints the link
|
||||
// WITHOUT sending anything (Tailscale user-invites: email optional).
|
||||
try {
|
||||
invite = await provider.createInvite({ role });
|
||||
} catch (err) {
|
||||
throw mapProviderError(err);
|
||||
}
|
||||
assertInviteResult(invite);
|
||||
deliveredVia = 'manual';
|
||||
}
|
||||
|
||||
// Pre-formed human sentence (never a JSON blob). Server-side canonical
|
||||
// wording so every client picks it up on refresh (DC-085 rule 3).
|
||||
const shareText = displayName
|
||||
+ ' invited you to join the family network — open this link on your device: '
|
||||
+ invite.inviteUrl;
|
||||
|
||||
log.info && log.info(
|
||||
'network-invites',
|
||||
'invite created',
|
||||
redact({ inviteId: invite.id || null, role, deliveredVia, email })
|
||||
);
|
||||
|
||||
created(res, {
|
||||
inviteUrl: invite.inviteUrl,
|
||||
shareText,
|
||||
deliveredVia,
|
||||
id: invite.id || null,
|
||||
role,
|
||||
displayName,
|
||||
});
|
||||
}));
|
||||
|
||||
// ─── DELETE /:id — revoke an invite ─────────────────────────────────────
|
||||
router.delete('/:id', safeAsync(async (req, res) => {
|
||||
const id = req.params && req.params.id;
|
||||
if (!id) throw new ValidationError('invite id is required', 'id');
|
||||
const provider = requireProvider();
|
||||
try {
|
||||
await provider.revokeInvite(id);
|
||||
} catch (err) {
|
||||
throw mapProviderError(err);
|
||||
}
|
||||
// inviteId is provider metadata, not the token — safe to log.
|
||||
log.info && log.info('network-invites', 'invite revoked', { inviteId: id });
|
||||
noContent(res);
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Network lifecycle routes — DC-143 (specs/PRIVATE-NETWORK-SPEC.md §5).
|
||||
*
|
||||
* Standalone router (same factory pattern as routes/network-people.js);
|
||||
* auth middleware is the caller's job:
|
||||
*
|
||||
* POST /network/people/:id/block → block person (deny network, expire
|
||||
* device keys, kill sessions, purge
|
||||
* grants with snapshot for restore)
|
||||
* POST /network/people/:id/unblock → honest unblock (restore grants +
|
||||
* status; Tailscale devices must sign
|
||||
* in again — response says so)
|
||||
* POST /network/people/:id/remove → remove person + provider devices +
|
||||
* grants + sessions
|
||||
* POST /network/devices/:id/revoke → revoke one device (body: {personId})
|
||||
*
|
||||
* Errors: LastAdminError → 409, ProviderNotActiveError → 503, other
|
||||
* ProviderError → 502, NotFound → 404, ValidationError → 400.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ProviderError, ProviderNotActiveError } = require('../src/network/errors');
|
||||
const { LastAdminError } = require('../src/security/network-people-store');
|
||||
const { ok } = require('../src/utils/responses');
|
||||
|
||||
function _statusFor(err) {
|
||||
if (err instanceof LastAdminError) return 409;
|
||||
if (err instanceof ProviderNotActiveError) return 503;
|
||||
if (err instanceof ProviderError) return 502;
|
||||
if (err && err.statusCode) return err.statusCode;
|
||||
if (err && err.name === 'ValidationError') return 400;
|
||||
if (err && err.name === 'NotFoundError') return 404;
|
||||
return 500;
|
||||
}
|
||||
|
||||
module.exports = function networkLifecycleRoutesFactory({
|
||||
lifecycle,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router(); // eslint-disable-line global-require
|
||||
|
||||
if (!lifecycle) {
|
||||
throw new Error('networkLifecycleRoutes requires a lifecycle service');
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkLifecycleRoutes requires asyncHandler');
|
||||
}
|
||||
/* eslint-disable no-param-reassign */
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
/* eslint-enable no-param-reassign */
|
||||
}
|
||||
|
||||
const wrap = (fn) => asyncHandler(async (req, res) => {
|
||||
try {
|
||||
await fn(req, res);
|
||||
} catch (err) {
|
||||
log.warn && log.warn('network-lifecycle', 'route error', {
|
||||
path: req.path,
|
||||
err: err && err.message,
|
||||
code: err && err.code,
|
||||
});
|
||||
const status = _statusFor(err);
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: err && err.message ? err.message : 'lifecycle failure',
|
||||
code: (err && err.code) || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/people/:id/block', wrap(async (req, res) => {
|
||||
const result = await lifecycle.blockPerson(req.params.id);
|
||||
ok(res, { success: true, ...result });
|
||||
}));
|
||||
|
||||
router.post('/people/:id/unblock', wrap(async (req, res) => {
|
||||
const result = await lifecycle.unblockPerson(req.params.id);
|
||||
// Honest-unblock (spec §5): the note is part of the contract —
|
||||
// Tailscale users must be told their devices need to sign in again;
|
||||
// WireGuard users are told peers reconnect immediately.
|
||||
ok(res, { success: true, ...result });
|
||||
}));
|
||||
|
||||
router.post('/people/:id/remove', wrap(async (req, res) => {
|
||||
const result = await lifecycle.removePerson(req.params.id);
|
||||
ok(res, { success: true, ...result });
|
||||
}));
|
||||
|
||||
router.post('/devices/:id/revoke', wrap(async (req, res) => {
|
||||
const personId = req.body && req.body.personId;
|
||||
if (!personId || typeof personId !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'personId is required in the body',
|
||||
code: 'bad_input',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await lifecycle.revokeDevice(personId, req.params.id);
|
||||
ok(res, { success: true, ...result });
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Network people routes — DC-141.
|
||||
*
|
||||
* CRUD for the Private Network people store (spec §2/§5) plus a provider
|
||||
* sync endpoint. Mounted on the authed apiRouter under
|
||||
* /api/v1/network/people — auth middleware is the caller's job (same as
|
||||
* every other route module in this repo).
|
||||
*
|
||||
* GET /network/people → list all people
|
||||
* GET /network/people/:id → one person
|
||||
* POST /network/people → create (first person = bootstrap
|
||||
* admin: groups ['Admin'] + protectedAt)
|
||||
* PATCH /network/people/:id → update
|
||||
* DELETE /network/people/:id → delete (assertNotLastAdmin first)
|
||||
* POST /network/people/sync → merge provider.listPeople() into the
|
||||
* store (idempotent, guarded)
|
||||
*
|
||||
* The provider registry (DC-140, src/network/) is built in parallel; the
|
||||
* only dependency is deps.networkRegistry.getActiveProvider() — treated
|
||||
* as optional. When absent, POST /sync returns a clean "provider
|
||||
* inactive" response instead of throwing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
|
||||
const { ok, created } = require('../src/utils/responses');
|
||||
const { createPeopleStore } = require('../src/security/network-people-store');
|
||||
const { LastAdminError } = require('../src/security/network-people-store');
|
||||
|
||||
const VALID_STATUSES = new Set(['active', 'blocked', 'removed']);
|
||||
|
||||
function _validDisplayName(s) {
|
||||
return typeof s === 'string' && s.trim().length > 0 && s.length <= 200;
|
||||
}
|
||||
|
||||
function _validProviderUserId(s) {
|
||||
return typeof s === 'string' && s.length > 0 && s.length <= 256;
|
||||
}
|
||||
|
||||
module.exports = function networkPeopleRoutesFactory({
|
||||
peopleStore,
|
||||
networkRegistry,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router(); // eslint-disable-line global-require
|
||||
|
||||
const store = peopleStore || createPeopleStore({});
|
||||
if (!asyncHandler) {
|
||||
// Must always be wired in prod (see share.js); test fallback only.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkPeopleRoutes requires asyncHandler');
|
||||
}
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign
|
||||
}
|
||||
|
||||
// ─── Reads ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const people = await store.listPeople();
|
||||
ok(res, { data: people, count: people.length });
|
||||
}, 'network-people-list'));
|
||||
|
||||
router.get('/:id', asyncHandler(async (req, res) => {
|
||||
const person = await store.getPerson(req.params.id);
|
||||
if (!person) throw new NotFoundError('person');
|
||||
ok(res, { person });
|
||||
}, 'network-people-get'));
|
||||
|
||||
// ─── Create ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
const { displayName, providerUserId, groups, devices } = req.body || {};
|
||||
if (!_validDisplayName(displayName)) {
|
||||
throw new ValidationError('displayName is required (1-200 chars)', 'displayName');
|
||||
}
|
||||
if (!_validProviderUserId(providerUserId)) {
|
||||
throw new ValidationError('providerUserId is required (1-256 chars)', 'providerUserId');
|
||||
}
|
||||
if (groups !== undefined && !Array.isArray(groups)) {
|
||||
throw new ValidationError('groups must be an array of strings', 'groups');
|
||||
}
|
||||
if (devices !== undefined && !Array.isArray(devices)) {
|
||||
throw new ValidationError('devices must be an array', 'devices');
|
||||
}
|
||||
// First person created in an empty store becomes the bootstrap admin
|
||||
// (groups ['Admin'] + protectedAt) — enforced inside the store.
|
||||
const person = await store.upsertPerson({
|
||||
displayName: displayName.trim(),
|
||||
providerUserId,
|
||||
...(groups !== undefined ? { groups } : {}),
|
||||
...(devices !== undefined ? { devices } : {}),
|
||||
});
|
||||
log.info && log.info('people', 'person created via API', { id: person.id });
|
||||
created(res, { person });
|
||||
}, 'network-people-create'));
|
||||
|
||||
// ─── Update ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.patch('/:id', asyncHandler(async (req, res) => {
|
||||
const patch = req.body || {};
|
||||
if (patch.displayName !== undefined && !_validDisplayName(patch.displayName)) {
|
||||
throw new ValidationError('displayName must be 1-200 chars', 'displayName');
|
||||
}
|
||||
if (patch.providerUserId !== undefined && !_validProviderUserId(patch.providerUserId)) {
|
||||
throw new ValidationError('providerUserId must be 1-256 chars', 'providerUserId');
|
||||
}
|
||||
if (patch.groups !== undefined && !Array.isArray(patch.groups)) {
|
||||
throw new ValidationError('groups must be an array of strings', 'groups');
|
||||
}
|
||||
if (patch.devices !== undefined && !Array.isArray(patch.devices)) {
|
||||
throw new ValidationError('devices must be an array', 'devices');
|
||||
}
|
||||
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) {
|
||||
throw new ValidationError(`status must be one of active|blocked|removed`, 'status');
|
||||
}
|
||||
const updated = await store.updatePerson(req.params.id, patch);
|
||||
if (!updated) throw new NotFoundError('person');
|
||||
ok(res, { person: updated });
|
||||
}, 'network-people-update'));
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.delete('/:id', asyncHandler(async (req, res) => {
|
||||
// Lockout guard first (spec §5): the last active admin is undeletable.
|
||||
try {
|
||||
await store.assertNotLastAdmin(req.params.id);
|
||||
} catch (err) {
|
||||
if (err instanceof LastAdminError) {
|
||||
throw new ConflictError('cannot remove the last active admin');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const deleted = await store.deletePerson(req.params.id);
|
||||
if (!deleted) throw new NotFoundError('person');
|
||||
ok(res, { deleted: true, id: req.params.id });
|
||||
}, 'network-people-delete'));
|
||||
|
||||
// ─── Provider sync ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Merge provider.listPeople() into the local store:
|
||||
// - match by providerUserId
|
||||
// - add people not present locally
|
||||
// - update displayName of existing people
|
||||
// - idempotent: running twice yields no further change
|
||||
// - never downgrades protectedAt (admin protection is sticky)
|
||||
// - blocked/removed people are NOT resurrected by sync — a local
|
||||
// terminal status wins over the provider saying the user exists.
|
||||
|
||||
router.post('/sync', asyncHandler(async (req, res) => {
|
||||
const registry = networkRegistry;
|
||||
if (!registry || typeof registry.getActiveProvider !== 'function') {
|
||||
// Feature inactive (provider registry not built yet / no provider
|
||||
// configured) — report cleanly instead of 500ing.
|
||||
ok(res, { synced: false, reason: 'network provider not active', added: 0, updated: 0 });
|
||||
return;
|
||||
}
|
||||
let provider = null;
|
||||
try {
|
||||
provider = registry.getActiveProvider();
|
||||
} catch (_) {
|
||||
provider = null;
|
||||
}
|
||||
if (!provider || typeof provider.listPeople !== 'function') {
|
||||
ok(res, { synced: false, reason: 'network provider not active', added: 0, updated: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const providerPeople = await provider.listPeople();
|
||||
if (!Array.isArray(providerPeople)) {
|
||||
throw new ValidationError('provider listPeople() must return an array', 'provider');
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
for (const pp of providerPeople) {
|
||||
if (!pp || !_validProviderUserId(pp.providerUserId)) continue; // skip junk
|
||||
const existing = await store.findByProviderUserId(pp.providerUserId);
|
||||
if (!existing) {
|
||||
await store.upsertPerson({
|
||||
displayName: _validDisplayName(pp.displayName) ? pp.displayName : pp.providerUserId,
|
||||
providerUserId: pp.providerUserId,
|
||||
groups: [],
|
||||
devices: [],
|
||||
});
|
||||
added += 1;
|
||||
} else if (existing.status === 'active'
|
||||
&& _validDisplayName(pp.displayName)
|
||||
&& pp.displayName !== existing.displayName) {
|
||||
await store.updatePerson(existing.id, { displayName: pp.displayName });
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
log.info && log.info('people', 'provider sync complete', { added, updated });
|
||||
ok(res, { synced: true, added, updated });
|
||||
}, 'network-people-sync'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Network resources routes — DC-144.
|
||||
*
|
||||
* CRUD for Private Network resources (spec §2): apps or devices reachable
|
||||
* by name (`plex.internal`, `nas.internal`) — the dst side of the access
|
||||
* map (DC-145) and the compiled ACLs (DC-148).
|
||||
*
|
||||
* GET /network/resources → list
|
||||
* GET /network/resources/:name → one resource
|
||||
* POST /network/resources → create (name slug, port
|
||||
* 1-65535, host non-empty)
|
||||
* PATCH /network/resources/:name → update (name immutable)
|
||||
* DELETE /network/resources/:name → delete
|
||||
*
|
||||
* `sensitive` resources additionally require DashCaddy login + TOTP at
|
||||
* the forward_auth layer (spec §4 step-up); here it just round-trips.
|
||||
*
|
||||
* Mounted on the authed apiRouter — auth middleware is the caller's job
|
||||
* (same as every other route module in this repo). Factory pattern.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
|
||||
const { ok, created } = require('../src/utils/responses');
|
||||
const { createResourcesStore, ResourceError } = require('../src/security/network-resources');
|
||||
|
||||
module.exports = function networkResourcesRoutesFactory({
|
||||
resourcesStore,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router(); // eslint-disable-line global-require
|
||||
|
||||
const store = resourcesStore || createResourcesStore({ log });
|
||||
if (!store || typeof store.listResources !== 'function') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
log.warn && log.warn('network-resources', 'resourcesStore missing — routes returning 404 in this environment');
|
||||
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-144] resources unavailable' }));
|
||||
return router;
|
||||
}
|
||||
throw new Error('networkResourcesRoutes requires resourcesStore');
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
// Must always be wired in prod (see share.js); test fallback only.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkResourcesRoutes requires asyncHandler');
|
||||
}
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign
|
||||
}
|
||||
|
||||
// Map ResourceError (statusCode-carrying, operational) onto the repo's
|
||||
// error classes so the shared error handler renders the right status.
|
||||
function _map(err) {
|
||||
if (err instanceof ResourceError) {
|
||||
if (err.statusCode === 404) return new NotFoundError('resource');
|
||||
if (err.statusCode === 409) return new ConflictError(err.message);
|
||||
return new ValidationError(err.message, 'resource');
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const resources = await store.listResources();
|
||||
ok(res, { data: resources, count: resources.length });
|
||||
}, 'network-resources-list'));
|
||||
|
||||
router.get('/:name', asyncHandler(async (req, res) => {
|
||||
const resource = await store.getResource(req.params.name);
|
||||
if (!resource) throw new NotFoundError('resource');
|
||||
ok(res, { resource });
|
||||
}, 'network-resources-get'));
|
||||
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const resource = await store.createResource(req.body || {});
|
||||
log.info && log.info('network-resources', 'resource created via API', { name: resource.name });
|
||||
created(res, { resource });
|
||||
} catch (err) {
|
||||
throw _map(err);
|
||||
}
|
||||
}, 'network-resources-create'));
|
||||
|
||||
router.patch('/:name', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const resource = await store.updateResource(req.params.name, req.body || {});
|
||||
ok(res, { resource });
|
||||
} catch (err) {
|
||||
throw _map(err);
|
||||
}
|
||||
}, 'network-resources-update'));
|
||||
|
||||
router.delete('/:name', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const result = await store.deleteResource(req.params.name);
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
throw _map(err);
|
||||
}
|
||||
}, 'network-resources-delete'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* WireGuard invite redemption routes — DC-150.
|
||||
*
|
||||
* PUBLIC, unauthenticated mount (the invite token IS the proof, spec §8.5):
|
||||
*
|
||||
* GET /network/wg/redeem/:token → self-contained redemption page
|
||||
* (browser X25519 keygen, spec §8.2)
|
||||
* POST /network/wg/redeem/:token → redeem API: validates the one-time
|
||||
* token, registers the browser-generated
|
||||
* PUBLIC key, returns the client config
|
||||
*
|
||||
* Key-lifecycle invariants (NORMATIVE, spec §8.2):
|
||||
* - The client keypair is generated in the invitee's BROWSER via WebCrypto
|
||||
* X25519; the server receives ONLY the base64 public key.
|
||||
* - The private key never transits or rests on the server: it lives in the
|
||||
* page session, rendered into the config text + QR CLIENT-side.
|
||||
* - This module must never reference, accept, or emit a private key.
|
||||
*
|
||||
* The provider contract used here (DC-149, src/network/providers/wireguard.js):
|
||||
* - provider.redeemInvite(token) — single-use + 7d TTL, marks the token
|
||||
* used on success, throws on unknown/used/expired tokens.
|
||||
* (Note: the DC-149 provider has no separate getInvite peek; redeemInvite
|
||||
* is the validation primitive. Any failure there maps to 410 Gone.)
|
||||
* - provider.addPeer({ personId, deviceId, publicKey, name }) →
|
||||
* { address, allowedIps, dns, hubPublicKey, hubAddress, listenPort, ... }
|
||||
*
|
||||
* Middleware registration (NOT done here — src/utilities/middleware.js is
|
||||
* drift-test guarded): export PUBLIC_REDEEM_PATH_PREFIX and add
|
||||
* { path: PUBLIC_REDEEM_PATH_PREFIX, prefix: true } → PUBLIC_ROUTES
|
||||
* plus the CSRF excludedPaths entry for the POST.
|
||||
* The CSRF skip is safe because the token is single-use, 7-day TTL, and the
|
||||
* page is served cross-origin cold (no session cookie exists yet).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/** Public mount prefix — also the constant middleware registration needs. */
|
||||
const PUBLIC_REDEEM_PATH_PREFIX = '/network/wg/redeem/';
|
||||
|
||||
/** WireGuard public/private key format: 43 base64 chars + '=' pad → 32 bytes. */
|
||||
const WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
|
||||
|
||||
const MAX_DEVICE_NAME = 100;
|
||||
|
||||
/**
|
||||
* Deterministic personId for a redeemed invite. The DC-149 redeemInvite
|
||||
* returns { role, email } only (no person record), so the route derives a
|
||||
* stable id: invite-carried personId > invite email > hash of the token.
|
||||
* Deterministic-on-token means a retried redemption (e.g. addPeer failed
|
||||
* after the token burned) maps to the same person, not a duplicate.
|
||||
*/
|
||||
function derivePersonId(invite, token) {
|
||||
if (invite && typeof invite.personId === 'string' && invite.personId) {
|
||||
return invite.personId;
|
||||
}
|
||||
if (invite && typeof invite.email === 'string' && invite.email.includes('@')) {
|
||||
return 'wge:' + invite.email;
|
||||
}
|
||||
return (
|
||||
'wgi:' + crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 24)
|
||||
);
|
||||
}
|
||||
|
||||
function validDeviceName(s) {
|
||||
return typeof s === 'string' && s.trim().length > 0 && s.trim().length <= MAX_DEVICE_NAME;
|
||||
}
|
||||
|
||||
function validPublicKey(s) {
|
||||
if (typeof s !== 'string' || !WG_KEY_RE.test(s)) return false;
|
||||
try {
|
||||
return Buffer.from(s, 'base64').length === 32;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The redemption page. Self-contained: no build step, no server-side
|
||||
* keygen, no server-side QR dependency (CDN qrcode with a graceful
|
||||
* copyable-text + download fallback when the CDN is unreachable).
|
||||
*/
|
||||
function renderRedemptionPage() {
|
||||
return '<!DOCTYPE html>\n'
|
||||
+ '<html lang="en">\n'
|
||||
+ '<head>\n'
|
||||
+ '<meta charset="utf-8">\n'
|
||||
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
+ '<meta name="robots" content="noindex, nofollow">\n'
|
||||
+ '<title>DashCaddy Private Network — Device Setup</title>\n'
|
||||
+ '<style>\n'
|
||||
+ ' :root { color-scheme: dark; }\n'
|
||||
+ ' body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; display: flex; min-height: 100vh; align-items: center; justify-content: center; }\n'
|
||||
+ ' .card { background: #1e293b; border-radius: 12px; padding: 2rem; max-width: 640px; width: 92%; box-shadow: 0 10px 40px rgba(0,0,0,.4); }\n'
|
||||
+ ' h1 { font-size: 1.25rem; margin: 0 0 .5rem; }\n'
|
||||
+ ' p.sub { color: #94a3b8; margin-top: 0; }\n'
|
||||
+ ' label { display: block; margin: 1rem 0 .35rem; font-size: .9rem; }\n'
|
||||
+ ' input[type=text] { width: 100%; box-sizing: border-box; padding: .6rem .75rem; border-radius: 8px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: 1rem; }\n'
|
||||
+ ' button { margin-top: 1.25rem; width: 100%; padding: .7rem; border: 0; border-radius: 8px; background: #2563eb; color: #fff; font-size: 1rem; cursor: pointer; }\n'
|
||||
+ ' button:disabled { opacity: .5; cursor: wait; }\n'
|
||||
+ ' .warn { background: #451a03; border: 1px solid #b45309; color: #fbbf24; padding: .75rem; border-radius: 8px; font-size: .85rem; margin: 1rem 0; }\n'
|
||||
+ ' .err { background: #450a0a; border: 1px solid #b91c1c; color: #fca5a5; padding: .75rem; border-radius: 8px; font-size: .85rem; margin: 1rem 0; }\n'
|
||||
+ ' pre { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 1rem; overflow-x: auto; font-size: .8rem; white-space: pre-wrap; word-break: break-all; }\n'
|
||||
+ ' canvas { background: #fff; border-radius: 8px; margin: 1rem auto; display: block; max-width: 260px; }\n'
|
||||
+ ' a.dl { display: inline-block; margin-top: .75rem; color: #60a5fa; }\n'
|
||||
+ ' .hidden { display: none; }\n'
|
||||
+ '</style>\n'
|
||||
+ '</head>\n'
|
||||
+ '<body>\n'
|
||||
+ '<div class="card">\n'
|
||||
+ ' <h1>Join the DashCaddy Private Network</h1>\n'
|
||||
+ ' <p class="sub">Your device keys are generated <strong>in this browser</strong> and never leave it — the server only receives your public key.</p>\n'
|
||||
+ ' <div id="form-area">\n'
|
||||
+ ' <label for="deviceName">Device name</label>\n'
|
||||
+ ' <input type="text" id="deviceName" maxlength="100" placeholder="e.g. sam-laptop" autocomplete="off">\n'
|
||||
+ ' <button id="go" type="button">Generate keys & join</button>\n'
|
||||
+ ' </div>\n'
|
||||
+ ' <div id="err" class="err hidden"></div>\n'
|
||||
+ ' <div id="result" class="hidden">\n'
|
||||
+ ' <div class="warn"><strong>Save this config now.</strong> The private key exists only in this page session and is shown exactly once — it is never sent to the server and cannot be recovered later.</div>\n'
|
||||
+ ' <pre id="config"></pre>\n'
|
||||
+ ' <canvas id="qr" width="260" height="260" class="hidden"></canvas>\n'
|
||||
+ ' <p id="qr-fallback" class="sub hidden">QR unavailable (offline / CDN blocked) — use the copyable config above or the download link below.</p>\n'
|
||||
+ ' <a id="dl" class="dl" download="dashcaddy-wg.conf">Download config file</a>\n'
|
||||
+ ' </div>\n'
|
||||
+ '</div>\n'
|
||||
+ '<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js" onerror="window.__qrCdnFailed=true"></script>\n'
|
||||
+ '<script>\n'
|
||||
+ '(function () {\n'
|
||||
+ ' "use strict";\n'
|
||||
+ ' // Fixed PKCS#8 DER prefix for an X25519 private key (16 bytes):\n'
|
||||
+ ' // SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.110 }, OCTET STRING (32) }\n'
|
||||
+ ' var PKCS8_X25519_PREFIX = [0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x6e,0x04,0x22,0x04,0x20];\n'
|
||||
+ ' function toB64(buf) {\n'
|
||||
+ ' var s = String.fromCharCode.apply(null, new Uint8Array(buf));\n'
|
||||
+ ' return btoa(s);\n'
|
||||
+ ' }\n'
|
||||
+ ' function fail(msg) {\n'
|
||||
+ ' var e = document.getElementById("err");\n'
|
||||
+ ' e.textContent = msg;\n'
|
||||
+ ' e.classList.remove("hidden");\n'
|
||||
+ ' document.getElementById("go").disabled = false;\n'
|
||||
+ ' }\n'
|
||||
+ ' function tokenFromPath() {\n'
|
||||
+ ' var m = location.pathname.match(/\\/network\\/wg\\/redeem\\/([^/?#]+)/);\n'
|
||||
+ ' return m ? decodeURIComponent(m[1]) : null;\n'
|
||||
+ ' }\n'
|
||||
+ ' async function generateKeypair() {\n'
|
||||
+ ' if (!window.crypto || !window.crypto.subtle || !window.crypto.subtle.generateKey) {\n'
|
||||
+ ' throw new Error("This browser has no WebCrypto (crypto.subtle) — use a modern browser over HTTPS.");\n'
|
||||
+ ' }\n'
|
||||
+ ' var kp;\n'
|
||||
+ ' try {\n'
|
||||
+ ' kp = await window.crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);\n'
|
||||
+ ' } catch (e) {\n'
|
||||
+ ' throw new Error("This browser cannot generate X25519 keys (required for WireGuard). Use an up-to-date browser.");\n'
|
||||
+ ' }\n'
|
||||
+ ' var rawPub = await window.crypto.subtle.exportKey("raw", kp.publicKey);\n'
|
||||
+ ' var pkcs8 = new Uint8Array(await window.crypto.subtle.exportKey("pkcs8", kp.privateKey));\n'
|
||||
+ ' // HARD FAIL on prefix mismatch: never slice a key of unknown layout.\n'
|
||||
+ ' if (pkcs8.length !== 48) { throw new Error("Unexpected pkcs8 length (" + pkcs8.length + ") — refusing to derive private key."); }\n'
|
||||
+ ' for (var i = 0; i < PKCS8_X25519_PREFIX.length; i++) {\n'
|
||||
+ ' if (pkcs8[i] !== PKCS8_X25519_PREFIX[i]) { throw new Error("pkcs8 prefix mismatch at byte " + i + " — refusing to derive private key."); }\n'
|
||||
+ ' }\n'
|
||||
+ ' var rawPriv = pkcs8.slice(PKCS8_X25519_PREFIX.length); // raw 32 bytes\n'
|
||||
+ ' return { publicKey: toB64(rawPub), privateKey: toB64(rawPriv) };\n'
|
||||
+ ' }\n'
|
||||
+ ' function renderConfig(cfg, deviceName, privB64) {\n'
|
||||
+ ' var lines = [\n'
|
||||
+ ' "[Interface]",\n'
|
||||
+ ' "PrivateKey = " + privB64,\n'
|
||||
+ ' "Address = " + cfg.address,\n'
|
||||
+ ' "DNS = " + cfg.dns,\n'
|
||||
+ ' "",\n'
|
||||
+ ' "[Peer]",\n'
|
||||
+ ' "PublicKey = " + cfg.hubPublicKey,\n'
|
||||
+ ' "AllowedIPs = " + cfg.allowedIps,\n'
|
||||
+ ' "Endpoint = " + cfg.endpoint,\n'
|
||||
+ ' "PersistentKeepalive = 25",\n'
|
||||
+ ' ""\n'
|
||||
+ ' ];\n'
|
||||
+ ' var text = lines.join("\\n");\n'
|
||||
+ ' var pre = document.getElementById("config");\n'
|
||||
+ ' pre.textContent = text;\n'
|
||||
+ ' var blob = new Blob([text], { type: "text/plain" });\n'
|
||||
+ ' var dl = document.getElementById("dl");\n'
|
||||
+ ' dl.href = URL.createObjectURL(blob);\n'
|
||||
+ ' dl.download = "dashcaddy-" + String(deviceName).replace(/[^A-Za-z0-9._-]+/g, "-") + ".conf";\n'
|
||||
+ ' document.getElementById("form-area").classList.add("hidden");\n'
|
||||
+ ' document.getElementById("result").classList.remove("hidden");\n'
|
||||
+ ' var canvas = document.getElementById("qr");\n'
|
||||
+ ' if (window.QRCode && typeof window.QRCode.toCanvas === "function") {\n'
|
||||
+ ' window.QRCode.toCanvas(canvas, text, { width: 260, margin: 1 }, function (err) {\n'
|
||||
+ ' if (err) { canvas.classList.add("hidden"); document.getElementById("qr-fallback").classList.remove("hidden"); }\n'
|
||||
+ ' else { canvas.classList.remove("hidden"); }\n'
|
||||
+ ' });\n'
|
||||
+ ' } else {\n'
|
||||
+ ' canvas.classList.add("hidden");\n'
|
||||
+ ' document.getElementById("qr-fallback").classList.remove("hidden");\n'
|
||||
+ ' }\n'
|
||||
+ ' }\n'
|
||||
+ ' document.getElementById("go").addEventListener("click", async function () {\n'
|
||||
+ ' var btn = this;\n'
|
||||
+ ' btn.disabled = true;\n'
|
||||
+ ' document.getElementById("err").classList.add("hidden");\n'
|
||||
+ ' var deviceName = document.getElementById("deviceName").value.trim();\n'
|
||||
+ ' if (!deviceName) { fail("Please enter a device name."); return; }\n'
|
||||
+ ' var token = tokenFromPath();\n'
|
||||
+ ' if (!token) { fail("Invalid invite link (no token in URL)."); return; }\n'
|
||||
+ ' var keys;\n'
|
||||
+ ' try { keys = await generateKeypair(); } catch (e) { fail(e.message); return; }\n'
|
||||
+ ' try {\n'
|
||||
+ ' // ONLY the public key is sent — never the private key.\n'
|
||||
+ ' var res = await fetch(location.pathname, {\n'
|
||||
+ ' method: "POST",\n'
|
||||
+ ' headers: { "Content-Type": "application/json" },\n'
|
||||
+ ' body: JSON.stringify({ token: token, deviceName: deviceName, publicKey: keys.publicKey })\n'
|
||||
+ ' });\n'
|
||||
+ ' if (res.status === 410) { fail("This invite link has expired or was already used."); return; }\n'
|
||||
+ ' if (res.status === 400) { var b = await res.json(); fail(b.error || "Invalid request."); return; }\n'
|
||||
+ ' if (!res.ok) { fail("Redemption failed (" + res.status + "). " + (await res.text()).slice(0, 120)); return; }\n'
|
||||
+ ' var body = await res.json();\n'
|
||||
+ ' renderConfig(body.config, body.deviceName || deviceName, keys.privateKey);\n'
|
||||
+ ' } catch (e) {\n'
|
||||
+ ' fail("Network error: " + e.message);\n'
|
||||
+ ' }\n'
|
||||
+ ' });\n'
|
||||
+ '})();\n'
|
||||
+ '</script>\n'
|
||||
+ '</body>\n'
|
||||
+ '</html>\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the redemption router.
|
||||
*
|
||||
* @param {object} deps
|
||||
* @param {object} deps.provider - WireGuard NetworkProvider (DC-149):
|
||||
* redeemInvite(token) + addPeer({...}) are required.
|
||||
* @param {string} [deps.endpoint] - hub endpoint host:port for the client
|
||||
* config. Defaults to the request's own host + the port addPeer reports
|
||||
* (behind NAT the setup wizard supplies the explicit endpoint).
|
||||
* @param {Function} [deps.asyncHandler] - prod wiring; test fallback wraps.
|
||||
* @param {object} [deps.log] - logger; MUST NEVER receive the token value.
|
||||
*/
|
||||
module.exports = function networkWgRedeemRoutesFactory({
|
||||
provider,
|
||||
endpoint,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router(); // eslint-disable-line global-require
|
||||
|
||||
if (!provider || typeof provider.redeemInvite !== 'function' || typeof provider.addPeer !== 'function') {
|
||||
throw new Error('networkWgRedeemRoutes requires a WireGuard provider (redeemInvite + addPeer)');
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkWgRedeemRoutes requires asyncHandler');
|
||||
}
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign
|
||||
}
|
||||
|
||||
/** Derive the client-facing endpoint for THIS request if none injected.
|
||||
* The host's HTTP port is NOT the WG UDP port — always rebuild as
|
||||
* hostname:listenPort. */
|
||||
function endpointFor(req, listenPort) {
|
||||
if (endpoint) return endpoint;
|
||||
const fwd = req.headers['x-forwarded-host'];
|
||||
const rawHost = (typeof fwd === 'string' && fwd.split(',')[0].trim()) || req.get('host') || req.hostname;
|
||||
const hostname = String(rawHost).split(':')[0];
|
||||
const port = listenPort || 51820;
|
||||
return hostname + ':' + port;
|
||||
}
|
||||
|
||||
// ── GET /:token — the redemption page (static; token validated at POST) ──
|
||||
router.get('/:token', (req, res) => {
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store'); // page embeds the keygen flow — never cache
|
||||
res.set('X-Frame-Options', 'DENY');
|
||||
res.set('Referrer-Policy', 'no-referrer');
|
||||
res.send(renderRedemptionPage());
|
||||
});
|
||||
|
||||
// ── POST /:token — redeem (public API, rate-limit friendly: one store
|
||||
// read + one store write, no crypto on this path) ──────────────────────
|
||||
router.post('/:token', asyncHandler(async (req, res) => {
|
||||
const token = req.params.token;
|
||||
const { deviceName, publicKey } = req.body || {};
|
||||
|
||||
// 1. Input validation FIRST — never burn a one-time token on a bad body.
|
||||
if (!validDeviceName(deviceName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'deviceName is required (1-100 characters)',
|
||||
code: 'DC-400',
|
||||
});
|
||||
}
|
||||
if (!validPublicKey(publicKey)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'publicKey must be a base64 WireGuard public key (44 chars, decodes to 32 bytes)',
|
||||
code: 'DC-400',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Token validation + single-use burn (DC-149 redeemInvite: 7d TTL,
|
||||
// marks used on success, throws on unknown/used/expired). Any failure
|
||||
// here is a dead token → 410 Gone. The token value is never logged.
|
||||
let invite;
|
||||
try {
|
||||
invite = await provider.redeemInvite(token);
|
||||
} catch (err) {
|
||||
log.warn && log.warn('network:wg:redeem', 'invite token rejected (not logged)', {
|
||||
reason: (err && err.code) || 'unknown',
|
||||
});
|
||||
return res.status(410).json({
|
||||
success: false,
|
||||
error: 'This invite link has expired or was already used.',
|
||||
code: 'DC-410',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Register the browser-generated PUBLIC key as a hub peer.
|
||||
const personId = derivePersonId(invite, token);
|
||||
const deviceId = 'wgd_' + crypto.randomBytes(8).toString('hex');
|
||||
let added;
|
||||
try {
|
||||
added = await provider.addPeer({ personId, deviceId, publicKey, name: deviceName.trim() });
|
||||
} catch (err) {
|
||||
if (err && err.code === 'bad_input') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: err.message || 'peer rejected',
|
||||
code: 'DC-400',
|
||||
});
|
||||
}
|
||||
throw err; // provider/infra failure → app error handler (5xx)
|
||||
}
|
||||
|
||||
log.info && log.info('network:wg:redeem', 'device joined via invite', {
|
||||
personId,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
// 4. Client config. NO private key anywhere in this response — the
|
||||
// browser composes the full config (with its session-local private
|
||||
// key) client-side per spec §8.2.
|
||||
const cfg = added || {};
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
deviceName: deviceName.trim(),
|
||||
deviceId,
|
||||
config: {
|
||||
address: cfg.address,
|
||||
dns: cfg.dns || '10.53.0.1', // spec §8.3: Technitium on the hub IP
|
||||
hubPublicKey: cfg.hubPublicKey,
|
||||
allowedIps: cfg.allowedIps || '10.53.0.0/16', // spec §8.1: split tunnel
|
||||
endpoint: endpointFor(req, cfg.listenPort),
|
||||
},
|
||||
});
|
||||
}, 'network-wg-redeem'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports.PUBLIC_REDEEM_PATH_PREFIX = PUBLIC_REDEEM_PATH_PREFIX;
|
||||
module.exports.renderRedemptionPage = renderRedemptionPage;
|
||||
module.exports.derivePersonId = derivePersonId;
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Private Network wizard + status routes — DC-152
|
||||
* (specs/PRIVATE-NETWORK-SPEC.md §9).
|
||||
*
|
||||
* Mounted on the authed apiRouter under /api/v1/network/wizard (auth
|
||||
* middleware is the caller's job, same as every route module here).
|
||||
*
|
||||
* GET /state → current step + which checks pass
|
||||
* POST /mode → pick tailscale|wireguard + credentials
|
||||
* POST /domain → private TLD pick (default .internal)
|
||||
* POST /connect → health check + first sync
|
||||
* POST /dns → technitium checkSetup + split-DNS (gated)
|
||||
* POST /ca → named root CA (locked-in warning)
|
||||
* POST /admin → first person (bootstrap admin)
|
||||
* POST /complete → gate: all checks green → completedAt
|
||||
*
|
||||
* Status surface (same file, mounted separately under /network/status):
|
||||
* GET /status → who's online, recent connections,
|
||||
* ACL/firewall sync state, drift banner
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { createNetworkWizard, WizardError } = require('../src/network/wizard');
|
||||
const { ok, created, errorResponse } = require('../src/utils/responses');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const noopLog = { info() {}, warn() {}, error() {} };
|
||||
|
||||
function _asyncHandlerFallback(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a WizardError (or anything statusCode-shaped) to an HTTP response.
|
||||
*/
|
||||
function _wizardErrorHandler(res, err) {
|
||||
const status = err instanceof WizardError ? err.statusCode : (err.statusCode || 500);
|
||||
const payload = {
|
||||
success: false,
|
||||
error: err.message || 'wizard error',
|
||||
code: err.code || null,
|
||||
};
|
||||
if (err.extra && typeof err.extra === 'object' && Object.keys(err.extra).length) {
|
||||
payload.details = err.extra;
|
||||
}
|
||||
return errorResponse(res, status, payload.error, { code: payload.code, details: payload.details });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a wizard instance from loose route deps (prod path) or accept a
|
||||
* pre-built one (tests / composition root reuse).
|
||||
*/
|
||||
function _buildWizard(wizardDeps, stateFile) {
|
||||
return createNetworkWizard({
|
||||
registry: wizardDeps.registry,
|
||||
providers: wizardDeps.providers || {},
|
||||
peopleStore: wizardDeps.peopleStore,
|
||||
accessStore: wizardDeps.accessStore || null,
|
||||
technitium: wizardDeps.technitium || null,
|
||||
caSetup: wizardDeps.caSetup || null,
|
||||
credentialStore: wizardDeps.credentialStore || null,
|
||||
logger: wizardDeps.log || noopLog,
|
||||
...(stateFile ? { stateFile } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function networkWizardRoutesFactory({
|
||||
wizard = null, // pre-built orchestrator (optional)
|
||||
registry = null,
|
||||
providers = {},
|
||||
peopleStore = null,
|
||||
accessStore = null,
|
||||
technitium = null,
|
||||
caSetup = null,
|
||||
credentialStore = null,
|
||||
stateFile = null, // default: data/network-wizard-state.json
|
||||
connectionLogReader = null, // GET /status injectable
|
||||
aclStateReader = null, // GET /status injectable (acl-push state)
|
||||
firewallStateReader = null, // GET /status injectable (wg-firewall state)
|
||||
asyncHandler = null,
|
||||
log = noopLog,
|
||||
} = {}) {
|
||||
const router = express.Router();
|
||||
const wrap = asyncHandler || _asyncHandlerFallback;
|
||||
if (!asyncHandler && process.env.NODE_ENV !== 'test') {
|
||||
// Must always be wired in prod (see share.js); fallback is test-only.
|
||||
}
|
||||
|
||||
const w = wizard || _buildWizard(
|
||||
{ registry, providers, peopleStore, accessStore, technitium, caSetup, credentialStore, log },
|
||||
stateFile
|
||||
);
|
||||
|
||||
// ─── Wizard state ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/state', wrap(async (req, res) => {
|
||||
ok(res, w.getState());
|
||||
}, 'network-wizard-state'));
|
||||
|
||||
// ─── Step 1: mode + credentials ────────────────────────────────────────
|
||||
|
||||
router.post('/mode', wrap(async (req, res) => {
|
||||
try {
|
||||
const result = await w.setMode(req.body || {});
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
_wizardErrorHandler(res, err);
|
||||
}
|
||||
}, 'network-wizard-mode'));
|
||||
|
||||
// ─── Step 2: private domain ────────────────────────────────────────────
|
||||
|
||||
router.post('/domain', wrap(async (req, res) => {
|
||||
try {
|
||||
const result = w.setDomain(req.body || {});
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
_wizardErrorHandler(res, err);
|
||||
}
|
||||
}, 'network-wizard-domain'));
|
||||
|
||||
// ─── Step 3: connect + first sync ──────────────────────────────────────
|
||||
|
||||
router.post('/connect', wrap(async (req, res) => {
|
||||
try {
|
||||
const result = await w.connect();
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
_wizardErrorHandler(res, err);
|
||||
}
|
||||
}, 'network-wizard-connect'));
|
||||
|
||||
// ─── Step 4: DNS (technitium, capability-gated) ───────────────────────
|
||||
|
||||
router.post('/dns', wrap(async (req, res) => {
|
||||
try {
|
||||
const result = await w.configureDns();
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
_wizardErrorHandler(res, err);
|
||||
}
|
||||
}, 'network-wizard-dns'));
|
||||
|
||||
// ─── Step 5: CA ───────────────────────────────────────────────────────
|
||||
|
||||
router.post('/ca', wrap(async (req, res) => {
|
||||
try {
|
||||
const result = await w.setupCa(req.body || {});
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
_wizardErrorHandler(res, err);
|
||||
}
|
||||
}, 'network-wizard-ca'));
|
||||
|
||||
// ─── Step 6: first admin ──────────────────────────────────────────────
|
||||
|
||||
router.post('/admin', wrap(async (req, res) => {
|
||||
try {
|
||||
const result = await w.createAdmin(req.body || {});
|
||||
created(res, result);
|
||||
} catch (err) {
|
||||
_wizardErrorHandler(res, err);
|
||||
}
|
||||
}, 'network-wizard-admin'));
|
||||
|
||||
// ─── Completion gate ──────────────────────────────────────────────────
|
||||
|
||||
router.post('/complete', wrap(async (req, res) => {
|
||||
try {
|
||||
const result = w.complete();
|
||||
ok(res, result);
|
||||
} catch (err) {
|
||||
_wizardErrorHandler(res, err);
|
||||
}
|
||||
}, 'network-wizard-complete'));
|
||||
|
||||
// ─── Status (GET /status mounted here too; also exported standalone) ──
|
||||
|
||||
router.get('/status', wrap(async (req, res) => {
|
||||
ok(res, await w.getStatus({
|
||||
connectionLogReader,
|
||||
aclStateReader,
|
||||
firewallStateReader,
|
||||
}));
|
||||
}, 'network-wizard-status'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone status router for mounting at /api/v1/network/status without
|
||||
* the wizard routes. Same aggregation, same injectables.
|
||||
*/
|
||||
module.exports.networkStatusRoutesFactory = function networkStatusRoutesFactory({
|
||||
wizard = null,
|
||||
connectionLogReader = null,
|
||||
aclStateReader = null,
|
||||
firewallStateReader = null,
|
||||
asyncHandler = null,
|
||||
} = {}) {
|
||||
const router = express.Router();
|
||||
const wrap = asyncHandler || _asyncHandlerFallback;
|
||||
const w = wizard; // required for the standalone form
|
||||
router.get('/', wrap(async (req, res) => {
|
||||
if (!w) {
|
||||
return ok(res, {
|
||||
wizard: { completedAt: null, mode: null, privateTld: null },
|
||||
online: { devices: [], peopleOnline: 0 },
|
||||
recentConnections: { events: [], note: 'no connection log source configured' },
|
||||
sync: { acl: null, firewall: null },
|
||||
driftBanner: false,
|
||||
});
|
||||
}
|
||||
ok(res, await w.getStatus({
|
||||
connectionLogReader,
|
||||
aclStateReader,
|
||||
firewallStateReader,
|
||||
}));
|
||||
}, 'network-status'));
|
||||
return router;
|
||||
};
|
||||
|
||||
// Default state-file path resolution exported for the composition root.
|
||||
module.exports.defaultWizardStateFile = function defaultWizardStateFile(dataDir) {
|
||||
return path.join(dataDir || os.tmpdir(), 'network-wizard-state.json');
|
||||
};
|
||||
Reference in new issue
Block a user