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
71 changes: 71 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ pppd --version
when building the vendored worker on the VM. `iptables` provides the
`iptables-nft` fallback used only when `nft` is unavailable.

### Optional: higher-speed modem engine (V.32bis / V.34, slmodem datapump)

The default engine is the spandsp worker (V.21 / V.22bis). To enable the
higher-speed engine (`SIPFAX_MODEM_ENGINE=slmodem`), the SmartLink `slmodemd`
datapump is required. It is a **32-bit x86** binary object (`dsplibs.o`), so the
build host needs multilib:

```bash
sudo apt-get install -y gcc-multilib libc6-dev-i386
./vendor/slmodem/fetch.sh # stages slmodemd/ + dsplibs.o, verifies sha256
make -C vendor/slmodem/slmodemd # 32-bit slmodemd
make -C vendor/slmodem-bridge # the SIPfax<->slmodemd audio bridge
```

`dsplibs.o` is **gratis and redistributable but closed-source** (Debian "non-free"
= not open-licensed, not paid). Provenance and the pinned SHA-256 are recorded in
`vendor/slmodem/README.md`. The engine runs as a 32-bit process; the rest of
SIPfax stays 64-bit. The default `spandsp` engine needs none of this.

Create the service user and checkout path:

```bash
Expand Down Expand Up @@ -212,6 +231,58 @@ sudo deploy/sipfaxctl status
sudo deploy/sipfaxctl logs
```

## PPP Runtime Requirements

PPP only works once the host can actually run `pppd` and create a `ppp0`
interface. Three things the base VM does not provide by default:

1. **The `ppp` package and a kernel with PPP support.** `apt-get install -y ppp`
provides `/usr/sbin/pppd` (setuid-root, group `dip`). The Debian **cloud**
kernel ships **no PPP modules** — `/dev/ppp` will be missing. Install the
generic kernel and boot it:

```bash
sudo apt-get install -y ppp linux-image-amd64
echo ppp_generic | sudo tee /etc/modules-load.d/ppp.conf
# ensure GRUB boots the generic (non-cloud) kernel, then reboot
uname -r # expect e.g. 6.1.0-NN-amd64 (not -cloud-amd64)
ls -l /dev/ppp # must exist
```

2. **A systemd drop-in so the unprivileged service can run setuid pppd.** The
shipped unit's hardening blocks it. Install
[`sipfax.service.d/ppp.conf`](sipfax.service.d/ppp.conf):

```bash
sudo install -D -m 0644 deploy/sipfax.service.d/ppp.conf \
/etc/systemd/system/sipfax.service.d/ppp.conf
sudo systemctl daemon-reload && sudo systemctl restart sipfax.service
# verify: NoNewPrivs must be 0 on the running process
grep NoNewPrivs /proc/$(systemctl show -p MainPID --value sipfax.service)/status
```

3. **Secrets files the service can rewrite.** `pppd` always reads
`/etc/ppp/chap-secrets`; the service renders per-call credentials there and
clears them on teardown, so pre-create them owned by `sipfax`:

```bash
sudo touch /etc/ppp/chap-secrets /etc/ppp/pap-secrets
sudo chown sipfax:sipfax /etc/ppp/chap-secrets /etc/ppp/pap-secrets
sudo chmod 600 /etc/ppp/chap-secrets /etc/ppp/pap-secrets
```

## Modulation Note (V.8 vs forced V.22bis)

Set `SIPFAX_MODEM_START_MODE=v22bis` for the live service. With real Windows
dial-up modems, standards **V.8 negotiation selects V.22bis but the modem then
fails to complete V.22bis training** (it sits on an unscrambled carrier while
the answerer trains) — a timing/interop quirk that does not reproduce in a
spandsp-to-spandsp loopback (see
[`tests/v8handoff_test.c`](../vendor/sipfax-softmodem/tests/v8handoff_test.c)).
**Forcing V.22bis** presents a continuous answer carrier the modem locks onto
and trains reliably. The V.8 handoff code itself is correct (the loopback test
passes); fixing the real-modem path needs lab tuning against the physical modem.

## Firewall Expectations

