Files
dashcaddy/dashcaddy-api/src/security/network-resources.js
T

309 lines
11 KiB
JavaScript

/**
* 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 };