DC-005: Fix all 138 broken test paths after src/ refactor
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
// Cache Configuration Module
|
||||
// Provides LRU cache configurations to prevent memory leaks
|
||||
|
||||
const { LRUCache } = require('lru-cache');
|
||||
|
||||
/**
|
||||
* Cache configuration presets for different data types
|
||||
* All TTL values are in milliseconds
|
||||
*/
|
||||
const CACHE_CONFIGS = {
|
||||
// App session cookies (login tokens for SSO)
|
||||
appSessions: {
|
||||
max: 500, // Max 500 different services
|
||||
ttl: 60 * 60 * 1000, // 1 hour TTL
|
||||
updateAgeOnGet: true, // Refresh TTL on access
|
||||
ttlAutopurge: true // Auto-cleanup expired entries
|
||||
},
|
||||
|
||||
// IP-based router sessions (Frontier NVG468MQ)
|
||||
ipSessions: {
|
||||
max: 1000, // Support up to 1000 IP addresses
|
||||
ttl: 24 * 60 * 60 * 1000, // 24 hour TTL
|
||||
updateAgeOnGet: true,
|
||||
ttlAutopurge: true
|
||||
},
|
||||
|
||||
// DNS server authentication tokens (Technitium)
|
||||
dnsTokens: {
|
||||
max: 50, // Max 50 DNS servers
|
||||
ttl: 6 * 60 * 60 * 1000, // 6 hour TTL (matches SESSION_TTL.DNS_TOKEN)
|
||||
updateAgeOnGet: false, // Don't refresh - tokens have fixed expiry
|
||||
ttlAutopurge: true
|
||||
},
|
||||
|
||||
// Tailscale network status
|
||||
tailscaleStatus: {
|
||||
max: 1, // Only one status object
|
||||
ttl: 60 * 1000, // 1 minute TTL
|
||||
updateAgeOnGet: false,
|
||||
ttlAutopurge: true
|
||||
},
|
||||
|
||||
// Tailscale API responses (devices, ACLs)
|
||||
tailscaleAPI: {
|
||||
max: 5, // devices + ACL + misc
|
||||
ttl: 5 * 60 * 1000, // 5 min (matches sync interval)
|
||||
updateAgeOnGet: false,
|
||||
ttlAutopurge: true
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create a configured LRU cache
|
||||
* @param {Object} config - Cache configuration from CACHE_CONFIGS
|
||||
* @returns {LRUCache} Configured LRU cache instance
|
||||
*/
|
||||
function createCache(config) {
|
||||
return new LRUCache(config);
|
||||
}
|
||||
|
||||
module.exports = { CACHE_CONFIGS, createCache };
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Config Schema Validation for DashCaddy
|
||||
* Validates config.json structure to catch typos and invalid values early.
|
||||
*/
|
||||
|
||||
const VALID_TIMEZONES_SAMPLE = [
|
||||
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai',
|
||||
'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland'
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate a config object and return errors/warnings.
|
||||
* @param {object} config - The config object to validate
|
||||
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
||||
*/
|
||||
function validateConfig(config) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { valid: false, errors: ['Config must be a non-null object'], warnings };
|
||||
}
|
||||
|
||||
// TLD validation
|
||||
if (config.tld !== undefined) {
|
||||
if (typeof config.tld !== 'string') {
|
||||
errors.push('tld must be a string');
|
||||
} else {
|
||||
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
|
||||
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
|
||||
errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
|
||||
}
|
||||
if (tld.length > 20) {
|
||||
warnings.push(`tld "${config.tld}" is unusually long`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DNS config validation
|
||||
if (config.dns !== undefined) {
|
||||
if (typeof config.dns !== 'object' || config.dns === null) {
|
||||
errors.push('dns must be an object');
|
||||
} else {
|
||||
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
|
||||
errors.push('dns.ip must be a string');
|
||||
}
|
||||
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
|
||||
errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
|
||||
}
|
||||
if (config.dns.port !== undefined) {
|
||||
const port = parseInt(config.dns.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
|
||||
}
|
||||
}
|
||||
if (config.dns.servers !== undefined) {
|
||||
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
|
||||
errors.push('dns.servers must be an object');
|
||||
}
|
||||
}
|
||||
// DNS provider validation
|
||||
if (config.dns.provider !== undefined) {
|
||||
const validProviders = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
||||
if (typeof config.dns.provider !== 'string') {
|
||||
errors.push('dns.provider must be a string');
|
||||
} else if (!validProviders.includes(config.dns.provider)) {
|
||||
warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${validProviders.join(', ')}. It may still work if a custom adapter is installed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard host validation
|
||||
if (config.dashboardHost !== undefined) {
|
||||
if (typeof config.dashboardHost !== 'string') {
|
||||
errors.push('dashboardHost must be a string');
|
||||
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
|
||||
errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Timezone validation
|
||||
if (config.timezone !== undefined) {
|
||||
if (typeof config.timezone !== 'string') {
|
||||
errors.push('timezone must be a string');
|
||||
} else if (config.timezone) {
|
||||
// Basic format check — full validation would require Intl API
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
|
||||
} catch {
|
||||
errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Theme validation
|
||||
if (config.theme !== undefined) {
|
||||
const validThemes = ['dark', 'light', 'blue'];
|
||||
if (!validThemes.includes(config.theme)) {
|
||||
warnings.push(`theme "${config.theme}" is not one of: ${validThemes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Routing mode validation
|
||||
if (config.routingMode !== undefined) {
|
||||
const validModes = ['subdomain', 'subdirectory'];
|
||||
if (!validModes.includes(config.routingMode)) {
|
||||
errors.push(`routingMode "${config.routingMode}" is not one of: ${validModes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Domain validation
|
||||
if (config.domain !== undefined) {
|
||||
if (typeof config.domain !== 'string') {
|
||||
errors.push('domain must be a string');
|
||||
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
|
||||
warnings.push(`domain "${config.domain}" may not be a valid domain name`);
|
||||
}
|
||||
}
|
||||
|
||||
// Warn on unknown top-level keys
|
||||
const knownKeys = [
|
||||
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
|
||||
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
|
||||
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
|
||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||
'customLogoDark', 'customLogoLight'
|
||||
];
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!knownKeys.includes(key)) {
|
||||
warnings.push(`Unknown config key "${key}" — possible typo?`);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
module.exports = { validateConfig };
|
||||
@@ -0,0 +1,186 @@
|
||||
// DashCaddy Shared Constants
|
||||
// Centralizes configuration values used across the application.
|
||||
// Edit values here instead of hunting through server.js.
|
||||
|
||||
// ── App Identity ──────────────────────────────────────────────
|
||||
const APP = {
|
||||
NAME: 'DashCaddy',
|
||||
VERSION: '1.1',
|
||||
PORT: 3001,
|
||||
USER_AGENTS: {
|
||||
PROBE: 'DashCaddy-Probe/1.0',
|
||||
API: 'DashCaddy/1.0',
|
||||
HEALTH: 'DashCaddy-HealthCheck/1.0',
|
||||
},
|
||||
DEVICE_IDS: {
|
||||
SSO: 'dashcaddy-sso', // Backend auth gate (never invalidates browser token)
|
||||
BROWSER: 'dashcaddy-browser', // Browser-side localStorage token
|
||||
},
|
||||
};
|
||||
|
||||
// ── Default Ports for Media/Arr Apps ──────────────────────────
|
||||
const APP_PORTS = {
|
||||
plex: 32400,
|
||||
radarr: 7878,
|
||||
sonarr: 8989,
|
||||
seerr: 5055,
|
||||
lidarr: 8686,
|
||||
prowlarr: 9696,
|
||||
};
|
||||
|
||||
// Arr service discovery config (used by /api/arr/* endpoints)
|
||||
const ARR_SERVICES = {
|
||||
plex: { names: ['plex'], port: APP_PORTS.plex, configPath: 'Plex' },
|
||||
radarr: { names: ['radarr'], port: APP_PORTS.radarr, configPath: 'radarr' },
|
||||
sonarr: { names: ['sonarr'], port: APP_PORTS.sonarr, configPath: 'sonarr' },
|
||||
seerr: { names: ['seerr', 'requests'], port: APP_PORTS.seerr, configPath: 'seerr' },
|
||||
lidarr: { names: ['lidarr'], port: APP_PORTS.lidarr, configPath: 'lidarr' },
|
||||
prowlarr: { names: ['prowlarr'], port: APP_PORTS.prowlarr, configPath: 'prowlarr' },
|
||||
};
|
||||
|
||||
// ── Timeouts (ms) ─────────────────────────────────────────────
|
||||
const TIMEOUTS = {
|
||||
HTTP_DEFAULT: 5000, // Standard fetch/http timeout
|
||||
HTTP_LONG: 10000, // DNS ops, downloads, login requests
|
||||
DEPLOY_SETTLE: 3000, // Wait after container start before health check
|
||||
CADDY_PRE_RELOAD: 2000, // Pause before Caddy reload
|
||||
RETRY_SHORT: 1000, // Short retry delay
|
||||
RETRY_MEDIUM: 2000, // Medium retry delay
|
||||
SHUTDOWN_GRACE: 5000, // Graceful shutdown window
|
||||
SHUTDOWN_ERROR: 1000, // Error shutdown window
|
||||
PORT_CHECK: 2000, // TCP port availability check
|
||||
};
|
||||
|
||||
// ── Retry Configuration ───────────────────────────────────────
|
||||
const RETRIES = {
|
||||
CADDY_RELOAD: 3, // Max attempts to reload Caddy
|
||||
};
|
||||
|
||||
// ── Session / Cache Expiry (ms) ───────────────────────────────
|
||||
const SESSION_TTL = {
|
||||
IP_SESSION: 30 * 60 * 1000, // 30 min — router IP-based sessions
|
||||
COOKIE_SESSION: 30 * 60 * 1000, // 30 min — cookie-based login sessions
|
||||
TOKEN_SESSION: 60 * 60 * 1000, // 60 min — JWT/access token sessions (Jellyfin, Plex, etc.)
|
||||
FAILED_LOGIN: 5 * 60 * 1000, // 5 min — cooldown after failed login
|
||||
DNS_TOKEN: 6 * 60 * 60 * 1000, // 6 hrs — DNS auto-refresh interval
|
||||
};
|
||||
|
||||
// ── Rate Limiting ─────────────────────────────────────────────
|
||||
const RATE_LIMITS = {
|
||||
GENERAL: {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 1000,
|
||||
},
|
||||
STRICT: {
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
},
|
||||
TOTP: {
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Caddy ─────────────────────────────────────────────────────
|
||||
const CADDY = {
|
||||
CONTENT_TYPE: 'text/caddyfile',
|
||||
DEFAULT_DNS_PORT: '5380',
|
||||
DEFAULT_TTL: 300,
|
||||
TTL_MIN: 60,
|
||||
TTL_MAX: 86400,
|
||||
};
|
||||
|
||||
// ── Validation Patterns ─────────────────────────────────────────
|
||||
const REGEX = {
|
||||
SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i,
|
||||
DOMAIN: /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i,
|
||||
};
|
||||
|
||||
// ── DNS ─────────────────────────────────────────────────────────
|
||||
const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR', 'SOA'];
|
||||
|
||||
// ── Docker ──────────────────────────────────────────────────────
|
||||
const DOCKER = {
|
||||
CONTAINER_PREFIX: 'sami-',
|
||||
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
|
||||
LOG_CONFIG: {
|
||||
Type: 'json-file',
|
||||
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
|
||||
},
|
||||
MAINTENANCE: {
|
||||
INTERVAL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
DISK_WARN_GB: 20, // Warn when Docker uses more than 20GB
|
||||
},
|
||||
DIGEST: {
|
||||
COLLECT_INTERVAL: 60 * 60 * 1000, // Hourly log collection
|
||||
DIGEST_HOUR: 0, // Generate daily digest at midnight
|
||||
MAX_HOURLY_ENTRIES: 24, // Keep 24 hours of hourly summaries
|
||||
MAX_DIGEST_FILES: 30, // Keep 30 days of daily digests
|
||||
LOG_TAIL: 500, // Lines to fetch per container per hour
|
||||
},
|
||||
};
|
||||
|
||||
// ── Emby/Jellyfin Auth Header Builder ─────────────────────────
|
||||
function buildMediaAuth(deviceId) {
|
||||
return `MediaBrowser Client="${APP.NAME}", Device="${APP.NAME}", DeviceId="${deviceId}", Version="${APP.VERSION}"`;
|
||||
}
|
||||
|
||||
// ── Plex Auth Headers ─────────────────────────────────────────
|
||||
const PLEX = {
|
||||
AUTH_URL: 'https://plex.tv/users/sign_in.json',
|
||||
};
|
||||
|
||||
// ── Tailscale API ────────────────────────────────────────────
|
||||
const TAILSCALE = {
|
||||
API_BASE: 'https://api.tailscale.com/api/v2',
|
||||
OAUTH_TOKEN_URL: 'https://api.tailscale.com/api/v2/oauth/token',
|
||||
};
|
||||
|
||||
// ── Error Log ─────────────────────────────────────────────────
|
||||
const LIMITS = {
|
||||
ERROR_LOG_SIZE: 5 * 1024 * 1024, // 5 MB
|
||||
BODY_DEFAULT: '1mb',
|
||||
BODY_UPLOAD: '10mb',
|
||||
};
|
||||
|
||||
|
||||
// HTTP Status Codes
|
||||
const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
INTERNAL_ERROR: 500,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
};
|
||||
|
||||
// Network Constants
|
||||
const NETWORK = {
|
||||
LOCALHOST: '127.0.0.1',
|
||||
PRIVATE_RANGES: ['192.168.', '10.', /^172\.(1[6-9]|2[0-9]|3[0-1])\./],
|
||||
BUFFER_SIZE: 1024,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
APP,
|
||||
TAILSCALE,
|
||||
APP_PORTS,
|
||||
ARR_SERVICES,
|
||||
TIMEOUTS,
|
||||
RETRIES,
|
||||
SESSION_TTL,
|
||||
RATE_LIMITS,
|
||||
CADDY,
|
||||
PLEX,
|
||||
LIMITS,
|
||||
REGEX,
|
||||
DNS_RECORD_TYPES,
|
||||
DOCKER,
|
||||
buildMediaAuth,
|
||||
HTTP_STATUS,
|
||||
NETWORK,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* DashCaddy Error Handler Middleware
|
||||
* Centralizes error handling logic to eliminate duplicate catch blocks
|
||||
*
|
||||
* Logging: this middleware uses the unified logError from src/utils/logging.js
|
||||
* (same one src/app.js uses), so all errors go to one log file. The legacy
|
||||
* ./error-logger.js and its ./error.log file have been retired.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { AppError } = require('./errors');
|
||||
const { LIMITS } = require('./constants');
|
||||
const { logError: unifiedLogError, safeErrorMessage } = require('../utils/logging');
|
||||
const { errorResponse } = require('../utils/responses');
|
||||
|
||||
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
|
||||
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||||
|
||||
/**
|
||||
* Global error handling middleware
|
||||
* MUST be registered after all routes in server.js
|
||||
*/
|
||||
function errorMiddleware(err, req, res, next) {
|
||||
// Log all errors with request context (unified, same file the rest of the app uses)
|
||||
unifiedLogError(
|
||||
ERROR_LOG_FILE,
|
||||
MAX_ERROR_LOG_SIZE,
|
||||
req.path,
|
||||
err,
|
||||
{
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userId: req.user?.id,
|
||||
body: req.body
|
||||
}
|
||||
).catch(e => console.error('Failed to write to error log:', e.message));
|
||||
|
||||
// Determine if this is an operational error (AppError) or programming error
|
||||
const isOperational = err.isOperational || err instanceof AppError;
|
||||
|
||||
// Status code
|
||||
const statusCode = err.statusCode || 500;
|
||||
|
||||
// Error code (DC-XXX format)
|
||||
const code = err.code || `DC-${statusCode}`;
|
||||
|
||||
// Build extras for response
|
||||
const extras = { code };
|
||||
|
||||
// Add optional fields if present
|
||||
if (err.requiresTotp) extras.requiresTotp = true;
|
||||
if (err.retryAfter) extras.retryAfter = err.retryAfter;
|
||||
if (err.field) extras.field = err.field;
|
||||
if (err.resource) extras.resource = err.resource;
|
||||
if (err.details && Object.keys(err.details).length > 0) extras.details = err.details;
|
||||
|
||||
// Development mode: include stack trace
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
extras.stack = err.stack;
|
||||
}
|
||||
|
||||
// Send response
|
||||
errorResponse(res, statusCode, isOperational ? safeErrorMessage(err) : 'Internal server error', extras);
|
||||
|
||||
// For non-operational errors, log as fatal
|
||||
if (!isOperational) {
|
||||
console.error('FATAL: Non-operational error detected', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 handler for routes not found
|
||||
* Register this before the global error handler
|
||||
*/
|
||||
function notFoundHandler(req, res, next) {
|
||||
const { NotFoundError } = require('./errors');
|
||||
next(new NotFoundError(`Route ${req.method} ${req.path}`));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorMiddleware,
|
||||
notFoundHandler
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* DashCaddy API Error Classes
|
||||
* All errors inherit from AppError and provide consistent structure.
|
||||
*/
|
||||
|
||||
class AppError extends Error {
|
||||
constructor(message, statusCode = 500, code = null) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.code = code || this.constructor.name.toUpperCase().replace(/ERROR$/, '_ERROR');
|
||||
this.isOperational = true; // Distinguishes from programming errors
|
||||
}
|
||||
}
|
||||
|
||||
// 4xx Client Errors
|
||||
|
||||
class ValidationError extends AppError {
|
||||
constructor(message, field = null) {
|
||||
super(message, 400, 'DC-400');
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
class AuthenticationError extends AppError {
|
||||
constructor(message = 'Authentication required', requiresTotp = false) {
|
||||
super(message, 401, 'DC-401');
|
||||
this.requiresTotp = requiresTotp;
|
||||
}
|
||||
}
|
||||
|
||||
class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403, 'DC-403');
|
||||
}
|
||||
}
|
||||
|
||||
class NotFoundError extends AppError {
|
||||
constructor(resource = 'Resource') {
|
||||
super(`${resource} not found`, 404, 'DC-404');
|
||||
this.resource = resource;
|
||||
}
|
||||
}
|
||||
|
||||
class ConflictError extends AppError {
|
||||
constructor(message, conflictingResource = null) {
|
||||
super(message, 409, 'DC-409');
|
||||
this.conflictingResource = conflictingResource;
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends AppError {
|
||||
constructor(retryAfter = 60) {
|
||||
super('Rate limit exceeded', 429, 'DC-429');
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
// 5xx Server Errors
|
||||
|
||||
class DockerError extends AppError {
|
||||
constructor(message, operation = null, details = {}) {
|
||||
super(message, 500, 'DC-500-DOCKER');
|
||||
this.operation = operation;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class CaddyError extends AppError {
|
||||
constructor(message, operation = null, details = {}) {
|
||||
super(message, 502, 'DC-502-CADDY');
|
||||
this.operation = operation;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class DNSError extends AppError {
|
||||
constructor(message, operation = null, details = {}) {
|
||||
super(message, 502, 'DC-502-DNS');
|
||||
this.operation = operation;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceUnavailableError extends AppError {
|
||||
constructor(service, retryAfter = null) {
|
||||
super(`Service unavailable: ${service}`, 503, 'DC-503');
|
||||
this.service = service;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AppError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
RateLimitError,
|
||||
DockerError,
|
||||
CaddyError,
|
||||
DNSError,
|
||||
ServiceUnavailableError
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Async File System Helpers for DashCaddy
|
||||
* Replaces common sync patterns with async equivalents.
|
||||
*/
|
||||
|
||||
const fsp = require('fs').promises;
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Async file existence check (replaces fs.existsSync)
|
||||
*/
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a JSON file with fallback (replaces existsSync + readFileSync + JSON.parse)
|
||||
*/
|
||||
async function readJsonFile(filePath, fallback = null) {
|
||||
try {
|
||||
const content = await fsp.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return fallback;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data as formatted JSON (replaces writeFileSync + JSON.stringify)
|
||||
*/
|
||||
async function writeJsonFile(filePath, data) {
|
||||
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a text file with fallback (replaces existsSync + readFileSync)
|
||||
*/
|
||||
async function readTextFile(filePath, fallback = '') {
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf8');
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return fallback;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is accessible with given mode (replaces accessSync)
|
||||
*/
|
||||
async function isAccessible(filePath, mode = fs.constants.R_OK) {
|
||||
try {
|
||||
await fsp.access(filePath, mode);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { exists, readJsonFile, writeJsonFile, readTextFile, isAccessible };
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Middleware Configuration Module
|
||||
* Extracts the entire middleware stack from server.js (Phase 3 refactoring)
|
||||
*
|
||||
* Configures: CORS, Helmet, body parser, compression, CSRF, request IDs,
|
||||
* metrics/access logging, Tailscale auth, TOTP sessions, JWT/API key auth,
|
||||
* rate limiting, and audit logging.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('../security/csrf-protection');
|
||||
const { RATE_LIMITS, LIMITS, APP } = require('./constants');
|
||||
const { errorResponse, unauthorized, forbidden, validationError } = require('../utils/responses');
|
||||
const { CACHE_CONFIGS, createCache } = require('./cache-config');
|
||||
|
||||
/**
|
||||
* Configure all middleware on the Express app.
|
||||
*
|
||||
* @param {import('express').Express} app
|
||||
* @param {Object} deps - Dependencies from server.js
|
||||
* @returns {Object} Items that routes and ctx need
|
||||
*/
|
||||
module.exports = function configureMiddleware(app, {
|
||||
siteConfig, totpConfig, tailscaleConfig,
|
||||
metrics, auditLogger, authManager, log, cryptoUtils,
|
||||
isValidContainerId, isTailscaleIP, getTailscaleStatus
|
||||
}) {
|
||||
|
||||
// ── Container ID param validation ──
|
||||
app.param('id', (req, res, next, id) => {
|
||||
if (req.path.includes('/containers/') && !isValidContainerId(id)) {
|
||||
return validationError(res, 'Invalid container ID');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ── CORS (#9: origins derived from config) ──
|
||||
const corsOrigins = [`https://${siteConfig.dashboardHost}`];
|
||||
if (process.env.NODE_ENV !== 'production') corsOrigins.push('http://localhost:3001');
|
||||
app.use(cors({
|
||||
origin: corsOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// ── Security headers with Helmet ──
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"]
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" }
|
||||
}));
|
||||
|
||||
// ── Trust proxy (one hop — Caddy) ──
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// ── JSON body parser (default 1MB limit) ──
|
||||
app.use(express.json({ limit: LIMITS.BODY_DEFAULT }));
|
||||
|
||||
// ── Compress responses (gzip/brotli) ──
|
||||
app.use(compression());
|
||||
|
||||
// ── CSRF protection (cookie domain set to TLD for cross-subdomain SSO) ──
|
||||
const { csrfCookieMiddleware, renewCSRFToken } = createCSRFMiddleware({
|
||||
cookieDomain: siteConfig.tld || undefined
|
||||
});
|
||||
app.use(csrfCookieMiddleware);
|
||||
app.use(csrfValidationMiddleware);
|
||||
|
||||
// ── Request ID ──
|
||||
app.use((req, res, next) => {
|
||||
req.id = crypto.randomUUID();
|
||||
res.setHeader('X-Request-ID', req.id);
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Metrics + access log ──
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
metrics.recordRequest(req.method, req.path, res.statusCode, duration);
|
||||
if (req.path !== '/health' && req.path !== '/api/v1/health') {
|
||||
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug';
|
||||
log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, {
|
||||
ms: duration, ip: req.ip, id: req.id
|
||||
});
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Tailscale authentication middleware (optional) ──
|
||||
const tailscaleAuthMiddleware = async (req, res, next) => {
|
||||
if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/v1/tailscale/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const realIP = req.headers['x-real-ip'];
|
||||
|
||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||
const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||
|
||||
if (!fromTailscale) {
|
||||
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
|
||||
requiresTailscale: true,
|
||||
clientIP: clientIP
|
||||
});
|
||||
}
|
||||
|
||||
if (tailscaleConfig.allowedTailnet) {
|
||||
try {
|
||||
const status = await getTailscaleStatus();
|
||||
if (status) {
|
||||
const clientTailscaleIP = ipsToCheck
|
||||
.map(ip => ip.toString().split(',')[0].trim())
|
||||
.find(ip => isTailscaleIP(ip));
|
||||
|
||||
if (clientTailscaleIP) {
|
||||
const knownIPs = new Set();
|
||||
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
for (const peer of Object.values(status.Peer || {})) {
|
||||
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
}
|
||||
if (!knownIPs.has(clientTailscaleIP)) {
|
||||
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
|
||||
requiresTailscale: true,
|
||||
clientIP
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
app.use(tailscaleAuthMiddleware);
|
||||
|
||||
// ── TOTP AUTHENTICATION ──
|
||||
|
||||
const SESSION_COOKIE_NAME = 'dashcaddy_session';
|
||||
const SESSION_DURATIONS = {
|
||||
'15m': 15 * 60 * 1000,
|
||||
'30m': 30 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'2h': 2 * 60 * 60 * 1000,
|
||||
'4h': 4 * 60 * 60 * 1000,
|
||||
'8h': 8 * 60 * 60 * 1000,
|
||||
'12h': 12 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'never': null
|
||||
};
|
||||
|
||||
// IP-based session store (solves cross-domain cookie issues with .sami TLD)
|
||||
const ipSessions = createCache(CACHE_CONFIGS.ipSessions);
|
||||
|
||||
function getClientIP(req) {
|
||||
return req.ip || req.socket?.remoteAddress || '';
|
||||
}
|
||||
|
||||
function createIPSession(req, durationKey) {
|
||||
const durationMs = SESSION_DURATIONS[durationKey];
|
||||
if (!durationMs) {
|
||||
log.warn('auth', 'createIPSession: invalid duration, no session created', { durationKey });
|
||||
return;
|
||||
}
|
||||
const ip = getClientIP(req);
|
||||
ipSessions.set(ip, { exp: Date.now() + durationMs });
|
||||
}
|
||||
|
||||
function verifyIPSession(req) {
|
||||
const ip = getClientIP(req);
|
||||
const session = ipSessions.get(ip);
|
||||
if (!session) return false;
|
||||
if (session.exp <= Date.now()) {
|
||||
ipSessions.delete(ip);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearIPSession(req) {
|
||||
ipSessions.delete(getClientIP(req));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, durationKey) {
|
||||
const durationMs = SESSION_DURATIONS[durationKey];
|
||||
if (!durationMs) return;
|
||||
const maxAge = Math.floor(durationMs / 1000);
|
||||
const payload = { v: true, exp: Date.now() + durationMs };
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||
res.setHeader('Set-Cookie',
|
||||
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||
);
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {};
|
||||
if (!cookieHeader) return cookies;
|
||||
cookieHeader.split(';').forEach(pair => {
|
||||
const [name, ...rest] = pair.trim().split('=');
|
||||
if (name) cookies[name.trim()] = rest.join('=').trim();
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function verifySessionCookie(cookieValue) {
|
||||
if (!cookieValue) return false;
|
||||
const parts = cookieValue.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
const [payloadB64, sig] = parts;
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
const expectedSig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||
try {
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false;
|
||||
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
||||
return payload.v === true && payload.exp > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||
res.setHeader('Set-Cookie',
|
||||
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
|
||||
);
|
||||
}
|
||||
|
||||
function isSessionValid(req) {
|
||||
if (verifyIPSession(req)) return true;
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
|
||||
const ip = getClientIP(req);
|
||||
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
|
||||
ipSessions.set(ip, { exp: Date.now() + SESSION_DURATIONS[totpConfig.sessionDuration] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Public routes (bypass TOTP and JWT auth) ──
|
||||
// Routes here are accessible without authentication. By default the
|
||||
// monitoring/health-check endpoints are public so the dashboard can
|
||||
// render widgets before the user logs in. Set MONITORING_PUBLIC=false
|
||||
// (env var) or `monitoring: { public: false }` (config.json) to require
|
||||
// auth for these — useful for internet-exposed deployments where
|
||||
// CPU/memory/disk data is sensitive.
|
||||
const MONITORING_PUBLIC = (() => {
|
||||
if (process.env.MONITORING_PUBLIC === 'false') return false;
|
||||
if (process.env.MONITORING_PUBLIC === 'true') return true;
|
||||
// Default: check config.json if loaded
|
||||
try {
|
||||
const cfg = require('../config/site').siteConfig;
|
||||
if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') {
|
||||
return cfg.monitoring.public;
|
||||
}
|
||||
} catch { /* config not loaded yet, use default */ }
|
||||
return true; // default: public (current behavior, dashboard needs it)
|
||||
})();
|
||||
|
||||
const PUBLIC_ROUTES = [
|
||||
{ path: '/health', exact: true },
|
||||
{ path: '/health/live', exact: true },
|
||||
{ path: '/health/ready', exact: true },
|
||||
{ path: '/api/v1/health', exact: true },
|
||||
{ path: '/api/v1/health/live', exact: true },
|
||||
{ path: '/api/v1/health/ready', exact: true },
|
||||
{ path: '/probe/', prefix: true },
|
||||
{ path: '/api/v1/tailscale/', prefix: true },
|
||||
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/totp/verify', exact: true },
|
||||
{ path: '/api/v1/totp/setup', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/totp/check-session', exact: true },
|
||||
{ path: '/api/v1/auth/gate/', prefix: true },
|
||||
{ path: '/api/v1/auth/app-token/', prefix: true },
|
||||
{ path: '/api/v1/services', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/install-script', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health/ca', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/cert/', prefix: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/certs', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/csrf-token', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/logo', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/favicon', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/themes', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/license/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
// Monitoring endpoints — only public if MONITORING_PUBLIC is true
|
||||
...(MONITORING_PUBLIC ? [
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
] : []),
|
||||
{ path: '/api/v1/version', exact: true, method: 'GET' },
|
||||
];
|
||||
|
||||
function isPublicRoute(req) {
|
||||
return PUBLIC_ROUTES.some(r => {
|
||||
if (r.method && req.method !== r.method) return false;
|
||||
return r.prefix ? req.path.startsWith(r.path) : req.path === r.path;
|
||||
});
|
||||
}
|
||||
|
||||
// ── TOTP auth middleware ──
|
||||
const totpAuthMiddleware = (req, res, next) => {
|
||||
// If TOTP is not enabled at all, skip auth entirely — this is the initial-setup state
|
||||
if (!totpConfig.enabled) {
|
||||
req.auth = {
|
||||
type: 'none',
|
||||
scope: ['admin']
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// TOTP is enabled — require a valid session, JWT, or API key
|
||||
if (isPublicRoute(req)) return next();
|
||||
if (isSessionValid(req)) return next();
|
||||
|
||||
return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true });
|
||||
};
|
||||
|
||||
app.use(totpAuthMiddleware);
|
||||
|
||||
// ── JWT/API Key authentication middleware ──
|
||||
const jwtApiKeyAuthMiddleware = async (req, res, next) => {
|
||||
if (req.totpSessionValid || isSessionValid(req)) {
|
||||
req.auth = {
|
||||
type: 'session',
|
||||
scope: ['admin']
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
if (isPublicRoute(req)) return next();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const jwtPayload = await authManager.verifyJWT(token);
|
||||
|
||||
if (jwtPayload) {
|
||||
req.auth = {
|
||||
type: 'jwt',
|
||||
userId: jwtPayload.userId,
|
||||
scope: jwtPayload.scope || []
|
||||
};
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey) {
|
||||
const keyData = await authManager.verifyAPIKey(apiKey);
|
||||
|
||||
if (keyData) {
|
||||
req.auth = {
|
||||
type: 'apikey',
|
||||
keyId: keyData.keyId,
|
||||
name: keyData.name,
|
||||
scope: keyData.scopes || []
|
||||
};
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// No valid auth — reject
|
||||
return errorResponse(res, 401, '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', {
|
||||
requiresTotp: totpConfig.enabled
|
||||
});
|
||||
};
|
||||
|
||||
app.use(jwtApiKeyAuthMiddleware);
|
||||
|
||||
// ── Rate limiting (skipped in test environment) ──
|
||||
const isTest = process.env.NODE_ENV === 'test';
|
||||
const generalLimiter = rateLimit({
|
||||
...RATE_LIMITS.GENERAL,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
|
||||
message: { success: false, error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
const strictLimiter = rateLimit({
|
||||
...RATE_LIMITS.STRICT,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: () => isTest,
|
||||
message: { success: false, error: 'Too many requests to this endpoint, please try again later' }
|
||||
});
|
||||
|
||||
app.use(generalLimiter);
|
||||
app.use('/api/v1/dns/credentials', strictLimiter);
|
||||
app.use('/api/v1/apps/deploy', strictLimiter);
|
||||
app.use('/api/v1/backup/restore', strictLimiter);
|
||||
app.use('/api/v1/site', strictLimiter);
|
||||
app.use('/api/v1/credentials/rotate-key', strictLimiter);
|
||||
|
||||
const totpLimiter = rateLimit({
|
||||
...RATE_LIMITS.TOTP,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many TOTP attempts, please try again later' }
|
||||
});
|
||||
app.use('/api/v1/totp/verify', totpLimiter);
|
||||
app.use('/api/v1/totp/verify-setup', totpLimiter);
|
||||
|
||||
// ── Audit logging middleware (logs non-GET API requests) ──
|
||||
app.use(auditLogger.middleware());
|
||||
|
||||
// ── Return items that routes and ctx need ──
|
||||
return {
|
||||
strictLimiter,
|
||||
SESSION_DURATIONS,
|
||||
getClientIP,
|
||||
createIPSession,
|
||||
setSessionCookie,
|
||||
clearIPSession,
|
||||
clearSessionCookie,
|
||||
isSessionValid,
|
||||
ipSessions,
|
||||
renewCSRFToken
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Pagination helper for list endpoints.
|
||||
* Only paginates when ?page= or ?limit= query params are present (backward compat).
|
||||
*
|
||||
* Usage:
|
||||
* const { paginate, parsePaginationParams } = require('./pagination');
|
||||
* router.get('/items', asyncHandler(async (req, res) => {
|
||||
* const items = await getAllItems();
|
||||
* const params = parsePaginationParams(req.query);
|
||||
* res.json({ success: true, ...paginate(items, params) });
|
||||
* }));
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* Parse pagination params from query string.
|
||||
* Returns null if no pagination requested (backward compat: return full list).
|
||||
*/
|
||||
function parsePaginationParams(query) {
|
||||
if (!query.page && !query.limit) return null;
|
||||
const page = Math.max(1, parseInt(query.page, 10) || 1);
|
||||
const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(query.limit, 10) || DEFAULT_LIMIT));
|
||||
return { page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate an array of items.
|
||||
* If params is null, returns { data: items } (no pagination metadata).
|
||||
*/
|
||||
function paginate(items, params) {
|
||||
if (!params) return { data: items };
|
||||
|
||||
const { page, limit } = params;
|
||||
const total = items.length;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const start = (page - 1) * limit;
|
||||
const data = items.slice(start, start + limit);
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasMore: page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT };
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Startup Validation Module
|
||||
* Extracts startup configuration validation and health checker sync from server.js
|
||||
* (Phase 3 refactoring)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { exists, isAccessible } = require('./fs-helpers');
|
||||
const { resolveServiceUrl } = require('./url-resolver');
|
||||
|
||||
/**
|
||||
* Validate startup configuration and environment.
|
||||
* Fail fast with clear error messages if critical issues are found.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.log - structured logger
|
||||
* @param {string} deps.CADDYFILE_PATH
|
||||
* @param {string} deps.SERVICES_FILE
|
||||
* @param {string} deps.CONFIG_FILE
|
||||
* @param {string} deps.CADDY_ADMIN_URL
|
||||
* @param {number} deps.PORT
|
||||
*/
|
||||
async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
log.info('startup', 'Validating startup configuration...');
|
||||
|
||||
// 1. Check if Caddyfile exists and is writable
|
||||
try {
|
||||
if (await exists(CADDYFILE_PATH)) {
|
||||
if (!(await isAccessible(CADDYFILE_PATH, fs.constants.R_OK | fs.constants.W_OK))) {
|
||||
errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`);
|
||||
} else {
|
||||
log.info('startup', 'Caddyfile is accessible', { path: CADDYFILE_PATH });
|
||||
}
|
||||
} else {
|
||||
warnings.push(`Caddyfile does not exist: ${CADDYFILE_PATH} (will be created if needed)`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`);
|
||||
}
|
||||
|
||||
// 2. Check if services file exists or can be created
|
||||
const servicesDir = path.dirname(SERVICES_FILE);
|
||||
try {
|
||||
if (await exists(SERVICES_FILE)) {
|
||||
// Validate it's valid JSON
|
||||
try {
|
||||
const content = await fsp.readFile(SERVICES_FILE, 'utf8');
|
||||
JSON.parse(content);
|
||||
log.info('startup', 'Services file is valid JSON', { path: SERVICES_FILE });
|
||||
} catch (parseError) {
|
||||
errors.push(`Services file exists but contains invalid JSON: ${SERVICES_FILE}`);
|
||||
}
|
||||
} else {
|
||||
// Check if parent directory exists and is writable
|
||||
if (await exists(servicesDir)) {
|
||||
if (!(await isAccessible(servicesDir, fs.constants.W_OK))) {
|
||||
errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`);
|
||||
} else {
|
||||
log.info('startup', 'Services file directory is writable', { path: servicesDir });
|
||||
}
|
||||
} else {
|
||||
errors.push(`Services file directory does not exist: ${servicesDir}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`);
|
||||
}
|
||||
|
||||
// 3. Check if port is available
|
||||
const net = require('net');
|
||||
const portCheckServer = net.createServer();
|
||||
try {
|
||||
portCheckServer.listen(PORT, '0.0.0.0');
|
||||
portCheckServer.close();
|
||||
log.info('startup', `Port ${PORT} is available`);
|
||||
} catch (error) {
|
||||
errors.push(`Port ${PORT} is already in use or cannot be bound`);
|
||||
}
|
||||
|
||||
// 4. Check if config file is valid JSON (if exists)
|
||||
try {
|
||||
if (await exists(CONFIG_FILE)) {
|
||||
const content = await fsp.readFile(CONFIG_FILE, 'utf8');
|
||||
JSON.parse(content);
|
||||
log.info('startup', 'Config file is valid JSON', { path: CONFIG_FILE });
|
||||
} else {
|
||||
log.warn('startup', 'Config file does not exist (will use defaults)', { path: CONFIG_FILE });
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Config file exists but contains invalid JSON: ${CONFIG_FILE}`);
|
||||
}
|
||||
|
||||
// 5. Check Caddy admin API reachability (warning only, not critical)
|
||||
const checkCaddyAdmin = () => {
|
||||
return new Promise((resolve) => {
|
||||
const urlObj = new URL(CADDY_ADMIN_URL);
|
||||
const client = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = client.request({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
path: '/config/',
|
||||
method: 'GET',
|
||||
timeout: 2000
|
||||
}, (res) => {
|
||||
resolve(res.statusCode >= 200 && res.statusCode < 500);
|
||||
});
|
||||
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
// Run async Caddy check (don't block startup)
|
||||
checkCaddyAdmin().then(isReachable => {
|
||||
if (isReachable) {
|
||||
log.info('startup', 'Caddy admin API is reachable', { url: CADDY_ADMIN_URL });
|
||||
} else {
|
||||
log.warn('startup', 'Caddy admin API is not reachable (may start later)', { url: CADDY_ADMIN_URL });
|
||||
}
|
||||
});
|
||||
|
||||
// Print warnings
|
||||
if (warnings.length > 0) {
|
||||
warnings.forEach(warning => log.warn('startup', warning));
|
||||
}
|
||||
|
||||
// Fail fast if there are critical errors
|
||||
if (errors.length > 0) {
|
||||
errors.forEach(err => log.error('startup', err));
|
||||
log.error('startup', 'Cannot start server — fix the above errors and try again');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.info('startup', 'Startup configuration validation passed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-sync health checker from services.json + top-card services.
|
||||
* Adds missing, updates changed URLs, removes deleted services.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.log - structured logger
|
||||
* @param {string} deps.SERVICES_FILE
|
||||
* @param {Object} deps.servicesStateManager - StateManager instance
|
||||
* @param {Object} deps.healthChecker - health checker module
|
||||
* @param {Function} deps.buildServiceUrl - canonical URL builder
|
||||
* @param {Object} deps.siteConfig - site config (dnsServers, etc.)
|
||||
* @param {Object} deps.APP - app constants (USER_AGENTS)
|
||||
*/
|
||||
async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }) {
|
||||
try {
|
||||
const topCardServices = [
|
||||
{ id: 'internet', name: 'Internet' },
|
||||
];
|
||||
|
||||
// Dynamically add all configured DNS servers from config
|
||||
for (const [id, info] of Object.entries(siteConfig?.dnsServers || {})) {
|
||||
topCardServices.push({ id, name: info.name || id.toUpperCase() });
|
||||
}
|
||||
|
||||
let appServices = [];
|
||||
if (await exists(SERVICES_FILE)) {
|
||||
const data = await servicesStateManager.read();
|
||||
appServices = Array.isArray(data) ? data : data.services || [];
|
||||
}
|
||||
|
||||
const allServices = [...topCardServices, ...appServices];
|
||||
const desiredIds = new Set();
|
||||
let added = 0, updated = 0, removed = 0;
|
||||
|
||||
for (const svc of allServices) {
|
||||
const id = svc.id || svc.name?.toLowerCase();
|
||||
if (!id) continue;
|
||||
desiredIds.add(id);
|
||||
|
||||
const url = resolveServiceUrl(id, svc, siteConfig, buildServiceUrl);
|
||||
const existing = healthChecker.config.services?.[id];
|
||||
|
||||
if (!existing) {
|
||||
healthChecker.configureService(id, {
|
||||
name: svc.name || id,
|
||||
url,
|
||||
method: 'HEAD',
|
||||
timeout: 5000,
|
||||
expectedStatusCodes: [200, 201, 204, 301, 302, 303, 307, 308, 401, 403],
|
||||
headers: { 'User-Agent': APP.USER_AGENTS.HEALTH },
|
||||
});
|
||||
added++;
|
||||
} else if (existing.url !== url) {
|
||||
healthChecker.configureService(id, { ...existing, url });
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove services no longer in the desired set
|
||||
const configuredIds = Object.keys(healthChecker.config.services || {});
|
||||
for (const id of configuredIds) {
|
||||
if (!desiredIds.has(id)) {
|
||||
healthChecker.removeService(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (added > 0 || updated > 0 || removed > 0) {
|
||||
log.info('health', 'Health checker synced', { added, updated, removed });
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('health', 'Error syncing health checker', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateStartupConfig, syncHealthCheckerServices };
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Unified URL Resolver
|
||||
* Single source of truth for resolving service URLs across all systems
|
||||
* (probes, health checks, health checker auto-config, SSO).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the canonical URL for a service.
|
||||
*
|
||||
* Priority:
|
||||
* 1. internet → https://www.google.com
|
||||
* 2. isExternal + externalUrl → use as-is
|
||||
* 3. service.url → prepend https:// if no protocol
|
||||
* 4. dnsServers config → http://{ip}:{port}
|
||||
* 5. fallback → buildServiceUrl(id)
|
||||
*
|
||||
* @param {string} id - service identifier
|
||||
* @param {Object|null} service - service object from services.json (may be null for top-card services)
|
||||
* @param {Object|null} siteConfig - site config containing dnsServers etc.
|
||||
* @param {Function} buildServiceUrl - fallback URL builder (subdomain or subdirectory mode)
|
||||
* @returns {string} resolved URL
|
||||
*/
|
||||
function resolveServiceUrl(id, service, siteConfig, buildServiceUrl) {
|
||||
if (id === 'internet') return 'https://www.google.com';
|
||||
if (service?.isExternal && service.externalUrl) return service.externalUrl;
|
||||
if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`;
|
||||
const dnsServer = siteConfig?.dnsServers?.[id];
|
||||
if (dnsServer) return `http://${dnsServer.ip}:${dnsServer.port || 5380}`;
|
||||
return buildServiceUrl(id);
|
||||
}
|
||||
|
||||
module.exports = { resolveServiceUrl };
|
||||
Reference in New Issue
Block a user