Files
dashcaddy/dashcaddy-api/src/app.js
T
Krystie f71e5c52d4
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
feat(api): unify logger — single source of truth for logs, errors, audit
Cherry-picks the unified logger design from the 171c1ad WIP (which Hermes
signed off on as 'Ship this') and applies all Hermes review fixes
(krystie-wip/logger-refactor, 2026-06-15).

  src/utils/logging.js is now the single entry point for:
    - log.info / log.warn / log.error / log.debug  (with level filtering,
      color-coded dev output, JSON prod output)
    - log.audit() / log.auditMiddleware()           (audit-log.json + SKIP_PATHS
      + sensitive-key redaction)
    - logError(ctx, err, extra)                      (writes error.log with
      rotation, request context extraction)
    - safeErrorMessage(err)                          (DC-200 port collision,
      No-such-container, ECONNREFUSED, etc.)

  Existing src/security/audit-logger.js kept untouched — routes/errorlogs.js
  still uses auditLogger.query/clear, no callers migrated.

  Hermes' must-fixes (all addressed):
    [1] Syntax error on logger.js:401 — old logger.js at repo root is gone;
        refactored src/utils/logging.js is the new home, no Chinese IME bug.
    [2] /health/live and /health/ready endpoints — untouched in src/app.js.
    [3] Tests — added __tests__/logging.test.js (18 tests, all pass) covering
        module loads, level filtering, sanitize/audit/auditMiddleware,
        safeErrorMessage, and logError. Full suite: 897/897 pass across 31
        suites (was 879 + 18 new).

  Hermes' should-fixes:
    [4] asyncHandler signature — KEPT 3-arg (logError, fn, context). 49 route
        files still call it this way; src/app.js's boundAsyncHandler unchanged.
    [5] platformPaths.pkiRootCert — UNTOUCHED, still used in src/app.js.
    [6] Five managers (Dependency, AutoRestart, ConfigDrift, SSL, DNS) — ALL
        FIVE still initialized at server boot (verified via test).
    [7] ok(res, ...) helper — UNTOUCHED, all routes still use it.
    [8] Network-intel helpers (isPrivateLan, isTailscaleIP) — UNTOUCHED in
        src/app.js, no duplicate inline logic added.

  - setLevel() now updates both GLOBAL_LEVEL and the singleton log._level,
    so level-filter tests don't pollute later tests.
  - Logger.audit() and Logger.error() now return promises so await works.
  - Logger._log() awaits writeErrorLog so callers using await can rely on
    the error.log being flushed.
  - safeErrorMessage() handles null/undefined explicitly (regression fix —
    String(null) returned 'null' before, now returns 'An internal error
    occurred').
  - src/app.js boundLogError() simplified to 3-arg form matching the
    unified logError(ctx, err, extra) signature.

  - createLogger(level) alias exported so existing src/app.js callers work.
  - logError, safeErrorMessage, LOG_LEVELS still exported.
  - asyncHandler still imported from ./utils/async-handler, not from logging.
  - No changes to routes/* (audit-logger.js still consumed unchanged).

  - jest: 897/897 tests pass across 31 suites
  - node -e "require('./src/app.js')" loads cleanly
  - node server.js boots through full init (all 5 managers start)
  - Color-coded logger output visible in dev mode (no NODE_ENV)
  - JSON output in production mode (NODE_ENV=production)
2026-06-19 18:41:26 -07:00

711 lines
24 KiB
JavaScript

/**
* Express application setup
* Configures middleware, assembles context, and mounts routes
*/
const express = require('express');
const https = require('https');
const fs = require('fs');
// Configuration
const config = require('./config');
const { assembleContext } = require('./context');
const { createLogger, logError, safeErrorMessage } = require('./utils/logging');
const { fetchT } = require('./utils/http');
const { errorResponse, ok } = require('./utils/responses');
// Note: 3-arg asyncHandler signature (logError, fn, context) preserved per Hermes review
// — 49 route files still use this signature.
const { asyncHandler } = require('./utils/async-handler');
// Managers and utilities
const StateManager = require('../state-manager');
const { LicenseManager } = require('../license-manager');
const credentialManager = require('../credential-manager');
const authManager = require('../auth-manager');
const dockerSecurity = require('../docker-security');
const auditLogger = require('../audit-logger');
const portLockManager = require('../port-lock-manager');
const resourceMonitor = require('../resource-monitor');
const backupManager = require('../backup-manager');
const healthChecker = require('../health-checker');
const updateManager = require('../update-manager');
const selfUpdater = require('../self-updater');
const configureMiddleware = require('../middleware');
const { validateStartupConfig, syncHealthCheckerServices } = require('../startup-validator');
const { CSRF_HEADER_NAME } = require('../csrf-protection');
const { resolveServiceUrl } = require('../url-resolver');
const metrics = require('../metrics');
const { validateURL } = require('../input-validator');
// Optional modules
let dockerMaintenance, logDigest;
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
// Workflow engine (bundled workflows)
let bundledWorkflowsModule;
let workflowEngine = null;
try {
bundledWorkflowsModule = require('../bundled-workflows');
} catch (_) { /* optional module */ }
// Templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
// Route modules
const healthRoutes = require('../routes/health');
const monitoringRoutes = require('../routes/monitoring');
const updatesRoutes = require('../routes/updates');
const authRoutes = require('../routes/auth');
const configRoutes = require('../routes/config');
const dnsRoutes = require('../routes/dns');
const notificationRoutes = require('../routes/notifications');
const containerRoutes = require('../routes/containers');
const serviceRoutes = require('../routes/services');
const tailscaleRoutes = require('../routes/tailscale');
const sitesRoutes = require('../routes/sites');
const credentialsRoutes = require('../routes/credentials');
const arrRoutes = require('../routes/arr');
const appsRoutes = require('../routes/apps');
const logsRoutes = require('../routes/logs');
const backupsRoutes = require('../routes/backups');
const caRoutes = require('../routes/ca');
const browseRoutes = require('../routes/browse');
const errorLogsRoutes = require('../routes/errorlogs');
const licenseRoutes = require('../routes/license');
const openClawRoutes = require('../routes/openclaw');
const recipesRoutes = require('../routes/recipes');
const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows');
// Constants
const { APP } = require('../constants');
/**
* Create and configure the Express application
*/
async function createApp() {
const app = express();
// Initialize logging
const log = createLogger(config.LOG_LEVEL);
// Load site configuration
config.loadSiteConfig(config.CONFIG_FILE, log);
// Create state managers
const servicesStateManager = new StateManager(config.SERVICES_FILE);
const configStateManager = new StateManager(config.CONFIG_FILE);
// Initialize license manager
const licenseManager = new LicenseManager(credentialManager, config.CONFIG_FILE, console);
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
// HTTPS agent for internal CA
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
let httpsAgent;
try {
const caCert = fs.readFileSync(CA_CERT_PATH);
httpsAgent = new https.Agent({ ca: [...require('tls').rootCertificates, caCert] });
log.info('server', 'HTTPS agent configured with CA certificate', { path: CA_CERT_PATH });
} catch {
httpsAgent = new https.Agent();
log.warn('server', 'CA cert not found — HTTPS calls may fail', { path: CA_CERT_PATH });
}
// TOTP configuration
const totpConfig = {
enabled: false,
sessionDuration: 'never',
isSetUp: false
};
// Load TOTP config from file
try {
if (fs.existsSync(config.TOTP_CONFIG_FILE)) {
const loaded = JSON.parse(fs.readFileSync(config.TOTP_CONFIG_FILE, 'utf8'));
delete loaded.secret; // secret belongs only in credential-manager
Object.assign(totpConfig, loaded);
log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled });
}
} catch (e) {
log.warn('config', 'Could not load TOTP config', { error: e.message });
}
// Tailscale configuration
const tailscaleConfig = {
enabled: false,
requireAuth: false,
allowedTailnet: null,
devices: [],
oauthConfigured: false,
tailnet: null,
syncInterval: 300,
lastSync: null
};
// Helper functions needed by middleware
function isValidContainerId(id) {
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
return typeof id === 'string' && CONTAINER_ID_RE.test(id);
}
function isTailscaleIP(ip) {
if (!ip) return false;
const parts = ip.split('.');
if (parts.length !== 4) return false;
const first = parseInt(parts[0]);
const second = parseInt(parts[1]);
return first === 100 && second >= 64 && second <= 127;
}
async function getTailscaleStatus() {
// Stub for now - will be populated by context
return null;
}
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
// auto-login pages) historically call these endpoints under the pre-1.5.0
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
// before configureMiddleware() so CSRF/auth see the canonical path. This is
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
app.use((req, res, next) => {
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
}
next();
});
// Configure middleware
const middlewareResult = configureMiddleware(app, {
siteConfig: config.siteConfig,
totpConfig,
tailscaleConfig,
metrics,
auditLogger,
authManager,
log,
cryptoUtils: require('../crypto-utils'),
isValidContainerId,
isTailscaleIP,
getTailscaleStatus,
RATE_LIMITS: require('../constants').RATE_LIMITS,
LIMITS: require('../constants').LIMITS,
APP: require('../constants').APP,
CACHE_CONFIGS: require('../cache-config').CACHE_CONFIGS,
createCache: require('../cache-config').createCache,
});
const { strictLimiter } = middlewareResult;
// Helper functions
async function getServiceById(serviceId) {
const services = await servicesStateManager.read();
return services.find(s => s.id === serviceId) || null;
}
async function readConfig() {
const { readJsonFile } = require('../fs-helpers');
return readJsonFile(config.CONFIG_FILE, {});
}
async function saveConfig(updates) {
return await configStateManager.update(cfg => Object.assign(cfg, updates));
}
async function addServiceToConfig(service) {
await servicesStateManager.update(services => {
const existingIndex = services.findIndex(s => s.id === service.id);
if (existingIndex >= 0) {
services[existingIndex] = { ...services[existingIndex], ...service };
} else {
services.push(service);
}
return services;
});
log.info('deploy', 'Service added to config', { serviceId: service.id });
}
async function saveTotpConfig() {
try {
const { writeJsonFile } = require('../fs-helpers');
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
} catch (e) {
log.error('config', 'Could not save TOTP config', { error: e.message });
}
}
async function loadNotificationConfig() {
// Stub - will be implemented
}
async function resyncHealthChecker() {
return syncHealthCheckerServices({
log,
SERVICES_FILE: config.SERVICES_FILE,
servicesStateManager,
healthChecker,
buildServiceUrl: config.buildServiceUrl,
siteConfig: config.siteConfig,
APP
});
}
// Create bound logError function (3-arg signature: ctx, err, extra)
// The unified logger module has its own ERROR_LOG_FILE from process.env,
// so we just route through its logErrorWrapper.
const boundLogError = (context, error, additionalInfo) =>
logError(context, error, additionalInfo);
// Create bound asyncHandler (3-arg: logError, fn, context)
const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context);
// Assemble context
const ctx = assembleContext({
// Config
siteConfig: config.siteConfig,
buildDomain: config.buildDomain,
buildServiceUrl: config.buildServiceUrl,
SERVICES_FILE: config.SERVICES_FILE,
CONFIG_FILE: config.CONFIG_FILE,
TOTP_CONFIG_FILE: config.TOTP_CONFIG_FILE,
TAILSCALE_CONFIG_FILE: config.TAILSCALE_CONFIG_FILE,
NOTIFICATIONS_FILE: config.NOTIFICATIONS_FILE,
ERROR_LOG_FILE: config.ERROR_LOG_FILE,
DNS_CREDENTIALS_FILE: config.DNS_CREDENTIALS_FILE,
CADDYFILE_PATH: config.CADDYFILE_PATH,
CADDY_ADMIN_URL: config.CADDY_ADMIN_URL,
// State managers
servicesStateManager,
configStateManager,
// Managers
credentialManager,
authManager,
licenseManager,
healthChecker,
updateManager,
backupManager,
resourceMonitor,
auditLogger,
portLockManager,
selfUpdater,
dockerMaintenance,
logDigest,
dockerSecurity,
// Templates
APP_TEMPLATES,
TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS,
RECIPE_TEMPLATES,
RECIPE_CATEGORIES,
// Helpers
asyncHandler: boundAsyncHandler,
errorResponse,
ok,
fetchT,
httpsAgent,
log,
logError: boundLogError,
safeErrorMessage,
getServiceById,
readConfig,
saveConfig,
addServiceToConfig,
validateURL,
strictLimiter,
totpConfig,
saveTotpConfig,
loadSiteConfig: () => config.loadSiteConfig(config.CONFIG_FILE, log),
loadNotificationConfig,
resyncHealthChecker,
// Middleware result
middlewareResult,
// App
app,
});
// Initialize workflow engine if bundled-workflows is available
if (bundledWorkflowsModule && ctx.docker) {
try {
const { WorkflowEngine } = bundledWorkflowsModule;
const workflowCtx = {
docker: ctx.docker,
notification: ctx.notification,
backupManager: ctx.backupManager,
resourceMonitor: ctx.resourceMonitor,
servicesStateManager: ctx.servicesStateManager
};
workflowEngine = new WorkflowEngine(workflowCtx);
ctx.workflowEngine = workflowEngine;
log.info('app', 'Workflow engine initialized');
} catch (err) {
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
}
}
// Build versioned API router
const apiRouter = express.Router();
// Wire up notification listeners for resourceMonitor and backupManager
if (ctx.notification && ctx.resourceMonitor) {
ctx.resourceMonitor.on('alert', (alertData) => {
ctx.notification.sendAlert(alertData).catch(err => {
log.error('notification', 'Failed to send alert', { error: err.message });
});
});
ctx.resourceMonitor.on('auto-restart', (data) => {
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
});
});
}
if (ctx.notification && ctx.backupManager) {
ctx.backupManager.on('backup-complete', (data) => {
ctx.notification.send('backup-complete', data).catch(err => {
log.error('notification', 'Failed to send backup-complete', { error: err.message });
});
});
ctx.backupManager.on('backup-failed', (data) => {
ctx.notification.send('backup-failed', data).catch(err => {
log.error('notification', 'Failed to send backup-failed', { error: err.message });
});
});
}
// Mount route modules
apiRouter.use(authRoutes(ctx));
apiRouter.use(configRoutes(ctx));
apiRouter.use('/dns', dnsRoutes({
dns: ctx.dns,
siteConfig: ctx.siteConfig,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
safeErrorMessage: ctx.safeErrorMessage,
fetchT: ctx.fetchT,
credentialManager: ctx.credentialManager
}));
apiRouter.use('/notifications', notificationRoutes({
notification: ctx.notification,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/containers', containerRoutes({
docker: ctx.docker,
log: ctx.log,
asyncHandler: ctx.asyncHandler,
workflowEngine: ctx.workflowEngine
}));
apiRouter.use(serviceRoutes({
servicesStateManager: ctx.servicesStateManager,
credentialManager: ctx.credentialManager,
siteConfig: ctx.siteConfig,
buildServiceUrl: ctx.buildServiceUrl,
buildDomain: ctx.buildDomain,
fetchT: ctx.fetchT,
asyncHandler: ctx.asyncHandler,
SERVICES_FILE: ctx.SERVICES_FILE,
log: ctx.log,
safeErrorMessage: ctx.safeErrorMessage,
resyncHealthChecker: ctx.resyncHealthChecker,
caddy: ctx.caddy,
dns: ctx.dns
}));
apiRouter.use(healthRoutes({
fetchT: ctx.fetchT,
SERVICES_FILE: ctx.SERVICES_FILE,
servicesStateManager: ctx.servicesStateManager,
siteConfig: ctx.siteConfig,
buildServiceUrl: ctx.buildServiceUrl,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
healthChecker: ctx.healthChecker
}));
apiRouter.use(monitoringRoutes({
resourceMonitor: ctx.resourceMonitor,
docker: ctx.docker,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
notificationManager: ctx.notification
}));
apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager,
selfUpdater: ctx.selfUpdater,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError
}));
apiRouter.use('/tailscale', tailscaleRoutes({
tailscale: ctx.tailscale,
caddy: ctx.caddy,
servicesStateManager: ctx.servicesStateManager,
credentialManager: ctx.credentialManager,
buildDomain: ctx.buildDomain,
asyncHandler: ctx.asyncHandler,
SERVICES_FILE: ctx.SERVICES_FILE,
log: ctx.log
}));
apiRouter.use(sitesRoutes({
asyncHandler: ctx.asyncHandler,
caddy: ctx.caddy,
dns: ctx.dns,
fetchT: ctx.fetchT,
buildDomain: ctx.buildDomain,
addServiceToConfig: ctx.addServiceToConfig,
siteConfig: ctx.siteConfig,
log: ctx.log
}));
apiRouter.use(credentialsRoutes({
credentialManager: ctx.credentialManager,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use(arrRoutes(ctx));
apiRouter.use(appsRoutes(ctx));
apiRouter.use('/openclaw', openClawRoutes(ctx));
apiRouter.use(logsRoutes({
asyncHandler: ctx.asyncHandler,
docker: ctx.docker,
logDigest: ctx.logDigest,
dockerMaintenance: ctx.dockerMaintenance
}));
apiRouter.use(backupsRoutes({
backupManager: ctx.backupManager,
licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/ca', caRoutes(ctx));
apiRouter.use(browseRoutes({
asyncHandler: ctx.asyncHandler,
validateSecurePath: ctx.validateSecurePath,
auditLogger: ctx.auditLogger,
docker: ctx.docker
}));
apiRouter.use(errorLogsRoutes({
ERROR_LOG_FILE: ctx.ERROR_LOG_FILE,
auditLogger: ctx.auditLogger,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/license', licenseRoutes({
licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/recipes', recipesRoutes(ctx));
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler, log: ctx.log }));
apiRouter.use('/docker', dockerResourcesRoutes({
docker: ctx.docker,
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/events', eventsRoutes({
resourceMonitor: ctx.resourceMonitor,
healthChecker: ctx.healthChecker,
updateManager: ctx.updateManager,
logError: ctx.logError
}));
apiRouter.use(workflowsRoutes({
workflowEngine: ctx.workflowEngine,
licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler
}));
// Inline API routes
apiRouter.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
apiRouter.get('/csrf-token', (req, res) => {
res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME });
});
apiRouter.get('/metrics', (req, res) => {
res.json({ success: true, metrics: metrics.getSummary() });
});
// Mount at /api/v1 (canonical, single version)
app.use('/api/v1', apiRouter);
// Root-level health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Lightweight probe endpoint
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
const id = req.params.id;
const { exists } = require('../fs-helpers');
let service = null;
if (id !== 'internet' && await exists(config.SERVICES_FILE)) {
const data = await servicesStateManager.read();
const services = Array.isArray(data) ? data : data.services || [];
service = services.find(s => s.id === id);
}
const url = resolveServiceUrl(id, service, config.siteConfig, config.buildServiceUrl);
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const lib = isHttps ? https : require('http');
const options = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'HEAD',
timeout: 5000,
agent: isHttps ? httpsAgent : undefined,
headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
};
const makeRequest = (method) => new Promise((resolve, reject) => {
const reqOpts = { ...options, method };
const probeReq = lib.request(reqOpts, (response) => {
response.resume();
resolve(response.statusCode);
});
probeReq.on('error', reject);
probeReq.on('timeout', () => { probeReq.destroy(); reject(new Error('Timeout')); });
probeReq.end();
});
let statusCode;
try {
statusCode = await makeRequest('HEAD');
if (statusCode === 501 || statusCode === 405) {
statusCode = await makeRequest('GET');
}
} catch {
// Direct probe failed — try Pylon relay if configured
const pylonConfig = config.siteConfig?.pylon;
if (pylonConfig?.url) {
try {
const pylonUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(url)}`;
const headers = { 'User-Agent': APP.USER_AGENTS.PROBE };
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
const controller = new AbortController();
const pylonTimeout = setTimeout(() => controller.abort(), 8000);
const pylonRes = await fetchT(pylonUrl, { method: 'GET', signal: controller.signal, headers });
clearTimeout(pylonTimeout);
if (pylonRes.ok) {
const data = await pylonRes.json();
statusCode = data.statusCode || 502;
}
} catch {
// Pylon also failed — fall through to domain fallback
}
}
// Domain-based fallback (last resort)
if (!statusCode) {
const fallbackUrl = `https://${config.buildDomain(id)}`;
const fp = new URL(fallbackUrl);
statusCode = await new Promise((resolve, reject) => {
const fReq = https.request({
hostname: fp.hostname,
port: 443,
path: '/',
method: 'GET',
timeout: 5000,
agent: httpsAgent,
headers: { 'User-Agent': APP.USER_AGENTS.PROBE }
}, (fRes) => {
fRes.resume();
resolve(fRes.statusCode);
});
fReq.on('error', reject);
fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); });
fReq.end();
}).catch(() => 502);
}
}
res.status(statusCode).send();
}, 'probe'));
// Network IPs endpoint
app.get('/api/v1/network/ips', (req, res) => {
try {
const os = require('os');
const envLan = process.env.HOST_LAN_IP;
const envTailscale = process.env.HOST_TAILSCALE_IP;
const result = {
localhost: '127.0.0.1',
lan: envLan || null,
tailscale: envTailscale || null,
all: []
};
if (!envLan || !envTailscale) {
const interfaces = os.networkInterfaces();
for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs) {
if (addr.internal || addr.family !== 'IPv4') continue;
const ip = addr.address;
result.all.push({ name, ip });
if (!result.tailscale && ip.startsWith('100.')) {
result.tailscale = ip;
} else if (!result.lan && (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./))) {
result.lan = ip;
}
}
}
}
res.json(result);
} catch (error) {
errorResponse(res, 500, safeErrorMessage(error));
}
});
// API Documentation
app.get('/api/v1/docs', (req, res) => {
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://unpkg.com;");
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>DashCaddy API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"/>
<style>body{margin:0} .swagger-ui .topbar{display:none}</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>SwaggerUIBundle({url:'/api/v1/docs/spec',dom_id:'#swagger-ui',deepLinking:true})</script>
</body>
</html>`);
});
app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => {
const path = require('path');
const { exists } = require('../fs-helpers');
const fsp = require('fs').promises;
const specPath = path.join(__dirname, '../openapi.yaml');
if (await exists(specPath)) {
const yaml = await fsp.readFile(specPath, 'utf8');
res.type('text/yaml').send(yaml);
} else {
errorResponse(res, 404, 'OpenAPI spec not found');
}
}, 'api-docs-spec'));
// Error handlers (MUST be last)
const { notFoundHandler, errorMiddleware } = require('../error-handler');
app.use('/api', notFoundHandler);
app.use(errorMiddleware);
return { app, log, config: config.siteConfig, licenseManager };
}
module.exports = { createApp };