diff --git a/package-lock.json b/package-lock.json index 8db7c2f..0b042b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1917,7 +1916,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2035,7 +2033,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2374,7 +2371,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -2430,7 +2426,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2710,7 +2705,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2901,7 +2895,6 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3389,7 +3382,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3632,7 +3624,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5071,7 +5062,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6094,7 +6084,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6148,7 +6137,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6280,7 +6268,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6356,7 +6343,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts index 5fb7c50..21603e2 100644 --- a/src/api/middleware/auth.ts +++ b/src/api/middleware/auth.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { zone } from '../../logging/zone'; +import { getClientIP } from './utils'; const log = zone('api:auth'); @@ -48,14 +49,3 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): // Key is valid, proceed next(); } - -/** - * Extract client IP from request - */ -function getClientIP(req: Request): string { - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded === 'string') { - return forwarded.split(',')[0].trim(); - } - return req.socket.remoteAddress || 'unknown'; -} diff --git a/src/api/middleware/index.ts b/src/api/middleware/index.ts index 3b84cd2..6d363a6 100644 --- a/src/api/middleware/index.ts +++ b/src/api/middleware/index.ts @@ -3,3 +3,4 @@ export { apiLimiter } from './ratelimit'; export { authMiddleware, setAPIKey } from './auth'; export { errorHandler, notFoundHandler } from './errorHandler'; export { validateQuery, validateBodySize } from './validation'; +export { getClientIP } from './utils'; diff --git a/src/api/middleware/logging.ts b/src/api/middleware/logging.ts index f18bbb5..97a95f8 100644 --- a/src/api/middleware/logging.ts +++ b/src/api/middleware/logging.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { zone } from '../../logging/zone'; +import { getClientIP } from './utils'; const log = zone('api:request'); @@ -41,15 +42,3 @@ export function requestLogging(req: Request, res: Response, next: NextFunction): next(); } - -/** - * Extract client IP from request - * Handles X-Forwarded-For header if behind a proxy - */ -function getClientIP(req: Request): string { - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded === 'string') { - return forwarded.split(',')[0].trim(); - } - return req.socket.remoteAddress || 'unknown'; -} diff --git a/src/api/middleware/utils.ts b/src/api/middleware/utils.ts new file mode 100644 index 0000000..7ddc159 --- /dev/null +++ b/src/api/middleware/utils.ts @@ -0,0 +1,13 @@ +import { Request } from 'express'; + +/** + * Extract client IP from request. + * Handles X-Forwarded-For header when behind a proxy. + */ +export function getClientIP(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',')[0].trim(); + } + return req.socket.remoteAddress || 'unknown'; +} diff --git a/src/api/types.ts b/src/api/types.ts index a76c282..ebcd31d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,5 +1 @@ export type { APIConfig } from '../types/config'; - -export interface FieldData { - [key: string]: unknown; -} diff --git a/src/backends/backendPlugin.ts b/src/backends/backendPlugin.ts index 857373f..70e6c1f 100644 --- a/src/backends/backendPlugin.ts +++ b/src/backends/backendPlugin.ts @@ -2,22 +2,30 @@ import { loadConfigFile } from '../config'; import { MagicProxyConfigFile } from '../types/config'; import { HostEntry } from '../types/host'; -type BackendStatus = { registered?: string[]; outputFile?: string | null;[key: string]: unknown }; +/** Status returned by backend getStatus() */ +export interface BackendStatus { + registered?: string[]; + outputFile?: string | null; + [key: string]: unknown; +} -type BackendModule = { +/** Interface that all backend modules must implement */ +export interface BackendModule { initialize: (config?: MagicProxyConfigFile) => Promise; addProxiedApp: (entry: HostEntry) => Promise; removeProxiedApp: (appName: string) => Promise; getStatus: () => Promise; -}; +} let activeBackend: BackendModule | null = null; let activeName: string | null = null; +/** + * Load a backend module by name. + */ async function loadBackend(name: string): Promise { switch (name) { case 'traefik': { - // dynamic import to avoid load-time side effects const mod = await import('./traefik/traefik'); return { initialize: mod.initialize, @@ -31,11 +39,16 @@ async function loadBackend(name: string): Promise { } } +/** + * Initialize the backend from configuration. + */ export async function initialize(config?: MagicProxyConfigFile): Promise { const cfg = config || await loadConfigFile(); - const backendName: string = cfg.proxyBackend; + const backendName = cfg.proxyBackend; - if (!backendName) throw new Error('No proxyBackend configured'); + if (!backendName) { + throw new Error('No proxyBackend configured'); + } if (!activeBackend || activeName !== backendName) { activeBackend = await loadBackend(backendName); @@ -45,17 +58,39 @@ export async function initialize(config?: MagicProxyConfigFile): Promise { await activeBackend.initialize(cfg); } +/** + * Get the active backend, initializing if needed. + */ +async function ensureBackend(): Promise { + if (!activeBackend) { + await initialize(); + } + if (!activeBackend) { + throw new Error('Backend initialization failed - no active backend'); + } + return activeBackend; +} + +/** + * Add or update a proxied application. + */ export async function addProxiedApp(entry: HostEntry): Promise { - if (!activeBackend) await initialize(); - return activeBackend!.addProxiedApp(entry); + const backend = await ensureBackend(); + return backend.addProxiedApp(entry); } +/** + * Remove a proxied application. + */ export async function removeProxiedApp(appName: string): Promise { - if (!activeBackend) await initialize(); - return activeBackend!.removeProxiedApp(appName); + const backend = await ensureBackend(); + return backend.removeProxiedApp(appName); } +/** + * Get the current backend status. + */ export async function getStatus(): Promise { - if (!activeBackend) await initialize(); - return activeBackend!.getStatus(); + const backend = await ensureBackend(); + return backend.getStatus(); } diff --git a/src/backends/readme.md b/src/backends/readme.md index 1372856..fbff2c0 100644 --- a/src/backends/readme.md +++ b/src/backends/readme.md @@ -1,56 +1,57 @@ # Adding a new proxy backend -magic-proxy is more or less setup for being fully proxy-agnostic. - -Summary -- Backends are loaded dynamically by [`loadBackend`](src/backends/backendPlugin.ts) and must match the backend shape defined by [`BackendModule`](src/backends/backendPlugin.ts). -- The platform initializes the selected backend via [`initialize`](src/backends/backendPlugin.ts) (which is called from [`startApp`](src/index.ts) after config is loaded with [`loadConfigFile`](src/config.ts) and validated by [`validateConfig`](src/config.ts)). - -Backend API (required) -- Export an object that implements the [`BackendModule`](src/backends/backendPlugin.ts) shape: - - initialize(config?: [`MagicProxyConfigFile`](src/types/config.d.ts)): Promise - - addProxiedApp(entry: [`HostEntry`](src/types/host.d.ts)): Promise - - removeProxiedApp(appName: string): Promise - - getStatus(): Promise<{ registered?: string[]; outputFile?: string | null; [key: string]: unknown }> - -Practical guidance / checklist -1. Module location & loading - - Add a module under `src/backends//` and export the required functions. - - Add a case in [`loadBackend`](src/backends/backendPlugin.ts) to dynamically import your module by `proxyBackend` name. - -2. Initialization - - Load any backend-specific configuration from the provided [`MagicProxyConfigFile`](src/types/config.d.ts). - - Validate required config and fail clearly (throw or log + exit). See the [`traefik`](src/backends/traefik/traefik.ts) behavior for examples. - - Set up any on-disk output paths or runtime state the backend needs. - -3. Registry / state & atomic writes - - Provide deterministic IDs for registered apps so getStatus and subsequent calls are stable. - - If writing files (e.g., dynamic proxy config): write atomically (tmp file + rename) and validate output where possible. See [`register`](src/backends/traefik/traefikManager.ts) and [`flushToDisk`](src/backends/traefik/traefikManager.ts) for patterns. - -4. addProxiedApp / removeProxiedApp behavior - - Accept a [`HostEntry`](src/types/host.d.ts) and perform idempotent registration. - - Ensure remove cleans up state so `getStatus()` reflects current registrations. - -5. getStatus - - Return an object containing `registered` (array of app names) and optionally `outputFile` (or other runtime metadata). - -6. Tests - - Add unit tests covering: - - Template loading and rendering. - - Registration/unregistration behavior. - - Output format validation (if generating files). - - Reuse helpers in [test/helpers/mockHelpers.ts](test/helpers/mockHelpers.ts) (FS mocks like `setupFSMocks` and `mockFileWrite`) and follow existing test patterns (see [test/legacy/backend.test.ts](test/legacy/backend.test.ts) and [test/legacy/traefik-file.test.ts](test/legacy/traefik-file.test.ts)). - -7. Config schema - - If new config fields are required, update [`src/types/config.d.ts`](src/types/config.d.ts) and ensure [`validateConfig`](src/config.ts) accepts the backend name (add to valid backends if needed). - -8. Error handling & startup - - Be explicit on fatal vs recoverable errors. The application startup (`startApp` in [`src/index.ts`](src/index.ts)) will exit on uncaught initialization errors; handle accordingly. - -Examples & references -- Reference backend implementation: [`src/backends/traefik/traefik.ts`](src/backends/traefik/traefik.ts) -- Manager utilities: [`src/backends/traefik/traefikManager.ts`](src/backends/traefik/traefikManager.ts) -- Backend plugin loader and API: [`src/backends/backendPlugin.ts`](src/backends/backendPlugin.ts) -- Types: [`MagicProxyConfigFile`](src/types/config.d.ts), [`HostEntry`](src/types/host.d.ts) - -If you want, I can scaffold a minimal backend module (with tests) using these patterns. \ No newline at end of file +magic-proxy supports pluggable proxy backends. + +## Summary +- Backends are loaded dynamically by `loadBackend` in `backendPlugin.ts` +- Backends must implement the `BackendModule` interface exported from `backendPlugin.ts` +- The platform initializes the selected backend via `initialize()` during startup + +## Backend API (required) + +Export a module that implements the `BackendModule` interface: + +```typescript +interface BackendModule { + initialize(config?: MagicProxyConfigFile): Promise; + addProxiedApp(entry: HostEntry): Promise; + removeProxiedApp(appName: string): Promise; + getStatus(): Promise; +} +``` + +## Implementation Checklist + +1. **Module location & loading** + - Add a module under `src/backends//` + - Add a case in `loadBackend()` to dynamically import your module + +2. **Initialization** + - Load backend-specific configuration from `MagicProxyConfigFile` + - Validate required config and fail clearly (throw or log + exit) + +3. **Registry & atomic writes** + - Provide deterministic IDs for registered apps + - Write files atomically (tmp file + rename) and validate output + +4. **addProxiedApp / removeProxiedApp** + - Accept a `HostEntry` and perform idempotent registration + - Ensure remove cleans up state so `getStatus()` reflects current registrations + +5. **getStatus** + - Return an object with `registered` (array of app names) and optionally `outputFile` + +6. **Tests** + - Add unit tests for template loading/rendering, registration, and output validation + - Reuse helpers in `test/helpers/mockHelpers.ts` + +7. **Config schema** + - Update `src/types/config.d.ts` if new config fields are required + - Update `validateConfig()` to accept the backend name + +## Reference Implementation + +See the Traefik backend: +- `src/backends/traefik/traefik.ts` - Main backend module +- `src/backends/traefik/traefikManager.ts` - Registry and file management +- `src/backends/backendPlugin.ts` - Plugin loader and interface \ No newline at end of file diff --git a/src/backends/traefik/helpers.ts b/src/backends/traefik/helpers.ts new file mode 100644 index 0000000..11f219e --- /dev/null +++ b/src/backends/traefik/helpers.ts @@ -0,0 +1,11 @@ +/** + * Small shared helpers for the Traefik backend. + */ + +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function detectCollisions(target: Record = {}, source: Record = {}): string[] { + return Object.keys(source).filter(k => k in target); +} diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index fbd28f1..7e05ae4 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -1,6 +1,7 @@ import yaml from 'js-yaml'; import { XMagicProxyData } from '../../types/xmagic'; import { zone } from '../../logging/zone'; +import { getErrorMessage } from './helpers'; const log = zone('backends.traefik.template'); @@ -17,10 +18,18 @@ const VALID_KEY_PATTERN = /^[a-zA-Z0-9_]+$/; * - Flat keys: {{ port }} (for backward compatibility) * - Nested access: {{ userData.port }} (explicit namespace) */ -function buildContext(appName: string, data: XMagicProxyData): Record { +type Context = { + app_name: string; + hostname: string; + target_url: string; + userData: Record; + [key: string]: string | Record; +}; + +function buildContext(appName: string, data: XMagicProxyData): Context { const CORE_KEYS = new Set(['app_name', 'hostname', 'target_url', 'userData']); - const context: Record = { + const context: Context = { app_name: appName, hostname: data.hostname, target_url: data.target, @@ -51,7 +60,7 @@ function buildContext(appName: string, data: XMagicProxyData): Record)[part]; + } + + // Convert primitives to strings, reject objects/arrays/functions + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); } - - return value == null ? undefined : String(value); + return undefined; } // Replace all {{ key }} occurrences @@ -102,12 +116,33 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr throw new Error(message); } - // Parse and re-dump for consistent YAML formatting + return rendered; +} + +/** Result from rendering a template with both raw string and parsed object */ +export type RenderResult = { + raw: string; + parsed: T; +}; + +/** + * Render a template and parse it as YAML. + * Returns both the raw rendered string and the parsed object. + * + * @param template - The template content with {{ variable }} placeholders + * @param appName - The application name + * @param data - The proxy configuration data + * @returns Object containing both raw string and parsed YAML + * @throws Error if unknown template variables are encountered or YAML is invalid + */ +export function renderTemplateParsed(template: string, appName: string, data: XMagicProxyData): RenderResult { + const raw = renderTemplate(template, appName, data); + try { - const parsed = yaml.load(rendered); - return yaml.dump(parsed, { noRefs: true, skipInvalid: true }); + const parsed = yaml.load(raw) as T; + return { raw, parsed }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = getErrorMessage(err); log.error({ message: 'Template produced invalid YAML', data: { appName, error: message } diff --git a/src/backends/traefik/traefik.ts b/src/backends/traefik/traefik.ts index 079a9fc..ddc5a4f 100644 --- a/src/backends/traefik/traefik.ts +++ b/src/backends/traefik/traefik.ts @@ -1,8 +1,8 @@ import fs from 'fs/promises'; import path from 'path'; -import yaml from 'js-yaml'; import { OUTPUT_DIRECTORY, CONFIG_DIRECTORY } from '../../config'; -import { renderTemplate } from './templateParser'; +import { renderTemplateParsed } from './templateParser'; +import { getErrorMessage } from './helpers'; import { TraefikConfigYamlFormat } from './types/traefik'; import * as manager from './traefikManager'; import { MagicProxyConfigFile } from '../../types/config'; @@ -15,10 +15,6 @@ const log = zone('backends.traefik'); /** Template storage: maps template filename -> content */ const templates = new Map(); -/** Tracking for debugging */ -let lastRendered: string | null = null; -let lastUserData: string | null = null; - /** * Load a template file from disk. * Relative paths are resolved against CONFIG_DIRECTORY. @@ -33,7 +29,7 @@ async function loadTemplate(templatePath: string): Promise { try { return await fs.readFile(resolved, 'utf-8'); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = getErrorMessage(err); log.error({ message: 'Failed to load template', data: { templatePath, resolved, error: message } }); throw new Error(`Failed to load template '${templatePath}': ${message}`); } @@ -44,8 +40,6 @@ async function loadTemplate(templatePath: string): Promise { * Returns null if template rendering fails (template not found or render error). */ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYamlFormat | null { - lastUserData = data.userData ? JSON.stringify(data.userData) : null; - const templateContent = templates.get(data.template); if (!templateContent) { const available = Array.from(templates.keys()).join(', ') || '(none)'; @@ -53,20 +47,13 @@ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYam return null; } - log.debug({ - message: 'Rendering template', - data: { appName, template: data.template, target: data.target, hostname: data.hostname } - }); - try { - const rendered = renderTemplate(templateContent, appName, data); - lastRendered = rendered; - return yaml.load(rendered) as TraefikConfigYamlFormat; + const { parsed } = renderTemplateParsed(templateContent, appName, data); + return parsed; } catch (err) { - const message = err instanceof Error ? err.message : String(err); log.error({ message: 'Failed to render template', - data: { appName, error: message } + data: { appName, error: getErrorMessage(err) } }); return null; } @@ -76,14 +63,6 @@ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYam // Test utilities // ───────────────────────────────────────────────────────────────────────────── -export function _getLastRendered(): string | null { - return lastRendered; -} - -export function _getLastUserData(): string | null { - return lastUserData; -} - export function _setTemplateForTesting(name: string, content: string): void { templates.set(name, content); } @@ -91,8 +70,6 @@ export function _setTemplateForTesting(name: string, content: string): void { export function _resetForTesting(): void { manager._resetForTesting?.(); templates.clear(); - lastRendered = null; - lastUserData = null; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/backends/traefik/traefikManager.ts b/src/backends/traefik/traefikManager.ts index c62140f..112312c 100644 --- a/src/backends/traefik/traefikManager.ts +++ b/src/backends/traefik/traefikManager.ts @@ -3,23 +3,19 @@ import path from 'path'; import yaml from 'js-yaml'; import { TraefikConfigYamlFormat } from './types/traefik'; import { validateGeneratedConfig } from './validators'; +import { detectCollisions } from './helpers'; import { zone } from '../../logging/zone'; const log = zone('backends.traefik.manager'); -/** Registry of app configs keyed by app name */ +// Registry of app configs keyed by app name const registry = new Map(); let outputFile: string | null = null; -// ───────────────────────────────────────────────────────────────────────────── -// Flush Debouncing -// ───────────────────────────────────────────────────────────────────────────── +// Track whether temp file cleanup has been performed for current output file +let tempFilesCleanedUp = false; -/** - * Pending flush state for debouncing multiple rapid flushToDisk() calls. - * This prevents race conditions where multiple writes with different registry - * states could complete out of order. - */ +// Pending flush state for debouncing multiple rapid flushToDisk() calls let pendingFlush: { resolvers: Array<{ resolve: () => void; reject: (err: Error) => void }>; scheduled: boolean; @@ -31,8 +27,18 @@ let pendingFlush: { /** * Merge two records, with source values overwriting target values. + * Logs a warning if any keys would be overwritten. */ -function mergeRecord(target: Record = {}, source: Record = {}): Record { +function mergeRecord(target: Record = {}, source: Record = {}, section?: string): Record { + if (section) { + const collisions = detectCollisions(target, source); + if (collisions.length > 0) { + log.warn({ + message: 'Config name collision detected - values will be overwritten', + data: { section, collisions } + }); + } + } return { ...target, ...source }; } @@ -46,22 +52,22 @@ function buildCombinedConfig(): TraefikConfigYamlFormat { // HTTP section if (cfg.http?.routers || cfg.http?.services || cfg.http?.middlewares) { combined.http ??= {}; - combined.http.routers = mergeRecord(combined.http.routers, cfg.http.routers); - combined.http.services = mergeRecord(combined.http.services, cfg.http.services); - combined.http.middlewares = mergeRecord(combined.http.middlewares, cfg.http.middlewares); + combined.http.routers = mergeRecord(combined.http.routers, cfg.http.routers, 'http.routers'); + combined.http.services = mergeRecord(combined.http.services, cfg.http.services, 'http.services'); + combined.http.middlewares = mergeRecord(combined.http.middlewares, cfg.http.middlewares, 'http.middlewares'); } // TCP section if (cfg.tcp?.routers || cfg.tcp?.services) { combined.tcp ??= {}; - combined.tcp.routers = mergeRecord(combined.tcp.routers, cfg.tcp.routers); - combined.tcp.services = mergeRecord(combined.tcp.services, cfg.tcp.services); + combined.tcp.routers = mergeRecord(combined.tcp.routers, cfg.tcp.routers, 'tcp.routers'); + combined.tcp.services = mergeRecord(combined.tcp.services, cfg.tcp.services, 'tcp.services'); } // UDP section if (cfg.udp?.services) { combined.udp ??= {}; - combined.udp.services = mergeRecord(combined.udp.services, cfg.udp.services); + combined.udp.services = mergeRecord(combined.udp.services, cfg.udp.services, 'udp.services'); } } @@ -106,8 +112,11 @@ async function writeAtomically(filePath: string, content: string): Promise try { await fs.mkdir(path.dirname(filePath), { recursive: true }); - // Clean up any stale temp files before writing - await cleanupTempFiles(filePath); + // Clean up any stale temp files on first write only + if (!tempFilesCleanedUp) { + await cleanupTempFiles(filePath); + tempFilesCleanedUp = true; + } await fs.writeFile(tmpFile, content, 'utf-8'); await fs.rename(tmpFile, filePath); log.debug({ message: 'Config written', data: { filePath } }); @@ -123,6 +132,9 @@ async function writeAtomically(filePath: string, content: string): Promise // ───────────────────────────────────────────────────────────────────────────── export function setOutputFile(file: string | null): void { + if (file !== outputFile) { + tempFilesCleanedUp = false; // Reset cleanup flag for new file + } outputFile = file; } @@ -201,14 +213,25 @@ async function doFlushToDisk(): Promise { /** * Register or update an app's configuration. + * Performs deep merge of routers, services, and middlewares. */ export function register(appName: string, config: Partial): void { const existing = registry.get(appName) ?? {}; + // Deep merge each section to preserve existing routers/services/middlewares const merged: TraefikConfigYamlFormat = { - http: { ...existing.http, ...config.http }, - tcp: { ...existing.tcp, ...config.tcp }, - udp: { ...existing.udp, ...config.udp }, + http: (existing.http || config.http) ? { + routers: { ...existing.http?.routers, ...config.http?.routers }, + services: { ...existing.http?.services, ...config.http?.services }, + middlewares: { ...existing.http?.middlewares, ...config.http?.middlewares }, + } : undefined, + tcp: (existing.tcp || config.tcp) ? { + routers: { ...existing.tcp?.routers, ...config.tcp?.routers }, + services: { ...existing.tcp?.services, ...config.tcp?.services }, + } : undefined, + udp: (existing.udp || config.udp) ? { + services: { ...existing.udp?.services, ...config.udp?.services }, + } : undefined, }; registry.set(appName, merged); @@ -246,4 +269,5 @@ export function _resetForTesting(): void { registry.clear(); outputFile = null; pendingFlush = null; + tempFilesCleanedUp = false; } diff --git a/src/backends/traefik/validators.ts b/src/backends/traefik/validators.ts index 45d3238..7de7914 100644 --- a/src/backends/traefik/validators.ts +++ b/src/backends/traefik/validators.ts @@ -1,4 +1,5 @@ import yaml from 'js-yaml'; +import { getErrorMessage } from './helpers'; /** Allowed top-level keys in Traefik dynamic config */ const ALLOWED_TOP_KEYS = new Set(['http', 'tcp', 'udp']); @@ -60,7 +61,7 @@ export function validateGeneratedConfig(yamlText: string): ValidationResult { try { parsed = yaml.load(yamlText); } catch (err) { - return { valid: false, error: `Invalid YAML: ${err instanceof Error ? err.message : String(err)}` }; + return { valid: false, error: `Invalid YAML: ${getErrorMessage(err)}` }; } // Allow empty config diff --git a/src/config.ts b/src/config.ts index a2a28f0..41ba613 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,16 +1,16 @@ +import fs from 'fs'; +import yaml from 'js-yaml'; import isDocker from "is-docker"; +import { MagicProxyConfigFile } from './types/config'; - -// set config direcotry to CONFIG_DIRECOTRY env or default to ./config: - +// Configuration directories - use environment variables or sensible defaults export const CONFIG_DIRECTORY = process.env.CONFIG_DIRECTORY || (isDocker() ? '/var/config/' : './config/'); - -// set output directory to CONFIG_DIRECOTRY env or default to ./generated: - export const OUTPUT_DIRECTORY = process.env.OUTPUT_DIRECTORY || (isDocker() ? '/var/generated/' : './generated/'); -// Lazy evaluation of DEFAULT_CONFIG_FILE to support FS mocking in tests -// (tests set up mocks before loading config module) +/** + * Get the default config file path. + * Uses lazy evaluation to support FS mocking in tests. + */ let _defaultConfigFile: string | null = null; export function getDefaultConfigFile(): string { if (_defaultConfigFile === null) { @@ -19,35 +19,34 @@ export function getDefaultConfigFile(): string { return _defaultConfigFile; } -// Export DEFAULT_CONFIG_FILE for backwards compatibility - it will be computed on first access -// This is important for FS mocking in tests +// For backwards compatibility export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); -// Read the config file and load the YAML: -import fs from 'fs'; -import yaml from 'js-yaml'; -import { MagicProxyConfigFile } from './types/config'; +/** Valid proxy backend names */ +const VALID_BACKENDS: readonly ['traefik'] = ['traefik']; +/** + * Load and validate a configuration file. + */ export async function loadConfigFile(path?: string): Promise { const configPath = path || getDefaultConfigFile(); try { const fileContent = await fs.promises.readFile(configPath, 'utf-8'); const config = yaml.load(fileContent) as MagicProxyConfigFile; - - // validateConfig throws if invalid: validateConfig(config); - return config; } catch (error) { throw new Error(`Error loading config file at ${configPath}: ${error instanceof Error ? error.message : String(error)}`); } } -// config validator: +/** + * Validate configuration object. + * Throws if invalid. + */ export function validateConfig(config: MagicProxyConfigFile): boolean { - const validBackends = ['traefik']; - if (!config.proxyBackend || !validBackends.includes(config.proxyBackend)) { - throw new Error(`Invalid proxyBackend in config file. Must be one of: ${validBackends.join(', ')}`); + if (!config.proxyBackend || !VALID_BACKENDS.includes(config.proxyBackend)) { + throw new Error(`Invalid proxyBackend in config file. Must be one of: ${VALID_BACKENDS.join(', ')}`); } return true; } \ No newline at end of file diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 1e8ff1c..4bcc05d 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,24 +1,32 @@ import * as winston from "winston"; +import type { Logform } from 'winston'; import fs from 'fs'; import path from 'path'; import util from 'util'; +// Configuration constants export const OVERSIZE_THRESHOLD = 100_000; // bytes export const CONSOLE_TRUNCATE_LENGTH = 1_000; // characters const LOG_FILE = process.env.LOG_FILE || path.join(process.cwd(), 'logs', 'app.log'); -// Define colors for levels and add them to winston +// Log info type with custom fields +type LogInfo = Logform.TransformableInfo & { + __serializedData?: string; + zone?: string; + data?: unknown; + timestamp?: string; +}; + +// Define colors for levels const levelColors: Record = { - info: 'cyan', // light blue - debug: 'gray', // gray + info: 'cyan', + debug: 'gray', error: 'red', warn: 'yellow' }; winston.addColors(levelColors); - - // Ensure log directory exists for file transport try { const logDir = path.dirname(LOG_FILE); @@ -26,18 +34,16 @@ try { fs.mkdirSync(logDir, { recursive: true }); } } catch (err) { - // If we can't make the dir, continue; file transport will error when used - // We don't want to crash the app during logger initialization console.error('[Logger] Failed to ensure log directory exists:', err); } -// Type-aware serialization for log data. Exported for testing and extension. +/** + * Type-aware serialization for log data. + * Safely converts any value to a string for logging. + */ export function serializeLogData(data: unknown): string { - // Strings are returned as-is if (typeof data === 'string') return data; - if (data === null) return 'null'; - if (data === undefined) return ''; if (typeof data === 'number') { @@ -47,42 +53,35 @@ export function serializeLogData(data: unknown): string { } if (typeof data === 'boolean') return data ? 'true' : 'false'; - if (typeof data === 'symbol') return data.toString(); + if (typeof data === 'function') { - const fn = data as (...args: unknown[]) => unknown; - return ``; + return ``; } if (Buffer.isBuffer(data)) { - // Use base64 to safely serialize binary data without huge expansion - return ``; + return ``; } - // Objects: try JSON.stringify, fallback to util.inspect for circular references + // Objects: try JSON.stringify, fallback to util.inspect for circular refs try { return JSON.stringify(data); } catch { - // Fallback to util.inspect for objects that can't be JSON-stringified return util.inspect(data, { depth: 2, breakLength: Infinity }); } } -import type { Logform } from 'winston'; - -type LogInfo = Logform.TransformableInfo & { __serializedData?: string; zone?: string; data?: unknown; timestamp?: string }; - -// Guard that checks incoming data size and replaces oversized payloads +/** + * Winston format that checks data size and replaces oversized payloads with an error. + */ export const overflowGuard = winston.format((info: LogInfo) => { - // If data is not present at all, or explicitly undefined, treat it as missing and do nothing if (!Object.prototype.hasOwnProperty.call(info, 'data') || info.data === undefined) { return info; } - // Serialize using type-aware serializer to avoid undefined/non-serializable issues let serialized: string; try { - serialized = serializeLogData(info.data as unknown); + serialized = serializeLogData(info.data); } catch (err) { const stack = err instanceof Error && err.stack ? err.stack : String(err); info.zone = 'logger'; @@ -92,13 +91,10 @@ export const overflowGuard = winston.format((info: LogInfo) => { return info; } - // Attach serialized copy for formatters and debugging without mutating original data info.__serializedData = serialized; - const bytes = Buffer.byteLength(serialized, 'utf8'); if (bytes > OVERSIZE_THRESHOLD) { - // Throw and capture stack for context, then replace message/meta try { throw new Error('Oversized log message'); } catch (err) { @@ -113,7 +109,10 @@ export const overflowGuard = winston.format((info: LogInfo) => { return info; }); -export function formatDataForConsole(data: unknown) { +/** + * Format data for console output with truncation. + */ +export function formatDataForConsole(data: unknown): string { if (data === undefined) return ''; let s: string; @@ -123,19 +122,23 @@ export function formatDataForConsole(data: unknown) { s = String(data); } - const bytes = Buffer.byteLength(s, 'utf8'); if (s.length > CONSOLE_TRUNCATE_LENGTH) { + const bytes = Buffer.byteLength(s, 'utf8'); return s.slice(0, CONSOLE_TRUNCATE_LENGTH) + ` ... `; } return s; } +/** + * Winston format to normalize log levels to lowercase. + */ export const lowerCaseLevel = winston.format((info) => { if (info.level) info.level = String(info.level).toLowerCase(); return info; }); +// Console format with colored output export const consoleFormat = winston.format.combine( overflowGuard(), lowerCaseLevel(), @@ -143,13 +146,13 @@ export const consoleFormat = winston.format.combine( winston.format.printf((info: LogInfo) => { const levelLabel = (info.level as string) ?? ''; const zone = info.zone ?? 'core'; - // Prefer serialized data for consistent, safe output - const dataSource = typeof info.__serializedData !== 'undefined' ? info.__serializedData : (Object.prototype.hasOwnProperty.call(info, 'data') ? info.data : undefined); - const dataPart = typeof dataSource !== 'undefined' ? ' ' + formatDataForConsole(dataSource) : ''; + const dataSource = info.__serializedData ?? (Object.prototype.hasOwnProperty.call(info, 'data') ? info.data : undefined); + const dataPart = dataSource !== undefined ? ' ' + formatDataForConsole(dataSource) : ''; return `[${levelLabel}][${zone}] ${info.message}${dataPart}`; }) ); +// File format with timestamps in syslog style const fileFormat = winston.format.combine( overflowGuard(), winston.format.timestamp(), @@ -157,10 +160,10 @@ const fileFormat = winston.format.combine( const ts = info.timestamp || new Date().toISOString(); const zone = info.zone ?? 'core'; const level = ((info.level as string) ?? '').toUpperCase(); + let dataPart = ''; - const serialized = info.__serializedData; - if (typeof serialized !== 'undefined') { - dataPart = ' ' + serialized; + if (info.__serializedData !== undefined) { + dataPart = ' ' + info.__serializedData; } else if (Object.prototype.hasOwnProperty.call(info, 'data')) { try { dataPart = ' ' + (typeof info.data === 'string' ? info.data : JSON.stringify(info.data)); @@ -169,12 +172,11 @@ const fileFormat = winston.format.combine( } } - // Simple syslog-like line: TIMESTAMP ZONE LEVEL: MESSAGE DATA return `${ts} ${zone} ${level}: ${info.message}${dataPart}`; }) ); -// When running unit tests we should avoid printing logs to console +// Suppress console output during tests const isTestEnv = process.env.NODE_ENV === 'test'; export const baseLogger = winston.createLogger({ diff --git a/src/types/docker.d.ts b/src/types/docker.d.ts index 784f6b5..0e31fdc 100644 --- a/src/types/docker.d.ts +++ b/src/types/docker.d.ts @@ -1,8 +1,9 @@ -import Docker from 'dockerode'; import { XMagicProxyData } from './xmagic'; -// Definition of Docker Compose file structure, with support for -// our custom x-magic-proxy object under services..x-magic-proxy +/** + * Docker Compose file structure with support for custom x-magic-proxy extension. + * @see https://docs.docker.com/compose/compose-file/ + */ export type ComposeFileData = { version?: string | number; @@ -58,14 +59,3 @@ export type ComposeFileData = { secrets?: Record; }; - - - -export interface ComposeFileReference { - path: string | null; - error?: string; - composeFile?: string | null; - // composeData contains x-magic-proxy info if successfully loaded - composeData?: ComposeFileData; - containers: Docker.ContainerInfo[]; -} diff --git a/src/types/index.ts b/src/types/index.ts index b62b061..f9f63ed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,5 @@ -// Re-export all types from their respective modules for convenient importing +// Re-export all types from their respective modules export * from './config'; export * from './docker'; export * from './host'; -export * from '../backends/traefik/types/traefik'; export * from './xmagic'; diff --git a/src/types/xmagic.ts b/src/types/xmagic.ts index 8c07193..c088e75 100644 --- a/src/types/xmagic.ts +++ b/src/types/xmagic.ts @@ -48,5 +48,3 @@ export function validateXMagicProxyData(data: unknown): XMagicProxyValidationRes return { valid: false, reason }; } - -export { XMagicProxySchema as xMagicProxySchema }; diff --git a/test/unit/traefik/helpers.test.ts b/test/unit/traefik/helpers.test.ts new file mode 100644 index 0000000..74f7574 --- /dev/null +++ b/test/unit/traefik/helpers.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { getErrorMessage, detectCollisions } from '../../../src/backends/traefik/helpers'; + +describe('Traefik Helpers', () => { + describe('getErrorMessage', () => { + it('extracts message from Error object', () => { + const error = new Error('Something went wrong'); + expect(getErrorMessage(error)).toBe('Something went wrong'); + }); + + it('converts string to string', () => { + expect(getErrorMessage('plain string error')).toBe('plain string error'); + }); + + it('converts number to string', () => { + expect(getErrorMessage(42)).toBe('42'); + }); + + it('converts null to string', () => { + expect(getErrorMessage(null)).toBe('null'); + }); + + it('converts undefined to string', () => { + expect(getErrorMessage(undefined)).toBe('undefined'); + }); + + it('converts object to string', () => { + const obj = { code: 'ERR_UNKNOWN' }; + expect(getErrorMessage(obj)).toBe('[object Object]'); + }); + + it('handles TypeError', () => { + const error = new TypeError('Invalid type'); + expect(getErrorMessage(error)).toBe('Invalid type'); + }); + + it('handles custom Error subclass', () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + const error = new CustomError('Custom error message'); + expect(getErrorMessage(error)).toBe('Custom error message'); + }); + }); + + describe('detectCollisions', () => { + it('detects single collision', () => { + const target = { foo: 1, bar: 2 }; + const source = { foo: 3, baz: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['foo']); + }); + + it('detects multiple collisions', () => { + const target = { foo: 1, bar: 2, baz: 3 }; + const source = { foo: 10, bar: 20, qux: 40 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('foo'); + expect(collisions).toContain('bar'); + expect(collisions).toHaveLength(2); + }); + + it('returns empty array when no collisions', () => { + const target = { foo: 1, bar: 2 }; + const source = { baz: 3, qux: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles empty target', () => { + const target = {}; + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles empty source', () => { + const target = { foo: 1, bar: 2 }; + const source = {}; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles both empty objects', () => { + const collisions = detectCollisions({}, {}); + expect(collisions).toEqual([]); + }); + + it('handles undefined target (default parameter)', () => { + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(undefined, source); + expect(collisions).toEqual([]); + }); + + it('handles undefined source (default parameter)', () => { + const target = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, undefined); + expect(collisions).toEqual([]); + }); + + it('works with different value types', () => { + const target = { str: 'hello', num: 42, bool: true }; + const source = { str: 'world', obj: { nested: 'value' } }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['str']); + }); + + it('detects all keys when source is subset of target', () => { + const target = { a: 1, b: 2, c: 3, d: 4 }; + const source = { a: 10, c: 30 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('a'); + expect(collisions).toContain('c'); + expect(collisions).toHaveLength(2); + }); + + it('preserves order of keys from source', () => { + const target = { a: 1, b: 2, c: 3 }; + const source = { c: 30, a: 10, b: 20 }; + const collisions = detectCollisions(target, source); + // Object.keys() order in modern JS follows insertion order + expect(collisions).toEqual(['c', 'a', 'b']); + }); + }); +}); diff --git a/test/unit/traefik/template-error-handling.test.ts b/test/unit/traefik/template-error-handling.test.ts index 188f73d..1ac5d1d 100644 --- a/test/unit/traefik/template-error-handling.test.ts +++ b/test/unit/traefik/template-error-handling.test.ts @@ -1,5 +1,5 @@ import { describe, it, beforeEach, expect } from 'vitest'; -import { renderTemplate } from '../../../src/backends/traefik/templateParser'; +import { renderTemplate, renderTemplateParsed } from '../../../src/backends/traefik/templateParser'; import * as traefik from '../../../src/backends/traefik/traefik'; import { XMagicProxyData } from '../../../src/types/xmagic'; import { HostEntry } from '../../../src/types/host'; @@ -58,7 +58,7 @@ key: {{ app_name }} hostname: 'h', }; - expect(() => renderTemplate(tmpl, 'app', data)).toThrow( + expect(() => renderTemplateParsed(tmpl, 'app', data)).toThrow( /Template produced invalid YAML/ ); }); diff --git a/test/unit/traefik/user-data-substitution.test.ts b/test/unit/traefik/user-data-substitution.test.ts index 68c424f..7635ac7 100644 --- a/test/unit/traefik/user-data-substitution.test.ts +++ b/test/unit/traefik/user-data-substitution.test.ts @@ -124,8 +124,8 @@ config: }; const result = renderTemplate(template, 'app', data); - // null should be converted to empty string, YAML output will have single quotes - expect(result).toContain("optional_setting: ''"); + // null should be converted to empty string in the raw template output + expect(result).toContain('optional_setting: ""'); }); it('core variables cannot be overwritten by userData', () => {