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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions deploy/sipfax.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand Down
78 changes: 2 additions & 76 deletions src/media.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down Expand Up @@ -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;
Expand All @@ -342,30 +331,25 @@ 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;
this.framesOut = 0;
this.trainingHits = 0;
this.carrierHits = 0;
this.stateFramesOut = 0;
this.pppProbeFramesOut = 0;
this.lastInboundEnergy = 0;
this.stateChangedAt = null;
}

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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
};
}
}
Expand Down Expand Up @@ -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;
Expand Down
70 changes: 69 additions & 1 deletion src/ppp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -36,6 +37,9 @@ export class PppCredentialStore {
}

this.users.set(username, passwordHash ?? hashPassword(password));
if (password) {
this.secrets.set(username, password);
}
}

verify({ username, password }) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
}

Expand All @@ -206,6 +223,7 @@ export class PppSessionController {
username: null,
lease: null,
dnsServers: [],
pppd: null,
egress: this.egressPolicy.diagnostics()
};

Expand Down Expand Up @@ -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) {
Expand All @@ -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 }
};
}
Expand All @@ -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))
};
Expand Down
Loading
Loading