Restrict SIP and RTP ingress to the FreePBX IP. Replace `<freepbx-ip>` with the
Expand Down
31 changes: 31 additions & 0 deletions deploy/sipfax.service.d/ppp.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Drop-in to let the unprivileged sipfax service run pppd.
#
# pppd (from the Debian `ppp` package) is installed setuid-root, mode 4754
# root:dip, and needs CAP_NET_ADMIN to create the ppp0 interface. The base
# unit's hardening (NoNewPrivileges, RestrictSUIDSGID, a CAP_NET_BIND_SERVICE-
# only bounding set, plus the seccomp-based LockPersonality/SystemCallArchitectures
# which silently re-imply NoNewPrivileges) all block that. Relax exactly what
# pppd needs; the node process itself stays unprivileged and gains nothing
# (only the setuid pppd child escalates).
#
# Install: copy to /etc/systemd/system/sipfax.service.d/ppp.conf, then
# systemctl daemon-reload && systemctl restart sipfax.service
#
# Prerequisites on the host:
# - apt-get install ppp (provides /usr/sbin/pppd, ppp_generic module)
# - a kernel with PPP support (the Debian "cloud" kernel has NONE; install
# linux-image-amd64 and boot it)
# - echo ppp_generic > /etc/modules-load.d/ppp.conf
# - pre-create the secrets files the service rewrites in place:
# touch /etc/ppp/chap-secrets /etc/ppp/pap-secrets
# chown sipfax:sipfax /etc/ppp/chap-secrets /etc/ppp/pap-secrets
# chmod 600 /etc/ppp/chap-secrets /etc/ppp/pap-secrets

[Service]
NoNewPrivileges=false
RestrictSUIDSGID=false
LockPersonality=false
SystemCallArchitectures=
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW CAP_SETUID CAP_SETGID CAP_SYS_TTY_CONFIG CAP_DAC_OVERRIDE CAP_CHOWN CAP_FOWNER CAP_KILL CAP_AUDIT_WRITE
SupplementaryGroups=dip
ReadWritePaths=/run/lock /etc/ppp
13 changes: 12 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AddressPool, EgressPolicy, PppCredentialStore, PppSessionController, pa
import { PppdSupervisor } from './pppd-supervisor.js';

export const DEFAULT_SOFTMODEM_BINARY = '/opt/sipfax/bin/sipfax-softmodem';
export const DEFAULT_SLMODEM_BRIDGE = '/opt/sipfax/bin/sipfax-slmodem-bridge';

const modemCommand = process.env.SIPFAX_MODEM_COMMAND;
const softmodemBinary = process.env.SIPFAX_SOFTMODEM_BINARY ?? DEFAULT_SOFTMODEM_BINARY;
Expand Down Expand Up @@ -77,8 +78,18 @@ process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

