/** * Network resources store — DC-144. * * A resource is an app or device reachable by name on the DashCaddy * Private Network (spec §2): `plex.internal`, `nas.internal`. Resources * feed the access map (DC-145) and the ACL compiler's dst entries * (DC-148). * * Storage: data/network-resources.json — atomic JSON pattern (fsync'd * tmp+rename via the shared atomic-write util), promise-serialized * mutations: * * { resources: [{ name, displayName, type: 'app'|'device', host, port, * protocol, sensitive: bool, manual: bool }] } * * - name: DashCaddy-local slug /^[a-z0-9-]+$/ (immutable, the key). * - port: integer 1-65535. host: non-empty string. * - sensitive: resources flagged sensitive additionally require DashCaddy * login + TOTP at the forward_auth layer (spec §4 step-up). * - manual: true for entries created via the API; false for entries * seeded from the services registry. * * Seeding: on first load, if `servicesFile` is provided and readable, the * store best-effort seeds one resource per services.json entry that has * an id and a usable port (host defaults to 'localhost'). Any failure — * missing file, unreadable JSON, no seedable entries — is skipped * silently: the store starts empty and manual entries only. Without a * `servicesFile` (tests, fresh installs) seeding is skipped entirely. */ 'use strict'; const path = require('path'); const fs = require('fs'); const platformPaths = require('../../platform-paths'); const { atomicWriteJSON } = require('../utils/atomic-write'); const SLUG_RE = /^[a-z0-9-]+$/; const TYPES = new Set(['app', 'device']); const PROTOCOLS = new Set(['http', 'https', 'tcp', 'udp']); class ResourceError extends Error { constructor(message, statusCode = 400, code = 'DC_RESOURCE_ERROR') { super(message); this.name = 'ResourceError'; this.statusCode = statusCode; this.code = code; this.isOperational = true; } } function _readJSON(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { if (err && err.code === 'ENOENT') return null; // caller distinguishes "absent" return fallback; } } function _normalizeResource(r) { return { name: r.name, displayName: typeof r.displayName === 'string' && r.displayName.length > 0 ? r.displayName : r.name, type: TYPES.has(r.type) ? r.type : 'app', host: r.host, port: r.port, protocol: typeof r.protocol === 'string' && r.protocol.length > 0 ? r.protocol : 'http', sensitive: r.sensitive === true, manual: r.manual !== false, // default true — manual entry }; } /** * Validate identity fields (name/displayName/type). `partial=true` allows * an omitted name (PATCH). Returns null when valid, else a problem. */ function _validateIdentity(input, partial) { if (!partial || input.name !== undefined) { if (typeof input.name !== 'string' || !SLUG_RE.test(input.name)) { return { message: `name must match ${SLUG_RE.toString()}`, field: 'name' }; } } if (input.displayName !== undefined && (typeof input.displayName !== 'string' || input.displayName.length === 0 || input.displayName.length > 200)) { return { message: 'displayName must be 1-200 chars', field: 'displayName' }; } if (input.type !== undefined && !TYPES.has(input.type)) { return { message: "type must be 'app' or 'device'", field: 'type' }; } return null; } /** * Validate connectivity/flag fields (host/port/protocol/sensitive/manual). * `partial=true` allows omitted host/port (PATCH). Returns null when * valid, else a problem. */ function _validateConnectivity(input, partial) { if (!partial || input.host !== undefined) { if (typeof input.host !== 'string' || input.host.trim().length === 0 || input.host.length > 253) { return { message: 'host is required (non-empty, ≤253 chars)', field: 'host' }; } } if (!partial || input.port !== undefined) { const p = input.port; if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) { return { message: 'port must be an integer between 1 and 65535', field: 'port' }; } } if (input.protocol !== undefined && !PROTOCOLS.has(input.protocol)) { return { message: 'protocol must be one of http|https|tcp|udp', field: 'protocol' }; } if (input.sensitive !== undefined && typeof input.sensitive !== 'boolean') { return { message: 'sensitive must be a boolean', field: 'sensitive' }; } if (input.manual !== undefined && typeof input.manual !== 'boolean') { return { message: 'manual must be a boolean', field: 'manual' }; } return null; } /** * Validate a resource payload. `partial=true` allows omitted fields * (PATCH). Returns null when valid, else a { message, field } problem. */ function validateResourcePayload(input, { partial = false } = {}) { if (!input || typeof input !== 'object' || Array.isArray(input)) { return { message: 'body must be a JSON object', field: null }; } return _validateIdentity(input, partial) || _validateConnectivity(input, partial); } function createResourcesStore(opts = {}) { 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(); // eslint-disable-line global-require const log = opts.log || { info() {}, warn() {}, error() {} }; const file = path.join(dataDir, 'network-resources.json'); let _mutex = Promise.resolve(); function _enqueue(fn) { const next = _mutex.then(fn, fn); _mutex = next.catch(() => {}); return next; } function _load() { const data = _readJSON(file, { resources: [] }); if (!data || !Array.isArray(data.resources)) return { resources: [] }; return { resources: data.resources .filter(r => r && typeof r === 'object' && typeof r.name === 'string') .map(_normalizeResource), }; } function _save(data) { atomicWriteJSON(file, data); } /** * Normalize one services.json entry into a seedable resource, or null * when the entry is unusable (no slug id / no integer port 1-65535). * host falls back through host/ip/container to 'localhost'. */ function _seedableServiceEntry(s) { if (!s || typeof s !== 'object') return null; let name = null; if (typeof s.id === 'string') name = s.id; else if (typeof s.name === 'string') name = s.name; if (!name || !SLUG_RE.test(name)) return null; const port = s.port; if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) return null; const host = [s.host, s.ip, s.container].find(h => typeof h === 'string' && h.length > 0) || 'localhost'; return _normalizeResource({ name, displayName: name, type: 'app', host, port, protocol: s.protocol || 'http', sensitive: false, manual: false, }); } /** * Best-effort one-time seed from the services registry (services.json — * the same file routes/services.js reads via StateManager). Only entries * with an id and an integer port 1-65535 are seedable. Any problem ⇒ * skip seeding entirely (manual entries only) — never throw at startup. */ function _seedFromServices() { if (!opts.servicesFile || typeof opts.servicesFile !== 'string') return 0; let services = null; try { const raw = JSON.parse(fs.readFileSync(opts.servicesFile, 'utf8')); if (Array.isArray(raw)) services = raw; else if (raw && Array.isArray(raw.services)) services = raw.services; } catch (_) { return 0; // missing/unreadable — skip seeding } if (!services) return 0; const seeded = []; for (const s of services) { const entry = _seedableServiceEntry(s); if (entry) seeded.push(entry); } if (seeded.length === 0) return 0; _save({ resources: seeded }); log.info && log.info('network-resources', 'seeded from services registry', { count: seeded.length }); return seeded.length; } function listResources() { return _enqueue(() => { if (!fs.existsSync(file)) _seedFromServices(); return [..._load().resources]; }); } async function getResource(name) { const all = await listResources(); return all.find(r => r.name === name) || null; } function createResource(input = {}) { return _enqueue(() => { const problem = validateResourcePayload(input, { partial: false }); if (problem) throw new ResourceError(problem.message, 400, 'DC_RESOURCE_INVALID'); const data = _load(); if (data.resources.some(r => r.name === input.name)) { throw new ResourceError(`resource '${input.name}' already exists`, 409, 'DC_RESOURCE_EXISTS'); } const resource = _normalizeResource({ name: input.name, displayName: input.displayName !== undefined ? input.displayName : input.name, type: input.type !== undefined ? input.type : 'app', host: input.host.trim ? input.host.trim() : input.host, port: input.port, protocol: input.protocol !== undefined ? input.protocol : 'http', sensitive: input.sensitive === true, manual: input.manual !== undefined ? input.manual : true, }); data.resources.push(resource); _save(data); log.info && log.info('network-resources', 'resource created', { name: resource.name }); return { ...resource }; }); } function updateResource(name, patch = {}) { return _enqueue(() => { const problem = validateResourcePayload(patch, { partial: true }); if (problem) throw new ResourceError(problem.message, 400, 'DC_RESOURCE_INVALID'); if (patch.name !== undefined && patch.name !== name) { throw new ResourceError('resource name is immutable', 400, 'DC_RESOURCE_IMMUTABLE_NAME'); } const data = _load(); const idx = data.resources.findIndex(r => r.name === name); if (idx < 0) throw new ResourceError(`resource '${name}' not found`, 404, 'DC_RESOURCE_NOT_FOUND'); const merged = _normalizeResource({ ...data.resources[idx], ...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}), ...(patch.type !== undefined ? { type: patch.type } : {}), ...(patch.host !== undefined ? { host: patch.host.trim ? patch.host.trim() : patch.host } : {}), ...(patch.port !== undefined ? { port: patch.port } : {}), ...(patch.protocol !== undefined ? { protocol: patch.protocol } : {}), ...(patch.sensitive !== undefined ? { sensitive: patch.sensitive } : {}), ...(patch.manual !== undefined ? { manual: patch.manual } : {}), }); data.resources[idx] = merged; _save(data); return { ...merged }; }); } function deleteResource(name) { return _enqueue(() => { const data = _load(); const before = data.resources.length; data.resources = data.resources.filter(r => r.name !== name); if (data.resources.length === before) { throw new ResourceError(`resource '${name}' not found`, 404, 'DC_RESOURCE_NOT_FOUND'); } _save(data); log.info && log.info('network-resources', 'resource deleted', { name }); return { deleted: true, name }; }); } return { listResources, getResource, createResource, updateResource, deleteResource, }; } module.exports = { createResourcesStore, ResourceError, validateResourcePayload };