8 reliability fixes across 8 files (AUDIT-2026-10-27.md findings
9/11/12/13/14/16/20 + calendar credential item):
- dns.js check-update/update HANGS: bare returns + empty else/catch
branches left requests hanging until client timeout; every path now
answers (502 with message); upstream JSON.parse wrapped (non-JSON ->
502)
- calendar.js ICS fetch timeout actually applies: fetchT ignores
opts.timeout (undici) — was running at 5s default, not the intended
15s; now timeoutMs third arg
- calendar.js PUT /sources: updating pass no longer nulls the stored
token (and vice versa) — existing credential record loaded, only the
provided field overwritten
- state-manager.js ATOMIC WRITES: _atomicWrite = tmp file + fs.rename
(POSIX-atomic); write()/update() no longer truncate-then-write the
live file (crash mid-write corrupted services.json/config.json with
no recovery path). Test mock extended to the tmp+rename contract
- dashboard-ws.js auth fallback FAIL-CLOSED: missing authVerifier used
to accept any non-empty session cookie (DC-061 footgun on unwired
boot paths); now rejects + loud CRITICAL log
- middleware.js tailscale gate: new tailscale.failClosed config (default
off = unchanged); when set, outages/errors deny (503) instead of
silently disabling the gate; fail-open path now logs the exact key
- sw.js: offline fallback actually works ((await match) || (await
match) — old Promise||Promise was always truthy); only 2xx cached
(5xx pages were stored and served as the app shell); install is
per-URL tolerant (one bad asset kept the stale SW alive forever)
- config/settings.js GET /config (public): corrupt config.json degrades
to {setupComplete:false, corrupt:true} instead of 500 on every load
SCOPE CORRECTION: audit finding #10 (7 reversed-arg errorResponse
sites) was a FALSE POSITIVE — those files alias message-first error();
see lesson #14. Full suite 173/173 suites, 3740/3740 tests.
95 lines
3.5 KiB
JavaScript
95 lines
3.5 KiB
JavaScript
const fsp = require('fs').promises;
|
|
const { validateConfig } = require('../../src/utilities/config-schema');
|
|
const { exists } = require('../../src/utilities/fs-helpers');
|
|
const { ValidationError } = require('../../src/utilities/errors');
|
|
const { ok, successMessage } = require('../../src/utils/responses');
|
|
|
|
/**
|
|
* Config settings routes factory
|
|
* @param {Object} deps - Explicit dependencies
|
|
* @param {Object} deps.configStateManager - Config state manager
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @param {Object} deps.log - Logger instance
|
|
* @param {string} deps.CONFIG_FILE - Config file path
|
|
* @param {Function} deps.errorResponse - Error response helper
|
|
* @param {Function} deps.loadSiteConfig - Site config reload helper
|
|
* @returns {express.Router}
|
|
*/
|
|
module.exports = function({ configStateManager: _configStateManager, asyncHandler, log, CONFIG_FILE, errorResponse, loadSiteConfig }) {
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const ctx = { CONFIG_FILE, errorResponse, loadSiteConfig };
|
|
|
|
// ===== DASHCADDY CONFIG ENDPOINTS =====
|
|
// Server-side config storage for setup wizard (shared across all browsers/machines)
|
|
|
|
router.get('/config', asyncHandler(async (req, res) => {
|
|
if (!await exists(ctx.CONFIG_FILE)) {
|
|
return res.json({ setupComplete: false });
|
|
}
|
|
const data = await fsp.readFile(ctx.CONFIG_FILE, 'utf8');
|
|
let config;
|
|
try {
|
|
config = JSON.parse(data);
|
|
} catch (_) {
|
|
// DC-170: a corrupt/partial config.json (e.g. non-atomic write crash)
|
|
// previously 500'd this PUBLIC endpoint on every dashboard load.
|
|
// Degrade to the setup wizard instead of an error page.
|
|
return res.json({ setupComplete: false, corrupt: true });
|
|
}
|
|
res.json(config);
|
|
}, 'config-get'));
|
|
|
|
router.post('/config', asyncHandler(async (req, res) => {
|
|
const incoming = req.body;
|
|
|
|
if (!incoming || typeof incoming !== 'object') {
|
|
throw new ValidationError('Invalid config object');
|
|
}
|
|
|
|
// Merge with existing config so partial saves don't wipe fields
|
|
let existing = {};
|
|
if (await exists(ctx.CONFIG_FILE)) {
|
|
try {
|
|
existing = JSON.parse(await fsp.readFile(ctx.CONFIG_FILE, 'utf8'));
|
|
} catch (_) { /* start fresh if file is corrupt */ }
|
|
}
|
|
const config = { ...existing, ...incoming };
|
|
|
|
// Merge nested dns object so partial dns updates don't wipe dns fields
|
|
if (existing.dns && incoming.dns) {
|
|
config.dns = { ...existing.dns, ...incoming.dns };
|
|
}
|
|
// Merge nested dnsServers object
|
|
if (existing.dnsServers && incoming.dnsServers) {
|
|
config.dnsServers = { ...existing.dnsServers, ...incoming.dnsServers };
|
|
}
|
|
|
|
// Validate merged config against schema
|
|
const { valid, errors, warnings } = validateConfig(config);
|
|
if (!valid) {
|
|
return ctx.errorResponse(res, 400, 'Config validation failed', { errors });
|
|
}
|
|
|
|
// Add timestamp
|
|
config.updatedAt = new Date().toISOString();
|
|
|
|
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
if (typeof ctx.loadSiteConfig === 'function') {
|
|
ctx.loadSiteConfig(); // Refresh in-memory config
|
|
}
|
|
log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
|
|
|
|
ok(res, { message: 'Configuration saved', config, warnings });
|
|
}, 'config-save'));
|
|
|
|
router.delete('/config', asyncHandler(async (req, res) => {
|
|
if (await exists(ctx.CONFIG_FILE)) {
|
|
await fsp.unlink(ctx.CONFIG_FILE);
|
|
}
|
|
successMessage(res, 'Configuration reset');
|
|
}, 'config-delete'));
|
|
|
|
return router;
|
|
};
|