feat(storage): persist siprouter data in smartdata and smartbucket

This commit is contained in:
2026-05-21 23:35:50 +00:00
parent 04e706715f
commit 3e2fee16c1
14 changed files with 2018 additions and 492 deletions
+178 -81
View File
@@ -1,13 +1,10 @@
/**
* Application configuration — loaded from .nogit/config.json.
* Application configuration models and normalization helpers.
*
* All network addresses, credentials, provider settings, device definitions,
* and routing rules come from this single config file. No hardcoded values
* in source.
* and routing rules are persisted through SmartData.
*/
import fs from 'node:fs';
import path from 'node:path';
import type { IFaxBoxConfig } from './faxbox.ts';
import type { IVoiceboxConfig } from './voicebox.js';
@@ -266,97 +263,197 @@ export interface IAppConfig {
}
// ---------------------------------------------------------------------------
// Loader
// Defaults and normalization
// ---------------------------------------------------------------------------
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
function requiredInitialEnv(keyArg: string): string {
const value = process.env[keyArg];
if (!value) {
throw new Error(`Missing required initial config environment variable: ${keyArg}`);
}
return value;
}
export function loadConfig(): IAppConfig {
let raw: string;
function numberFromEnv(keyArg: string, fallbackArg: number): number {
const value = process.env[keyArg];
if (!value) return fallbackArg;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallbackArg;
}
export function normalizeConfig(cfg: IAppConfig): IAppConfig {
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`);
// 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');
}
p.displayName ??= p.id;
p.registerIntervalSec ??= 300;
p.codecs ??= [9, 0, 8, 101];
p.quirks ??= { earlyMediaSilence: false };
}
cfg.proxy.webUiPort ??= 3060;
cfg.proxy.publicIpSeed ??= null;
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`);
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 };
}
d.displayName ??= d.id;
d.extension ??= '100';
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.incomingNumbers ??= [];
for (const incoming of cfg.incomingNumbers) {
if (!incoming.id) incoming.id = `incoming-${Date.now()}`;
incoming.label ??= incoming.id;
incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single';
incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49';
}
cfg.routing ??= { routes: [] };
cfg.routing.routes ??= [];
cfg.contacts ??= [];
for (const c of cfg.contacts) {
c.starred ??= false;
}
cfg.faxboxes ??= [];
for (const fb of cfg.faxboxes) {
fb.enabled ??= true;
fb.maxMessages ??= 50;
}
cfg.voiceboxes ??= [];
for (const vb of cfg.voiceboxes) {
vb.enabled ??= true;
vb.noAnswerTimeoutSec ??= 25;
vb.maxRecordingSec ??= 120;
vb.maxMessages ??= 50;
vb.greetingVoice ??= 'af_bella';
}
if (cfg.ivr) {
cfg.ivr.enabled ??= false;
cfg.ivr.menus ??= [];
for (const menu of cfg.ivr.menus) {
menu.timeoutSec ??= 5;
menu.maxRetries ??= 3;
menu.entries ??= [];
}
}
return cfg;
} catch (error) {
throw error;
}
}
cfg.incomingNumbers ??= [];
for (const incoming of cfg.incomingNumbers) {
if (!incoming.id) incoming.id = `incoming-${Date.now()}`;
incoming.label ??= incoming.id;
incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single';
incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49';
}
export function createInitialConfigFromEnv(): IAppConfig {
return normalizeConfig({
proxy: {
lanIp: requiredInitialEnv('SIPROUTER_LAN_IP'),
lanPort: numberFromEnv('SIPROUTER_LAN_PORT', 5070),
publicIpSeed: process.env.SIPROUTER_PUBLIC_IP || null,
rtpPortRange: {
min: numberFromEnv('SIPROUTER_RTP_PORT_MIN', 20000),
max: numberFromEnv('SIPROUTER_RTP_PORT_MAX', 20200),
},
webUiPort: numberFromEnv('SIPROUTER_WEB_UI_PORT', 3060),
},
providers: [],
devices: [
{
id: process.env.SIPROUTER_INITIAL_DEVICE_ID || 'desk-phone',
displayName: process.env.SIPROUTER_INITIAL_DEVICE_DISPLAY_NAME || 'Desk Phone',
expectedAddress: requiredInitialEnv('SIPROUTER_INITIAL_DEVICE_ADDRESS'),
extension: process.env.SIPROUTER_INITIAL_DEVICE_EXTENSION || '100',
},
],
incomingNumbers: [],
routing: { routes: [] },
contacts: [],
faxboxes: [],
voiceboxes: [],
ivr: {
enabled: false,
entryMenuId: 'main-menu',
menus: [],
},
});
}
cfg.routing ??= { routes: [] };
cfg.routing.routes ??= [];
export function maskConfig(configArg: IAppConfig): IAppConfig {
return {
...configArg,
providers: configArg.providers?.map((providerArg) => ({
...providerArg,
password: providerArg.password ? '••••••' : providerArg.password,
})) || [],
};
}
cfg.contacts ??= [];
for (const c of cfg.contacts) {
c.starred ??= false;
}
export function applyConfigUpdates(configArg: IAppConfig, updatesArg: any): IAppConfig {
const cfg = JSON.parse(JSON.stringify(configArg)) as IAppConfig;
cfg.faxboxes ??= [];
for (const fb of cfg.faxboxes) {
fb.enabled ??= true;
fb.maxMessages ??= 50;
}
// Voicebox defaults.
cfg.voiceboxes ??= [];
for (const vb of cfg.voiceboxes) {
vb.enabled ??= true;
vb.noAnswerTimeoutSec ??= 25;
vb.maxRecordingSec ??= 120;
vb.maxMessages ??= 50;
vb.greetingVoice ??= 'af_bella';
}
// IVR defaults.
if (cfg.ivr) {
cfg.ivr.enabled ??= false;
cfg.ivr.menus ??= [];
for (const menu of cfg.ivr.menus) {
menu.timeoutSec ??= 5;
menu.maxRetries ??= 3;
menu.entries ??= [];
if (updatesArg.providers) {
for (const up of updatesArg.providers) {
const existing = cfg.providers?.find((p: any) => p.id === up.id);
if (existing) {
if (up.displayName !== undefined) existing.displayName = up.displayName;
if (up.password && up.password !== '••••••') existing.password = up.password;
if (up.domain !== undefined) existing.domain = up.domain;
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
if (up.username !== undefined) existing.username = up.username;
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
if (up.codecs !== undefined) existing.codecs = up.codecs;
if (up.quirks !== undefined) existing.quirks = up.quirks;
}
}
}
return cfg;
if (updatesArg.addProvider) {
cfg.providers ??= [];
cfg.providers.push(updatesArg.addProvider);
}
if (updatesArg.removeProvider) {
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updatesArg.removeProvider);
if (cfg.routing?.routes) {
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
r.match?.sourceProvider !== updatesArg.removeProvider &&
r.action?.provider !== updatesArg.removeProvider
);
}
}
if (updatesArg.devices) {
for (const ud of updatesArg.devices) {
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
}
}
if (updatesArg.incomingNumbers !== undefined) cfg.incomingNumbers = updatesArg.incomingNumbers;
if (updatesArg.routing?.routes) cfg.routing.routes = updatesArg.routing.routes;
if (updatesArg.contacts !== undefined) cfg.contacts = updatesArg.contacts;
if (updatesArg.faxboxes !== undefined) cfg.faxboxes = updatesArg.faxboxes;
if (updatesArg.voiceboxes !== undefined) cfg.voiceboxes = updatesArg.voiceboxes;
if (updatesArg.ivr !== undefined) cfg.ivr = updatesArg.ivr;
return normalizeConfig(cfg);
}
// Route resolution, pattern matching, and provider/device lookup