diff --git a/.env.example b/.env.example
index 7e2c1b5..cae70ea 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5c64b72..b17e0ca 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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//selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN)
-# Gitea: //selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_TOKEN:
+# GitHub: ghcr.io//selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
+# Gitea: //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 . 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}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61e0a46..c2a274d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index f0c9ede..42e8262 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -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;
+ ami: Pick;
}
}
diff --git a/apps/api/src/config.test.ts b/apps/api/src/config.test.ts
index 7da3d43..0f8b2b0 100644
--- a/apps/api/src/config.test.ts
+++ b/apps/api/src/config.test.ts
@@ -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/);
});
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index bbbc43c..59421f6 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -1,43 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
-const EnvSchema = z.object({
- APP_SECRET: z
- .string()
- .refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
- PUBLIC_HOSTNAME: z.string().min(1),
- DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
- INITIAL_ADMIN_EMAIL: z.email(),
- INITIAL_ADMIN_PASSWORD: z.string().min(1),
- AMI_HOST: z.string().default('127.0.0.1'),
- AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
- AMI_USERNAME: z.string().default('selfphoneng'),
- AMI_SECRET: z.string().min(8),
- /** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
- ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
- /** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
- VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
- /** Uploaded prompts, played as custom/ (04 §6). Dev: ../../data/sounds-custom */
- SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
- /** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
- MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
- /** Backups (04 §8.1). Dev: ../../data/backups */
- BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'),
- /** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
- TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
- /** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
- VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
- /** MixMonitor's directory; recordings are YYYY/MM/DD/.wav under it (03 sp-sub-record). */
- RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'),
- /** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
- WEBRTC_WS_URL: z.url().optional(),
- 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),
- /** 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'),
-});
+const EnvSchema = z
+ .object({
+ APP_SECRET: z
+ .string()
+ .refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
+ PUBLIC_HOSTNAME: z.string().min(1),
+ DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
+ INITIAL_ADMIN_EMAIL: z.email(),
+ INITIAL_ADMIN_PASSWORD: z.string().min(1),
+ AMI_HOST: z.string().default('127.0.0.1'),
+ AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
+ AMI_USERNAME: z.string().default('selfphoneng'),
+ AMI_SECRET: z.string().min(8),
+ /** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
+ ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
+ /** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
+ VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
+ /** Uploaded prompts, played as custom/ (04 §6). Dev: ../../data/sounds-custom */
+ SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
+ /** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
+ MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
+ /** Backups (04 §8.1). Dev: ../../data/backups */
+ BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'),
+ /** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
+ TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
+ /** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
+ VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
+ /** MixMonitor's directory; recordings are YYYY/MM/DD/.wav under it (03 sp-sub-record). */
+ RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'),
+ /** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
+ WEBRTC_WS_URL: z.url().optional(),
+ 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;
diff --git a/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf
index 9f7edc2..1a1f546 100644
--- a/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf
@@ -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
diff --git a/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf
index 3ef2826..19031cd 100644
--- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
+rtpstart = 10000
+rtpend = 20000
icesupport = yes
diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts
index 1a3d850..38fbd64 100644
--- a/apps/api/src/generator/index.ts
+++ b/apps/api/src/generator/index.ts
@@ -101,6 +101,8 @@ export type GeneratorDeps = {
log: Pick;
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();
diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts
index 9947ae1..153513a 100644
--- a/apps/api/src/generator/load.ts
+++ b/apps/api/src/generator/load.ts
@@ -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);
diff --git a/apps/api/src/generator/pjsip.ts b/apps/api/src/generator/pjsip.ts
index ffabd10..eb49f32 100644
--- a/apps/api/src/generator/pjsip.ts
+++ b/apps/api/src/generator/pjsip.ts
@@ -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(
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 1820100..12064d5 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -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', {
diff --git a/apps/api/src/routes/system.ts b/apps/api/src/routes/system.ts
index 50803d0..a5fc66b 100644
--- a/apps/api/src/routes/system.ts
+++ b/apps/api/src/routes/system.ts
@@ -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 => ({
+ checks: await insideChecks(diag()),
+ }));
+ app.post('/api/system/diagnostics/network', async (req): Promise => {
+ 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) => ({
diff --git a/apps/api/src/services/diagnostics.ts b/apps/api/src/services/diagnostics.ts
new file mode 100644
index 0000000..b63c98f
--- /dev/null
+++ b/apps/api/src/services/diagnostics.ts
@@ -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;
+ ami: Pick;
+ /** Overridable in tests. */
+ lookupPublicIp?: () => Promise;
+ resolveHost?: (host: string) => Promise;
+ 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 {
+ 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((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 {
+ 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 {
+ 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[] = [
+ { 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((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 };
+}
diff --git a/apps/api/test/asterisk/call-test.ts b/apps/api/test/asterisk/call-test.ts
index f74464a..312eae6 100644
--- a/apps/api/test/asterisk/call-test.ts
+++ b/apps/api/test/asterisk/call-test.ts
@@ -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',
diff --git a/apps/api/test/diagnostics.test.ts b/apps/api/test/diagnostics.test.ts
new file mode 100644
index 0000000..b98a84e
--- /dev/null
+++ b/apps/api/test/diagnostics.test.ts
@@ -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>;
+ 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>((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((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,
+ );
+ });
+});
diff --git a/apps/probe/Dockerfile b/apps/probe/Dockerfile
new file mode 100644
index 0000000..f706db2
--- /dev/null
+++ b/apps/probe/Dockerfile
@@ -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"]
diff --git a/apps/probe/package.json b/apps/probe/package.json
new file mode 100644
index 0000000..95726e7
--- /dev/null
+++ b/apps/probe/package.json
@@ -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"
+ }
+}
diff --git a/apps/probe/src/checks.ts b/apps/probe/src/checks.ts
new file mode 100644
index 0000000..e61dee1
--- /dev/null
+++ b/apps/probe/src/checks.ts
@@ -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: ;tag=${rand()}`,
+ `To: `,
+ `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 {
+ 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 {
+ 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 {
+ 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((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,
+ );
+ }
+}
diff --git a/apps/probe/src/index.ts b/apps/probe/src/index.ts
new file mode 100644
index 0000000..47654d9
--- /dev/null
+++ b/apps/probe/src/index.ts
@@ -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)));
diff --git a/apps/probe/src/probe.test.ts b/apps/probe/src/probe.test.ts
new file mode 100644
index 0000000..6bd2252
--- /dev/null
+++ b/apps/probe/src/probe.test.ts
@@ -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 = (s: T) =>
+ new Promise((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((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();
+ });
+});
diff --git a/apps/probe/src/server.ts b/apps/probe/src/server.ts
new file mode 100644
index 0000000..39b7cf6
--- /dev/null
+++ b/apps/probe/src/server.ts
@@ -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[]) {
+ 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 {
+ 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();
+ 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' }));
+ });
+}
diff --git a/apps/probe/tsconfig.json b/apps/probe/tsconfig.json
new file mode 100644
index 0000000..2322097
--- /dev/null
+++ b/apps/probe/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "noEmit": true, "types": ["node"] },
+ "include": ["src"]
+}
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index c9b2740..c9c0fe7 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -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('GET', '/api/system/tls'),
+ diagnostics: () => api('GET', '/api/system/diagnostics'),
+ networkTest: (mode: 'quick' | 'complete') =>
+ api('POST', '/api/system/diagnostics/network', { mode }),
backups: () => api('GET', '/api/system/backups'),
backupNow: () => api('POST', '/api/system/backups'),
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx
index 3260c19..a158edd 100644
--- a/apps/web/src/pages/SetupPage.tsx
+++ b/apps/web/src/pages/SetupPage.tsx
@@ -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.
- Forward UDP 5060 and UDP 10000–20000 to this server (
+ Forward UDP 5060 and UDP {p.rtpPorts} to this server (
{lanIp || 'its LAN IP'}). Behind two routers, do it on both.
+ 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.
+
+
+
+
+
+ {inside.data && !hasProbe && (
+
+ Set a probe first:{' '}
+
+ Settings → Network
+
+ .
+
+
+
: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' }
diff --git a/docs/admin-guide/README.md b/docs/admin-guide/README.md
index d308ed7..2bf7032 100644
--- a/docs/admin-guide/README.md
+++ b/docs/admin-guide/README.md
@@ -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.
diff --git a/docs/admin-guide/network.md b/docs/admin-guide/network.md
index acf387d..042ba46 100644
--- a/docs/admin-guide/network.md
+++ b/docs/admin-guide/network.md
@@ -15,7 +15,7 @@ the wrong place.
" 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.

+## 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
```
diff --git a/docs/admin-guide/troubleshooting.md b/docs/admin-guide/troubleshooting.md
index 0534a4f..0836d91 100644
--- a/docs/admin-guide/troubleshooting.md
+++ b/docs/admin-guide/troubleshooting.md
@@ -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**.
diff --git a/docs/spec/01-infrastructure.md b/docs/spec/01-infrastructure.md
index 0855c5a..f59aca5 100644
--- a/docs/spec/01-infrastructure.md
+++ b/docs/spec/01-infrastructure.md
@@ -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
diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md
index 892b97a..c88e6a2 100644
--- a/docs/spec/05-web-ui.md
+++ b/docs/spec/05-web-ui.md
@@ -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`)
diff --git a/docs/spec/08-security-ops.md b/docs/spec/08-security-ops.md
index c67b27d..b3d0f3f 100644
--- a/docs/spec/08-security-ops.md
+++ b/docs/spec/08-security-ops.md
@@ -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
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index f27ec40..fe63f67 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -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;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6d39e5..44156ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -88,6 +88,8 @@ importers:
specifier: ^0.31.11
version: 0.31.11
+ apps/probe: {}
+
apps/web:
dependencies:
'@hookform/resolvers':
diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md
index 326c32b..ec3440a 100644
--- a/scripts/e2e/README.md
+++ b/scripts/e2e/README.md
@@ -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" 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 ` (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.
diff --git a/scripts/e2e/m11-diagnostics.mjs b/scripts/e2e/m11-diagnostics.mjs
new file mode 100644
index 0000000..e357307
--- /dev/null
+++ b/scripts/e2e/m11-diagnostics.mjs
@@ -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
+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();
+}