/** * Application configuration — loaded from .nogit/config.json. * * All network addresses, credentials, provider settings, device definitions, * and routing rules come from this single config file. No hardcoded values * in source. */ import fs from 'node:fs'; import path from 'node:path'; import type { IEndpoint } from './sip/index.ts'; // --------------------------------------------------------------------------- // Config interfaces // --------------------------------------------------------------------------- export interface IQuirks { earlyMediaSilence: boolean; silencePayloadType?: number; silenceMaxPackets?: number; } export interface IProviderConfig { id: string; displayName: string; domain: string; outboundProxy: IEndpoint; username: string; password: string; registerIntervalSec: number; codecs: number[]; quirks: IQuirks; } export interface IDeviceConfig { id: string; displayName: string; expectedAddress: string; extension: string; } // --------------------------------------------------------------------------- // Match/Action routing model // --------------------------------------------------------------------------- /** * Criteria for matching a call to a route. * All specified fields must match (AND logic). * Omitted fields are wildcards (match anything). */ export interface ISipRouteMatch { /** Whether this route applies to inbound or outbound calls. */ direction: 'inbound' | 'outbound'; /** * Match the dialed/called number (To/Request-URI for inbound DID, dialed digits for outbound). * Supports: exact string, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/"). */ numberPattern?: string; /** Match caller ID / From number (same pattern syntax). */ callerPattern?: string; /** For inbound: match by source provider ID. */ sourceProvider?: string; /** For outbound: match by source device ID (or 'browser' for browser devices). */ sourceDevice?: string; } /** What to do when a route matches. */ export interface ISipRouteAction { // --- Inbound actions (ring targets) --- /** Device IDs to ring. Empty/omitted = ring all devices. */ targets?: string[]; /** Also ring connected browser clients. Default false. */ ringBrowsers?: boolean; // --- Outbound actions (provider selection) --- /** Provider ID to use for outbound. */ provider?: string; /** Failover provider IDs, tried in order if primary is unregistered. */ failoverProviders?: string[]; // --- Number transformation (outbound) --- /** Strip this prefix from the dialed number before sending to provider. */ stripPrefix?: string; /** Prepend this prefix to the dialed number before sending to provider. */ prependPrefix?: string; } /** A single routing rule — the core unit of the match/action model. */ export interface ISipRoute { /** Unique identifier for this route. */ id: string; /** Human-readable name shown in the UI. */ name: string; /** Higher priority routes are evaluated first. Default 0. */ priority: number; /** Disabled routes are skipped during evaluation. Default true. */ enabled: boolean; /** Criteria that a call must match for this route to apply. */ match: ISipRouteMatch; /** What to do when the route matches. */ action: ISipRouteAction; } export interface IRoutingConfig { routes: ISipRoute[]; } export interface IProxyConfig { lanIp: string; lanPort: number; publicIpSeed: string | null; rtpPortRange: { min: number; max: number }; webUiPort: number; } export interface IContact { id: string; name: string; number: string; company?: string; notes?: string; starred?: boolean; } export interface IAppConfig { proxy: IProxyConfig; providers: IProviderConfig[]; devices: IDeviceConfig[]; routing: IRoutingConfig; contacts: IContact[]; } // --------------------------------------------------------------------------- // Loader // --------------------------------------------------------------------------- const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json'); export function loadConfig(): IAppConfig { let raw: string; try { raw = fs.readFileSync(CONFIG_PATH, 'utf8'); } catch { throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`); } const cfg = JSON.parse(raw) as IAppConfig; // Basic validation. if (!cfg.proxy) throw new Error('config: missing "proxy" section'); if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp'); if (!cfg.proxy.lanPort) throw new Error('config: missing proxy.lanPort'); if (!cfg.proxy.rtpPortRange?.min || !cfg.proxy.rtpPortRange?.max) { throw new Error('config: missing proxy.rtpPortRange.min/max'); } cfg.proxy.webUiPort ??= 3060; cfg.proxy.publicIpSeed ??= null; cfg.providers ??= []; for (const p of cfg.providers) { if (!p.id || !p.domain || !p.outboundProxy || !p.username || !p.password) { throw new Error(`config: provider "${p.id || '?'}" missing required fields`); } p.displayName ??= p.id; p.registerIntervalSec ??= 300; p.codecs ??= [9, 0, 8, 101]; p.quirks ??= { earlyMediaSilence: false }; } if (!Array.isArray(cfg.devices) || !cfg.devices.length) { throw new Error('config: need at least one device'); } for (const d of cfg.devices) { if (!d.id || !d.expectedAddress) { throw new Error(`config: device "${d.id || '?'}" missing required fields`); } d.displayName ??= d.id; d.extension ??= '100'; } cfg.routing ??= { routes: [] }; cfg.routing.routes ??= []; cfg.contacts ??= []; for (const c of cfg.contacts) { c.starred ??= false; } return cfg; } // --------------------------------------------------------------------------- // Pattern matching // --------------------------------------------------------------------------- /** * Test a value against a pattern string. * - undefined/empty pattern: matches everything (wildcard) * - Prefix: "pattern*" matches values starting with "pattern" * - Regex: "/pattern/" or "/pattern/i" compiles as RegExp * - Otherwise: exact match */ export function matchesPattern(pattern: string | undefined, value: string): boolean { if (!pattern) return true; // Prefix match: "+49*" if (pattern.endsWith('*')) { return value.startsWith(pattern.slice(0, -1)); } // Regex match: "/^\\+49/" or "/pattern/i" if (pattern.startsWith('/')) { const lastSlash = pattern.lastIndexOf('/'); if (lastSlash > 0) { const re = new RegExp(pattern.slice(1, lastSlash), pattern.slice(lastSlash + 1)); return re.test(value); } } // Exact match. return value === pattern; } // --------------------------------------------------------------------------- // Route resolution // --------------------------------------------------------------------------- /** Result of resolving an outbound route. */ export interface IOutboundRouteResult { provider: IProviderConfig; transformedNumber: string; } /** Result of resolving an inbound route. */ export interface IInboundRouteResult { /** Device IDs to ring (empty = all devices). */ deviceIds: string[]; ringBrowsers: boolean; } /** * Resolve which provider to use for an outbound call, and transform the number. * * @param cfg - app config * @param dialedNumber - the number being dialed * @param sourceDeviceId - optional device originating the call * @param isProviderRegistered - callback to check if a provider is currently registered */ export function resolveOutboundRoute( cfg: IAppConfig, dialedNumber: string, sourceDeviceId?: string, isProviderRegistered?: (providerId: string) => boolean, ): IOutboundRouteResult | null { const routes = cfg.routing.routes .filter((r) => r.enabled && r.match.direction === 'outbound') .sort((a, b) => b.priority - a.priority); for (const route of routes) { const m = route.match; if (!matchesPattern(m.numberPattern, dialedNumber)) continue; if (m.sourceDevice && m.sourceDevice !== sourceDeviceId) continue; // Find a registered provider (primary + failovers). const candidates = [route.action.provider, ...(route.action.failoverProviders || [])].filter(Boolean) as string[]; for (const pid of candidates) { const provider = getProvider(cfg, pid); if (!provider) continue; if (isProviderRegistered && !isProviderRegistered(pid)) continue; // Apply number transformation. let num = dialedNumber; if (route.action.stripPrefix && num.startsWith(route.action.stripPrefix)) { num = num.slice(route.action.stripPrefix.length); } if (route.action.prependPrefix) { num = route.action.prependPrefix + num; } return { provider, transformedNumber: num }; } // Route matched but no provider is available — continue to next route. } // Fallback: first available provider. const fallback = cfg.providers[0]; return fallback ? { provider: fallback, transformedNumber: dialedNumber } : null; } /** * Resolve which devices/browsers to ring for an inbound call. * * @param cfg - app config * @param providerId - the provider the call is coming from * @param calledNumber - the DID / called number (from Request-URI) * @param callerNumber - the caller ID (from From header) */ export function resolveInboundRoute( cfg: IAppConfig, providerId: string, calledNumber: string, callerNumber: string, ): IInboundRouteResult { const routes = cfg.routing.routes .filter((r) => r.enabled && r.match.direction === 'inbound') .sort((a, b) => b.priority - a.priority); for (const route of routes) { const m = route.match; if (m.sourceProvider && m.sourceProvider !== providerId) continue; if (!matchesPattern(m.numberPattern, calledNumber)) continue; if (!matchesPattern(m.callerPattern, callerNumber)) continue; return { deviceIds: route.action.targets || [], ringBrowsers: route.action.ringBrowsers ?? false, }; } // Fallback: ring all devices + browsers. return { deviceIds: [], ringBrowsers: true }; } // --------------------------------------------------------------------------- // Lookup helpers // --------------------------------------------------------------------------- export function getProvider(cfg: IAppConfig, id: string): IProviderConfig | null { return cfg.providers.find((p) => p.id === id) ?? null; } export function getDevice(cfg: IAppConfig, id: string): IDeviceConfig | null { return cfg.devices.find((d) => d.id === id) ?? null; } /** * @deprecated Use resolveOutboundRoute() instead. Kept for backward compat. */ export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null { const result = resolveOutboundRoute(cfg, ''); return result?.provider ?? null; } /** * @deprecated Use resolveInboundRoute() instead. Kept for backward compat. */ export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] { const result = resolveInboundRoute(cfg, providerId, '', ''); if (!result.deviceIds.length) return cfg.devices; return result.deviceIds.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[]; }