diff --git a/README.md b/README.md index e8ccf75..ab91d0e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Configuration is environment-driven: | `SIPFAX_PPP_POOL` | `10.64.0.0/24` | Client address pool; `.1` is reserved as the local peer by default | | `SIPFAX_PPP_LOCAL_ADDRESS` | first host in pool | Local peer address advertised to authenticated clients | | `SIPFAX_PPP_DNS` | `1.1.1.1,9.9.9.9` | DNS servers assigned to authenticated PPP clients | +| `SIPFAX_PPPD_COMMAND` | `/usr/sbin/pppd` | `pppd` binary from the Debian `ppp` package | +| `SIPFAX_PPP_AUTH` | `chap` | `chap` by default; set `pap` only for legacy clients | +| `SIPFAX_PPP_NOTIFY_SCRIPT` | unset | Optional pppd ip-up/ip-down notifier that emits JSON IPCP events | | `SIPFAX_EGRESS_INTERFACE` | `wan0` | Outbound interface used when rendering NAT/firewall rules | | `SIPFAX_EGRESS_ENABLED` | `true` | Set to `false` to disable internet forwarding | | `SIPFAX_EGRESS_DNS` | `true` | Set to `false` to block client DNS egress | diff --git a/deploy/README.md b/deploy/README.md index 7f59511..9d6fd91 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -69,6 +69,7 @@ Required first-deploy values: - `SIPFAX_PUBLIC_HOST`: the dedicated SIPfax VM IP on `vmbr0` - `SIPFAX_FREEPBX_EXTENSION`: `12345678` - `SIPFAX_PPP_USERS`: one or more `username:password` entries +- `SIPFAX_PPPD_COMMAND`: path to `pppd` from the Debian `ppp` package - `SIPFAX_EGRESS_INTERFACE`: the VM network interface used for outbound traffic Keep `SIPFAX_OPERATOR_HOST=127.0.0.1` unless an authenticated management network @@ -92,6 +93,16 @@ 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 +`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 +for legacy clients. If `SIPFAX_PPP_NOTIFY_SCRIPT` is set, the script is used for +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. + ## systemd Install Install the unit and start the service: diff --git a/deploy/sipfax.env.example b/deploy/sipfax.env.example index e3603ba..30c8a05 100644 --- a/deploy/sipfax.env.example +++ b/deploy/sipfax.env.example @@ -28,6 +28,14 @@ SIPFAX_PPP_POOL=10.64.0.0/24 # Optional; defaults to first host in SIPFAX_PPP_POOL. # SIPFAX_PPP_LOCAL_ADDRESS=10.64.0.1 SIPFAX_PPP_DNS=1.1.1.1,9.9.9.9 +# Requires the Debian ppp package. SIPfax starts pppd when the soft-modem +# worker reports a pty-opened control event. +SIPFAX_PPPD_COMMAND=/usr/sbin/pppd +# CHAP is the default; set to pap only for clients that cannot speak CHAP. +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 # Set to the VM interface used for outbound traffic, for example ens18. SIPFAX_EGRESS_INTERFACE=replace-me-uplink-interface diff --git a/src/index.js b/src/index.js index a7a7394..119e2a5 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import { SipFaxServer } from './server.js'; import { OperatorHttpServer } from './operator.js'; import { ExternalModemProcessBackend } from './media.js'; import { AddressPool, EgressPolicy, PppCredentialStore, PppSessionController, parseList, parseUsers } from './ppp.js'; +import { PppdSupervisor } from './pppd-supervisor.js'; export const DEFAULT_SOFTMODEM_BINARY = '/opt/sipfax/bin/sipfax-softmodem'; @@ -24,6 +25,12 @@ const config = { localAddress: process.env.SIPFAX_PPP_LOCAL_ADDRESS }), dnsServers: parseList(process.env.SIPFAX_PPP_DNS, ['1.1.1.1', '9.9.9.9']), + pppdSupervisor: new PppdSupervisor({ + 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 + }), egressPolicy: new EgressPolicy({ clientCidr: process.env.SIPFAX_PPP_POOL ?? '10.64.0.0/24', outboundInterface: process.env.SIPFAX_EGRESS_INTERFACE ?? 'wan0', @@ -54,6 +61,7 @@ console.log( ); console.log(`SIPfax operator HTTP listening on http://${config.operatorHost}:${config.operatorPort}`); console.log(`PPP users configured: ${config.ppp.diagnostics().configuredUsers}`); +console.log(`PPP daemon: ${config.ppp.diagnostics().pppd.command}`); console.log(`Modem backend: ${config.modem.diagnostics().type}`); const shutdown = async () => { diff --git a/src/media.js b/src/media.js index 2d01035..e7de43a 100644 --- a/src/media.js +++ b/src/media.js @@ -6,18 +6,8 @@ const DEFAULT_MODEM_FRAME_SAMPLES = 160; const DEFAULT_ANSWER_TONE_HZ = 2100; const DEFAULT_V8_REVERSAL_HZ = 15; const DEFAULT_CARRIER_TONE_HZ = 1800; -const DEFAULT_PPP_MARK_HZ = 1200; -const DEFAULT_PPP_SPACE_HZ = 2200; const MODEM_FRAME_HEADER_BYTES = 2; const MAX_MODEM_FRAME_BYTES = 0xffff; -const PPP_LCP_CONFIGURE_REQUEST = buildPppFrame([ - 0xc0, 0x21, // LCP - 0x01, // Configure-Request - 0x01, // Identifier - 0x00, 0x0e, // Length - 0x01, 0x04, 0x05, 0xdc, // MRU 1500 - 0x05, 0x06, 0x53, 0x49, 0x50, 0x46 // Magic-Number "SIPF" -]); export const G711_CODECS = new Map([ [0, { payloadType: 0, name: 'PCMU', clockRate: 8000 }], @@ -330,8 +320,7 @@ export class InProcessDialupTerminator extends EventEmitter { intervalMs = 20, inboundEnergyThreshold = 400, trainingFramesRequired = 3, - carrierFramesRequired = 6, - pppProbeFramesRequired = 4 + carrierFramesRequired = 6 } = {}) { super(); this.frameSamples = frameSamples; @@ -342,10 +331,8 @@ export class InProcessDialupTerminator extends EventEmitter { this.inboundEnergyThreshold = inboundEnergyThreshold; this.trainingFramesRequired = trainingFramesRequired; this.carrierFramesRequired = carrierFramesRequired; - this.pppProbeFramesRequired = pppProbeFramesRequired; this.codec = null; this.sampleOffset = 0; - this.pppSampleOffset = 0; this.timer = null; this.state = 'idle'; this.framesIn = 0; @@ -353,7 +340,6 @@ export class InProcessDialupTerminator extends EventEmitter { this.trainingHits = 0; this.carrierHits = 0; this.stateFramesOut = 0; - this.pppProbeFramesOut = 0; this.lastInboundEnergy = 0; this.stateChangedAt = null; } @@ -361,11 +347,9 @@ export class InProcessDialupTerminator extends EventEmitter { setSessionCodec(codec) { this.codec = codec ?? null; this.sampleOffset = 0; - this.pppSampleOffset = 0; this.trainingHits = 0; this.carrierHits = 0; this.stateFramesOut = 0; - this.pppProbeFramesOut = 0; this.lastInboundEnergy = 0; if (!this.codec) { @@ -429,12 +413,6 @@ export class InProcessDialupTerminator extends EventEmitter { this.framesOut += 1; this.stateFramesOut += 1; - if (this.state === 'carrier-training' && this.stateFramesOut >= this.pppProbeFramesRequired) { - this.transition('ppp-lcp-probe', 'carrier-training-complete'); - } - if (this.state === 'ppp-lcp-probe') { - this.pppProbeFramesOut += 1; - } this.emit('outbound-audio', this.buildNegotiationFrame(), { codec: this.codec, @@ -449,10 +427,6 @@ export class InProcessDialupTerminator extends EventEmitter { return this.buildCarrierTrainingFrame(); } - if (this.state === 'ppp-lcp-probe') { - return this.buildPppProbeFrame(); - } - const payload = Buffer.alloc(this.frameSamples); for (let index = 0; index < this.frameSamples; index += 1) { const absoluteSample = this.sampleOffset + index; @@ -480,27 +454,6 @@ export class InProcessDialupTerminator extends EventEmitter { return payload; } - buildPppProbeFrame() { - const payload = Buffer.alloc(this.frameSamples); - const samplesPerBit = this.codec.clockRate / 1200; - const bitCount = PPP_LCP_CONFIGURE_REQUEST.length * 8; - - for (let index = 0; index < this.frameSamples; index += 1) { - const absoluteSample = this.pppSampleOffset + index; - const bitIndex = Math.floor(absoluteSample / samplesPerBit) % bitCount; - const octet = PPP_LCP_CONFIGURE_REQUEST[Math.floor(bitIndex / 8)]; - const bit = (octet >> (bitIndex % 8)) & 1; - const toneHz = bit === 1 ? DEFAULT_PPP_MARK_HZ : DEFAULT_PPP_SPACE_HZ; - const sample = Math.round( - Math.sin((2 * Math.PI * toneHz * absoluteSample) / this.codec.clockRate) * this.amplitude - ); - payload[index] = this.codec.payloadType === 8 ? encodeALaw(sample) : encodeMuLaw(sample); - } - - this.pppSampleOffset += this.frameSamples; - return payload; - } - measureEnergy(payload) { if (!payload.length) { return 0; @@ -547,10 +500,7 @@ export class InProcessDialupTerminator extends EventEmitter { trainingHits: this.trainingHits, trainingFramesRequired: this.trainingFramesRequired, carrierHits: this.carrierHits, - carrierFramesRequired: this.carrierFramesRequired, - pppProbeFramesRequired: this.pppProbeFramesRequired, - pppProbeFramesOut: this.pppProbeFramesOut, - pppProbeBytes: PPP_LCP_CONFIGURE_REQUEST.length + carrierFramesRequired: this.carrierFramesRequired }; } } @@ -756,30 +706,6 @@ export class ExternalModemProcessBackend extends EventEmitter { } } -function buildPppFrame(payload) { - const body = Buffer.from([0xff, 0x03, ...payload]); - const fcs = pppFcs16(body); - return Buffer.from([ - 0x7e, - ...body, - fcs & 0xff, - (fcs >> 8) & 0xff, - 0x7e - ]); -} - -function pppFcs16(payload) { - let fcs = 0xffff; - for (const octet of payload) { - fcs ^= octet; - for (let bit = 0; bit < 8; bit += 1) { - fcs = (fcs & 1) !== 0 ? (fcs >> 1) ^ 0x8408 : fcs >> 1; - } - } - - return (~fcs) & 0xffff; -} - export function encodeMuLaw(sample) { const clipped = Math.max(-32635, Math.min(32635, sample)); const sign = clipped < 0 ? 0x80 : 0x00; diff --git a/src/ppp.js b/src/ppp.js index f0505a1..df6c2ff 100644 --- a/src/ppp.js +++ b/src/ppp.js @@ -20,6 +20,7 @@ const DEFAULT_BLOCKED_DESTINATIONS = [ export class PppCredentialStore { constructor(users = []) { this.users = new Map(); + this.secrets = new Map(); for (const user of users) { this.addUser(user); @@ -36,6 +37,9 @@ export class PppCredentialStore { } this.users.set(username, passwordHash ?? hashPassword(password)); + if (password) { + this.secrets.set(username, password); + } } verify({ username, password }) { @@ -51,6 +55,17 @@ export class PppCredentialStore { get size() { return this.users.size; } + + chapSecrets() { + return [...this.users.keys()].map((username) => { + const password = this.secrets.get(username); + if (!password) { + throw new Error(`PPP secret for ${username} is not renderable from a passwordHash-only credential`); + } + + return { username, password }; + }); + } } export class AddressPool { @@ -189,12 +204,14 @@ export class PppSessionController { credentials, addressPool = new AddressPool(), dnsServers = DEFAULT_DNS_SERVERS, - egressPolicy = new EgressPolicy({ clientCidr: addressPool.cidr }) + egressPolicy = new EgressPolicy({ clientCidr: addressPool.cidr }), + pppdSupervisor = null } = {}) { this.credentials = credentials ?? new PppCredentialStore(); this.addressPool = addressPool; this.dnsServers = dnsServers; this.egressPolicy = egressPolicy; + this.pppdSupervisor = pppdSupervisor; this.sessions = new Map(); } @@ -206,6 +223,7 @@ export class PppSessionController { username: null, lease: null, dnsServers: [], + pppd: null, egress: this.egressPolicy.diagnostics() }; @@ -239,11 +257,59 @@ export class PppSessionController { return false; } + this.stopPppd(callId); this.addressPool.release(callId); this.sessions.delete(callId); return true; } + startPppd(callId, { slavePath }) { + const session = this.sessions.get(callId); + if (!session || !this.pppdSupervisor) { + return false; + } + + session.lease = session.lease ?? this.addressPool.lease(callId); + session.dnsServers = [...this.dnsServers]; + session.state = 'pppd-starting'; + session.pppd = this.pppdSupervisor.start({ + callId, + slavePath, + lease: session.lease, + dnsServers: this.dnsServers, + credentials: this.credentials, + onEvent: (event) => { + this.acceptPppdEvent(callId, event); + } + }); + return true; + } + + stopPppd(callId) { + if (!this.pppdSupervisor) { + return false; + } + + return this.pppdSupervisor.stop(callId); + } + + acceptPppdEvent(callId, event) { + const session = this.sessions.get(callId); + if (!session) { + return; + } + + if (event.state) { + session.state = event.state; + } + + session.pppd = { + ...(session.pppd ?? {}), + ...event, + dnsServers: [...(event.dnsServers ?? session.dnsServers)] + }; + } + snapshot(callId) { const session = this.sessions.get(callId); if (!session) { @@ -257,6 +323,7 @@ export class PppSessionController { username: session.username, lease: session.lease ? { ...session.lease } : null, dnsServers: [...session.dnsServers], + pppd: session.pppd ? { ...session.pppd } : null, egress: { ...session.egress } }; } @@ -269,6 +336,7 @@ export class PppSessionController { localAddress: this.addressPool.localAddress, activeLeases: this.addressPool.leases.size }, + pppd: this.pppdSupervisor?.diagnostics ? this.pppdSupervisor.diagnostics() : null, egress: this.egressPolicy.diagnostics(), sessions: [...this.sessions.keys()].map((callId) => this.snapshot(callId)) }; diff --git a/src/pppd-supervisor.js b/src/pppd-supervisor.js new file mode 100644 index 0000000..415694a --- /dev/null +++ b/src/pppd-supervisor.js @@ -0,0 +1,310 @@ +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const DEFAULT_DNS_SERVERS = ['1.1.1.1', '9.9.9.9']; + +export function renderChapSecrets(credentials, path) { + const lines = credentials.chapSecrets().map(({ username, password }) => { + return `${quotePppSecret(username)} * ${quotePppSecret(password)} *`; + }); + writeFileSync(path, `${lines.join('\n')}\n`, { mode: 0o600 }); + return path; +} + +export function buildPppdArgs({ + slavePath, + lease, + dnsServers = DEFAULT_DNS_SERVERS, + authProtocol = 'chap', + secretsPath, + notifyScript = null, + mtu = 1500, + lcpEchoInterval = 30, + lcpEchoFailure = 4, + lcpRestart = 5, + lcpMaxConfigure = 20, + ipcpRestart = 5, + ipcpMaxConfigure = 20, + connectDelayMs = 1000, + callId = null +}) { + const normalizedAuth = authProtocol === 'pap' ? 'pap' : 'chap'; + const args = [ + slavePath, + 'nodetach', + 'nodefaultroute', + 'noccp', + `require-${normalizedAuth}`, + `${lease.localAddress}:${lease.clientAddress}`, + 'mtu', + String(mtu), + 'lcp-echo-interval', + String(lcpEchoInterval), + 'lcp-echo-failure', + String(lcpEchoFailure), + 'lcp-restart', + String(lcpRestart), + 'lcp-max-configure', + String(lcpMaxConfigure), + 'ipcp-restart', + String(ipcpRestart), + 'ipcp-max-configure', + String(ipcpMaxConfigure), + 'connect-delay', + String(connectDelayMs) + ]; + + for (const dnsServer of dnsServers) { + args.push('ms-dns', dnsServer); + } + + if (secretsPath) { + args.push(`${normalizedAuth}-secrets`, secretsPath); + } + + if (notifyScript) { + args.push('ip-up-script', notifyScript, 'ip-down-script', notifyScript); + } + + if (callId) { + args.push('ipparam', callId); + } + + return args; +} + +export class PppdSupervisor extends EventEmitter { + constructor({ + command = 'pppd', + authProtocol = 'chap', + dnsServers = DEFAULT_DNS_SERVERS, + notifyScript = null, + tempDir = tmpdir(), + spawnProcess = spawn, + cleanup = rmSync + } = {}) { + super(); + this.command = command; + this.authProtocol = authProtocol; + this.dnsServers = [...dnsServers]; + this.notifyScript = notifyScript; + this.tempDir = tempDir; + this.spawnProcess = spawnProcess; + this.cleanup = cleanup; + this.sessions = new Map(); + } + + start({ callId, slavePath, lease, dnsServers = this.dnsServers, credentials, onEvent = null }) { + this.stop(callId); + + const sessionDir = mkdtempSync(join(this.tempDir, `sipfax-pppd-${sanitizePathPart(callId)}-`)); + const session = { + callId, + slavePath, + lease: { ...lease }, + dnsServers: [...dnsServers], + sessionDir, + secretsPath: null, + process: null, + startedAt: null, + endedAt: null, + state: 'starting', + interfaceName: null, + sessionDurationSeconds: null, + lastEventAt: null, + lastError: null + }; + + const args = buildPppdArgs({ + slavePath, + lease, + dnsServers, + authProtocol: this.authProtocol, + notifyScript: this.notifyScript, + callId + }); + + const secretOption = this.authProtocol === 'pap' ? 'pap-secrets' : 'chap-secrets'; + const wrapper = [ + 'secrets="$1/' + secretOption + '-$$"', + 'shift', + 'while [ ! -f "$secrets" ]; do sleep 0.02; done', + `exec "$@" ${secretOption} "$secrets"` + ].join('; '); + const child = this.spawnProcess('/bin/sh', ['-c', wrapper, 'sipfax-pppd', sessionDir, this.command, ...args], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + session.process = child; + session.startedAt = new Date(); + session.secretsPath = join(sessionDir, `${secretOption}-${child.pid ?? 'unknown'}`); + renderChapSecrets(credentials, session.secretsPath); + session.args = args; + + child.stdout?.on('data', (chunk) => { + this.acceptNotifyChunk(callId, chunk); + }); + child.stderr?.on('data', (chunk) => { + const text = chunk.toString('utf8').trim(); + if (text) { + session.lastError = text; + this.emit('pppd-log', { callId, line: text }); + } + }); + child.on('error', (error) => { + session.lastError = error.message; + this.acceptEvent(callId, { state: 'failed', error: error.message }); + }); + child.on('exit', (code, signal) => { + session.endedAt = new Date(); + session.sessionDurationSeconds = Math.max(0, Math.floor((session.endedAt.getTime() - session.startedAt.getTime()) / 1000)); + this.acceptEvent(callId, { state: 'closed', code, signal }); + this.removeSessionFiles(session); + this.sessions.delete(callId); + }); + + this.sessions.set(callId, session); + this.acceptEvent(callId, { + state: 'starting', + localAddress: lease.localAddress, + clientAddress: lease.clientAddress, + dnsServers, + pid: child.pid, + secretsPath: session.secretsPath + }); + onEvent?.(this.snapshot(callId)); + session.onEvent = onEvent; + return this.snapshot(callId); + } + + stop(callId) { + const session = this.sessions.get(callId); + if (!session) { + return false; + } + + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + } + this.removeSessionFiles(session); + this.sessions.delete(callId); + return true; + } + + acceptNotifyChunk(callId, chunk) { + const session = this.sessions.get(callId); + if (!session) { + return; + } + + session.notifyBuffer = `${session.notifyBuffer ?? ''}${chunk.toString('utf8')}`; + while (true) { + const newlineIndex = session.notifyBuffer.indexOf('\n'); + if (newlineIndex < 0) { + return; + } + + const line = session.notifyBuffer.slice(0, newlineIndex).trim(); + session.notifyBuffer = session.notifyBuffer.slice(newlineIndex + 1); + if (!line) { + continue; + } + + try { + this.acceptEvent(callId, JSON.parse(line)); + } catch (error) { + session.lastError = `invalid pppd notify JSON: ${error.message}`; + this.emit('pppd-error', { callId, error: session.lastError }); + } + } + } + + acceptEvent(callId, event) { + const session = this.sessions.get(callId); + if (!session) { + return; + } + + const normalized = normalizeNotifyEvent(event); + session.state = normalized.state ?? session.state; + session.interfaceName = normalized.interfaceName ?? session.interfaceName; + session.localAddress = normalized.localAddress ?? session.lease.localAddress; + session.clientAddress = normalized.clientAddress ?? session.lease.clientAddress; + session.dnsServers = normalized.dnsServers ?? session.dnsServers; + session.lastEventAt = new Date().toISOString(); + + if (session.startedAt) { + session.sessionDurationSeconds = Math.max(0, Math.floor((Date.now() - session.startedAt.getTime()) / 1000)); + } + + const snapshot = this.snapshot(callId); + session.onEvent?.(snapshot); + this.emit('pppd-event', snapshot); + } + + snapshot(callId) { + const session = this.sessions.get(callId); + if (!session) { + return null; + } + + return { + callId, + state: session.state, + pid: session.process?.pid ?? null, + localAddress: session.localAddress ?? session.lease.localAddress, + clientAddress: session.clientAddress ?? session.lease.clientAddress, + dnsServers: [...session.dnsServers], + interfaceName: session.interfaceName, + sessionDurationSeconds: session.sessionDurationSeconds, + lastEventAt: session.lastEventAt, + lastError: session.lastError + }; + } + + diagnostics() { + return { + command: this.command, + authProtocol: this.authProtocol, + notifyScript: this.notifyScript, + activeSessions: this.sessions.size, + sessions: [...this.sessions.keys()].map((callId) => this.snapshot(callId)) + }; + } + + removeSessionFiles(session) { + try { + this.cleanup(session.sessionDir, { recursive: true, force: true }); + } catch (error) { + session.lastError = error.message; + } + } +} + +function normalizeNotifyEvent(event) { + const rawState = event.state ?? event.event ?? event.lastEvent; + const state = rawState === 'ip-up' || rawState === 'IPCP-open' || rawState === 'ipcp-open' + ? 'ipcp-open' + : rawState === 'ip-down' || rawState === 'IPCP-close' || rawState === 'ipcp-close' + ? 'ipcp-closed' + : rawState; + + return { + state, + localAddress: event.localAddress ?? event.local ?? event.ipLocal, + clientAddress: event.clientAddress ?? event.remote ?? event.ipRemote, + dnsServers: event.dnsServers, + interfaceName: event.interfaceName ?? event.ifname ?? event.interface, + sessionDurationSeconds: event.sessionDurationSeconds + }; +} + +function quotePppSecret(value) { + return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; +} + +function sanitizePathPart(value) { + return String(value).replace(/[^a-zA-Z0-9_.-]/g, '_'); +} diff --git a/src/server.js b/src/server.js index dc61c56..f7918ff 100644 --- a/src/server.js +++ b/src/server.js @@ -50,6 +50,9 @@ export class SipFaxServer { this.modem.on('backend-error', (error) => { console.error(`modem backend error: ${error.message}`); }); + this.modem.on('backend-control', (event) => { + this.handleModemControl(event); + }); } this.rtpEndpoint.on('dropped', () => { this.metrics.rtpFramesDropped += 1; @@ -142,6 +145,24 @@ export class SipFaxServer { ); } + handleModemControl(event) { + const callId = this.sessions.activeSession?.callId; + if (!callId) { + return; + } + + const eventName = event.event ?? event.lastEvent ?? event.state; + if (eventName === 'pty-opened') { + const slavePath = event.slavePath ?? event.ptySlavePath ?? event.ptyPath; + this.sessions.openPty(callId, { slavePath }); + return; + } + + if (eventName === 'pty-closed') { + this.sessions.closePty(callId); + } + } + sendSip(remote, message) { this.sipSocket.send(Buffer.from(message), remote.port, remote.address); } diff --git a/src/session.js b/src/session.js index fae7ccd..1055e0c 100644 --- a/src/session.js +++ b/src/session.js @@ -94,6 +94,26 @@ export class SingleSessionManager { return result; } + openPty(callId, { slavePath }) { + if (this.activeSession?.callId !== callId || !slavePath) { + return false; + } + + const started = this.ppp.startPppd(callId, { slavePath }); + if (started) { + this.activeSession.ppp = this.ppp.snapshot(callId); + } + return started; + } + + closePty(callId) { + if (this.activeSession?.callId !== callId) { + return false; + } + + return this.ppp.stopPppd(callId); + } + diagnostics() { return { active: this.activeSession ? 1 : 0, diff --git a/test/session.test.js b/test/session.test.js index be16774..0c29393 100644 --- a/test/session.test.js +++ b/test/session.test.js @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import dgram from 'node:dgram'; import { EventEmitter } from 'node:events'; +import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { test } from 'node:test'; import { buildRtpPacket, @@ -13,6 +16,7 @@ import { } from '../src/media.js'; import { buildHealth, 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'; import { SingleSessionManager } from '../src/session.js'; import { parseSdpOffer } from '../src/sdp.js'; @@ -100,6 +104,50 @@ test('PPP authentication assigns client address and DNS for an established call' assert.equal(ppp.diagnostics().addressPool.activeLeases, 0); }); +test('PPP controller starts and stops pppd supervisor from leased session state', () => { + const starts = []; + const stops = []; + const supervisor = { + start(options) { + starts.push(options); + return { + state: 'starting', + localAddress: options.lease.localAddress, + clientAddress: options.lease.clientAddress, + dnsServers: options.dnsServers, + interfaceName: null + }; + }, + stop(callId) { + stops.push(callId); + return true; + }, + diagnostics() { + return { activeSessions: starts.length - stops.length }; + } + }; + const ppp = new PppSessionController({ + credentials: new PppCredentialStore([{ username: 'fax', password: 'secret' }]), + addressPool: new AddressPool({ cidr: '10.80.0.0/30' }), + dnsServers: ['9.9.9.9'], + pppdSupervisor: supervisor + }); + + ppp.begin('call-pty'); + assert.equal(ppp.startPppd('call-pty', { slavePath: '/dev/pts/7' }), true); + assert.equal(starts.length, 1); + assert.equal(starts[0].slavePath, '/dev/pts/7'); + assert.equal(starts[0].lease.localAddress, '10.80.0.1'); + assert.equal(starts[0].lease.clientAddress, '10.80.0.2'); + assert.deepEqual(starts[0].dnsServers, ['9.9.9.9']); + assert.equal(ppp.snapshot('call-pty').state, 'pppd-starting'); + assert.equal(ppp.diagnostics().addressPool.activeLeases, 1); + + ppp.terminate('call-pty'); + assert.deepEqual(stops, ['call-pty']); + assert.equal(ppp.diagnostics().addressPool.activeLeases, 0); +}); + test('egress policy allows public internet and blocks private destinations by default', () => { const policy = new EgressPolicy({ clientCidr: '10.70.0.0/24', outboundInterface: 'eth0' }); @@ -336,11 +384,10 @@ test('in-process dial-up terminator emits ANSam frames and exposes negotiation s ); }); -test('in-process dial-up terminator advances beyond V.8 into PPP LCP probe frames', () => { +test('in-process dial-up terminator stops at carrier training without synthetic PPP frames', () => { const terminator = new InProcessDialupTerminator({ trainingFramesRequired: 2, - carrierFramesRequired: 2, - pppProbeFramesRequired: 2 + carrierFramesRequired: 2 }); const emitted = []; const states = []; @@ -359,18 +406,117 @@ test('in-process dial-up terminator advances beyond V.8 into PPP LCP probe frame assert.deepEqual( states.map((event) => event.state), - ['answer-tone', 'v8-training', 'carrier-training', 'ppp-lcp-probe'] + ['answer-tone', 'v8-training', 'carrier-training'] ); - assert.equal(terminator.diagnostics().state, 'ppp-lcp-probe'); + assert.equal(terminator.diagnostics().state, 'carrier-training'); assert.equal(terminator.diagnostics().carrierHits, 2); - assert.equal(terminator.diagnostics().pppProbeFramesOut >= 1, true); - assert.equal(terminator.diagnostics().pppProbeBytes > 0, true); + assert.equal('pppProbeFramesOut' in terminator.diagnostics(), false); + assert.equal('pppProbeBytes' in terminator.diagnostics(), false); assert.equal(emitted.some((frame) => frame.metadata.dialupState === 'carrier-training'), true); - assert.equal(emitted.some((frame) => frame.metadata.dialupState === 'ppp-lcp-probe'), true); + assert.equal(emitted.some((frame) => frame.metadata.dialupState === 'ppp-lcp-probe'), false); assert.equal(emitted.at(-1).payload.length, 160); assert.notEqual(new Set(emitted.at(-1).payload).size, 1); }); +test('pppd supervisor renders chap-secrets with restrictive permissions', () => { + const dir = mkdtempSync(join(tmpdir(), 'sipfax-pppd-test-')); + const secretsPath = join(dir, 'chap-secrets-123'); + const credentials = new PppCredentialStore([ + { username: 'fax', password: 'secret' }, + { username: 'quote"user', password: 'slash\\secret' } + ]); + + renderChapSecrets(credentials, secretsPath); + + assert.equal(readFileSync(secretsPath, 'utf8'), [ + '"fax" * "secret" *', + '"quote\\"user" * "slash\\\\secret" *', + '' + ].join('\n')); + assert.equal(statSync(secretsPath).mode & 0o777, 0o600); +}); + +test('pppd supervisor builds required daemon options', () => { + const args = buildPppdArgs({ + slavePath: '/dev/pts/9', + lease: { localAddress: '10.64.0.1', clientAddress: '10.64.0.2' }, + dnsServers: ['1.1.1.1', '9.9.9.9'], + authProtocol: 'chap', + secretsPath: '/tmp/chap-secrets-111', + notifyScript: '/usr/lib/sipfax/ppp-notify', + callId: 'call-pppd' + }); + + assert.deepEqual(args.slice(0, 6), [ + '/dev/pts/9', + 'nodetach', + 'nodefaultroute', + 'noccp', + 'require-chap', + '10.64.0.1:10.64.0.2' + ]); + assert.equal(args.includes('ms-dns'), true); + assert.equal(args.includes('chap-secrets'), true); + assert.equal(args.includes('/tmp/chap-secrets-111'), true); + assert.equal(args.includes('ip-up-script'), true); + assert.equal(args.includes('ip-down-script'), true); + assert.equal(args.includes('lcp-echo-interval'), true); + assert.equal(args.includes('lcp-max-configure'), true); + assert.equal(args.includes('ipcp-max-configure'), true); +}); + +test('pppd supervisor writes per-pid secrets, accepts notify events, and cleans shutdown', () => { + const child = new EventEmitter(); + child.pid = 4242; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.killed = false; + child.kill = (signal) => { + child.killed = signal; + }; + const spawns = []; + const removed = []; + const supervisor = new PppdSupervisor({ + command: '/usr/sbin/pppd', + tempDir: tmpdir(), + spawnProcess(command, args, options) { + spawns.push({ command, args, options }); + return child; + }, + cleanup(path) { + removed.push(path); + } + }); + const credentials = new PppCredentialStore([{ username: 'fax', password: 'secret' }]); + + const started = supervisor.start({ + callId: 'call-supervisor', + slavePath: '/dev/pts/3', + lease: { localAddress: '10.64.0.1', clientAddress: '10.64.0.2' }, + dnsServers: ['1.1.1.1'], + credentials + }); + + assert.equal(spawns[0].command, '/bin/sh'); + assert.equal(spawns[0].args.includes('/usr/sbin/pppd'), true); + assert.equal(started.pid, 4242); + const sessionDir = spawns[0].args[3]; + const secretsPath = join(sessionDir, 'chap-secrets-4242'); + assert.equal(existsSync(secretsPath), true); + assert.match(readFileSync(secretsPath, 'utf8'), /"fax" \* "secret" \*/); + + child.stdout.emit('data', Buffer.from('{"state":"IPCP-open","interfaceName":"ppp0"}\n')); + const snapshot = supervisor.snapshot('call-supervisor'); + assert.equal(snapshot.state, 'ipcp-open'); + assert.equal(snapshot.interfaceName, 'ppp0'); + assert.equal(snapshot.localAddress, '10.64.0.1'); + assert.equal(snapshot.clientAddress, '10.64.0.2'); + + assert.equal(supervisor.stop('call-supervisor'), true); + assert.equal(child.killed, 'SIGTERM'); + assert.deepEqual(removed, [sessionDir]); +}); + test('in-process dial-up terminator clears state when codec is removed', () => { const terminator = new InProcessDialupTerminator(); @@ -435,6 +581,44 @@ test('server runtime modem wiring sends outbound RTP after inbound media discove } }); +test('server modem control pty events start and stop pppd for the active call', () => { + const starts = []; + const stops = []; + const ppp = new PppSessionController({ + credentials: new PppCredentialStore([{ username: 'fax', password: 'secret' }]), + addressPool: new AddressPool({ cidr: '10.90.0.0/30' }), + pppdSupervisor: { + start(options) { + starts.push(options); + return { state: 'starting' }; + }, + stop(callId) { + stops.push(callId); + return true; + }, + diagnostics() { + return {}; + } + } + }); + const server = new SipFaxServer({ + host: '127.0.0.1', + publicHost: '127.0.0.1', + sipPort: 0, + rtpPort: 0, + ppp + }); + + server.sessions.startFromInvite(parseSipMessage(makeInvite({ callId: 'call-control', payloads: '0' }))); + server.sessions.acknowledge('call-control'); + server.handleModemControl({ event: 'pty-opened', slavePath: '/dev/pts/11' }); + server.handleModemControl({ event: 'pty-closed' }); + + assert.equal(starts.length, 1); + assert.equal(starts[0].slavePath, '/dev/pts/11'); + assert.deepEqual(stops, ['call-control']); +}); + test('server with unavailable softmodem worker emits no synthetic outbound RTP', async () => { const modem = new ExternalModemProcessBackend({ command: '/definitely/not-installed/sipfax-softmodem' }); const backendError = new Promise((resolve) => {