diff --git a/bin/sipfax-egress-apply b/bin/sipfax-egress-apply new file mode 100755 index 0000000..d818318 --- /dev/null +++ b/bin/sipfax-egress-apply @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const action = process.argv[2]; +const callId = process.argv[3]; +const interfaceName = process.argv[4] ?? ''; +const localAddress = process.argv[5] ?? ''; +const remoteAddress = process.argv[6] ?? ''; + +const leaseDir = process.env.SIPFAX_PPP_LEASE_DIR ?? '/run/sipfax/ppp-leases'; +const activeDir = process.env.SIPFAX_PPP_ACTIVE_DIR ?? '/run/sipfax/ppp-egress-active'; +const sysctlCommand = process.env.SIPFAX_SYSCTL_COMMAND ?? 'sysctl'; + +if (!['up', 'down'].includes(action) || !callId) { + console.error('usage: sipfax-egress-apply up|down [interface] [local-ip] [remote-ip]'); + process.exit(64); +} + +const safeCallId = basename(callId).replace(/[^a-zA-Z0-9_.-]/g, '_'); +const descriptorPath = join(leaseDir, `${safeCallId}.json`); +if (!existsSync(descriptorPath)) { + console.error(`sipfax egress descriptor not found: ${descriptorPath}`); + process.exit(66); +} + +const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8')); +mkdirSync(activeDir, { recursive: true, mode: 0o750 }); + +if (action === 'up') { + setForwarding(descriptor, true); + applyRules(descriptor, 'up'); + writeFileSync(activePath(safeCallId), JSON.stringify({ + callId, + interfaceName, + localAddress, + remoteAddress, + outboundInterface: descriptor.outboundInterface, + activatedAt: new Date().toISOString() + }, null, 2)); + await postOperatorEvent(descriptor, 'ip-up'); +} else { + applyRules(descriptor, 'down'); + rmSync(activePath(safeCallId), { force: true }); + if (!hasActiveLeases()) { + setForwarding(descriptor, false); + } + await postOperatorEvent(descriptor, 'ip-down'); +} + +function applyRules(descriptor, ruleAction) { + if (commandExists('nft') && descriptor.nft?.[ruleAction]?.length) { + run('nft', ['-f', '-'], descriptor.nft[ruleAction].join('\n') + '\n'); + return; + } + + const iptables = commandExists('iptables-nft') ? 'iptables-nft' : 'iptables'; + for (const rule of descriptor.iptables?.[ruleAction] ?? []) { + const args = rule.split(/\s+/); + const command = args.shift(); + run(command === 'iptables' ? iptables : command, args); + } +} + +function setForwarding(descriptor, enabled) { + const value = enabled ? '1' : '0'; + run(sysctlCommand, ['-w', `net.ipv4.ip_forward=${value}`]); + if (descriptor.outboundInterface) { + run(sysctlCommand, ['-w', `net.ipv4.conf.${descriptor.outboundInterface}.forwarding=${value}`]); + } +} + +function run(command, args, input = null) { + const result = spawnSync(command, args, { + input, + encoding: 'utf8', + stdio: input === null ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'] + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || '').trim(); + throw new Error(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`); + } +} + +function commandExists(command) { + return spawnSync('/bin/sh', ['-c', 'command -v -- "$1" >/dev/null 2>&1', 'sipfax-command-exists', command], { + stdio: 'ignore' + }).status === 0; +} + +function activePath(id) { + return join(activeDir, `${id}.json`); +} + +function hasActiveLeases() { + return existsSync(activeDir) && spawnSync('/bin/sh', ['-c', `find "$1" -type f -name '*.json' -print -quit | grep -q .`, 'sipfax-egress-active', activeDir]).status === 0; +} + +async function postOperatorEvent(descriptor, state) { + if (!descriptor.operatorUrl) { + return; + } + + try { + await fetch(new URL('/ppp/events', descriptor.operatorUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + state, + callId, + interfaceName, + localAddress, + remoteAddress, + outboundInterface: descriptor.outboundInterface + }) + }); + } catch { + // Firewall changes must not depend on diagnostics delivery. + } +} diff --git a/deploy/README.md b/deploy/README.md index 9d6fd91..a582c43 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -30,11 +30,11 @@ Start from a Debian 12 VM on `vmbr0` with a fixed LAN address reserved for SIPfax. The FreePBX VM should be able to reach that IP on UDP `5060` and `40000`. -Install base packages and Node `24.x`: +Install base packages, Node `24.x`, PPP, and nftables: ```bash sudo apt-get update -sudo apt-get install -y ca-certificates curl gnupg git ufw +sudo apt-get install -y ca-certificates curl gnupg git ufw ppp nftables curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - sudo apt-get install -y nodejs node --version @@ -93,8 +93,8 @@ keep the shipped unit's `ReadWritePaths=/var/cache/sipfax /var/log/sipfax` entry intact so `ProtectSystem=strict` does not make the artifact path read-only. -Install `ppp` on the SIPfax VM. When the modem worker emits a `pty-opened` -control event with `slavePath`, SIPfax starts `pppd` on that pty with +Install `ppp` and `nftables` on the SIPfax VM. When the modem worker emits a +`pty-opened` control event with `slavePath`, SIPfax starts `pppd` on that pty with `nodetach`, `nodefaultroute`, `noccp`, `require-chap` by default, the leased local/client address pair, configured `ms-dns` values, MTU 1500, and high-latency LCP/IPCP retry settings. `SIPFAX_PPP_AUTH=pap` switches the required auth mode @@ -103,6 +103,15 @@ pppd `ip-up-script` and `ip-down-script`; emit JSON lines such as `{"state":"IPCP-open","interfaceName":"ppp0"}` so operator diagnostics can show `ppp.state`, peer addresses, DNS servers, interface, and session duration. +SIPfax writes a per-call PPP egress descriptor under +`/run/sipfax/ppp-leases/.json` before `pppd` starts. The descriptor +contains the rendered nftables and iptables-nft fallback rules from +`EgressPolicy`. SIPfax itself runs as the unprivileged `sipfax` user and does +not write firewall state. The root-side pppd hooks installed by +`deploy/install-systemd.sh` are the only path that enables forwarding, applies +NAT/MASQUERADE, rolls rules back on `ip-down`, and posts best-effort loopback +diagnostics to operator HTTP. + ## systemd Install Install the unit and start the service: @@ -113,6 +122,16 @@ sudo systemctl enable --now sipfax.service sudo systemctl status sipfax.service ``` +`deploy/install-systemd.sh` also installs: + +- `/usr/lib/sipfax/sipfax-egress-apply` +- `/etc/ppp/ip-up.d/sipfax-egress` +- `/etc/ppp/ip-down.d/sipfax-egress` + +Operator review is required before enabling the hooks on a production VM, +because they write nftables/iptables state and toggle IPv4 forwarding while a +PPP lease is active. + The shipped unit leaves `MemoryDenyWriteExecute=false` because Node `24.x`/V8 requires executable anonymous memory during runtime startup. Do not add a live drop-in override for this setting; keep the repository unit as the source of @@ -151,6 +170,13 @@ ssh -L 8080:127.0.0.1:8080 @ Then query `http://127.0.0.1:8080` locally. +PPP egress is applied by nftables when `nft` is available. The helper falls back +to `iptables-nft` for systems where nftables is not present. On `ip-up`, the +helper enables `net.ipv4.ip_forward=1` and +`net.ipv4.conf..forwarding=1`, then applies the +per-call ruleset. On `ip-down`, it removes the per-call ruleset and disables +forwarding after the last active SIPfax PPP lease is gone. + ## Verification On the SIPfax VM: @@ -177,6 +203,11 @@ For the LKMA-196 path, a live Windows dial-up attempt should show real worker modulation, for example `media.modem.modulation` as `V.21`, and frame counters increasing from the worker control stream. +For PPP egress, an authenticated Linux client should be able to reach a public +HTTP destination with `curl --interface ppp0 `. After disconnect, confirm +that `sudo nft list ruleset | grep sipfax_` no longer shows the call-specific +table and forwarding is disabled when no other SIPfax PPP lease is active. + From the FreePBX side: 1. Create a dedicated PJSIP trunk or endpoint that targets diff --git a/deploy/install-systemd.sh b/deploy/install-systemd.sh index d354645..fb5834e 100755 --- a/deploy/install-systemd.sh +++ b/deploy/install-systemd.sh @@ -19,8 +19,13 @@ if [[ ! -f /etc/sipfax/sipfax.env ]]; then fi install -m 0644 "${repo_root}/deploy/sipfax.service" /etc/systemd/system/sipfax.service +install -d -m 0755 -o root -g root /usr/lib/sipfax +install -m 0755 -o root -g root "${repo_root}/bin/sipfax-egress-apply" /usr/lib/sipfax/sipfax-egress-apply +install -d -m 0755 -o root -g root /etc/ppp/ip-up.d /etc/ppp/ip-down.d +install -m 0755 -o root -g root "${repo_root}/deploy/ppp/ip-up" /etc/ppp/ip-up.d/sipfax-egress +install -m 0755 -o root -g root "${repo_root}/deploy/ppp/ip-down" /etc/ppp/ip-down.d/sipfax-egress install -d -m 0755 -o sipfax -g sipfax /var/cache/sipfax install -d -m 0755 -o sipfax -g sipfax /var/log/sipfax systemctl daemon-reload -echo "Installed sipfax.service. Run: systemctl enable --now sipfax.service" +echo "Installed sipfax.service, pppd hooks, and SIPfax egress helper. Run: systemctl enable --now sipfax.service" diff --git a/deploy/ppp/ip-down b/deploy/ppp/ip-down new file mode 100755 index 0000000..5e9e66b --- /dev/null +++ b/deploy/ppp/ip-down @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +helper="${SIPFAX_EGRESS_HELPER:-/usr/lib/sipfax/sipfax-egress-apply}" +call_id="${6:-${PPP_IPPARAM:-}}" + +if [ -z "$call_id" ]; then + echo "sipfax ip-down: missing pppd ipparam call id" >&2 + exit 0 +fi + +"$helper" down "$call_id" "${1:-}" "${4:-}" "${5:-}" diff --git a/deploy/ppp/ip-up b/deploy/ppp/ip-up new file mode 100755 index 0000000..8665584 --- /dev/null +++ b/deploy/ppp/ip-up @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +helper="${SIPFAX_EGRESS_HELPER:-/usr/lib/sipfax/sipfax-egress-apply}" +call_id="${6:-${PPP_IPPARAM:-}}" + +if [ -z "$call_id" ]; then + echo "sipfax ip-up: missing pppd ipparam call id" >&2 + exit 0 +fi + +"$helper" up "$call_id" "${1:-}" "${4:-}" "${5:-}" diff --git a/deploy/sipfax.env.example b/deploy/sipfax.env.example index 30c8a05..5468ecb 100644 --- a/deploy/sipfax.env.example +++ b/deploy/sipfax.env.example @@ -36,9 +36,14 @@ SIPFAX_PPP_AUTH=chap # Optional script used for pppd ip-up/ip-down notifications. It should emit # JSON lines with state IPCP-open/IPCP-close plus interfaceName when configured. # SIPFAX_PPP_NOTIFY_SCRIPT=/usr/lib/sipfax/ppp-notify +# SIPfax writes per-call egress descriptors here. The root-owned pppd hooks read +# these descriptors and apply nftables/iptables forwarding outside the service. +SIPFAX_PPP_LEASE_DIR=/run/sipfax/ppp-leases # Set to the VM interface used for outbound traffic, for example ens18. SIPFAX_EGRESS_INTERFACE=replace-me-uplink-interface SIPFAX_EGRESS_ENABLED=true SIPFAX_EGRESS_DNS=true SIPFAX_EGRESS_ALLOW=0.0.0.0/0 +# Loopback diagnostics endpoint used by the privileged egress helper. +# SIPFAX_OPERATOR_URL=http://127.0.0.1:8080 diff --git a/deploy/sipfax.service b/deploy/sipfax.service index 06fc6c2..cac3f51 100644 --- a/deploy/sipfax.service +++ b/deploy/sipfax.service @@ -12,6 +12,8 @@ WorkingDirectory=/opt/sipfax EnvironmentFile=/etc/sipfax/sipfax.env Environment=NODE_ENV=production Environment=NPM_CONFIG_CACHE=/var/cache/sipfax/npm +RuntimeDirectory=sipfax +RuntimeDirectoryMode=0750 ExecStart=/usr/bin/npm start Restart=on-failure RestartSec=5s @@ -26,7 +28,7 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/var/cache/sipfax /var/log/sipfax +ReadWritePaths=/var/cache/sipfax /var/log/sipfax /run/sipfax RestrictSUIDSGID=true LockPersonality=true # Node 24/V8 needs executable anonymous memory for JIT/runtime startup. diff --git a/src/index.js b/src/index.js index 119e2a5..7284d34 100644 --- a/src/index.js +++ b/src/index.js @@ -8,14 +8,16 @@ export const DEFAULT_SOFTMODEM_BINARY = '/opt/sipfax/bin/sipfax-softmodem'; const modemCommand = process.env.SIPFAX_MODEM_COMMAND; const softmodemBinary = process.env.SIPFAX_SOFTMODEM_BINARY ?? DEFAULT_SOFTMODEM_BINARY; +const operatorHost = process.env.SIPFAX_OPERATOR_HOST ?? '127.0.0.1'; +const operatorPort = Number.parseInt(process.env.SIPFAX_OPERATOR_PORT ?? '8080', 10); const config = { host: process.env.SIPFAX_HOST ?? '0.0.0.0', publicHost: process.env.SIPFAX_PUBLIC_HOST ?? '127.0.0.1', sipPort: Number.parseInt(process.env.SIPFAX_SIP_PORT ?? '5060', 10), rtpPort: Number.parseInt(process.env.SIPFAX_RTP_PORT ?? '40000', 10), - operatorHost: process.env.SIPFAX_OPERATOR_HOST ?? '127.0.0.1', - operatorPort: Number.parseInt(process.env.SIPFAX_OPERATOR_PORT ?? '8080', 10), + operatorHost, + operatorPort, freepbxExtension: process.env.SIPFAX_FREEPBX_EXTENSION ?? 'faxmodem', modem: createModemBackend(), ppp: new PppSessionController({ @@ -29,11 +31,13 @@ const config = { command: process.env.SIPFAX_PPPD_COMMAND ?? '/usr/sbin/pppd', authProtocol: process.env.SIPFAX_PPP_AUTH ?? 'chap', dnsServers: parseList(process.env.SIPFAX_PPP_DNS, ['1.1.1.1', '9.9.9.9']), - notifyScript: process.env.SIPFAX_PPP_NOTIFY_SCRIPT || null + notifyScript: process.env.SIPFAX_PPP_NOTIFY_SCRIPT || null, + leaseDir: process.env.SIPFAX_PPP_LEASE_DIR ?? '/run/sipfax/ppp-leases' }), egressPolicy: new EgressPolicy({ clientCidr: process.env.SIPFAX_PPP_POOL ?? '10.64.0.0/24', outboundInterface: process.env.SIPFAX_EGRESS_INTERFACE ?? 'wan0', + operatorUrl: process.env.SIPFAX_OPERATOR_URL ?? `http://${operatorHost}:${operatorPort}`, allowInternet: process.env.SIPFAX_EGRESS_ENABLED !== 'false', allowDns: process.env.SIPFAX_EGRESS_DNS !== 'false', allowedDestinations: parseList(process.env.SIPFAX_EGRESS_ALLOW, ['0.0.0.0/0']) diff --git a/src/operator.js b/src/operator.js index 8774d70..62fe151 100644 --- a/src/operator.js +++ b/src/operator.js @@ -6,6 +6,7 @@ export class OperatorHttpServer { this.port = port; this.diagnostics = diagnostics; this.freepbx = freepbx; + this.pppEvents = []; this.server = http.createServer((request, response) => { this.handleRequest(request, response); }); @@ -30,12 +31,17 @@ export class OperatorHttpServer { } handleRequest(request, response) { + const url = new URL(request.url, `http://${request.headers.host ?? 'localhost'}`); + if (request.method === 'POST' && url.pathname === '/ppp/events') { + this.acceptPppEvent(request, response); + return; + } + if (request.method !== 'GET') { sendText(response, 405, 'method not allowed\n'); return; } - const url = new URL(request.url, `http://${request.headers.host ?? 'localhost'}`); if (url.pathname === '/healthz') { sendJson(response, 200, buildHealth(this.diagnostics())); return; @@ -51,8 +57,28 @@ export class OperatorHttpServer { return; } + if (url.pathname === '/ppp/events') { + sendJson(response, 200, { events: this.pppEvents }); + return; + } + sendText(response, 404, 'not found\n'); } + + acceptPppEvent(request, response) { + readJsonBody(request, 8192) + .then((event) => { + this.pppEvents.push({ + ...event, + receivedAt: new Date().toISOString() + }); + this.pppEvents = this.pppEvents.slice(-20); + sendJson(response, 202, { accepted: true }); + }) + .catch((error) => { + sendJson(response, 400, { error: error.message }); + }); + } } export function buildHealth(diagnostics) { @@ -151,3 +177,25 @@ function sendText(response, statusCode, body, contentType = 'text/plain') { }); response.end(body); } + +function readJsonBody(request, maxBytes) { + return new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + if (body.length > maxBytes) { + reject(new Error('request body too large')); + request.destroy(); + } + }); + request.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(new Error(`invalid JSON: ${error.message}`)); + } + }); + request.on('error', reject); + }); +} diff --git a/src/ppp.js b/src/ppp.js index df6c2ff..1e261b8 100644 --- a/src/ppp.js +++ b/src/ppp.js @@ -128,6 +128,7 @@ export class EgressPolicy { constructor({ clientCidr = '10.64.0.0/24', outboundInterface = 'wan0', + operatorUrl = 'http://127.0.0.1:8080', allowInternet = true, allowDns = true, allowedDestinations = ['0.0.0.0/0'], @@ -135,6 +136,7 @@ export class EgressPolicy { } = {}) { this.clientCidr = clientCidr; this.outboundInterface = outboundInterface; + this.operatorUrl = operatorUrl; this.allowInternet = allowInternet; this.allowDns = allowDns; this.allowedDestinations = allowedDestinations.map(parseCidr); @@ -158,39 +160,105 @@ export class EgressPolicy { return this.allowedDestinations.some((cidr) => cidrContains(cidr, destinationInt)); } - firewallRules() { + firewallRules({ action = 'up' } = {}) { + const iptablesAction = action === 'down' ? '-D' : '-A'; const rules = [ - `sysctl -w net.ipv4.ip_forward=${this.allowInternet ? '1' : '0'}`, - `iptables -A FORWARD -s ${this.clientCidr} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`, - `iptables -A FORWARD -d ${this.clientCidr} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT` + `sysctl -w net.ipv4.ip_forward=${action === 'up' && this.allowInternet ? '1' : '0'}`, + `iptables ${iptablesAction} FORWARD -s ${this.clientCidr} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`, + `iptables ${iptablesAction} FORWARD -d ${this.clientCidr} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT` ]; for (const destination of this.blockedDestinations) { - rules.push(`iptables -A FORWARD -s ${this.clientCidr} -d ${formatCidr(destination)} -j REJECT`); + rules.push(`iptables ${iptablesAction} FORWARD -s ${this.clientCidr} -d ${formatCidr(destination)} -j REJECT`); } if (this.allowDns) { - rules.push(`iptables -A FORWARD -s ${this.clientCidr} -p udp --dport 53 -j ACCEPT`); - rules.push(`iptables -A FORWARD -s ${this.clientCidr} -p tcp --dport 53 -j ACCEPT`); + rules.push(`iptables ${iptablesAction} FORWARD -s ${this.clientCidr} -p udp --dport 53 -j ACCEPT`); + rules.push(`iptables ${iptablesAction} FORWARD -s ${this.clientCidr} -p tcp --dport 53 -j ACCEPT`); } if (this.allowInternet) { for (const destination of this.allowedDestinations) { rules.push( - `iptables -A FORWARD -s ${this.clientCidr} -d ${formatCidr(destination)} -o ${this.outboundInterface} -j ACCEPT` + `iptables ${iptablesAction} FORWARD -s ${this.clientCidr} -d ${formatCidr(destination)} -o ${this.outboundInterface} -j ACCEPT` ); } - rules.push(`iptables -t nat -A POSTROUTING -s ${this.clientCidr} -o ${this.outboundInterface} -j MASQUERADE`); + rules.push(`iptables -t nat ${iptablesAction} POSTROUTING -s ${this.clientCidr} -o ${this.outboundInterface} -j MASQUERADE`); } - rules.push(`iptables -A FORWARD -s ${this.clientCidr} -j REJECT`); + rules.push(`iptables ${iptablesAction} FORWARD -s ${this.clientCidr} -j REJECT`); return rules; } + firewallRulesNft({ tableSuffix = 'lease', action = 'up' } = {}) { + const suffix = sanitizeNftName(tableSuffix); + const filterTable = `sipfax_${suffix}`; + const natTable = `sipfax_nat_${suffix}`; + if (action === 'down') { + const rules = []; + if (this.allowInternet) { + rules.push(`delete table ip ${natTable}`); + } + rules.push(`delete table inet ${filterTable}`); + return rules; + } + + const rules = [ + `add table inet ${filterTable}`, + `add chain inet ${filterTable} forward { type filter hook forward priority 0; policy accept; }`, + `add rule inet ${filterTable} forward ip saddr ${this.clientCidr} ct state established,related accept`, + `add rule inet ${filterTable} forward ip daddr ${this.clientCidr} ct state established,related accept` + ]; + + for (const destination of this.blockedDestinations) { + rules.push(`add rule inet ${filterTable} forward ip saddr ${this.clientCidr} ip daddr ${formatCidr(destination)} reject`); + } + + if (this.allowDns) { + rules.push(`add rule inet ${filterTable} forward ip saddr ${this.clientCidr} udp dport 53 accept`); + rules.push(`add rule inet ${filterTable} forward ip saddr ${this.clientCidr} tcp dport 53 accept`); + } + + if (this.allowInternet) { + for (const destination of this.allowedDestinations) { + rules.push( + `add rule inet ${filterTable} forward ip saddr ${this.clientCidr} ip daddr ${formatCidr(destination)} oifname "${this.outboundInterface}" accept` + ); + } + rules.push(`add table ip ${natTable}`); + rules.push(`add chain ip ${natTable} postrouting { type nat hook postrouting priority 100; policy accept; }`); + rules.push(`add rule ip ${natTable} postrouting ip saddr ${this.clientCidr} oifname "${this.outboundInterface}" masquerade`); + } + + rules.push(`add rule inet ${filterTable} forward ip saddr ${this.clientCidr} reject`); + return rules; + } + + leaseDescriptor({ callId, lease }) { + return { + version: 1, + callId, + lease: { ...lease }, + clientCidr: this.clientCidr, + outboundInterface: this.outboundInterface, + operatorUrl: this.operatorUrl, + allowInternet: this.allowInternet, + nft: { + up: this.firewallRulesNft({ tableSuffix: callId, action: 'up' }), + down: this.firewallRulesNft({ tableSuffix: callId, action: 'down' }) + }, + iptables: { + up: this.firewallRules({ action: 'up' }).filter((rule) => rule.startsWith('iptables ')), + down: this.firewallRules({ action: 'down' }).filter((rule) => rule.startsWith('iptables ')) + } + }; + } + diagnostics() { return { clientCidr: this.clientCidr, outboundInterface: this.outboundInterface, + operatorUrl: this.operatorUrl, allowInternet: this.allowInternet, allowDns: this.allowDns, allowedDestinations: this.allowedDestinations.map(formatCidr), @@ -224,7 +292,8 @@ export class PppSessionController { lease: null, dnsServers: [], pppd: null, - egress: this.egressPolicy.diagnostics() + egress: this.egressPolicy.diagnostics(), + egressDescriptor: null }; this.sessions.set(callId, session); @@ -278,10 +347,12 @@ export class PppSessionController { lease: session.lease, dnsServers: this.dnsServers, credentials: this.credentials, + egressDescriptor: this.egressPolicy.leaseDescriptor({ callId, lease: session.lease }), onEvent: (event) => { this.acceptPppdEvent(callId, event); } }); + session.egressDescriptor = session.pppd?.egressDescriptorPath ?? null; return true; } @@ -324,7 +395,8 @@ export class PppSessionController { lease: session.lease ? { ...session.lease } : null, dnsServers: [...session.dnsServers], pppd: session.pppd ? { ...session.pppd } : null, - egress: { ...session.egress } + egress: { ...session.egress }, + egressDescriptor: session.egressDescriptor }; } @@ -403,6 +475,11 @@ function formatCidr(cidr) { return `${intToIp(cidr.network)}/${cidr.prefixLength}`; } +function sanitizeNftName(value) { + const sanitized = String(value).replace(/[^a-zA-Z0-9_]/g, '_'); + return sanitized || 'lease'; +} + function ipToInt(address) { const octets = address.split('.').map((part) => Number.parseInt(part, 10)); if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { diff --git a/src/pppd-supervisor.js b/src/pppd-supervisor.js index 415694a..48e4883 100644 --- a/src/pppd-supervisor.js +++ b/src/pppd-supervisor.js @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -82,6 +82,7 @@ export class PppdSupervisor extends EventEmitter { authProtocol = 'chap', dnsServers = DEFAULT_DNS_SERVERS, notifyScript = null, + leaseDir = '/run/sipfax/ppp-leases', tempDir = tmpdir(), spawnProcess = spawn, cleanup = rmSync @@ -91,22 +92,27 @@ export class PppdSupervisor extends EventEmitter { this.authProtocol = authProtocol; this.dnsServers = [...dnsServers]; this.notifyScript = notifyScript; + this.leaseDir = leaseDir; this.tempDir = tempDir; this.spawnProcess = spawnProcess; this.cleanup = cleanup; this.sessions = new Map(); } - start({ callId, slavePath, lease, dnsServers = this.dnsServers, credentials, onEvent = null }) { + start({ callId, slavePath, lease, dnsServers = this.dnsServers, credentials, egressDescriptor = null, onEvent = null }) { this.stop(callId); const sessionDir = mkdtempSync(join(this.tempDir, `sipfax-pppd-${sanitizePathPart(callId)}-`)); + const egressDescriptorPath = egressDescriptor + ? this.writeEgressDescriptor(callId, egressDescriptor) + : null; const session = { callId, slavePath, lease: { ...lease }, dnsServers: [...dnsServers], sessionDir, + egressDescriptorPath, secretsPath: null, process: null, startedAt: null, @@ -260,7 +266,8 @@ export class PppdSupervisor extends EventEmitter { interfaceName: session.interfaceName, sessionDurationSeconds: session.sessionDurationSeconds, lastEventAt: session.lastEventAt, - lastError: session.lastError + lastError: session.lastError, + egressDescriptorPath: session.egressDescriptorPath }; } @@ -269,6 +276,7 @@ export class PppdSupervisor extends EventEmitter { command: this.command, authProtocol: this.authProtocol, notifyScript: this.notifyScript, + leaseDir: this.leaseDir, activeSessions: this.sessions.size, sessions: [...this.sessions.keys()].map((callId) => this.snapshot(callId)) }; @@ -281,6 +289,13 @@ export class PppdSupervisor extends EventEmitter { session.lastError = error.message; } } + + writeEgressDescriptor(callId, descriptor) { + mkdirSync(this.leaseDir, { recursive: true, mode: 0o750 }); + const descriptorPath = join(this.leaseDir, `${sanitizePathPart(callId)}.json`); + writeFileSync(descriptorPath, `${JSON.stringify(descriptor, null, 2)}\n`, { mode: 0o640 }); + return descriptorPath; + } } function normalizeNotifyEvent(event) { diff --git a/test/session.test.js b/test/session.test.js index 0c29393..998ecf0 100644 --- a/test/session.test.js +++ b/test/session.test.js @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import dgram from 'node:dgram'; import { EventEmitter } from 'node:events'; -import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -14,7 +15,7 @@ import { ModemBridge, parseRtpPacket } from '../src/media.js'; -import { buildHealth, renderFreePbxPjsip, renderMetrics } from '../src/operator.js'; +import { buildHealth, OperatorHttpServer, renderFreePbxPjsip, renderMetrics } from '../src/operator.js'; import { AddressPool, EgressPolicy, PppCredentialStore, PppSessionController } from '../src/ppp.js'; import { buildPppdArgs, PppdSupervisor, renderChapSecrets } from '../src/pppd-supervisor.js'; import { SipFaxServer } from '../src/server.js'; @@ -159,6 +160,60 @@ test('egress policy allows public internet and blocks private destinations by de assert.match(policy.firewallRules().join('\n'), /-d 192\.168\.0\.0\/16 -j REJECT/); }); +test('egress policy renders nftables rules and per-call descriptors', () => { + const policy = new EgressPolicy({ + clientCidr: '10.70.0.0/24', + outboundInterface: 'eth0', + operatorUrl: '' + }); + const descriptor = policy.leaseDescriptor({ + callId: 'call:nft', + lease: { localAddress: '10.70.0.1', clientAddress: '10.70.0.2' } + }); + + assert.match(descriptor.nft.up.join('\n'), /add table inet sipfax_call_nft/); + assert.match(descriptor.nft.up.join('\n'), /oifname "eth0" masquerade/); + assert.deepEqual(descriptor.nft.down, [ + 'delete table ip sipfax_nat_call_nft', + 'delete table inet sipfax_call_nft' + ]); + assert.match(descriptor.iptables.down.join('\n'), /iptables -D FORWARD/); +}); + +test('operator HTTP accepts PPP egress diagnostics events', async () => { + const operator = new OperatorHttpServer({ + host: '127.0.0.1', + port: 0, + diagnostics: () => ({ + sip: { listening: true }, + rtp: { listening: true }, + ppp: { configuredUsers: 1 }, + sessions: { active: 0, limit: 1 }, + media: {}, + metrics: {} + }), + freepbx: { serverHost: '127.0.0.1', sipPort: 5060 } + }); + await operator.start(); + const { port } = operator.server.address(); + + try { + const post = await fetch(`http://127.0.0.1:${port}/ppp/events`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ state: 'ip-up', callId: 'call-egress', interfaceName: 'ppp0' }) + }); + assert.equal(post.status, 202); + + const events = await fetch(`http://127.0.0.1:${port}/ppp/events`).then((response) => response.json()); + assert.equal(events.events.length, 1); + assert.equal(events.events[0].state, 'ip-up'); + assert.equal(events.events[0].callId, 'call-egress'); + } finally { + await operator.stop(); + } +}); + test('SIP 200 OK answer carries SDP and dialog headers', () => { const request = parseSipMessage(makeInvite({ callId: 'call-5', payloads: '0' })); const response = buildResponse(request, 200, 'OK', { @@ -517,6 +572,90 @@ test('pppd supervisor writes per-pid secrets, accepts notify events, and cleans assert.deepEqual(removed, [sessionDir]); }); +test('pppd supervisor writes egress lease descriptor before daemon start', () => { + const child = new EventEmitter(); + child.pid = 5252; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => {}; + const leaseDir = mkdtempSync(join(tmpdir(), 'sipfax-lease-dir-')); + const supervisor = new PppdSupervisor({ + command: '/usr/sbin/pppd', + leaseDir, + spawnProcess() { + return child; + } + }); + const credentials = new PppCredentialStore([{ username: 'fax', password: 'secret' }]); + + const started = supervisor.start({ + callId: 'call-descriptor', + slavePath: '/dev/pts/4', + lease: { localAddress: '10.64.0.1', clientAddress: '10.64.0.2' }, + credentials, + egressDescriptor: { + callId: 'call-descriptor', + outboundInterface: 'eth0', + nft: { up: ['add table inet sipfax_call_descriptor'], down: ['delete table inet sipfax_call_descriptor'] }, + iptables: { up: [], down: [] } + } + }); + + assert.equal(started.egressDescriptorPath, join(leaseDir, 'call-descriptor.json')); + assert.equal(JSON.parse(readFileSync(started.egressDescriptorPath, 'utf8')).outboundInterface, 'eth0'); +}); + +test('sipfax-egress-apply applies and rolls back nft rules across a PPP cycle', () => { + const root = mkdtempSync(join(tmpdir(), 'sipfax-egress-helper-')); + const mockBin = join(root, 'bin'); + const leaseDir = join(root, 'leases'); + const activeDir = join(root, 'active'); + const logPath = join(root, 'commands.log'); + mkdirSync(mockBin); + mkdirSync(leaseDir); + + writeFileSync(join(mockBin, 'nft'), [ + '#!/bin/sh', + 'printf "nft %s\\n" "$*" >> "$SIPFAX_TEST_LOG"', + 'cat >> "$SIPFAX_TEST_LOG"' + ].join('\n')); + writeFileSync(join(mockBin, 'sysctl'), [ + '#!/bin/sh', + 'printf "sysctl %s\\n" "$*" >> "$SIPFAX_TEST_LOG"' + ].join('\n')); + chmodSync(join(mockBin, 'nft'), 0o755); + chmodSync(join(mockBin, 'sysctl'), 0o755); + + const descriptor = new EgressPolicy({ + clientCidr: '10.88.0.0/24', + outboundInterface: 'eth-test0', + operatorUrl: '' + }).leaseDescriptor({ + callId: 'call-cycle', + lease: { localAddress: '10.88.0.1', clientAddress: '10.88.0.2' } + }); + writeFileSync(join(leaseDir, 'call-cycle.json'), `${JSON.stringify(descriptor)}\n`); + + const env = { + ...process.env, + PATH: `${mockBin}:${process.env.PATH}`, + SIPFAX_PPP_LEASE_DIR: leaseDir, + SIPFAX_PPP_ACTIVE_DIR: activeDir, + SIPFAX_TEST_LOG: logPath + }; + const helper = join(process.cwd(), 'bin/sipfax-egress-apply'); + + execFileSync(process.execPath, [helper, 'up', 'call-cycle', 'ppp0', '10.88.0.1', '10.88.0.2'], { env }); + execFileSync(process.execPath, [helper, 'down', 'call-cycle', 'ppp0', '10.88.0.1', '10.88.0.2'], { env }); + + const log = readFileSync(logPath, 'utf8'); + assert.match(log, /sysctl -w net\.ipv4\.ip_forward=1/); + assert.match(log, /sysctl -w net\.ipv4\.conf\.eth-test0\.forwarding=1/); + assert.match(log, /add table inet sipfax_call_cycle/); + assert.match(log, /delete table inet sipfax_call_cycle/); + assert.match(log, /sysctl -w net\.ipv4\.ip_forward=0/); +}); + test('in-process dial-up terminator clears state when codec is removed', () => { const terminator = new InProcessDialupTerminator();