Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions bin/sipfax-egress-apply
Original file line number Diff line number Diff line change
@@ -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 <call-id> [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.
}
}
39 changes: 35 additions & 4 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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/<call-id>.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:
Expand All @@ -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
Expand Down Expand Up @@ -151,6 +170,13 @@ ssh -L 8080:127.0.0.1:8080 <admin>@<sipfax-vm-ip>

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.<SIPFAX_EGRESS_INTERFACE>.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:
Expand All @@ -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 <url>`. 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
Expand Down
7 changes: 6 additions & 1 deletion deploy/install-systemd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 12 additions & 0 deletions deploy/ppp/ip-down
Original file line number Diff line number Diff line change
@@ -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:-}"
12 changes: 12 additions & 0 deletions deploy/ppp/ip-up
Original file line number Diff line number Diff line change
@@ -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:-}"
5 changes: 5 additions & 0 deletions deploy/sipfax.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion deploy/sipfax.service
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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'])
Expand Down
50 changes: 49 additions & 1 deletion src/operator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
});
}
Loading
Loading