function createModemBackend() {
// Engine selection. Default 'spandsp' (the V.21/V.22bis worker) so nothing
// regresses. 'slmodem' uses the higher-speed datapump bridge (V.32bis/V.34).
// An explicit SIPFAX_MODEM_COMMAND always wins. The bridge reads its own
// config (SIPFAX_MODEM_MODULATION, SIPFAX_SLMODEMD, ...) from the environment,
// which media.js forwards to the spawned process.
const engine = (process.env.SIPFAX_MODEM_ENGINE ?? 'spandsp').toLowerCase();
let command = modemCommand ?? softmodemBinary;
if (!modemCommand && engine === 'slmodem') {
command = process.env.SIPFAX_SLMODEM_BRIDGE ?? DEFAULT_SLMODEM_BRIDGE;
}
return new ExternalModemProcessBackend({
command: modemCommand ?? softmodemBinary,
command,
args: parseList(process.env.SIPFAX_MODEM_ARGS, [])
});
}
32 changes: 22 additions & 10 deletions src/pppd-supervisor.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class PppdSupervisor extends EventEmitter {
dnsServers = DEFAULT_DNS_SERVERS,
notifyScript = null,
leaseDir = '/run/sipfax/ppp-leases',
secretsDir = '/etc/ppp',
tempDir = tmpdir(),
spawnProcess = spawn,
cleanup = rmSync
Expand All @@ -93,6 +94,7 @@ export class PppdSupervisor extends EventEmitter {
this.dnsServers = [...dnsServers];
this.notifyScript = notifyScript;
this.leaseDir = leaseDir;
this.secretsDir = secretsDir;
this.tempDir = tempDir;
this.spawnProcess = spawnProcess;
this.cleanup = cleanup;
Expand Down Expand Up @@ -133,20 +135,21 @@ export class PppdSupervisor extends EventEmitter {
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], {
// pppd has no command-line option to select a secrets file; it always
// reads /etc/ppp/{chap,pap}-secrets. Render the per-call credentials there
// before launch (single active call) and remove the file on teardown.
// secretsDir defaults to /etc/ppp; tests inject a writable temp dir.
const secretsFile = join(
this.secretsDir,
this.authProtocol === 'pap' ? 'pap-secrets' : 'chap-secrets'
);
renderChapSecrets(credentials, secretsFile);
const child = this.spawnProcess(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.secretsPath = secretsFile;
session.args = args;

child.stdout?.on('data', (chunk) => {
Expand Down Expand Up @@ -288,6 +291,15 @@ export class PppdSupervisor extends EventEmitter {
} catch (error) {
session.lastError = error.message;
}
if (session.secretsPath) {
// The secrets file lives in root-owned /etc/ppp; we own the file but
// not the directory, so clear it in place rather than unlinking it.
try {
writeFileSync(session.secretsPath, '', { mode: 0o600 });
} catch (error) {
session.lastError = error.message;
}
}
}

writeEgressDescriptor(callId, descriptor) {
Expand Down
16 changes: 10 additions & 6 deletions test/session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ test('pppd supervisor builds required daemon options', () => {
assert.equal(args.includes('ipcp-max-configure'), true);
});

test('pppd supervisor writes per-pid secrets, accepts notify events, and cleans shutdown', () => {
test('pppd supervisor writes secrets, accepts notify events, and cleans shutdown', () => {
const child = new EventEmitter();
child.pid = 4242;
child.stdout = new EventEmitter();
Expand All @@ -539,9 +539,11 @@ test('pppd supervisor writes per-pid secrets, accepts notify events, and cleans
};
const spawns = [];
const removed = [];
const secretsDir = mkdtempSync(join(tmpdir(), 'sipfax-secrets-'));
const supervisor = new PppdSupervisor({
command: '/usr/sbin/pppd',
tempDir: tmpdir(),
secretsDir,
spawnProcess(command, args, options) {
spawns.push({ command, args, options });
return child;
Expand All @@ -560,11 +562,11 @@ test('pppd supervisor writes per-pid secrets, accepts notify events, and cleans
credentials
});

assert.equal(spawns[0].command, '/bin/sh');
assert.equal(spawns[0].args.includes('/usr/sbin/pppd'), true);
assert.equal(spawns[0].command, '/usr/sbin/pppd');
assert.equal(spawns[0].args[0], '/dev/pts/3');
assert.equal(spawns[0].args.includes('nodetach'), true);
assert.equal(started.pid, 4242);
const sessionDir = spawns[0].args[3];
const secretsPath = join(sessionDir, 'chap-secrets-4242');
const secretsPath = join(secretsDir, 'chap-secrets');
assert.equal(existsSync(secretsPath), true);
assert.match(readFileSync(secretsPath, 'utf8'), /"fax" \* "secret" \*/);

Expand All @@ -577,7 +579,7 @@ test('pppd supervisor writes per-pid secrets, accepts notify events, and cleans

assert.equal(supervisor.stop('call-supervisor'), true);
assert.equal(child.killed, 'SIGTERM');
assert.deepEqual(removed, [sessionDir]);
assert.equal(removed.length, 1);
});

test('pppd supervisor writes egress lease descriptor before daemon start', () => {
Expand All @@ -587,9 +589,11 @@ test('pppd supervisor writes egress lease descriptor before daemon start', () =>
child.stderr = new EventEmitter();
child.kill = () => {};
const leaseDir = mkdtempSync(join(tmpdir(), 'sipfax-lease-dir-'));
const secretsDir = mkdtempSync(join(tmpdir(), 'sipfax-secrets-'));
const supervisor = new PppdSupervisor({
command: '/usr/sbin/pppd',
leaseDir,
secretsDir,
spawnProcess() {
return child;
}
Expand Down
Loading
Loading