2026-04-09 23:03:55 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 08:22:12 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 23:03:55 +00:00
|
|
|
export interface IRoutingConfig {
|
2026-04-10 08:22:12 +00:00
|
|
|
routes: ISipRoute[];
|
2026-04-09 23:03:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 08:22:12 +00:00
|
|
|
cfg.routing ??= { routes: [] };
|
|
|
|
|
cfg.routing.routes ??= [];
|
|
|
|
|
|
2026-04-09 23:03:55 +00:00
|
|
|
cfg.contacts ??= [];
|
|
|
|
|
for (const c of cfg.contacts) {
|
|
|
|
|
c.starred ??= false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cfg;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 08:22:12 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 23:03:55 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 08:22:12 +00:00
|
|
|
/**
|
|
|
|
|
* @deprecated Use resolveOutboundRoute() instead. Kept for backward compat.
|
|
|
|
|
*/
|
2026-04-09 23:03:55 +00:00
|
|
|
export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null {
|
2026-04-10 08:22:12 +00:00
|
|
|
const result = resolveOutboundRoute(cfg, '');
|
|
|
|
|
return result?.provider ?? null;
|
2026-04-09 23:03:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 08:22:12 +00:00
|
|
|
/**
|
|
|
|
|
* @deprecated Use resolveInboundRoute() instead. Kept for backward compat.
|
|
|
|
|
*/
|
2026-04-09 23:03:55 +00:00
|
|
|
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
|
2026-04-10 08:22:12 +00:00
|
|
|
const result = resolveInboundRoute(cfg, providerId, '', '');
|
|
|
|
|
if (!result.deviceIds.length) return cfg.devices;
|
|
|
|
|
return result.deviceIds.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
|
2026-04-09 23:03:55 +00:00
|
|
|
}
|