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
+1704 -59

No files matched your search

+2
View File
@@ -14,3 +14,5 @@ APP_UID=1000
APP_GID=1000 APP_GID=1000
LOG_LEVEL=info LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx 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 # 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 # 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: # Gitea Actions (both read .github/workflows); each pushes to its own registry:
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN) # GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_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) # 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. # Tags: the version (0.0.1), plus <major>.<minor> and latest; a pre-release gets the version and dev.
name: release name: release
@@ -79,6 +79,7 @@ jobs:
) )
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" . docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" .
- name: Check - name: Check
shell: bash shell: bash
run: | run: |
@@ -88,15 +89,16 @@ jobs:
docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')" 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 }}" 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 --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 - name: Push
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
p="${{ steps.v.outputs.prefix }}" 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 for t in ${{ steps.v.outputs.tags }}; do
docker tag "$p-$image:build" "$p-$image:$t" docker tag "$p-$image:build" "$p-$image:$t"
docker push "$p-$image:$t" docker push "$p-$image:$t"
done done
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 ## 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 - 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 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 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. */ /** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator; generator: Generator;
/** Decorated by the caller too (tests pass a fake). */ /** 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: undefined })).toThrow(ConfigError);
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/); 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', () => { it('rejects a non-postgres DATABASE_URL', () => {
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/); expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
}); });
+45 -37
View File
@@ -1,43 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod'; import { z } from 'zod';
const EnvSchema = z.object({ const EnvSchema = z
APP_SECRET: z .object({
.string() APP_SECRET: z
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'), .string()
PUBLIC_HOSTNAME: z.string().min(1), .refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'), PUBLIC_HOSTNAME: z.string().min(1),
INITIAL_ADMIN_EMAIL: z.email(), DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
INITIAL_ADMIN_PASSWORD: z.string().min(1), INITIAL_ADMIN_EMAIL: z.email(),
AMI_HOST: z.string().default('127.0.0.1'), INITIAL_ADMIN_PASSWORD: z.string().min(1),
AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038), AMI_HOST: z.string().default('127.0.0.1'),
AMI_USERNAME: z.string().default('selfphoneng'), AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
AMI_SECRET: z.string().min(8), AMI_USERNAME: z.string().default('selfphoneng'),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */ AMI_SECRET: z.string().min(8),
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'), /** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */ ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'), /** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
/** Uploaded prompts, played as custom/<id> (04 §6). Dev: ../../data/sounds-custom */ VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'), /** Uploaded prompts, played as custom/<id> (04 §6). Dev: ../../data/sounds-custom */
/** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */ SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
MOH_DIR: z.string().default('/var/lib/asterisk/moh'), /** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
/** Backups (04 §8.1). Dev: ../../data/backups */ MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'), /** Backups (04 §8.1). Dev: ../../data/backups */
/** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */ BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'),
TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'), /** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
/** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */ TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'), /** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
/** MixMonitor's directory; recordings are YYYY/MM/DD/<uniqueid>.wav under it (03 sp-sub-record). */ VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'), /** MixMonitor's directory; recordings are YYYY/MM/DD/<uniqueid>.wav under it (03 sp-sub-record). */
/** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */ RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'),
WEBRTC_WS_URL: z.url().optional(), /** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080), WEBRTC_WS_URL: z.url().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
TRUST_PROXY: z.stringbool().default(true), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
/** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */ TRUST_PROXY: z.stringbool().default(true),
SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false), /** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
TZ: z.string().default('America/Chicago'), 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>; export type Config = z.infer<typeof EnvSchema>;
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,4 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
stunaddr = stun.example.org:3478 stunaddr = stun.example.org:3478
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes icesupport = yes
+3 -1
View File
@@ -101,6 +101,8 @@ export type GeneratorDeps = {
log: Pick<FastifyBaseLogger, 'info' | 'error'>; log: Pick<FastifyBaseLogger, 'info' | 'error'>;
onApplied?: (r: ApplyResult) => void; onApplied?: (r: ApplyResult) => void;
debounceMs?: number; debounceMs?: number;
/** RTP_PORT_START/END (config.ts). */
rtpPorts?: { start: number; end: number };
}; };
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -165,7 +167,7 @@ export function createGenerator(deps: GeneratorDeps) {
restartRequired: false, restartRequired: false,
}; };
try { try {
const snap = await loadSnapshot(db); const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
const files = render(snap); const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true }); await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map<GeneratedFileName, string | null>(); const current = new Map<GeneratedFileName, string | null>();
+4
View File
@@ -203,8 +203,12 @@ export type Snapshot = {
mohClasses: string[]; mohClasses: string[];
/** Played before recording a call that a phone number records (Settings → Recording). */ /** Played before recording a call that a phone number records (Settings → Recording). */
recordingAnnouncement: string | null; 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 { export function todayIn(timezone: string, now = new Date()): string {
// en-CA formats as YYYY-MM-DD. // en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now); 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 // SPDX-License-Identifier: AGPL-3.0-or-later
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4). // pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
import { CLASS_RANK } from '@selfphoneng/shared'; 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'; import * as s from './sanitize.ts';
const section = (title: string, lines: (string | false | null | undefined)[]) => 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 { export function renderRtp(snap: Snapshot): string {
const st = snap.settings; 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.stunServer']) lines.push(`stunaddr = ${hostPort(st['network.stunServer'])}`);
if (st['network.lanIp'] && st['network.publicIp']) if (st['network.lanIp'] && st['network.publicIp'])
lines.push( lines.push(
+1
View File
@@ -46,6 +46,7 @@ const generator = createGenerator({
db, db,
ami, ami,
dir: resolve(config.ASTERISK_CONFIG_DIR), dir: resolve(config.ASTERISK_CONFIG_DIR),
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
log: app.log, log: app.log,
onApplied: (r) => { onApplied: (r) => {
live.emit('message', { live.emit('message', {
+24 -3
View File
@@ -5,7 +5,13 @@ import { networkInterfaces } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { desc, lt } from 'drizzle-orm'; import { desc, lt } from 'drizzle-orm';
import { z } from 'zod'; 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 type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts'; import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts'; import { audit } from '../services/audit.ts';
@@ -14,7 +20,8 @@ import { systemEvents } from '../db/schema.ts';
import { appVersion } from './about.ts'; import { appVersion } from './about.ts';
import { csvCell } from './calls.ts'; import { csvCell } from './calls.ts';
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.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 { AmiDisconnectedError } from '../ami/client.ts';
import { GENERATED_FILES } from '../generator/index.ts'; import { GENERATED_FILES } from '../generator/index.ts';
@@ -67,7 +74,7 @@ export async function systemRoutes(app: FastifyInstance) {
sip: s['sip.udpPort'], sip: s['sip.udpPort'],
sipTcp: s['sip.tcpEnabled'], sipTcp: s['sip.tcpEnabled'],
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null, 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, https: 443,
}, },
ami: { connected: app.ami.isConnected() }, ami: { connected: app.ami.isConnected() },
@@ -163,6 +170,20 @@ export async function systemRoutes(app: FastifyInstance) {
return reply.sendFile(name, dir); 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 () => ({ app.get('/api/system/generated-files', async () => ({
files: await Promise.all( files: await Promise.all(
GENERATED_FILES.map(async (name) => ({ 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 AST = 'sp-test-ast';
const PHONES = 'sp-test-phones'; const PHONES = 'sp-test-phones';
const PN_PHONE = 'sp-test-pn-phone'; const PN_PHONE = 'sp-test-pn-phone';
const RTP_PORTS = { start: 31000, end: 31200 };
const AMI_PORT = 15038; const AMI_PORT = 15038;
const AMI_SECRET = 'call-test-secret'; const AMI_SECRET = 'call-test-secret';
const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' }; const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' };
@@ -79,6 +80,8 @@ function snapshot(): Snapshot {
announcements: [], announcements: [],
recordingAnnouncement: null, recordingAnnouncement: null,
extensions: [ext('101', 'Alice'), ext('102', 'Bob')], 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(); ami.start();
await new Promise((r) => ami.once('connected', r)); await new Promise((r) => ami.once('connected', r));
log('AMI connected from the host'); 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) { if (RECORD) {
// A second, raw AMI session so the fixture holds exactly what Asterisk sent. // A second, raw AMI session so the fixture holds exactly what Asterisk sent.
recorder = new Socket(); recorder = new Socket();
@@ -237,6 +247,27 @@ async function main() {
).out.trim(); ).out.trim();
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`); 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'); 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. // 101 also on an iOS Linphone: '&' in its push params used to break Dial for every device.
const pn = docker( const pn = docker(
'run', '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, AboutDto,
SystemEventDto, SystemEventDto,
SystemStatus, SystemStatus,
DiagnosticsDto,
NetworkTestDto,
QueueReportRow, QueueReportRow,
ReportRange, ReportRange,
ReportSummary, ReportSummary,
@@ -239,6 +241,9 @@ export const systemApi = {
detectPublicIp: () => detectPublicIp: () =>
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip), api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
tls: () => api<TlsCert>('GET', '/api/system/tls'), 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'), backups: () => api<BackupDto[]>('GET', '/api/system/backups'),
backupNow: () => api<BackupDto>('POST', '/api/system/backups'), backupNow: () => api<BackupDto>('POST', '/api/system/backups'),
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'), 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'] ?? ''} publicIp={s.data['network.publicIp'] ?? ''}
lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''} lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''}
lanIps={status.data.lanIps} lanIps={status.data.lanIps}
rtpPorts={status.data.ports.rtp}
localNets={s.data['network.localNets'].join(', ')} localNets={s.data['network.localNets'].join(', ')}
/> />
); );
@@ -226,6 +227,7 @@ function NetworkForm(p: {
publicIp: string; publicIp: string;
lanIp: string; lanIp: string;
lanIps: string[]; lanIps: string[];
rtpPorts: string;
localNets: string; localNets: string;
}) { }) {
const [publicIp, setPublicIp] = useState(p.publicIp); const [publicIp, setPublicIp] = useState(p.publicIp);
@@ -325,7 +327,7 @@ function NetworkForm(p: {
breaks them. breaks them.
</li> </li>
<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. {lanIp || 'its LAN IP'}). Behind two routers, do it on both.
</li> </li>
</ol> </ol>
+21 -1
View File
@@ -417,13 +417,18 @@ function NetworkTab({ s }: { s: SettingsDto }) {
const save = useSave(); const save = useSave();
const [ip, setIp] = useState(s['network.publicIp'] ?? ''); const [ip, setIp] = useState(s['network.publicIp'] ?? '');
const [watch, setWatch] = useState(s['network.publicIpWatch']); const [watch, setWatch] = useState(s['network.publicIpWatch']);
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp }); const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
return ( return (
<form <form
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); 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)"> <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. 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. Asks api.ipify.org (or ifconfig.me) for the address.
</p> </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"> <div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}> <Button type="submit" variant="primary" disabled={save.isPending}>
Save Save
+104 -3
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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; // 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. // 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 { useState, type ReactNode } from 'react';
import { Link } from 'react-router'; 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 { systemApi } from '../../api/client.ts';
import { Alert, Button } from '../../components/ui.tsx'; 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]> = { const SEVERITY: Record<SystemEventDto['severity'], [string, string]> = {
info: ['Info', 'bg-sky-500'], info: ['Info', 'bg-sky-500'],
warning: ['Warning', 'bg-amber-500'], warning: ['Warning', 'bg-amber-500'],
@@ -181,6 +280,8 @@ export function SystemPage() {
</div> </div>
</Panel> </Panel>
<Diagnostics />
<Panel <Panel
title="Event log" title="Event log"
path={LIST} 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 | | 80 | TCP | Only with the bundled Caddy: getting the HTTPS certificate |
| 5060 | UDP (+TCP) | SIP: voip.ms and desk phones | | 5060 | UDP (+TCP) | SIP: voip.ms and desk phones |
| 5061 | TCP | Only if you turn on SIP over TLS | | 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 Everything else (the database, the api on 8080, Asterisk's 5038 and 8088) listens on
127.0.0.1 only. Don't open them. 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". "<router model> disable SIP ALG".
2. **Forward ports** to the server's LAN IP: 2. **Forward ports** to the server's LAN IP:
- UDP 5060 (SIP); - 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 443 (and 80 with the bundled Caddy);
- TCP 5061 only if you use SIP over TLS. - 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 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) ![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 ## Firewall
Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the 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 443/tcp
ufw allow 80/tcp # only with the bundled Caddy ufw allow 80/tcp # only with the bundled Caddy
ufw allow 5060/udp ufw allow 5060/udp
ufw allow 10000:20000/udp ufw allow 10000:20000/udp # or your RTP_PORT_START:RTP_PORT_END
ufw enable 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. 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, 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`). 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. 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, 5. **Calls drop after about 30 seconds:** same causes. The answer never reaches the other side,
so it gives up. 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 ## The trunk won't register
Trunks shows **Rejected** or **Not registered**. Trunks shows **Rejected** or **Not registered**.
+4 -1
View File
@@ -382,7 +382,10 @@ rtpend = 20000
#include selfphoneng/rtp_selfphoneng.conf #include selfphoneng/rtp_selfphoneng.conf
``` ```
(The generator writes `icesupport`, `stunaddr` and `[ice_host_candidates]` into (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` ### 3.7 `pjsip.conf`
```ini ```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 | | SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | | | 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 | | 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`) ### 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). - `.env` mode 0600 (install.sh sets it).
- Recordings and voicemail are served only through authenticated api routes, never as static files. - 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. Operations
### 3.1 Install / update ### 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']), .default(['192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12']),
'network.lanIp': z.ipv4().nullable().default(null), 'network.lanIp': z.ipv4().nullable().default(null),
'network.publicIpWatch': z.boolean().default(false), '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 'network.stunServer': z
.string() .string()
.regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/) .regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/)
@@ -128,6 +133,26 @@ export type SystemStatus = {
restartRequired: boolean; 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. */ /** GET /api/system/events: the System page's event log, newest first. */
export type SystemEventDto = { export type SystemEventDto = {
id: number; id: number;
+2
View File
@@ -88,6 +88,8 @@ importers:
specifier: ^0.31.11 specifier: ^0.31.11
version: 0.31.11 version: 0.31.11
apps/probe: {}
apps/web: apps/web:
dependencies: dependencies:
'@hookform/resolvers': '@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 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 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. 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();
}