diagnostics: System → Diagnostics with a network test probe; RTP_PORT_START/END

The server checks itself (public IP, DNS, local networks, phone ACL, SIP transports, free audio
ports) and says how to fix each problem. A quick or complete network test asks the probe
(apps/probe, own image) to test this server from outside: HTTP/HTTPS, SIP over UDP/TCP/TLS, the
browser phone's /ws, sampled audio ports. The probe only tests the address that asks.
The audio port range comes from RTP_PORT_START/END (default 10000-20000).
This commit is contained in:
iamdoubz committed 2026-10-21 12:37:16 -05:00
1 parent 90b3081368
commit 65a0438dde
50 files changed
+1668 -23

No files matched your search

+2
View File
@@ -14,3 +14,5 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
+6 -4
View File
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Container images, built only when a release is published (08 §3.1). Runs on GitHub Actions and
# Gitea Actions (both read .github/workflows); each pushes to its own registry:
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN)
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_TOKEN:
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk,probe} (secrets REGISTRY_USER + REGISTRY_TOKEN:
# a personal access token with write:package; Gitea's job token can't push packages)
# Tags: the version (0.0.1), plus <major>.<minor> and latest; a pre-release gets the version and dev.
name: release
@@ -79,6 +79,7 @@ jobs:
)
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" .
- name: Check
shell: bash
run: |
@@ -88,15 +89,16 @@ jobs:
docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')"
test "$(docker run --rm --entrypoint cat "$p-api:build" /app/VERSION)" = "${{ steps.v.outputs.version }}"
docker run --rm --entrypoint asterisk "$p-asterisk:build" -V
docker run --rm "$p-probe:build" node --input-type=module -e "await import('/app/src/server.ts')"
- name: Push
shell: bash
run: |
set -euo pipefail
p="${{ steps.v.outputs.prefix }}"
for image in api asterisk; do
for image in api asterisk probe; do
for t in ${{ steps.v.outputs.tags }}; do
docker tag "$p-$image:build" "$p-$image:$t"
docker push "$p-$image:$t"
done
done
echo "pushed: $p-{api,asterisk}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
echo "pushed: $p-{api,asterisk,probe}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
+7
View File
@@ -5,6 +5,13 @@ tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on
## Unreleased
- **System → Diagnostics:** checks what usually keeps phones off the network (public IP, DNS,
local networks, the phone network limit, SIP listening, free audio ports), with how to fix each.
A **Quick** or **Complete network test** checks the ports from the internet through a network
test probe (`apps/probe`, image `selfphoneng-probe`) you run outside your network; set its URL
in Settings → Network.
- The call audio port range is configurable: `RTP_PORT_START` and `RTP_PORT_END` (default
10000–20000).
- Updating (Docker only): a new version backs up before it changes the database, and stops
instead if the backup fails. `scripts/update.sh` updates a compose install to a release, checks
the result and goes back by itself if the check fails. The admin guide's "Updating" section
+1 -1
View File
@@ -47,7 +47,7 @@ declare module 'fastify' {
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator;
/** Decorated by the caller too (tests pass a fake). */
ami: Pick<AmiClient, 'action' | 'isConnected'>;
ami: Pick<AmiClient, 'action' | 'command' | 'isConnected'>;
}
}
+10
View File
@@ -19,6 +19,16 @@ describe('config', () => {
expect(() => loadConfig({ ...env, APP_SECRET: undefined })).toThrow(ConfigError);
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
});
it('call audio ports: 10000–20000 by default; the end above the start', () => {
expect(loadConfig(env)).toMatchObject({ RTP_PORT_START: 10000, RTP_PORT_END: 20000 });
expect(loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '40199' })).toMatchObject({
RTP_PORT_START: 40000,
RTP_PORT_END: 40199,
});
expect(() => loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '30000' })).toThrow(
/RTP_PORT_END/,
);
});
it('rejects a non-postgres DATABASE_URL', () => {
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
});
+9 -1
View File
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
const EnvSchema = z.object({
const EnvSchema = z
.object({
APP_SECRET: z
.string()
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
@@ -34,9 +35,16 @@ const EnvSchema = z.object({
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
/** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */
SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false),
TZ: z.string().default('America/Chicago'),
})
.refine((c) => c.RTP_PORT_END > c.RTP_PORT_START, {
path: ['RTP_PORT_END'],
message: 'must be above RTP_PORT_START',
});
export type Config = z.infer<typeof EnvSchema>;
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,4 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
stunaddr = stun.example.org:3478
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
+3 -1
View File
@@ -101,6 +101,8 @@ export type GeneratorDeps = {
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
onApplied?: (r: ApplyResult) => void;
debounceMs?: number;
/** RTP_PORT_START/END (config.ts). */
rtpPorts?: { start: number; end: number };
};
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -165,7 +167,7 @@ export function createGenerator(deps: GeneratorDeps) {
restartRequired: false,
};
try {
const snap = await loadSnapshot(db);
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map<GeneratedFileName, string | null>();
+4
View File
@@ -203,8 +203,12 @@ export type Snapshot = {
mohClasses: string[];
/** Played before recording a call that a phone number records (Settings → Recording). */
recordingAnnouncement: string | null;
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
rtpPorts?: { start: number; end: number };
};
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
export function todayIn(timezone: string, now = new Date()): string {
// en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
+14 -2
View File
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
import { CLASS_RANK } from '@selfphoneng/shared';
import { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts';
import {
byNumber,
DEFAULT_RTP_PORTS,
type SnapExtension,
type SnapTrunk,
type Snapshot,
} from './load.ts';
import * as s from './sanitize.ts';
const section = (title: string, lines: (string | false | null | undefined)[]) =>
@@ -275,7 +281,13 @@ export function renderPjsip(snap: Snapshot): string {
export function renderRtp(snap: Snapshot): string {
const st = snap.settings;
const lines = ['icesupport = yes'];
const rtp = snap.rtpPorts ?? DEFAULT_RTP_PORTS;
const lines = [
// After rtp.conf's own 10000–20000 (Asterisk takes a setting's last value): RTP_PORT_* (env).
`rtpstart = ${s.digits(String(rtp.start))}`,
`rtpend = ${s.digits(String(rtp.end))}`,
'icesupport = yes',
];
if (st['network.stunServer']) lines.push(`stunaddr = ${hostPort(st['network.stunServer'])}`);
if (st['network.lanIp'] && st['network.publicIp'])
lines.push(
+1
View File
@@ -46,6 +46,7 @@ const generator = createGenerator({
db,
ami,
dir: resolve(config.ASTERISK_CONFIG_DIR),
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
log: app.log,
onApplied: (r) => {
live.emit('message', {
+24 -3
View File
@@ -5,7 +5,13 @@ import { networkInterfaces } from 'node:os';
import { join, resolve } from 'node:path';
import { desc, lt } from 'drizzle-orm';
import { z } from 'zod';
import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared';
import type {
DiagnosticsDto,
ListResponse,
NetworkTestDto,
SystemEventDto,
SystemStatus,
} from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
@@ -14,7 +20,8 @@ import { systemEvents } from '../db/schema.ts';
import { appVersion } from './about.ts';
import { csvCell } from './calls.ts';
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts';
import { HttpError, notFound } from '../errors.ts';
import { badRequest, HttpError, notFound } from '../errors.ts';
import { insideChecks, networkTest } from '../services/diagnostics.ts';
import { AmiDisconnectedError } from '../ami/client.ts';
import { GENERATED_FILES } from '../generator/index.ts';
@@ -67,7 +74,7 @@ export async function systemRoutes(app: FastifyInstance) {
sip: s['sip.udpPort'],
sipTcp: s['sip.tcpEnabled'],
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null,
rtp: '10000–20000', // asterisk/conf/rtp.conf
rtp: `${app.config.RTP_PORT_START}–${app.config.RTP_PORT_END}`, // RTP_PORT_START/END
https: 443,
},
ami: { connected: app.ami.isConnected() },
@@ -163,6 +170,20 @@ export async function systemRoutes(app: FastifyInstance) {
return reply.sendFile(name, dir);
});
// Diagnostics (08 §2.6): the inside checks; the probe's outside test, quick or complete.
const diag = () => ({ db: app.db, config: app.config, ami: app.ami });
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
checks: await insideChecks(diag()),
}));
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
const { mode } = z
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
.parse(req.body ?? {});
if (!(await getAllSettings(app.db))['network.probeUrl'])
throw badRequest('No network test probe set (Settings → Network)');
return networkTest(diag(), mode);
});
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
GENERATED_FILES.map(async (name) => ({
+483
View File
@@ -0,0 +1,483 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System → Diagnostics (08 §2.6). insideChecks: what this server can tell about itself (settings,
// DNS, listening transports, the audio port range). networkTest: the probe (apps/probe) tests it
// from outside, the only way to know what a phone on mobile data or the SIP provider can reach.
import { randomBytes } from 'node:crypto';
import { createSocket, type Socket } from 'node:dgram';
import { resolve4 } from 'node:dns/promises';
import { BlockList, isIPv4 } from 'node:net';
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
import type { AmiClient } from '../ami/client.ts';
import type { Config } from '../config.ts';
import type { Db } from '../db/client.ts';
import { detectPublicIp } from '../jobs.ts';
import { getAllSettings } from './settings.ts';
export type DiagDeps = {
db: Db;
config: Pick<Config, 'PUBLIC_HOSTNAME' | 'RTP_PORT_START' | 'RTP_PORT_END'>;
ami: Pick<AmiClient, 'isConnected' | 'command'>;
/** Overridable in tests. */
lookupPublicIp?: () => Promise<string | null>;
resolveHost?: (host: string) => Promise<string[]>;
fetchProbe?: typeof fetch;
};
const PRIVATE = new BlockList();
for (const [a, b] of [
['10.0.0.0', 8],
['172.16.0.0', 12],
['192.168.0.0', 16],
['100.64.0.0', 10],
['127.0.0.0', 8],
] as const)
PRIVATE.addSubnet(a, b, 'ipv4');
const inNets = (ip: string, nets: string[]) => {
const list = new BlockList();
for (const n of nets) {
const [a, b] = n.split('/');
list.addSubnet(a!, Number(b), 'ipv4');
}
return list.check(ip, 'ipv4');
};
const check = (
id: string,
label: string,
status: DiagnosticCheck['status'],
detail: string,
fix?: string,
): DiagnosticCheck => ({ id, label, status, detail, ...(fix && { fix }) });
/** Even ports in the range that something else already holds (RTP takes even ones). */
async function busyRtpPorts(start: number, end: number): Promise<number> {
const ports: number[] = [];
for (let p = start + (start % 2); p <= end; p += 2) ports.push(p);
let busy = 0;
// ponytail: binds every even port once, 200 at a time (~1 s for 10 000 ports).
for (let i = 0; i < ports.length; i += 200)
await Promise.all(
ports.slice(i, i + 200).map(
(port) =>
new Promise<void>((done) => {
const s = createSocket('udp4');
s.once('error', () => (busy++, s.close(), done()));
s.bind(port, () => s.close(done));
}),
),
);
return busy;
}
export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
const s = await getAllSettings(d.db);
const host = d.config.PUBLIC_HOSTNAME;
const out: DiagnosticCheck[] = [];
const setIp = s['network.publicIp'];
const seenIp = await (d.lookupPublicIp ?? detectPublicIp)();
const ip = setIp ?? seenIp;
if (!setIp)
out.push(
check(
'public-ip',
'Public IP',
'fail',
"Not set. Phones outside your network are given the server's private address, so their calls fail or have no sound.",
`Settings → Network → Public IP: ${seenIp ?? "this server's internet address"}, then restart telephony (System).`,
),
);
else if (seenIp && seenIp !== setIp)
out.push(
check(
'public-ip',
'Public IP',
'fail',
`Set to ${setIp}, but this server reaches the internet from ${seenIp}.`,
`Settings → Network → Public IP: ${seenIp}, then restart telephony. If it keeps changing, turn on "Re-check the public IP".`,
),
);
else
out.push(
check(
'public-ip',
'Public IP',
seenIp ? 'ok' : 'warn',
seenIp ? setIp : `${setIp} (couldn't look it up to compare: no internet?)`,
),
);
const names = await (d.resolveHost ?? resolve4)(host).catch(() => [] as string[]);
if (!names.length)
out.push(
check(
'dns',
`DNS for ${host}`,
'fail',
`${host} doesn't resolve. Phones and the browser find the server by this name.`,
`At your DNS provider, add an A record: ${host} → ${ip ?? 'your public IP'}.`,
),
);
else if (ip && !names.includes(ip))
out.push(
check(
'dns',
`DNS for ${host}`,
'fail',
`${host} points at ${names.join(', ')}, not ${ip}.`,
`Point the A record at ${ip}. With Cloudflare, set it to "DNS only" (grey cloud): its proxy carries web pages, not phone calls (SIP).`,
),
);
else out.push(check('dns', `DNS for ${host}`, 'ok', `${host} → ${names.join(', ')}`));
const nets = s['network.localNets'];
const lan = s['network.lanIp'];
if (lan && !inNets(lan, nets))
out.push(
check(
'local-nets',
'Local networks',
'fail',
`The server's LAN address ${lan} isn't in them (${nets.join(', ') || 'none'}).`,
`Settings → Network: add ${lan.replace(/\.\d+$/, '.0')}/24 (or your LAN's range) to the local networks.`,
),
);
else out.push(check('local-nets', 'Local networks', 'ok', nets.join(', ') || 'none'));
out.push(
s['sip.restrictPhonesToLocalNets']
? check(
'phone-acl',
'Where phones can sign in from',
'warn',
`Only from ${[...nets, ...s['sip.extraAllowedNets']].join(', ')}. Phone apps on mobile data, or phones at home, are refused.`,
'Settings → Security: untick "Only accept desk phones from the networks below", or add the networks they call from.',
)
: check('phone-acl', 'Where phones can sign in from', 'ok', 'Anywhere, with their password'),
);
if (s['provisioning.lanOnly'])
out.push(
check(
'prov-lan',
'Phone setup files',
'warn',
'Only phones on the local networks get them: the Linphone QR code fails on mobile data.',
'Settings → Security: untick "Only to phones on the local networks" while setting up phones elsewhere.',
),
);
const port = s['sip.udpPort'];
if (!d.ami.isConnected())
out.push(
check(
'sip',
'SIP',
'fail',
"Telephony (Asterisk) isn't reachable.",
'Check that the asterisk container runs: docker compose ps, then docker compose logs asterisk.',
),
);
else {
const t = await d.ami.command('pjsip show transports').catch(() => '');
const want = [
['transport-udp', `UDP ${port}`],
...(s['sip.tcpEnabled'] ? [['transport-tcp', `TCP ${port}`]] : []),
...(s['sip.tlsEnabled'] ? [['transport-tls', `TLS ${s['sip.tlsPort']}`]] : []),
];
const missing = want.filter(([name]) => !t.includes(name!)).map(([, what]) => what);
out.push(
missing.length
? check(
'sip',
'SIP',
'fail',
`Not listening on ${missing.join(', ')}.`,
'Restart telephony (System): ports and TLS changes only take effect then.',
)
: check('sip', 'SIP', 'ok', `Listening on ${want.map(([, w]) => w).join(', ')}`),
);
}
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
const total = Math.floor((b - a) / 2) + 1;
const busy = await busyRtpPorts(a, b);
const range = `UDP ${a}–${b}`;
out.push(
busy > total / 4
? check(
'rtp',
'Call audio ports',
'warn',
`${range}: ${busy} of ${total} are taken by other programs (or calls).`,
`Pick a free range with RTP_PORT_START and RTP_PORT_END (in .env, or the api's environment in your stack), restart, and open that range instead. Two ports per call: 200 ports is plenty for a small office.`,
)
: check(
'rtp',
'Call audio ports',
'ok',
`${range}, ${total} usable${busy ? `, ${busy} in use` : ''}`,
),
);
out.push(
s['network.probeUrl']
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
: check(
'probe',
'Network test probe',
'skip',
'None set, so nothing is tested from outside your network.',
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
),
);
return out;
}
type ProbeResult = { id: string; ok: boolean; detail: string };
/** The probe's reason in brackets, unless it only repeats "no answer". */
const why = (detail: string) => (detail === 'no answer' ? '' : ` (${detail})`);
/** The probe's outside tests; `complete` adds TLS, the browser phone's /ws and audio ports. */
export async function networkTest(
d: DiagDeps,
mode: 'quick' | 'complete',
): Promise<NetworkTestDto> {
const s = await getAllSettings(d.db);
const probe = s['network.probeUrl'];
if (!probe) throw new Error('No network test probe set (Settings → Network)');
const host = d.config.PUBLIC_HOSTNAME;
const port = s['sip.udpPort'];
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
const lan = s['network.lanIp'] ?? 'this server';
const tests: Record<string, unknown>[] = [
{ id: 'http', kind: 'http', port: 80, path: '/' },
{ id: 'https', kind: 'https', port: 443, path: '/healthz' },
{ id: 'sip-udp', kind: 'sip-udp', port },
...(s['sip.tcpEnabled'] ? [{ id: 'sip-tcp', kind: 'sip-tcp', port }] : []),
];
// Audio ports: a few, listened on here for the probe's datagrams.
const listeners: { port: number; sock: Socket; got: boolean }[] = [];
if (mode === 'complete') {
tests.push({ id: 'ws', kind: 'https', port: 443, path: '/ws', websocket: true });
if (s['sip.tlsEnabled']) tests.push({ id: 'sip-tls', kind: 'sip-tls', port: s['sip.tlsPort'] });
const even = (p: number) => p - (p % 2);
const sample = [
...new Set([
even(a + 1),
even(b),
even(a + Math.floor((b - a) / 2)),
even(Math.min(b, a + 2 + Math.floor(Math.random() * Math.max(0, b - a - 2)))),
]),
];
const token = randomBytes(8).toString('hex');
for (const p of sample) {
const sock = createSocket('udp4');
const ok = await new Promise<boolean>((r) => {
sock.once('error', () => r(false));
sock.bind(p, () => r(true));
});
if (!ok) continue; // a call or another program has it: try the others
const l = { port: p, sock, got: false };
sock.on('message', (m) => (l.got ||= m.toString().includes(token)));
listeners.push(l);
tests.push({ id: `rtp-${p}`, kind: 'udp', port: p, token });
}
}
let ip: string | null = null;
let results: ProbeResult[] = [];
let probeError: string | null = null;
try {
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ host, tests }),
signal: AbortSignal.timeout(30_000),
});
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
// Datagrams sent last arrive just after the answer.
await new Promise((r) => setTimeout(r, 700));
} catch (e) {
probeError = (e as Error).message;
} finally {
for (const l of listeners) l.sock.close();
}
if (probeError)
return {
mode,
testedIp: null,
checks: [
check(
'probe',
'Network test probe',
'fail',
`${probe} didn't answer: ${probeError}`,
'Check the probe runs and is reachable from this server, then try again.',
),
],
};
const res = (id: string) => results.find((x) => x.id === id);
const out: DiagnosticCheck[] = [];
if (ip && isIPv4(ip) && PRIVATE.check(ip, 'ipv4'))
out.push(
check(
'probe-ip',
'Tested address',
'warn',
`The probe saw ${ip}, a private address: it's inside your network, so these results don't show what the internet sees.`,
'Run the probe on a machine outside this network.',
),
);
else if (ip && s['network.publicIp'] && ip !== s['network.publicIp'])
out.push(
check(
'probe-ip',
'Tested address',
'warn',
`The probe saw this server as ${ip}, but the public IP is set to ${s['network.publicIp']} (a VPN, or a changed address?).`,
`If ${ip} is right: Settings → Network → Public IP, then restart telephony.`,
),
);
else if (ip) out.push(check('probe-ip', 'Tested address', 'ok', ip));
const http = res('http');
if (http)
out.push(
http.ok
? check('http', 'HTTP (TCP 80)', 'ok', http.detail)
: check(
'http',
'HTTP (TCP 80)',
'warn',
`Not reachable${why(http.detail)}. Only needed to get the HTTPS certificate over HTTP (the bundled Caddy, or certbot).`,
'If you need it: forward TCP 80 to this server on the router, and sudo ufw allow 80/tcp.',
),
);
const https = res('https');
if (https) {
const status = Number(/HTTP (\d{3})/.exec(https.detail)?.[1]);
out.push(
!https.ok
? check(
'https',
'HTTPS (TCP 443)',
'fail',
`Not reachable${why(https.detail)}. The web app, the browser phone and phone setup all use it.`,
`Forward TCP 443 to ${lan} on the router, and sudo ufw allow 443/tcp.`,
)
: status !== 200
? check(
'https',
'HTTPS (TCP 443)',
'fail',
`Reachable, but answered ${https.detail.split('; ')[0]} instead of SelfPhoneNG.`,
"Your reverse proxy can't reach the api: it must send this host name to 127.0.0.1 on the api's port (API_PORT, 8080 by default).",
)
: /certificate isn't valid/.test(https.detail)
? check(
'https',
'HTTPS (TCP 443)',
'warn',
https.detail,
`Get a certificate for ${host}: the bundled Caddy does it by itself; with your own proxy, certbot.`,
)
: check('https', 'HTTPS (TCP 443)', 'ok', https.detail),
);
}
const udp = res('sip-udp');
if (udp)
out.push(
udp.ok
? check('sip-udp', `SIP (UDP ${port})`, 'ok', `Asterisk answered: ${udp.detail}`)
: check(
'sip-udp',
`SIP (UDP ${port})`,
'fail',
`No answer${why(udp.detail)}. Phones away from the office, phone apps on mobile data and the SIP provider's calls all need it.`,
`Forward UDP ${port} to ${lan} on the router (on both, behind two), sudo ufw allow ${port}/udp, and turn off SIP ALG on the router.`,
),
);
const tcp = res('sip-tcp');
if (tcp)
out.push(
tcp.ok
? check('sip-tcp', `SIP (TCP ${port})`, 'ok', `Asterisk answered: ${tcp.detail}`)
: check(
'sip-tcp',
`SIP (TCP ${port})`,
'warn',
`No answer${why(tcp.detail)}. Only phones set to TCP need it.`,
`Forward TCP ${port} to ${lan} and sudo ufw allow ${port}/tcp, or keep phones on UDP.`,
),
);
const tls = res('sip-tls');
if (tls)
out.push(
tls.ok
? check(
'sip-tls',
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
'ok',
`Asterisk answered: ${tls.detail}`,
)
: check(
'sip-tls',
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
'fail',
`No answer${why(tls.detail)}.`,
`Forward TCP ${s['sip.tlsPort']} to ${lan} and sudo ufw allow ${s['sip.tlsPort']}/tcp.`,
),
);
const ws = res('ws');
if (ws)
out.push(
ws.ok
? check('ws', 'Browser phone (/ws)', 'ok', ws.detail)
: check(
'ws',
'Browser phone (/ws)',
'fail',
`The WebSocket didn't open${why(ws.detail)}.`,
'Your reverse proxy must pass WebSocket upgrades on /ws to 127.0.0.1:8088 (see deploy/nginx/selfphoneng.conf.example).',
),
);
if (mode === 'complete') {
const got = listeners.filter((l) => l.got);
const range = `UDP ${a}–${b}`;
const fix = `Forward ${range} to ${lan} on the router, and sudo ufw allow ${a}:${b}/udp. Without it calls connect but have no sound, or only one way.`;
out.push(
!listeners.length
? check(
'rtp',
`Call audio (${range})`,
'skip',
'Every sampled port was in use here: try again.',
)
: got.length === listeners.length
? check(
'rtp',
`Call audio (${range})`,
'ok',
`${got.length} of ${listeners.length} sampled ports got through`,
)
: check(
'rtp',
`Call audio (${range})`,
got.length ? 'warn' : 'fail',
`${got.length} of ${listeners.length} sampled ports got through (missing: ${listeners
.filter((l) => !l.got)
.map((l) => l.port)
.join(', ')}).`,
got.length
? `The forward or firewall rule doesn't cover the whole range. ${fix}`
: fix,
),
);
}
return { mode, testedIp: ip, checks: out };
}
+31
View File
@@ -23,6 +23,7 @@ const NET = 'sp-test';
const AST = 'sp-test-ast';
const PHONES = 'sp-test-phones';
const PN_PHONE = 'sp-test-pn-phone';
const RTP_PORTS = { start: 31000, end: 31200 };
const AMI_PORT = 15038;
const AMI_SECRET = 'call-test-secret';
const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' };
@@ -79,6 +80,8 @@ function snapshot(): Snapshot {
announcements: [],
recordingAnnouncement: null,
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
// Not rtp.conf's 10000–20000: the generated range must win (RTP_PORT_START/END).
rtpPorts: RTP_PORTS,
};
}
@@ -174,6 +177,13 @@ async function main() {
ami.start();
await new Promise((r) => ami.once('connected', r));
log('AMI connected from the host');
const rtp = await ami.command('rtp show settings');
if (
!new RegExp(`Port start:\\s+${RTP_PORTS.start}\\b`).test(rtp) ||
!new RegExp(`Port end:\\s+${RTP_PORTS.end}\\b`).test(rtp)
)
problems.push(`RTP ports aren't ${RTP_PORTS.start}–${RTP_PORTS.end}:\n${rtp}`);
else log(`RTP ports ${RTP_PORTS.start}–${RTP_PORTS.end} (generated, over rtp.conf's)`);
if (RECORD) {
// A second, raw AMI session so the fixture holds exactly what Asterisk sent.
recorder = new Socket();
@@ -237,6 +247,27 @@ async function main() {
).out.trim();
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
else log('102-web over WebSocket from 127.0.0.1: registered');
// The network test probe's SIP checks (08 §2.6): Asterisk answers its OPTIONS, UDP and TCP.
const probed = docker(
'run',
'--rm',
'--network',
NET,
'-v',
`${join(import.meta.dirname, '../../../probe/src')}:/probe:ro`,
'node:24-trixie-slim',
'node',
'--input-type=module',
'-e',
`import { runTest } from '/probe/checks.ts';
for (const kind of ['sip-udp', 'sip-tcp'])
console.log(JSON.stringify(await runTest('${AST}', 'pbx.test', { id: kind, kind, port: 5060 })));`,
).out;
for (const kind of ['sip-udp', 'sip-tcp'])
if (!new RegExp(`"id":"${kind}","ok":true,"detail":"SIP/2.0 \\d{3}`).test(probed))
problems.push(`probe ${kind}: ${probed.trim()}`);
if (!problems.some((p) => p.startsWith('probe ')))
log(`probe: Asterisk answers SIP OPTIONS over UDP and TCP`);
// 101 also on an iOS Linphone: '&' in its push params used to break Dial for every device.
const pn = docker(
'run',
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System → Diagnostics (08 §2.6): the inside checks with the outside lookups faked, and the
// network test against a fake probe that really sends the audio-port datagrams.
import { createSocket } from 'node:dgram';
import type { DiagnosticCheck } from '@selfphoneng/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { insideChecks, networkTest, type DiagDeps } from '../src/services/diagnostics.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const byId = (checks: DiagnosticCheck[]) => Object.fromEntries(checks.map((c) => [c.id, c]));
describe.skipIf(!hasTestDb)('diagnostics', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let deps: DiagDeps;
beforeAll(async () => {
t = await createTestApp();
deps = {
db: t.db,
// A small range, so the busy-port scan and the samples stay quick and local.
config: { ...t.app.config, RTP_PORT_START: 41000, RTP_PORT_END: 41020 },
ami: t.app.ami,
lookupPublicIp: async () => '203.0.113.10',
resolveHost: async () => ['203.0.113.10'],
};
});
afterAll(() => t?.close());
it('flags what keeps phones off the network: no public IP, DNS elsewhere, the phone ACL', async () => {
let c = byId(await insideChecks({ ...deps, resolveHost: async () => ['198.51.100.7'] }));
expect(c['public-ip']).toMatchObject({ status: 'fail' });
expect(c['public-ip']!.fix).toContain('203.0.113.10');
expect(c.dns).toMatchObject({ status: 'fail' });
expect(c.dns!.fix).toContain('DNS only');
expect(c['phone-acl']).toMatchObject({ status: 'ok' });
expect(c.probe).toMatchObject({ status: 'skip' });
expect(c.rtp).toMatchObject({
status: 'ok',
detail: expect.stringContaining('UDP 41000–41020'),
});
await setSetting(t.db, 'network.publicIp', '203.0.113.10');
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', true);
await setSetting(t.db, 'network.lanIp', '10.9.0.5');
await setSetting(t.db, 'network.localNets', ['192.168.0.0/16']);
c = byId(await insideChecks(deps));
expect(c['public-ip']).toMatchObject({ status: 'ok', detail: '203.0.113.10' });
expect(c.dns).toMatchObject({ status: 'ok' });
expect(c['phone-acl']).toMatchObject({ status: 'warn' });
expect(c['phone-acl']!.detail).toContain('mobile data');
expect(c['local-nets']).toMatchObject({ status: 'fail' });
expect(c['local-nets']!.fix).toContain('10.9.0.0/24');
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', false);
});
it('a busy audio range is a warning that says how to move it', async () => {
const held = await Promise.all(
[41000, 41002, 41004, 41006].map(
(p) =>
new Promise<ReturnType<typeof createSocket>>((r) => {
const s = createSocket('udp4');
s.bind(p, () => r(s));
}),
),
);
const c = byId(await insideChecks(deps));
expect(c.rtp).toMatchObject({ status: 'warn' });
expect(c.rtp!.fix).toContain('RTP_PORT_START');
held.forEach((s) => s.close());
});
it('network test: maps the probe’s answers to fixes; audio ports must really get through', async () => {
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
let seen:
| { host: string; tests: { id: string; kind: string; port: number; token?: string }[] }
| undefined;
const fetchProbe = (async (_url: URL, init: RequestInit) => {
seen = JSON.parse(String(init.body));
// The firewall lets the first sampled audio port through and drops the rest.
const udp = seen!.tests.filter((x) => x.kind === 'udp');
const s = createSocket('udp4');
await new Promise<void>((r) =>
s.send(`selfphoneng-probe ${udp[0]!.token}`, udp[0]!.port, '127.0.0.1', () => r()),
);
s.close();
const ok = (id: string, detail: string) => ({ id, ok: true, detail });
const no = (id: string) => ({ id, ok: false, detail: 'no answer' });
return new Response(
JSON.stringify({
ip: '203.0.113.10',
results: [
no('http'),
ok('https', 'HTTP 200'),
no('sip-udp'),
ok('sip-tcp', 'SIP/2.0 401 Unauthorized'),
{ id: 'ws', ok: false, detail: 'HTTP 404' },
...udp.map((x) => ok(x.id, 'sent')),
],
}),
);
}) as unknown as typeof fetch;
const r = await networkTest({ ...deps, fetchProbe }, 'complete');
expect(seen!.host).toBe(t.app.config.PUBLIC_HOSTNAME);
expect(seen!.tests.map((x) => x.id)).toEqual(
expect.arrayContaining(['http', 'https', 'sip-udp', 'sip-tcp', 'ws']),
);
const c = byId(r.checks);
expect(r.testedIp).toBe('203.0.113.10');
expect(c['probe-ip']).toMatchObject({ status: 'ok' });
expect(c.http).toMatchObject({ status: 'warn' });
expect(c.https).toMatchObject({ status: 'ok' });
expect(c['sip-udp']).toMatchObject({ status: 'fail' });
expect(c['sip-udp']!.fix).toContain('SIP ALG');
expect(c['sip-tcp']).toMatchObject({ status: 'ok' });
expect(c.ws).toMatchObject({ status: 'fail' });
expect(c.rtp).toMatchObject({ status: 'warn' });
expect(c.rtp!.fix).toContain('ufw allow 41000:41020/udp');
// Quick: no audio ports, no /ws.
const quick = await networkTest(
{
...deps,
fetchProbe: (async (_u: URL, init: RequestInit) => {
seen = JSON.parse(String(init.body));
return new Response(JSON.stringify({ ip: '192.168.1.9', results: [] }));
}) as unknown as typeof fetch,
},
'quick',
);
expect(seen!.tests.map((x) => x.kind)).not.toContain('udp');
expect(byId(quick.checks)['probe-ip']).toMatchObject({ status: 'warn' }); // a private address
});
it('routes: admins only; no probe set is a 400', async () => {
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
method: 'GET',
url: '/api/system/diagnostics',
headers: { ...H, cookie },
});
expect(r.statusCode, r.body).toBe(200);
expect(r.json().checks.length).toBeGreaterThan(3);
await setSetting(t.db, 'network.probeUrl', null);
const n = await t.app.inject({
method: 'POST',
url: '/api/system/diagnostics/network',
headers: { ...H, cookie },
payload: { mode: 'quick' },
});
expect(n.statusCode).toBe(400);
expect((await t.app.inject({ method: 'GET', url: '/api/system/diagnostics' })).statusCode).toBe(
401,
);
});
});
+12
View File
@@ -0,0 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# The network test probe (08 §2.6), run outside the network SelfPhoneNG is in. No dependencies:
# Node 24 runs the TypeScript as it is. Build from the repo root:
# docker build -f apps/probe/Dockerfile -t selfphoneng-probe .
FROM node:24-alpine
WORKDIR /app
COPY apps/probe/package.json ./
COPY apps/probe/src/checks.ts apps/probe/src/server.ts apps/probe/src/index.ts ./src/
ENV NODE_ENV=production
USER node
EXPOSE 8080
CMD ["node", "src/index.ts"]
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@selfphoneng/probe",
"version": "0.0.2",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"start": "node src/index.ts",
"typecheck": "tsc --noEmit"
}
}
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The network test probe's checks (08 §2.6). Each reaches a SelfPhoneNG server from outside its
// network, the way a phone on mobile data or the SIP provider would, and only ever at the address
// that asked (server.ts), so nobody can aim it at someone else.
import { randomBytes } from 'node:crypto';
import { createSocket } from 'node:dgram';
import { connect as tcpConnect, isIP, type Socket } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
export type Test =
| { id: string; kind: 'tcp'; port: number }
/** An HTTP(S) request; `websocket` asks for a SIP WebSocket upgrade (the browser phone's /ws). */
| { id: string; kind: 'http' | 'https'; port: number; path?: string; websocket?: boolean }
/** SIP OPTIONS: any SIP answer means Asterisk got it. */
| { id: string; kind: 'sip-udp' | 'sip-tcp' | 'sip-tls'; port: number }
/** Datagrams carrying `token`; the server listens and knows whether they came. */
| { id: string; kind: 'udp'; port: number; token: string };
export type Result = { id: string; ok: boolean; detail: string; ms: number };
export const TIMEOUT_MS = 4000;
const rand = () => randomBytes(8).toString('hex');
/** Sends `request` over TCP or TLS and resolves with what came back once `done` matches it. */
function exchange(
ip: string,
port: number,
opts: { tls: boolean; host: string; request: string; done: RegExp },
): Promise<{ data: string; certError: string | null }> {
return new Promise((resolve, reject) => {
let data = '';
const onConnect = () => sock.write(opts.request);
const sock: Socket = opts.tls
? tlsConnect(
{
host: ip,
port,
// The name to check the certificate against; SNI can't carry an IP.
...(isIP(opts.host) ? {} : { servername: opts.host }),
rejectUnauthorized: false,
},
onConnect,
)
: tcpConnect({ host: ip, port }, onConnect);
const finish = (err?: Error) => {
clearTimeout(timer);
sock.destroy();
if (err) reject(err);
else {
const tls = sock as Socket & { authorized?: boolean; authorizationError?: Error };
resolve({
data,
certError:
opts.tls && !tls.authorized ? String(tls.authorizationError ?? 'not valid') : null,
});
}
};
const timer = setTimeout(() => finish(new Error('no answer')), TIMEOUT_MS);
sock.setEncoding('utf8');
sock.on('data', (d: string) => {
data += d;
if (opts.done.test(data)) finish();
});
sock.on('error', (e) => finish(e));
sock.on('close', () => (opts.done.test(data) ? finish() : finish(new Error('closed'))));
});
}
const sipOptions = (transport: string, host: string, port: number) =>
[
`OPTIONS sip:${host}:${port};transport=${transport.toLowerCase()} SIP/2.0`,
// rport: answer the address and port this came from (RFC 3581), not the made-up host.
`Via: SIP/2.0/${transport} probe.invalid;branch=z9hG4bK${rand()};rport`,
'Max-Forwards: 70',
`From: <sip:probe@probe.invalid>;tag=${rand()}`,
`To: <sip:${host}>`,
`Call-ID: ${rand()}@probe.invalid`,
'CSeq: 1 OPTIONS',
'User-Agent: selfphoneng-probe',
'Content-Length: 0',
'',
'',
].join('\r\n');
/** OPTIONS over UDP, sent three times (UDP drops); resolves with the first answer's status line. */
function sipUdp(ip: string, port: number, host: string): Promise<string> {
return new Promise((resolve, reject) => {
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
const msg = Buffer.from(sipOptions('UDP', host, port));
const timers = [0, 500, 1500].map((ms) =>
setTimeout(() => sock.send(msg, port, ip, () => {}), ms),
);
const done = (fn: () => void) => {
timers.forEach(clearTimeout);
clearTimeout(timer);
sock.close();
fn();
};
const timer = setTimeout(() => done(() => reject(new Error('no answer'))), TIMEOUT_MS);
sock.on('message', (m) => {
const line = m.toString('utf8').split('\r\n')[0]!;
if (line.startsWith('SIP/2.0 ')) done(() => resolve(line));
});
sock.on('error', (e) => done(() => reject(e)));
});
}
function sendUdp(ip: string, port: number, token: string): Promise<void> {
return new Promise((resolve) => {
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
const msg = Buffer.from(`selfphoneng-probe ${token}`);
let sent = 0;
const one = () =>
sock.send(msg, port, ip, () => {
if (++sent === 3) {
sock.close();
resolve();
}
});
one();
setTimeout(one, 300);
setTimeout(one, 600);
});
}
const HTTP_STATUS = /^HTTP\/1\.[01] (\d{3})[^\r\n]*\r\n/;
export async function runTest(ip: string, host: string, t: Test): Promise<Result> {
const start = Date.now();
const result = (ok: boolean, detail: string): Result => ({
id: t.id,
ok,
detail,
ms: Date.now() - start,
});
try {
switch (t.kind) {
case 'tcp':
await new Promise<void>((resolve, reject) => {
const s = tcpConnect({ host: ip, port: t.port }, () => (s.destroy(), resolve()));
s.setTimeout(TIMEOUT_MS, () => (s.destroy(), reject(new Error('no answer'))));
s.on('error', reject);
});
return result(true, 'connected');
case 'http':
case 'https': {
const upgrade = t.websocket
? [
'Connection: Upgrade',
'Upgrade: websocket',
`Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}`,
'Sec-WebSocket-Version: 13',
'Sec-WebSocket-Protocol: sip',
]
: ['Connection: close'];
const request = [
`GET ${t.path ?? '/'} HTTP/1.1`,
`Host: ${host}`,
'User-Agent: selfphoneng-probe',
...upgrade,
'',
'',
].join('\r\n');
const r = await exchange(ip, t.port, {
tls: t.kind === 'https',
host,
request,
done: HTTP_STATUS,
});
const status = Number(HTTP_STATUS.exec(r.data)?.[1]);
const cert = r.certError
? `; the certificate isn't valid for ${host} (${r.certError})`
: '';
if (t.websocket)
return result(status === 101, `${status === 101 ? 'upgraded' : `HTTP ${status}`}${cert}`);
return result(true, `HTTP ${status}${cert}`);
}
case 'sip-udp':
return result(true, await sipUdp(ip, t.port, host));
case 'sip-tcp':
case 'sip-tls': {
const tls = t.kind === 'sip-tls';
const r = await exchange(ip, t.port, {
tls,
host,
request: sipOptions(tls ? 'TLS' : 'TCP', host, t.port),
done: /^SIP\/2\.0 \d{3}[^\r\n]*\r\n/,
});
return result(true, r.data.split('\r\n')[0]!);
}
case 'udp':
await sendUdp(ip, t.port, t.token);
return result(true, 'sent');
}
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
return result(
false,
code === 'ECONNREFUSED' ? 'refused' : code ? code.toLowerCase() : (e as Error).message,
);
}
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
import { createProbe } from './server.ts';
const port = Number(process.env.PORT ?? 8080);
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
+167
View File
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runTest } from './checks.ts';
import { createProbe, parseCheck } from './server.ts';
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
const listen = <T extends Server | HttpServer>(s: T) =>
new Promise<T>((r) => s.listen(0, '127.0.0.1', () => r(s)));
const bindUdp = (
onMessage: (m: Buffer, from: { address: string; port: number }, s: UdpSocket) => void,
) =>
new Promise<UdpSocket>((r) => {
const s = createSocket('udp4');
s.on('message', (m, from) => onMessage(m, from, s));
s.bind(0, '127.0.0.1', () => r(s));
});
const SIP_OK = (req: string) =>
`SIP/2.0 401 Unauthorized\r\n${req
.split('\r\n')
.filter((l) => /^(Via|From|To|Call-ID|CSeq):/.test(l))
.join('\r\n')}\r\nContent-Length: 0\r\n\r\n`;
describe('probe checks', () => {
let http: HttpServer;
let sipTcp: Server;
let sipUdp: UdpSocket;
let raw: UdpSocket;
const got: string[] = [];
beforeAll(async () => {
http = await listen(httpServer((_req, res) => res.end('{"ok":true}')));
http.on('upgrade', (req, sock) => {
const ok = req.headers['sec-websocket-protocol'] === 'sip';
sock.end(
ok
? 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'
: 'HTTP/1.1 400 Bad Request\r\n\r\n',
);
});
sipTcp = await listen(tcpServer((s) => s.on('data', (d) => s.write(SIP_OK(d.toString())))));
sipUdp = await bindUdp((m, from, s) => s.send(SIP_OK(m.toString()), from.port, from.address));
raw = await bindUdp((m) => got.push(m.toString()));
});
afterAll(() => {
http.close();
sipTcp.close();
sipUdp.close();
raw.close();
});
it('TCP: connected, or refused', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: port(http) }),
).toMatchObject({ ok: true, detail: 'connected' });
const closed = await listen(tcpServer());
const p = port(closed);
closed.close();
expect(await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: p })).toMatchObject(
{ ok: false, detail: 'refused' },
);
});
it('HTTP status, and a SIP WebSocket upgrade', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'h',
kind: 'http',
port: port(http),
path: '/healthz',
}),
).toMatchObject({ ok: true, detail: 'HTTP 200' });
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'w',
kind: 'http',
port: port(http),
path: '/ws',
websocket: true,
}),
).toMatchObject({ ok: true, detail: 'upgraded' });
});
it('SIP OPTIONS over UDP and TCP: any SIP answer', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'u',
kind: 'sip-udp',
port: sipUdp.address().port,
}),
).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' });
expect(
await runTest('127.0.0.1', 'pbx.test', { id: 'c', kind: 'sip-tcp', port: port(sipTcp) }),
).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' });
});
it('UDP: the token arrives', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'r',
kind: 'udp',
port: raw.address().port,
token: 'abcd1234ef',
}),
).toMatchObject({ ok: true });
expect(got).toContain('selfphoneng-probe abcd1234ef');
});
});
describe('probe server', () => {
it('validates: a host, 1–20 known tests, no target of its own', () => {
expect(parseCheck({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 443 }] })).toEqual({
host: 'pbx.test',
tests: [{ id: 'a', kind: 'tcp', port: 443 }],
});
// A target address in the body is simply not read.
expect(
parseCheck({
host: 'pbx.test',
ip: '192.0.2.1',
tests: [{ id: 'a', kind: 'tcp', port: 1, ip: '192.0.2.1' }],
}),
).toEqual({
host: 'pbx.test',
tests: [{ id: 'a', kind: 'tcp', port: 1 }],
});
for (const bad of [
{ tests: [{ id: 'a', kind: 'tcp', port: 1 }] },
{ host: 'a b', tests: [{ id: 'a', kind: 'tcp', port: 1 }] },
{ host: 'pbx.test', tests: [] },
{
host: 'pbx.test',
tests: Array.from({ length: 21 }, (_, i) => ({ id: `a${i}`, kind: 'tcp', port: 1 })),
},
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'exec', port: 1 }] },
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 70000 }] },
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'udp', port: 1, token: 'x' }] },
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'http', port: 1, path: 'http://evil/' }] },
])
expect(typeof parseCheck(bad)).toBe('string');
});
it('tests the address that asked (X-Forwarded-For only behind a proxy); limits checks', async () => {
const target = await listen(tcpServer());
const probe = await listen(createProbe({ maxChecks: 2 }));
const ask = () =>
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
method: 'POST',
headers: { 'x-forwarded-for': '192.0.2.1' },
body: JSON.stringify({
host: 'pbx.test',
tests: [{ id: 'tcp', kind: 'tcp', port: port(target) }],
}),
});
const r = await ask();
expect(r.status).toBe(200);
expect(await r.json()).toMatchObject({ ip: '127.0.0.1', results: [{ id: 'tcp', ok: true }] });
expect((await ask()).status).toBe(200);
expect((await ask()).status).toBe(429);
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
probe.close();
target.close();
});
});
+112
View File
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
// from being aimed at someone else; each address gets a few checks per 10 minutes.
import { createServer, type IncomingMessage, type Server } from 'node:http';
import { isIP } from 'node:net';
import { runTest, type Result, type Test } from './checks.ts';
export type ProbeOptions = {
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
trustProxy?: boolean;
/** Checks per address per window. */
maxChecks?: number;
windowMs?: number;
};
const HOST =
/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
const KINDS = new Set(['tcp', 'http', 'https', 'sip-udp', 'sip-tcp', 'sip-tls', 'udp']);
const MAX_TESTS = 20;
/** The tests, or why they're not acceptable. */
export function parseCheck(body: unknown): { host: string; tests: Test[] } | string {
const b = body as { host?: unknown; tests?: unknown };
if (typeof b?.host !== 'string' || !(HOST.test(b.host) || isIP(b.host)))
return 'host: a host name';
if (!Array.isArray(b.tests) || !b.tests.length || b.tests.length > MAX_TESTS)
return `tests: 1 to ${MAX_TESTS}`;
const tests: Test[] = [];
for (const t of b.tests as Record<string, unknown>[]) {
if (typeof t?.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(t.id)) return 'tests[].id';
if (typeof t.kind !== 'string' || !KINDS.has(t.kind)) return `${t.id}: kind`;
if (!Number.isInteger(t.port) || (t.port as number) < 1 || (t.port as number) > 65535)
return `${t.id}: port`;
if (t.path !== undefined && (typeof t.path !== 'string' || !/^\/[\w./~-]{0,100}$/.test(t.path)))
return `${t.id}: path`;
if (t.kind === 'udp' && (typeof t.token !== 'string' || !/^[A-Za-z0-9]{8,64}$/.test(t.token)))
return `${t.id}: token`;
tests.push({
id: t.id,
kind: t.kind,
port: t.port,
...(t.path !== undefined && { path: t.path }),
...(t.websocket === true && { websocket: true }),
...(t.kind === 'udp' && { token: t.token }),
} as Test);
}
return { host: b.host, tests };
}
const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
const xff = req.headers['x-forwarded-for'];
const raw =
trustProxy && typeof xff === 'string'
? xff.split(',').at(-1)!.trim()
: req.socket.remoteAddress!;
return raw.replace(/^::ffff:/, '');
};
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => {
size += c.length;
if (size > 8192) req.destroy(new Error('too big'));
else chunks.push(c);
});
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (e) {
reject(e);
}
});
req.on('error', reject);
});
}
export function createProbe(opts: ProbeOptions = {}): Server {
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
// ponytail: in-process memory; addresses past the window are swept on each check.
const seen = new Map<string, number[]>();
const allow = (ip: string, now = Date.now()) => {
for (const [k, v] of seen) if (now - v.at(-1)! >= windowMs) seen.delete(k);
const list = (seen.get(ip) ?? []).filter((t) => now - t < windowMs);
if (list.length >= maxChecks) return false;
seen.set(ip, [...list, now]);
return true;
};
const send = (res: import('node:http').ServerResponse, code: number, body: unknown) =>
res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(body));
return createServer((req, res) => {
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
if (req.method !== 'POST' || req.url !== '/v1/check')
return send(res, 404, { error: 'not found' });
const ip = clientIp(req, trustProxy);
void readJson(req)
.then(async (body) => {
const check = parseCheck(body);
if (typeof check === 'string') return send(res, 400, { error: check });
if (!allow(ip))
return send(res, 429, { error: 'too many checks; try again in 10 minutes' });
const results: Result[] = await Promise.all(
check.tests.map((t) => runTest(ip, check.host, t)),
);
send(res, 200, { ip, results });
})
.catch(() => send(res, 400, { error: 'a JSON body' }));
});
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true, "types": ["node"] },
"include": ["src"]
}
+5
View File
@@ -4,6 +4,8 @@ import type {
AboutDto,
SystemEventDto,
SystemStatus,
DiagnosticsDto,
NetworkTestDto,
QueueReportRow,
ReportRange,
ReportSummary,
@@ -239,6 +241,9 @@ export const systemApi = {
detectPublicIp: () =>
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
tls: () => api<TlsCert>('GET', '/api/system/tls'),
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
networkTest: (mode: 'quick' | 'complete') =>
api<NetworkTestDto>('POST', '/api/system/diagnostics/network', { mode }),
backups: () => api<BackupDto[]>('GET', '/api/system/backups'),
backupNow: () => api<BackupDto>('POST', '/api/system/backups'),
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
+3 -1
View File
@@ -216,6 +216,7 @@ function NetworkStep({ next }: { next: () => void }) {
publicIp={s.data['network.publicIp'] ?? ''}
lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''}
lanIps={status.data.lanIps}
rtpPorts={status.data.ports.rtp}
localNets={s.data['network.localNets'].join(', ')}
/>
);
@@ -226,6 +227,7 @@ function NetworkForm(p: {
publicIp: string;
lanIp: string;
lanIps: string[];
rtpPorts: string;
localNets: string;
}) {
const [publicIp, setPublicIp] = useState(p.publicIp);
@@ -325,7 +327,7 @@ function NetworkForm(p: {
breaks them.
</li>
<li>
Forward <strong>UDP 5060</strong> and <strong>UDP 10000–20000</strong> to this server (
Forward <strong>UDP 5060</strong> and <strong>UDP {p.rtpPorts}</strong> to this server (
{lanIp || 'its LAN IP'}). Behind two routers, do it on both.
</li>
</ol>
+21 -1
View File
@@ -417,13 +417,18 @@ function NetworkTab({ s }: { s: SettingsDto }) {
const save = useSave();
const [ip, setIp] = useState(s['network.publicIp'] ?? '');
const [watch, setWatch] = useState(s['network.publicIpWatch']);
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ 'network.publicIp': ip.trim() || null, 'network.publicIpWatch': watch });
save.mutate({
'network.publicIp': ip.trim() || null,
'network.publicIpWatch': watch,
'network.probeUrl': probe.trim() || null,
});
}}
>
<Field label="Public IP" hint="Changing it needs a telephony restart (see System)">
@@ -449,6 +454,21 @@ function NetworkTab({ s }: { s: SettingsDto }) {
When it changes, the new IP is saved and admins get an email asking for a telephony restart.
Asks api.ipify.org (or ifconfig.me) for the address.
</p>
<Field
label="Network test probe"
hint="A probe running outside this network (apps/probe), for System → Diagnostics. It only ever tests the address that asks it. Empty: no tests from outside."
>
{(p) => (
<input
{...p}
type="url"
placeholder="https://probe.example.com"
className={inputClass}
value={probe}
onChange={(e) => setProbe(e.target.value)}
/>
)}
</Field>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
+104 -3
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System (05 §2): system information, how to restart telephony, and the event log.
// System (05 §2): system information, how to restart telephony, diagnostics, and the event log.
// ponytail: the spec's generator runs, generated files, CLI views and audit log aren't here yet;
// the admin guide's troubleshooting page covers them from the server's shell.
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { Link } from 'react-router';
import type { SystemEventDto } from '@selfphoneng/shared';
import type { DiagnosticCheck, SystemEventDto } from '@selfphoneng/shared';
import { systemApi } from '../../api/client.ts';
import { Alert, Button } from '../../components/ui.tsx';
@@ -77,6 +77,105 @@ function Item({ label, children }: { label: string; children: ReactNode }) {
);
}
const PULSE = 'M22 12h-4l-3 9L9 3l-3 9H2';
const STATUS: Record<DiagnosticCheck['status'], [string, string]> = {
ok: ['OK', 'bg-green-500'],
warn: ['Check this', 'bg-amber-500'],
fail: ['Problem', 'bg-red-500'],
skip: ['Not tested', 'bg-zinc-400'],
};
function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) {
return (
<ul aria-label={label} className="divide-y divide-zinc-100 dark:divide-zinc-800">
{checks.map((c) => (
<li key={c.id} className="flex gap-3 px-4 py-2 text-sm">
<span
aria-hidden
className={`mt-1.5 size-2 shrink-0 rounded-full ${STATUS[c.status][1]}`}
/>
<div className="min-w-0 flex-1">
<p>
<span className="font-medium">{c.label}</span>
<span className="text-zinc-500"> · {STATUS[c.status][0]}</span>
</p>
<p className="break-words text-zinc-600 dark:text-zinc-400">{c.detail}</p>
{c.fix && (
<p className="mt-1 rounded-md bg-amber-50 px-2 py-1 break-words text-amber-900 dark:bg-amber-950/50 dark:text-amber-100">
<strong>How to fix:</strong> {c.fix}
</p>
)}
</div>
</li>
))}
</ul>
);
}
/** Diagnostics (08 §2.6): the server's own checks, and the probe's test from outside. */
function Diagnostics() {
const inside = useQuery({ queryKey: ['diagnostics'], queryFn: systemApi.diagnostics });
const test = useMutation({ mutationFn: systemApi.networkTest });
const hasProbe = inside.data?.checks.some((c) => c.id === 'probe' && c.status === 'ok');
return (
<Panel
title="Diagnostics"
path={PULSE}
action={
<Button onClick={() => void inside.refetch()} disabled={inside.isFetching}>
Check again
</Button>
}
>
<h3 className="px-4 pt-3 text-sm font-medium">This server</h3>
{inside.isPending && <p className="px-4 py-2 text-sm text-zinc-500">Checking…</p>}
{inside.error && <Alert>{inside.error.message}</Alert>}
{inside.data && <Checks label="Server checks" checks={inside.data.checks} />}
<div className="flex flex-col gap-2 border-t border-zinc-200 px-4 py-3 text-sm dark:border-zinc-800">
<h3 className="font-medium">From the internet</h3>
<p className="text-zinc-600 dark:text-zinc-400">
The network test probe connects back to this server from outside, the way a phone on
mobile data or your SIP provider does. Quick: ports 80, 443 and SIP. Complete: also SIP
over TLS, the browser phone and the call audio ports.
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
onClick={() => test.mutate('quick')}
disabled={!hasProbe || test.isPending}
>
Quick network test
</Button>
<Button onClick={() => test.mutate('complete')} disabled={!hasProbe || test.isPending}>
Complete network test
</Button>
</div>
{inside.data && !hasProbe && (
<p className="text-xs text-zinc-500">
Set a probe first:{' '}
<Link to="/admin/settings/network" className="text-sky-700 underline dark:text-sky-400">
Settings → Network
</Link>
.
</p>
)}
{test.isPending && (
<p role="status" className="text-zinc-500">
Testing… (up to half a minute)
</p>
)}
{test.error && <Alert>{test.error.message}</Alert>}
</div>
{test.data && (
<Checks
label={`${test.data.mode === 'quick' ? 'Quick' : 'Complete'} network test`}
checks={test.data.checks}
/>
)}
</Panel>
);
}
const SEVERITY: Record<SystemEventDto['severity'], [string, string]> = {
info: ['Info', 'bg-sky-500'],
warning: ['Warning', 'bg-amber-500'],
@@ -181,6 +280,8 @@ export function SystemPage() {
</div>
</Panel>
<Diagnostics />
<Panel
title="Event log"
path={LIST}
+22
View File
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# The network test probe (08 §2.6). Run it on a machine OUTSIDE the network SelfPhoneNG is in (a
# small VPS is plenty), then put its URL in SelfPhoneNG: Settings → Network → Network test probe.
# It only ever tests the address that asks it, a few times per 10 minutes per address.
#
# docker compose -f deploy/probe/compose.yml up -d → http://<this machine>:8080
#
# Host networking, so it sees the real address that asks (Docker's published ports can hide it).
# Behind your own HTTPS reverse proxy instead: set PORT to a local port, TRUST_PROXY=true, and have
# the proxy set X-Forwarded-For. The SIP checks connect out from here, so no other ports are needed.
services:
probe:
# Built by each release, like the api and asterisk images (pick your registry and version).
image: ghcr.io/iamdoubz/selfphoneng-probe:latest
restart: unless-stopped
network_mode: host
environment:
PORT: '8080'
TRUST_PROXY: 'false'
logging:
driver: json-file
options: { max-size: 10m, max-file: '3' }
+1 -1
View File
@@ -35,7 +35,7 @@ server is behind one; see [Network](network.md)):
| 80 | TCP | Only with the bundled Caddy: getting the HTTPS certificate |
| 5060 | UDP (+TCP) | SIP: voip.ms and desk phones |
| 5061 | TCP | Only if you turn on SIP over TLS |
| 10000–20000 | UDP | Call audio (RTP) |
| 10000–20000 | UDP | Call audio (RTP); another range with `RTP_PORT_START`/`RTP_PORT_END` ([Network](network.md#call-audio-ports)) |
Everything else (the database, the api on 8080, Asterisk's 5038 and 8088) listens on
127.0.0.1 only. Don't open them.
+54 -2
View File
@@ -15,7 +15,7 @@ the wrong place.
"<router model> disable SIP ALG".
2. **Forward ports** to the server's LAN IP:
- UDP 5060 (SIP);
- UDP 10000–20000 (audio);
- UDP 10000–20000 (audio; see [Call audio ports](#call-audio-ports) to use another range);
- TCP 443 (and 80 with the bundled Caddy);
- TCP 5061 only if you use SIP over TLS.
3. **Double NAT** (the ISP's box in front of your own router): forward the ports on both, or put
@@ -34,6 +34,58 @@ the wrong place.
![Settings → Network](img/settings-network.png)
## Call audio ports
Call audio (RTP) uses UDP **10000–20000** unless you set another range in `.env` (or, in a
Portainer stack, in the api's environment):
```sh
RTP_PORT_START=40000
RTP_PORT_END=40199
```
Then `docker compose up -d` (the api writes the range into Asterisk's config), and open **the same
range** on the firewall and the router instead of 10000–20000. Each call uses two ports, so 200
ports is plenty for a small office. Pick a range nothing else on the server uses: System →
Diagnostics counts how many ports in the range are already taken.
## Diagnostics: is the server reachable?
**System → Diagnostics** checks what usually stops calls from working outside the office, and
says how to fix each problem it finds.
**This server** runs every time you open the page:
- the public IP is set, and is the address the server really reaches the internet from;
- the host name's DNS record points at that address (with Cloudflare, it must be "DNS only": its
proxy carries web pages, not SIP);
- the server's LAN address is in the local networks;
- whether phones may sign in from anywhere (the phone network limit refuses phone apps on mobile
data), and whether phone setup is limited to the local networks;
- Asterisk is listening on the SIP ports;
- how many call audio ports are free.
**From the internet** needs a **network test probe**: a small program, run on a machine outside
your network, that connects back to the server the way a phone on mobile data or your SIP provider
does. A server can't test this itself: most routers either don't route a connection to their own
public address back in, or route it without going through the port forwards.
- **Quick network test**: TCP 80 and 443, and SIP on 5060 over UDP (and TCP if it's on).
- **Complete network test**: also SIP over TLS (if on), the browser phone's `/ws`, and a sample
of the call audio ports (the server listens on them while the probe sends to them).
To run a probe, on any machine outside your network (a small VPS is plenty):
```sh
docker compose -f deploy/probe/compose.yml up -d
```
Then put its address, for example `http://203.0.113.50:8080`, in **Settings → Network → Network
test probe**. The probe only ever tests the address a request comes from, so nobody can use it
against someone else, and each address gets a few tests per 10 minutes. Its SIP test is one
OPTIONS request; if you run fail2ban or CrowdSec, add the probe's address to their allow list so a
test never counts toward a ban.
## Firewall
Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the
@@ -46,7 +98,7 @@ ufw allow OpenSSH
ufw allow 443/tcp
ufw allow 80/tcp # only with the bundled Caddy
ufw allow 5060/udp
ufw allow 10000:20000/udp
ufw allow 10000:20000/udp # or your RTP_PORT_START:RTP_PORT_END
ufw enable
```
+16 -1
View File
@@ -49,11 +49,26 @@ The call connects but one side (or both) hears nothing. It's almost always NAT
1. **SIP ALG** must be off on every router in the path. This is the first suspect.
2. **Public IP:** Settings → Network must show the server's current public IP. If it changed,
update it and restart telephony (`docker compose restart asterisk`).
3. **Audio ports:** UDP 10000–20000 must be forwarded to the server and open on its firewall.
3. **Audio ports:** UDP 10000–20000 (or your `RTP_PORT_START`–`RTP_PORT_END`) must be forwarded to the server and open on its firewall. **System → Diagnostics → Complete network test** checks it from outside.
4. **Local networks** (setup wizard, Network step) must list your office networks, and only them.
5. **Calls drop after about 30 seconds:** same causes. The answer never reaches the other side,
so it gives up.
## Phones work on the office Wi-Fi, not on mobile data
A phone app (Linphone) registers and calls on the office network, but not elsewhere. Open
**System → Diagnostics**: it checks each of these and says what to change.
1. **The phone network limit** (Settings → Security, "Only accept desk phones from the networks
below") refuses phones outside the local networks. Turn it off, or add the networks they use.
2. **Public IP** (Settings → Network) must be set: otherwise phones outside are given the
server's private address.
3. **DNS**: the phone finds the server by its host name, which must point at the public IP. Behind
Cloudflare's proxy (orange cloud) web pages work but SIP doesn't: set it to "DNS only".
4. **Port forwards**: UDP 5060 and the audio ports forwarded on the router, as well as open on the
server's firewall. On the office Wi-Fi the phone reaches the server directly, so missing
forwards only show up outside. The **Quick network test** checks 5060 from outside.
## The trunk won't register
Trunks shows **Rejected** or **Not registered**.
+4 -1
View File
@@ -382,7 +382,10 @@ rtpend = 20000
#include selfphoneng/rtp_selfphoneng.conf
```
(The generator writes `icesupport`, `stunaddr` and `[ice_host_candidates]` into
`rtp_selfphoneng.conf`, see `03-config-generator.md` §4.)
`rtp_selfphoneng.conf`, see `03-config-generator.md` §4. Since 2026-10-21 it also writes
`rtpstart`/`rtpend` there from the api's `RTP_PORT_START`/`RTP_PORT_END`: Asterisk takes a
setting's last value, so they win over the defaults above, which only cover the first boot.
`call-test.ts` checks `rtp show settings` with 31000–31200.)
### 3.7 `pjsip.conf`
```ini
+1 -1
View File
@@ -48,7 +48,7 @@ Left sidebar navigation, in this order:
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
### 2.1 First-run setup wizard (`/setup`)
+23
View File
@@ -95,6 +95,29 @@ get the proxy's address only (below). The setting is on Settings → Security, w
- `.env` mode 0600 (install.sh sets it).
- Recordings and voicemail are served only through authenticated api routes, never as static files.
### 2.6 Network diagnostics (owner, 2026-10-21)
System → Diagnostics (`services/diagnostics.ts`, admin only). Every failed or doubtful line says
how to fix it.
- **This server** (`GET /api/system/diagnostics`): public IP set and equal to what
api.ipify.org sees; `PUBLIC_HOSTNAME`'s A records include it (Cloudflare proxy named in the fix);
`network.lanIp` inside `network.localNets`; the phone ACL (§2.2) and `provisioning.lanOnly`
flagged as limits on phones elsewhere; `pjsip show transports` has the enabled transports; the
RTP range's even ports bound once each, a warning past a quarter taken; whether a probe is set.
- **From the internet** (`POST /api/system/diagnostics/network {mode}`) needs the probe,
`apps/probe` (no dependencies, its own image `selfphoneng-probe`, `deploy/probe/compose.yml`),
run outside the network; `network.probeUrl` points at it. A server can't test its own ports:
hairpin NAT is missing or skips the port forwards, and browsers refuse port 5060.
- `POST /v1/check {host, tests}`: the probe tests **only the address the request came from** (the
last X-Forwarded-For entry with `TRUST_PROXY=true`), so it can't be aimed at anyone else; 20
tests at most, 10 checks per address per 10 minutes, 4 s per test.
- Quick: HTTP 80, HTTPS 443 `/healthz` (status and certificate), SIP OPTIONS over UDP (and TCP
when on) to `sip.udpPort`. Complete adds SIP over TLS (when on), a WebSocket upgrade on `/ws`
(protocol `sip`), and up to four even ports of the RTP range: the api binds them and the probe
sends a token to each.
- The answer's `ip` is checked too: a private one means the probe is inside the network; another
public one than `network.publicIp` means a VPN or a changed address.
- The RTP range comes from `RTP_PORT_START`/`RTP_PORT_END` (api env, default 10000–20000; 01 §3.6).
## 3. Operations
### 3.1 Install / update
+25
View File
@@ -20,6 +20,11 @@ export const SettingsSchemas = {
.default(['192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12']),
'network.lanIp': z.ipv4().nullable().default(null),
'network.publicIpWatch': z.boolean().default(false),
/** The network test probe (apps/probe), outside this network; null: no outside tests (08 §2.6). */
'network.probeUrl': z
.url({ protocol: /^https?$/ })
.nullable()
.default(null),
'network.stunServer': z
.string()
.regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/)
@@ -128,6 +133,26 @@ export type SystemStatus = {
restartRequired: boolean;
};
/** One line of System → Diagnostics (08 §2.6). `fix` says what to do when it isn't ok. */
export type DiagnosticCheck = {
id: string;
label: string;
status: 'ok' | 'warn' | 'fail' | 'skip';
detail: string;
fix?: string;
};
/** GET /api/system/diagnostics: what the server can check from the inside. */
export type DiagnosticsDto = { checks: DiagnosticCheck[] };
/** POST /api/system/diagnostics/network {mode}: the probe's outside view of the server. */
export type NetworkTestDto = {
mode: 'quick' | 'complete';
/** The address the probe tested: the one this server reaches the internet from. */
testedIp: string | null;
checks: DiagnosticCheck[];
};
/** GET /api/system/events: the System page's event log, newest first. */
export type SystemEventDto = {
id: number;
+2
View File
@@ -88,6 +88,8 @@ importers:
specifier: ^0.31.11
version: 0.31.11
apps/probe: {}
apps/web:
dependencies:
'@hookform/resolvers':
+6
View File
@@ -270,3 +270,9 @@ warning is gone. System information shows the IP and ports, the event log and it
in, the login page's link opens About, whose "Code v<version>" downloads `selfphone.tar.gz` byte for
byte as built: the runner builds the api image and copies its `VERSION` and archive into the api's
working folder (5th argument). About 6 minutes.
`m11-diagnostics.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>` (after `m1-t4.mjs`, on a
database without `network.probeUrl`) checks System → Diagnostics: the server's checks show, the
network tests wait for a probe, then it sets one in Settings → Network (a probe it starts on
127.0.0.1:18090, so the results also warn that it's inside the network) and runs a quick and a
complete test. Asterisk isn't needed.
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System → Diagnostics (08 §2.6) in a browser: the server's own checks show, the network tests
// stay off until a probe is set in Settings → Network, then a quick and a complete test run
// against a probe started here (on 127.0.0.1, so it also flags that it's inside the network).
// Runs after m1-t4.mjs; Asterisk isn't needed (the SIP rows then show how to fix them).
// Usage: node scripts/e2e/m11-diagnostics.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { launch } from './cdp.mjs';
const { createProbe } = await import('../../apps/probe/src/server.ts');
const [base, email, pw, shotsDir] = process.argv.slice(2);
const probe = createProbe();
await new Promise((r) => probe.listen(18090, '127.0.0.1', r));
const p = await launch({ shotsDir });
const text = (s) => `document.body.innerText.includes(${JSON.stringify(s)})`;
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`, 'signed in');
await p.goto(`${base}/admin/system`);
await p.waitFor(`!!document.querySelector('ul[aria-label="Server checks"]')`, 'server checks');
for (const label of ['Public IP', 'DNS for', 'Where phones can sign in from', 'Call audio ports'])
await p.waitFor(text(label), label);
await p.waitFor(text('Set a probe first'), 'no probe yet');
assert.equal(
await p.evaluate(
`[...document.querySelectorAll('button')].find(b => b.textContent === 'Quick network test').disabled`,
),
true,
);
await p.evaluate(`document.querySelector('ul[aria-label="Server checks"]').scrollIntoView()`);
await p.shot('m11-diag-01-server-checks');
console.log('server checks shown; network tests wait for a probe');
await p.goto(`${base}/admin/settings/network`);
await p.waitFor(text('Network test probe'), 'network settings');
await p.fill('Network test probe', 'http://127.0.0.1:18090');
await p.click('Save');
await p.waitFor(text('Saved.'), 'saved');
await p.goto(`${base}/admin/system`);
await p.waitFor(text('Network test probe'), 'probe row');
await p.waitFor(
`[...document.querySelectorAll('button')].find(b => b.textContent === 'Quick network test')?.disabled === false`,
'quick enabled',
);
await p.click('Quick network test');
await p.waitFor(
`!!document.querySelector('ul[aria-label="Quick network test"]')`,
'quick results',
40_000,
);
for (const label of ['Tested address', 'HTTPS (TCP 443)', 'SIP (UDP 5060)', 'How to fix'])
await p.waitFor(text(label), label);
await p.waitFor(text('a private address'), 'inside-the-network warning');
await p.evaluate(
`document.querySelector('ul[aria-label="Quick network test"]').scrollIntoView()`,
);
await p.shot('m11-diag-02-quick');
console.log('quick test: results with fixes');
await p.click('Complete network test');
await p.waitFor(
`!!document.querySelector('ul[aria-label="Complete network test"]')`,
'complete results',
40_000,
);
await p.waitFor(text('Browser phone (/ws)'), '/ws row');
await p.waitFor(text('Call audio (UDP'), 'audio row');
await p.evaluate(
`document.querySelector('ul[aria-label="Complete network test"]').scrollIntoView()`,
);
await p.shot('m11-diag-03-complete');
console.log('complete test: /ws and call audio too');
} finally {
await p.close();
probe.close();
}