diff --git a/.env.example b/.env.example index 7e2c1b5..cae70ea 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,5 @@ APP_UID=1000 APP_GID=1000 LOG_LEVEL=info TRUST_PROXY=true # true when behind Caddy/nginx +RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the +RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c64b72..b17e0ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Container images, built only when a release is published (08 §3.1). Runs on GitHub Actions and # Gitea Actions (both read .github/workflows); each pushes to its own registry: -# GitHub: ghcr.io//selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN) -# Gitea: //selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_TOKEN: +# GitHub: ghcr.io//selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN) +# Gitea: //selfphoneng-{api,asterisk,probe} (secrets REGISTRY_USER + REGISTRY_TOKEN: # a personal access token with write:package; Gitea's job token can't push packages) # Tags: the version (0.0.1), plus . and latest; a pre-release gets the version and dev. name: release @@ -79,6 +79,7 @@ jobs: ) docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" . docker build "${labels[@]}" -t "$p-asterisk:build" asterisk + docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" . - name: Check shell: bash run: | @@ -88,15 +89,16 @@ jobs: docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')" test "$(docker run --rm --entrypoint cat "$p-api:build" /app/VERSION)" = "${{ steps.v.outputs.version }}" docker run --rm --entrypoint asterisk "$p-asterisk:build" -V + docker run --rm "$p-probe:build" node --input-type=module -e "await import('/app/src/server.ts')" - name: Push shell: bash run: | set -euo pipefail p="${{ steps.v.outputs.prefix }}" - for image in api asterisk; do + for image in api asterisk probe; do for t in ${{ steps.v.outputs.tags }}; do docker tag "$p-$image:build" "$p-$image:$t" docker push "$p-$image:$t" done done - echo "pushed: $p-{api,asterisk}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" + echo "pushed: $p-{api,asterisk,probe}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e0a46..c2a274d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on ## Unreleased +- **System → Diagnostics:** checks what usually keeps phones off the network (public IP, DNS, + local networks, the phone network limit, SIP listening, free audio ports), with how to fix each. + A **Quick** or **Complete network test** checks the ports from the internet through a network + test probe (`apps/probe`, image `selfphoneng-probe`) you run outside your network; set its URL + in Settings → Network. +- The call audio port range is configurable: `RTP_PORT_START` and `RTP_PORT_END` (default + 10000–20000). - Updating (Docker only): a new version backs up before it changes the database, and stops instead if the backup fails. `scripts/update.sh` updates a compose install to a release, checks the result and goes back by itself if the check fails. The admin guide's "Updating" section diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f0c9ede..42e8262 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -47,7 +47,7 @@ declare module 'fastify' { /** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */ generator: Generator; /** Decorated by the caller too (tests pass a fake). */ - ami: Pick; + ami: Pick; } } diff --git a/apps/api/src/config.test.ts b/apps/api/src/config.test.ts index 7da3d43..0f8b2b0 100644 --- a/apps/api/src/config.test.ts +++ b/apps/api/src/config.test.ts @@ -19,6 +19,16 @@ describe('config', () => { expect(() => loadConfig({ ...env, APP_SECRET: undefined })).toThrow(ConfigError); expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/); }); + it('call audio ports: 10000–20000 by default; the end above the start', () => { + expect(loadConfig(env)).toMatchObject({ RTP_PORT_START: 10000, RTP_PORT_END: 20000 }); + expect(loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '40199' })).toMatchObject({ + RTP_PORT_START: 40000, + RTP_PORT_END: 40199, + }); + expect(() => loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '30000' })).toThrow( + /RTP_PORT_END/, + ); + }); it('rejects a non-postgres DATABASE_URL', () => { expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/); }); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index bbbc43c..59421f6 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -1,43 +1,51 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { z } from 'zod'; -const EnvSchema = z.object({ - APP_SECRET: z - .string() - .refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'), - PUBLIC_HOSTNAME: z.string().min(1), - DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'), - INITIAL_ADMIN_EMAIL: z.email(), - INITIAL_ADMIN_PASSWORD: z.string().min(1), - AMI_HOST: z.string().default('127.0.0.1'), - AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038), - AMI_USERNAME: z.string().default('selfphoneng'), - AMI_SECRET: z.string().min(8), - /** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */ - ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'), - /** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */ - VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'), - /** Uploaded prompts, played as custom/ (04 §6). Dev: ../../data/sounds-custom */ - SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'), - /** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */ - MOH_DIR: z.string().default('/var/lib/asterisk/moh'), - /** Backups (04 §8.1). Dev: ../../data/backups */ - BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'), - /** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */ - TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'), - /** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */ - VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'), - /** MixMonitor's directory; recordings are YYYY/MM/DD/.wav under it (03 sp-sub-record). */ - RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'), - /** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */ - WEBRTC_WS_URL: z.url().optional(), - API_PORT: z.coerce.number().int().min(1).max(65535).default(8080), - LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), - TRUST_PROXY: z.stringbool().default(true), - /** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */ - SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false), - TZ: z.string().default('America/Chicago'), -}); +const EnvSchema = z + .object({ + APP_SECRET: z + .string() + .refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'), + PUBLIC_HOSTNAME: z.string().min(1), + DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'), + INITIAL_ADMIN_EMAIL: z.email(), + INITIAL_ADMIN_PASSWORD: z.string().min(1), + AMI_HOST: z.string().default('127.0.0.1'), + AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038), + AMI_USERNAME: z.string().default('selfphoneng'), + AMI_SECRET: z.string().min(8), + /** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */ + ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'), + /** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */ + VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'), + /** Uploaded prompts, played as custom/ (04 §6). Dev: ../../data/sounds-custom */ + SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'), + /** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */ + MOH_DIR: z.string().default('/var/lib/asterisk/moh'), + /** Backups (04 §8.1). Dev: ../../data/backups */ + BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'), + /** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */ + TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'), + /** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */ + VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'), + /** MixMonitor's directory; recordings are YYYY/MM/DD/.wav under it (03 sp-sub-record). */ + RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'), + /** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */ + WEBRTC_WS_URL: z.url().optional(), + API_PORT: z.coerce.number().int().min(1).max(65535).default(8080), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + TRUST_PROXY: z.stringbool().default(true), + /** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */ + RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000), + RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000), + /** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */ + SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false), + TZ: z.string().default('America/Chicago'), + }) + .refine((c) => c.RTP_PORT_END > c.RTP_PORT_START, { + path: ['RTP_PORT_END'], + message: 'must be above RTP_PORT_START', + }); export type Config = z.infer; diff --git a/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/conferences/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/empty/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/holidays/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/hostile-names/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr-business-hours/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf index 9f7edc2..1a1f546 100644 --- a/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/one-user/expected/rtp_selfphoneng.conf @@ -1,4 +1,6 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes stunaddr = stun.example.org:3478 diff --git a/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/paging/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/phone-acl/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/queue/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ringgroups/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/voipms-basic/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf index 3ef2826..19031cd 100644 --- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/rtp_selfphoneng.conf @@ -1,2 +1,4 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +rtpstart = 10000 +rtpend = 20000 icesupport = yes diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts index 1a3d850..38fbd64 100644 --- a/apps/api/src/generator/index.ts +++ b/apps/api/src/generator/index.ts @@ -101,6 +101,8 @@ export type GeneratorDeps = { log: Pick; onApplied?: (r: ApplyResult) => void; debounceMs?: number; + /** RTP_PORT_START/END (config.ts). */ + rtpPorts?: { start: number; end: number }; }; const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); @@ -165,7 +167,7 @@ export function createGenerator(deps: GeneratorDeps) { restartRequired: false, }; try { - const snap = await loadSnapshot(db); + const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts }; const files = render(snap); await mkdir(join(dir, '.prev'), { recursive: true }); const current = new Map(); diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts index 9947ae1..153513a 100644 --- a/apps/api/src/generator/load.ts +++ b/apps/api/src/generator/load.ts @@ -203,8 +203,12 @@ export type Snapshot = { mohClasses: string[]; /** Played before recording a call that a phone number records (Settings → Recording). */ recordingAnnouncement: string | null; + /** Call audio ports from the environment (RTP_PORT_START/END), not the database. */ + rtpPorts?: { start: number; end: number }; }; +export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 }; + export function todayIn(timezone: string, now = new Date()): string { // en-CA formats as YYYY-MM-DD. return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now); diff --git a/apps/api/src/generator/pjsip.ts b/apps/api/src/generator/pjsip.ts index ffabd10..eb49f32 100644 --- a/apps/api/src/generator/pjsip.ts +++ b/apps/api/src/generator/pjsip.ts @@ -1,7 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4). import { CLASS_RANK } from '@selfphoneng/shared'; -import { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts'; +import { + byNumber, + DEFAULT_RTP_PORTS, + type SnapExtension, + type SnapTrunk, + type Snapshot, +} from './load.ts'; import * as s from './sanitize.ts'; const section = (title: string, lines: (string | false | null | undefined)[]) => @@ -275,7 +281,13 @@ export function renderPjsip(snap: Snapshot): string { export function renderRtp(snap: Snapshot): string { const st = snap.settings; - const lines = ['icesupport = yes']; + const rtp = snap.rtpPorts ?? DEFAULT_RTP_PORTS; + const lines = [ + // After rtp.conf's own 10000–20000 (Asterisk takes a setting's last value): RTP_PORT_* (env). + `rtpstart = ${s.digits(String(rtp.start))}`, + `rtpend = ${s.digits(String(rtp.end))}`, + 'icesupport = yes', + ]; if (st['network.stunServer']) lines.push(`stunaddr = ${hostPort(st['network.stunServer'])}`); if (st['network.lanIp'] && st['network.publicIp']) lines.push( diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1820100..12064d5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -46,6 +46,7 @@ const generator = createGenerator({ db, ami, dir: resolve(config.ASTERISK_CONFIG_DIR), + rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END }, log: app.log, onApplied: (r) => { live.emit('message', { diff --git a/apps/api/src/routes/system.ts b/apps/api/src/routes/system.ts index 50803d0..a5fc66b 100644 --- a/apps/api/src/routes/system.ts +++ b/apps/api/src/routes/system.ts @@ -5,7 +5,13 @@ import { networkInterfaces } from 'node:os'; import { join, resolve } from 'node:path'; import { desc, lt } from 'drizzle-orm'; import { z } from 'zod'; -import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared'; +import type { + DiagnosticsDto, + ListResponse, + NetworkTestDto, + SystemEventDto, + SystemStatus, +} from '@selfphoneng/shared'; import type { FastifyInstance } from 'fastify'; import { requireAdmin } from '../auth/guards.ts'; import { audit } from '../services/audit.ts'; @@ -14,7 +20,8 @@ import { systemEvents } from '../db/schema.ts'; import { appVersion } from './about.ts'; import { csvCell } from './calls.ts'; import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts'; -import { HttpError, notFound } from '../errors.ts'; +import { badRequest, HttpError, notFound } from '../errors.ts'; +import { insideChecks, networkTest } from '../services/diagnostics.ts'; import { AmiDisconnectedError } from '../ami/client.ts'; import { GENERATED_FILES } from '../generator/index.ts'; @@ -67,7 +74,7 @@ export async function systemRoutes(app: FastifyInstance) { sip: s['sip.udpPort'], sipTcp: s['sip.tcpEnabled'], sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null, - rtp: '10000–20000', // asterisk/conf/rtp.conf + rtp: `${app.config.RTP_PORT_START}–${app.config.RTP_PORT_END}`, // RTP_PORT_START/END https: 443, }, ami: { connected: app.ami.isConnected() }, @@ -163,6 +170,20 @@ export async function systemRoutes(app: FastifyInstance) { return reply.sendFile(name, dir); }); + // Diagnostics (08 §2.6): the inside checks; the probe's outside test, quick or complete. + const diag = () => ({ db: app.db, config: app.config, ami: app.ami }); + app.get('/api/system/diagnostics', async (): Promise => ({ + checks: await insideChecks(diag()), + })); + app.post('/api/system/diagnostics/network', async (req): Promise => { + const { mode } = z + .object({ mode: z.enum(['quick', 'complete']).default('quick') }) + .parse(req.body ?? {}); + if (!(await getAllSettings(app.db))['network.probeUrl']) + throw badRequest('No network test probe set (Settings → Network)'); + return networkTest(diag(), mode); + }); + app.get('/api/system/generated-files', async () => ({ files: await Promise.all( GENERATED_FILES.map(async (name) => ({ diff --git a/apps/api/src/services/diagnostics.ts b/apps/api/src/services/diagnostics.ts new file mode 100644 index 0000000..b63c98f --- /dev/null +++ b/apps/api/src/services/diagnostics.ts @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// System → Diagnostics (08 §2.6). insideChecks: what this server can tell about itself (settings, +// DNS, listening transports, the audio port range). networkTest: the probe (apps/probe) tests it +// from outside, the only way to know what a phone on mobile data or the SIP provider can reach. +import { randomBytes } from 'node:crypto'; +import { createSocket, type Socket } from 'node:dgram'; +import { resolve4 } from 'node:dns/promises'; +import { BlockList, isIPv4 } from 'node:net'; +import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared'; +import type { AmiClient } from '../ami/client.ts'; +import type { Config } from '../config.ts'; +import type { Db } from '../db/client.ts'; +import { detectPublicIp } from '../jobs.ts'; +import { getAllSettings } from './settings.ts'; + +export type DiagDeps = { + db: Db; + config: Pick; + ami: Pick; + /** Overridable in tests. */ + lookupPublicIp?: () => Promise; + resolveHost?: (host: string) => Promise; + fetchProbe?: typeof fetch; +}; + +const PRIVATE = new BlockList(); +for (const [a, b] of [ + ['10.0.0.0', 8], + ['172.16.0.0', 12], + ['192.168.0.0', 16], + ['100.64.0.0', 10], + ['127.0.0.0', 8], +] as const) + PRIVATE.addSubnet(a, b, 'ipv4'); + +const inNets = (ip: string, nets: string[]) => { + const list = new BlockList(); + for (const n of nets) { + const [a, b] = n.split('/'); + list.addSubnet(a!, Number(b), 'ipv4'); + } + return list.check(ip, 'ipv4'); +}; + +const check = ( + id: string, + label: string, + status: DiagnosticCheck['status'], + detail: string, + fix?: string, +): DiagnosticCheck => ({ id, label, status, detail, ...(fix && { fix }) }); + +/** Even ports in the range that something else already holds (RTP takes even ones). */ +async function busyRtpPorts(start: number, end: number): Promise { + const ports: number[] = []; + for (let p = start + (start % 2); p <= end; p += 2) ports.push(p); + let busy = 0; + // ponytail: binds every even port once, 200 at a time (~1 s for 10 000 ports). + for (let i = 0; i < ports.length; i += 200) + await Promise.all( + ports.slice(i, i + 200).map( + (port) => + new Promise((done) => { + const s = createSocket('udp4'); + s.once('error', () => (busy++, s.close(), done())); + s.bind(port, () => s.close(done)); + }), + ), + ); + return busy; +} + +export async function insideChecks(d: DiagDeps): Promise { + const s = await getAllSettings(d.db); + const host = d.config.PUBLIC_HOSTNAME; + const out: DiagnosticCheck[] = []; + const setIp = s['network.publicIp']; + const seenIp = await (d.lookupPublicIp ?? detectPublicIp)(); + const ip = setIp ?? seenIp; + + if (!setIp) + out.push( + check( + 'public-ip', + 'Public IP', + 'fail', + "Not set. Phones outside your network are given the server's private address, so their calls fail or have no sound.", + `Settings → Network → Public IP: ${seenIp ?? "this server's internet address"}, then restart telephony (System).`, + ), + ); + else if (seenIp && seenIp !== setIp) + out.push( + check( + 'public-ip', + 'Public IP', + 'fail', + `Set to ${setIp}, but this server reaches the internet from ${seenIp}.`, + `Settings → Network → Public IP: ${seenIp}, then restart telephony. If it keeps changing, turn on "Re-check the public IP".`, + ), + ); + else + out.push( + check( + 'public-ip', + 'Public IP', + seenIp ? 'ok' : 'warn', + seenIp ? setIp : `${setIp} (couldn't look it up to compare: no internet?)`, + ), + ); + + const names = await (d.resolveHost ?? resolve4)(host).catch(() => [] as string[]); + if (!names.length) + out.push( + check( + 'dns', + `DNS for ${host}`, + 'fail', + `${host} doesn't resolve. Phones and the browser find the server by this name.`, + `At your DNS provider, add an A record: ${host} → ${ip ?? 'your public IP'}.`, + ), + ); + else if (ip && !names.includes(ip)) + out.push( + check( + 'dns', + `DNS for ${host}`, + 'fail', + `${host} points at ${names.join(', ')}, not ${ip}.`, + `Point the A record at ${ip}. With Cloudflare, set it to "DNS only" (grey cloud): its proxy carries web pages, not phone calls (SIP).`, + ), + ); + else out.push(check('dns', `DNS for ${host}`, 'ok', `${host} → ${names.join(', ')}`)); + + const nets = s['network.localNets']; + const lan = s['network.lanIp']; + if (lan && !inNets(lan, nets)) + out.push( + check( + 'local-nets', + 'Local networks', + 'fail', + `The server's LAN address ${lan} isn't in them (${nets.join(', ') || 'none'}).`, + `Settings → Network: add ${lan.replace(/\.\d+$/, '.0')}/24 (or your LAN's range) to the local networks.`, + ), + ); + else out.push(check('local-nets', 'Local networks', 'ok', nets.join(', ') || 'none')); + + out.push( + s['sip.restrictPhonesToLocalNets'] + ? check( + 'phone-acl', + 'Where phones can sign in from', + 'warn', + `Only from ${[...nets, ...s['sip.extraAllowedNets']].join(', ')}. Phone apps on mobile data, or phones at home, are refused.`, + 'Settings → Security: untick "Only accept desk phones from the networks below", or add the networks they call from.', + ) + : check('phone-acl', 'Where phones can sign in from', 'ok', 'Anywhere, with their password'), + ); + if (s['provisioning.lanOnly']) + out.push( + check( + 'prov-lan', + 'Phone setup files', + 'warn', + 'Only phones on the local networks get them: the Linphone QR code fails on mobile data.', + 'Settings → Security: untick "Only to phones on the local networks" while setting up phones elsewhere.', + ), + ); + + const port = s['sip.udpPort']; + if (!d.ami.isConnected()) + out.push( + check( + 'sip', + 'SIP', + 'fail', + "Telephony (Asterisk) isn't reachable.", + 'Check that the asterisk container runs: docker compose ps, then docker compose logs asterisk.', + ), + ); + else { + const t = await d.ami.command('pjsip show transports').catch(() => ''); + const want = [ + ['transport-udp', `UDP ${port}`], + ...(s['sip.tcpEnabled'] ? [['transport-tcp', `TCP ${port}`]] : []), + ...(s['sip.tlsEnabled'] ? [['transport-tls', `TLS ${s['sip.tlsPort']}`]] : []), + ]; + const missing = want.filter(([name]) => !t.includes(name!)).map(([, what]) => what); + out.push( + missing.length + ? check( + 'sip', + 'SIP', + 'fail', + `Not listening on ${missing.join(', ')}.`, + 'Restart telephony (System): ports and TLS changes only take effect then.', + ) + : check('sip', 'SIP', 'ok', `Listening on ${want.map(([, w]) => w).join(', ')}`), + ); + } + + const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config; + const total = Math.floor((b - a) / 2) + 1; + const busy = await busyRtpPorts(a, b); + const range = `UDP ${a}–${b}`; + out.push( + busy > total / 4 + ? check( + 'rtp', + 'Call audio ports', + 'warn', + `${range}: ${busy} of ${total} are taken by other programs (or calls).`, + `Pick a free range with RTP_PORT_START and RTP_PORT_END (in .env, or the api's environment in your stack), restart, and open that range instead. Two ports per call: 200 ports is plenty for a small office.`, + ) + : check( + 'rtp', + 'Call audio ports', + 'ok', + `${range}, ${total} usable${busy ? `, ${busy} in use` : ''}`, + ), + ); + + out.push( + s['network.probeUrl'] + ? check('probe', 'Network test probe', 'ok', s['network.probeUrl']) + : check( + 'probe', + 'Network test probe', + 'skip', + 'None set, so nothing is tested from outside your network.', + 'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.', + ), + ); + return out; +} + +type ProbeResult = { id: string; ok: boolean; detail: string }; +/** The probe's reason in brackets, unless it only repeats "no answer". */ +const why = (detail: string) => (detail === 'no answer' ? '' : ` (${detail})`); + +/** The probe's outside tests; `complete` adds TLS, the browser phone's /ws and audio ports. */ +export async function networkTest( + d: DiagDeps, + mode: 'quick' | 'complete', +): Promise { + const s = await getAllSettings(d.db); + const probe = s['network.probeUrl']; + if (!probe) throw new Error('No network test probe set (Settings → Network)'); + const host = d.config.PUBLIC_HOSTNAME; + const port = s['sip.udpPort']; + const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config; + const lan = s['network.lanIp'] ?? 'this server'; + + const tests: Record[] = [ + { id: 'http', kind: 'http', port: 80, path: '/' }, + { id: 'https', kind: 'https', port: 443, path: '/healthz' }, + { id: 'sip-udp', kind: 'sip-udp', port }, + ...(s['sip.tcpEnabled'] ? [{ id: 'sip-tcp', kind: 'sip-tcp', port }] : []), + ]; + // Audio ports: a few, listened on here for the probe's datagrams. + const listeners: { port: number; sock: Socket; got: boolean }[] = []; + if (mode === 'complete') { + tests.push({ id: 'ws', kind: 'https', port: 443, path: '/ws', websocket: true }); + if (s['sip.tlsEnabled']) tests.push({ id: 'sip-tls', kind: 'sip-tls', port: s['sip.tlsPort'] }); + const even = (p: number) => p - (p % 2); + const sample = [ + ...new Set([ + even(a + 1), + even(b), + even(a + Math.floor((b - a) / 2)), + even(Math.min(b, a + 2 + Math.floor(Math.random() * Math.max(0, b - a - 2)))), + ]), + ]; + const token = randomBytes(8).toString('hex'); + for (const p of sample) { + const sock = createSocket('udp4'); + const ok = await new Promise((r) => { + sock.once('error', () => r(false)); + sock.bind(p, () => r(true)); + }); + if (!ok) continue; // a call or another program has it: try the others + const l = { port: p, sock, got: false }; + sock.on('message', (m) => (l.got ||= m.toString().includes(token))); + listeners.push(l); + tests.push({ id: `rtp-${p}`, kind: 'udp', port: p, token }); + } + } + + let ip: string | null = null; + let results: ProbeResult[] = []; + let probeError: string | null = null; + try { + const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ host, tests }), + signal: AbortSignal.timeout(30_000), + }); + const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string }; + if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`; + else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] }); + // Datagrams sent last arrive just after the answer. + await new Promise((r) => setTimeout(r, 700)); + } catch (e) { + probeError = (e as Error).message; + } finally { + for (const l of listeners) l.sock.close(); + } + if (probeError) + return { + mode, + testedIp: null, + checks: [ + check( + 'probe', + 'Network test probe', + 'fail', + `${probe} didn't answer: ${probeError}`, + 'Check the probe runs and is reachable from this server, then try again.', + ), + ], + }; + + const res = (id: string) => results.find((x) => x.id === id); + const out: DiagnosticCheck[] = []; + if (ip && isIPv4(ip) && PRIVATE.check(ip, 'ipv4')) + out.push( + check( + 'probe-ip', + 'Tested address', + 'warn', + `The probe saw ${ip}, a private address: it's inside your network, so these results don't show what the internet sees.`, + 'Run the probe on a machine outside this network.', + ), + ); + else if (ip && s['network.publicIp'] && ip !== s['network.publicIp']) + out.push( + check( + 'probe-ip', + 'Tested address', + 'warn', + `The probe saw this server as ${ip}, but the public IP is set to ${s['network.publicIp']} (a VPN, or a changed address?).`, + `If ${ip} is right: Settings → Network → Public IP, then restart telephony.`, + ), + ); + else if (ip) out.push(check('probe-ip', 'Tested address', 'ok', ip)); + + const http = res('http'); + if (http) + out.push( + http.ok + ? check('http', 'HTTP (TCP 80)', 'ok', http.detail) + : check( + 'http', + 'HTTP (TCP 80)', + 'warn', + `Not reachable${why(http.detail)}. Only needed to get the HTTPS certificate over HTTP (the bundled Caddy, or certbot).`, + 'If you need it: forward TCP 80 to this server on the router, and sudo ufw allow 80/tcp.', + ), + ); + const https = res('https'); + if (https) { + const status = Number(/HTTP (\d{3})/.exec(https.detail)?.[1]); + out.push( + !https.ok + ? check( + 'https', + 'HTTPS (TCP 443)', + 'fail', + `Not reachable${why(https.detail)}. The web app, the browser phone and phone setup all use it.`, + `Forward TCP 443 to ${lan} on the router, and sudo ufw allow 443/tcp.`, + ) + : status !== 200 + ? check( + 'https', + 'HTTPS (TCP 443)', + 'fail', + `Reachable, but answered ${https.detail.split('; ')[0]} instead of SelfPhoneNG.`, + "Your reverse proxy can't reach the api: it must send this host name to 127.0.0.1 on the api's port (API_PORT, 8080 by default).", + ) + : /certificate isn't valid/.test(https.detail) + ? check( + 'https', + 'HTTPS (TCP 443)', + 'warn', + https.detail, + `Get a certificate for ${host}: the bundled Caddy does it by itself; with your own proxy, certbot.`, + ) + : check('https', 'HTTPS (TCP 443)', 'ok', https.detail), + ); + } + const udp = res('sip-udp'); + if (udp) + out.push( + udp.ok + ? check('sip-udp', `SIP (UDP ${port})`, 'ok', `Asterisk answered: ${udp.detail}`) + : check( + 'sip-udp', + `SIP (UDP ${port})`, + 'fail', + `No answer${why(udp.detail)}. Phones away from the office, phone apps on mobile data and the SIP provider's calls all need it.`, + `Forward UDP ${port} to ${lan} on the router (on both, behind two), sudo ufw allow ${port}/udp, and turn off SIP ALG on the router.`, + ), + ); + const tcp = res('sip-tcp'); + if (tcp) + out.push( + tcp.ok + ? check('sip-tcp', `SIP (TCP ${port})`, 'ok', `Asterisk answered: ${tcp.detail}`) + : check( + 'sip-tcp', + `SIP (TCP ${port})`, + 'warn', + `No answer${why(tcp.detail)}. Only phones set to TCP need it.`, + `Forward TCP ${port} to ${lan} and sudo ufw allow ${port}/tcp, or keep phones on UDP.`, + ), + ); + const tls = res('sip-tls'); + if (tls) + out.push( + tls.ok + ? check( + 'sip-tls', + `SIP over TLS (TCP ${s['sip.tlsPort']})`, + 'ok', + `Asterisk answered: ${tls.detail}`, + ) + : check( + 'sip-tls', + `SIP over TLS (TCP ${s['sip.tlsPort']})`, + 'fail', + `No answer${why(tls.detail)}.`, + `Forward TCP ${s['sip.tlsPort']} to ${lan} and sudo ufw allow ${s['sip.tlsPort']}/tcp.`, + ), + ); + const ws = res('ws'); + if (ws) + out.push( + ws.ok + ? check('ws', 'Browser phone (/ws)', 'ok', ws.detail) + : check( + 'ws', + 'Browser phone (/ws)', + 'fail', + `The WebSocket didn't open${why(ws.detail)}.`, + 'Your reverse proxy must pass WebSocket upgrades on /ws to 127.0.0.1:8088 (see deploy/nginx/selfphoneng.conf.example).', + ), + ); + if (mode === 'complete') { + const got = listeners.filter((l) => l.got); + const range = `UDP ${a}–${b}`; + const fix = `Forward ${range} to ${lan} on the router, and sudo ufw allow ${a}:${b}/udp. Without it calls connect but have no sound, or only one way.`; + out.push( + !listeners.length + ? check( + 'rtp', + `Call audio (${range})`, + 'skip', + 'Every sampled port was in use here: try again.', + ) + : got.length === listeners.length + ? check( + 'rtp', + `Call audio (${range})`, + 'ok', + `${got.length} of ${listeners.length} sampled ports got through`, + ) + : check( + 'rtp', + `Call audio (${range})`, + got.length ? 'warn' : 'fail', + `${got.length} of ${listeners.length} sampled ports got through (missing: ${listeners + .filter((l) => !l.got) + .map((l) => l.port) + .join(', ')}).`, + got.length + ? `The forward or firewall rule doesn't cover the whole range. ${fix}` + : fix, + ), + ); + } + return { mode, testedIp: ip, checks: out }; +} diff --git a/apps/api/test/asterisk/call-test.ts b/apps/api/test/asterisk/call-test.ts index f74464a..312eae6 100644 --- a/apps/api/test/asterisk/call-test.ts +++ b/apps/api/test/asterisk/call-test.ts @@ -23,6 +23,7 @@ const NET = 'sp-test'; const AST = 'sp-test-ast'; const PHONES = 'sp-test-phones'; const PN_PHONE = 'sp-test-pn-phone'; +const RTP_PORTS = { start: 31000, end: 31200 }; const AMI_PORT = 15038; const AMI_SECRET = 'call-test-secret'; const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' }; @@ -79,6 +80,8 @@ function snapshot(): Snapshot { announcements: [], recordingAnnouncement: null, extensions: [ext('101', 'Alice'), ext('102', 'Bob')], + // Not rtp.conf's 10000–20000: the generated range must win (RTP_PORT_START/END). + rtpPorts: RTP_PORTS, }; } @@ -174,6 +177,13 @@ async function main() { ami.start(); await new Promise((r) => ami.once('connected', r)); log('AMI connected from the host'); + const rtp = await ami.command('rtp show settings'); + if ( + !new RegExp(`Port start:\\s+${RTP_PORTS.start}\\b`).test(rtp) || + !new RegExp(`Port end:\\s+${RTP_PORTS.end}\\b`).test(rtp) + ) + problems.push(`RTP ports aren't ${RTP_PORTS.start}–${RTP_PORTS.end}:\n${rtp}`); + else log(`RTP ports ${RTP_PORTS.start}–${RTP_PORTS.end} (generated, over rtp.conf's)`); if (RECORD) { // A second, raw AMI session so the fixture holds exactly what Asterisk sent. recorder = new Socket(); @@ -237,6 +247,27 @@ async function main() { ).out.trim(); if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`); else log('102-web over WebSocket from 127.0.0.1: registered'); + // The network test probe's SIP checks (08 §2.6): Asterisk answers its OPTIONS, UDP and TCP. + const probed = docker( + 'run', + '--rm', + '--network', + NET, + '-v', + `${join(import.meta.dirname, '../../../probe/src')}:/probe:ro`, + 'node:24-trixie-slim', + 'node', + '--input-type=module', + '-e', + `import { runTest } from '/probe/checks.ts'; + for (const kind of ['sip-udp', 'sip-tcp']) + console.log(JSON.stringify(await runTest('${AST}', 'pbx.test', { id: kind, kind, port: 5060 })));`, + ).out; + for (const kind of ['sip-udp', 'sip-tcp']) + if (!new RegExp(`"id":"${kind}","ok":true,"detail":"SIP/2.0 \\d{3}`).test(probed)) + problems.push(`probe ${kind}: ${probed.trim()}`); + if (!problems.some((p) => p.startsWith('probe '))) + log(`probe: Asterisk answers SIP OPTIONS over UDP and TCP`); // 101 also on an iOS Linphone: '&' in its push params used to break Dial for every device. const pn = docker( 'run', diff --git a/apps/api/test/diagnostics.test.ts b/apps/api/test/diagnostics.test.ts new file mode 100644 index 0000000..b98a84e --- /dev/null +++ b/apps/api/test/diagnostics.test.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// System → Diagnostics (08 §2.6): the inside checks with the outside lookups faked, and the +// network test against a fake probe that really sends the audio-port datagrams. +import { createSocket } from 'node:dgram'; +import type { DiagnosticCheck } from '@selfphoneng/shared'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { insideChecks, networkTest, type DiagDeps } from '../src/services/diagnostics.ts'; +import { setSetting } from '../src/services/settings.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +const byId = (checks: DiagnosticCheck[]) => Object.fromEntries(checks.map((c) => [c.id, c])); + +describe.skipIf(!hasTestDb)('diagnostics', () => { + let t: Awaited>; + let deps: DiagDeps; + beforeAll(async () => { + t = await createTestApp(); + deps = { + db: t.db, + // A small range, so the busy-port scan and the samples stay quick and local. + config: { ...t.app.config, RTP_PORT_START: 41000, RTP_PORT_END: 41020 }, + ami: t.app.ami, + lookupPublicIp: async () => '203.0.113.10', + resolveHost: async () => ['203.0.113.10'], + }; + }); + afterAll(() => t?.close()); + + it('flags what keeps phones off the network: no public IP, DNS elsewhere, the phone ACL', async () => { + let c = byId(await insideChecks({ ...deps, resolveHost: async () => ['198.51.100.7'] })); + expect(c['public-ip']).toMatchObject({ status: 'fail' }); + expect(c['public-ip']!.fix).toContain('203.0.113.10'); + expect(c.dns).toMatchObject({ status: 'fail' }); + expect(c.dns!.fix).toContain('DNS only'); + expect(c['phone-acl']).toMatchObject({ status: 'ok' }); + expect(c.probe).toMatchObject({ status: 'skip' }); + expect(c.rtp).toMatchObject({ + status: 'ok', + detail: expect.stringContaining('UDP 41000–41020'), + }); + + await setSetting(t.db, 'network.publicIp', '203.0.113.10'); + await setSetting(t.db, 'sip.restrictPhonesToLocalNets', true); + await setSetting(t.db, 'network.lanIp', '10.9.0.5'); + await setSetting(t.db, 'network.localNets', ['192.168.0.0/16']); + c = byId(await insideChecks(deps)); + expect(c['public-ip']).toMatchObject({ status: 'ok', detail: '203.0.113.10' }); + expect(c.dns).toMatchObject({ status: 'ok' }); + expect(c['phone-acl']).toMatchObject({ status: 'warn' }); + expect(c['phone-acl']!.detail).toContain('mobile data'); + expect(c['local-nets']).toMatchObject({ status: 'fail' }); + expect(c['local-nets']!.fix).toContain('10.9.0.0/24'); + await setSetting(t.db, 'sip.restrictPhonesToLocalNets', false); + }); + + it('a busy audio range is a warning that says how to move it', async () => { + const held = await Promise.all( + [41000, 41002, 41004, 41006].map( + (p) => + new Promise>((r) => { + const s = createSocket('udp4'); + s.bind(p, () => r(s)); + }), + ), + ); + const c = byId(await insideChecks(deps)); + expect(c.rtp).toMatchObject({ status: 'warn' }); + expect(c.rtp!.fix).toContain('RTP_PORT_START'); + held.forEach((s) => s.close()); + }); + + it('network test: maps the probe’s answers to fixes; audio ports must really get through', async () => { + await setSetting(t.db, 'network.probeUrl', 'https://probe.test'); + let seen: + | { host: string; tests: { id: string; kind: string; port: number; token?: string }[] } + | undefined; + const fetchProbe = (async (_url: URL, init: RequestInit) => { + seen = JSON.parse(String(init.body)); + // The firewall lets the first sampled audio port through and drops the rest. + const udp = seen!.tests.filter((x) => x.kind === 'udp'); + const s = createSocket('udp4'); + await new Promise((r) => + s.send(`selfphoneng-probe ${udp[0]!.token}`, udp[0]!.port, '127.0.0.1', () => r()), + ); + s.close(); + const ok = (id: string, detail: string) => ({ id, ok: true, detail }); + const no = (id: string) => ({ id, ok: false, detail: 'no answer' }); + return new Response( + JSON.stringify({ + ip: '203.0.113.10', + results: [ + no('http'), + ok('https', 'HTTP 200'), + no('sip-udp'), + ok('sip-tcp', 'SIP/2.0 401 Unauthorized'), + { id: 'ws', ok: false, detail: 'HTTP 404' }, + ...udp.map((x) => ok(x.id, 'sent')), + ], + }), + ); + }) as unknown as typeof fetch; + const r = await networkTest({ ...deps, fetchProbe }, 'complete'); + expect(seen!.host).toBe(t.app.config.PUBLIC_HOSTNAME); + expect(seen!.tests.map((x) => x.id)).toEqual( + expect.arrayContaining(['http', 'https', 'sip-udp', 'sip-tcp', 'ws']), + ); + const c = byId(r.checks); + expect(r.testedIp).toBe('203.0.113.10'); + expect(c['probe-ip']).toMatchObject({ status: 'ok' }); + expect(c.http).toMatchObject({ status: 'warn' }); + expect(c.https).toMatchObject({ status: 'ok' }); + expect(c['sip-udp']).toMatchObject({ status: 'fail' }); + expect(c['sip-udp']!.fix).toContain('SIP ALG'); + expect(c['sip-tcp']).toMatchObject({ status: 'ok' }); + expect(c.ws).toMatchObject({ status: 'fail' }); + expect(c.rtp).toMatchObject({ status: 'warn' }); + expect(c.rtp!.fix).toContain('ufw allow 41000:41020/udp'); + + // Quick: no audio ports, no /ws. + const quick = await networkTest( + { + ...deps, + fetchProbe: (async (_u: URL, init: RequestInit) => { + seen = JSON.parse(String(init.body)); + return new Response(JSON.stringify({ ip: '192.168.1.9', results: [] })); + }) as unknown as typeof fetch, + }, + 'quick', + ); + expect(seen!.tests.map((x) => x.kind)).not.toContain('udp'); + expect(byId(quick.checks)['probe-ip']).toMatchObject({ status: 'warn' }); // a private address + }); + + it('routes: admins only; no probe set is a 400', async () => { + await t.clearMustChange(ADMIN.email); + const cookie = await t.login(ADMIN.email, ADMIN.password); + const r = await t.app.inject({ + method: 'GET', + url: '/api/system/diagnostics', + headers: { ...H, cookie }, + }); + expect(r.statusCode, r.body).toBe(200); + expect(r.json().checks.length).toBeGreaterThan(3); + await setSetting(t.db, 'network.probeUrl', null); + const n = await t.app.inject({ + method: 'POST', + url: '/api/system/diagnostics/network', + headers: { ...H, cookie }, + payload: { mode: 'quick' }, + }); + expect(n.statusCode).toBe(400); + expect((await t.app.inject({ method: 'GET', url: '/api/system/diagnostics' })).statusCode).toBe( + 401, + ); + }); +}); diff --git a/apps/probe/Dockerfile b/apps/probe/Dockerfile new file mode 100644 index 0000000..f706db2 --- /dev/null +++ b/apps/probe/Dockerfile @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# The network test probe (08 §2.6), run outside the network SelfPhoneNG is in. No dependencies: +# Node 24 runs the TypeScript as it is. Build from the repo root: +# docker build -f apps/probe/Dockerfile -t selfphoneng-probe . +FROM node:24-alpine +WORKDIR /app +COPY apps/probe/package.json ./ +COPY apps/probe/src/checks.ts apps/probe/src/server.ts apps/probe/src/index.ts ./src/ +ENV NODE_ENV=production +USER node +EXPOSE 8080 +CMD ["node", "src/index.ts"] diff --git a/apps/probe/package.json b/apps/probe/package.json new file mode 100644 index 0000000..95726e7 --- /dev/null +++ b/apps/probe/package.json @@ -0,0 +1,11 @@ +{ + "name": "@selfphoneng/probe", + "version": "0.0.2", + "private": true, + "license": "AGPL-3.0-or-later", + "type": "module", + "scripts": { + "start": "node src/index.ts", + "typecheck": "tsc --noEmit" + } +} diff --git a/apps/probe/src/checks.ts b/apps/probe/src/checks.ts new file mode 100644 index 0000000..e61dee1 --- /dev/null +++ b/apps/probe/src/checks.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The network test probe's checks (08 §2.6). Each reaches a SelfPhoneNG server from outside its +// network, the way a phone on mobile data or the SIP provider would, and only ever at the address +// that asked (server.ts), so nobody can aim it at someone else. +import { randomBytes } from 'node:crypto'; +import { createSocket } from 'node:dgram'; +import { connect as tcpConnect, isIP, type Socket } from 'node:net'; +import { connect as tlsConnect } from 'node:tls'; + +export type Test = + | { id: string; kind: 'tcp'; port: number } + /** An HTTP(S) request; `websocket` asks for a SIP WebSocket upgrade (the browser phone's /ws). */ + | { id: string; kind: 'http' | 'https'; port: number; path?: string; websocket?: boolean } + /** SIP OPTIONS: any SIP answer means Asterisk got it. */ + | { id: string; kind: 'sip-udp' | 'sip-tcp' | 'sip-tls'; port: number } + /** Datagrams carrying `token`; the server listens and knows whether they came. */ + | { id: string; kind: 'udp'; port: number; token: string }; + +export type Result = { id: string; ok: boolean; detail: string; ms: number }; + +export const TIMEOUT_MS = 4000; +const rand = () => randomBytes(8).toString('hex'); + +/** Sends `request` over TCP or TLS and resolves with what came back once `done` matches it. */ +function exchange( + ip: string, + port: number, + opts: { tls: boolean; host: string; request: string; done: RegExp }, +): Promise<{ data: string; certError: string | null }> { + return new Promise((resolve, reject) => { + let data = ''; + const onConnect = () => sock.write(opts.request); + const sock: Socket = opts.tls + ? tlsConnect( + { + host: ip, + port, + // The name to check the certificate against; SNI can't carry an IP. + ...(isIP(opts.host) ? {} : { servername: opts.host }), + rejectUnauthorized: false, + }, + onConnect, + ) + : tcpConnect({ host: ip, port }, onConnect); + const finish = (err?: Error) => { + clearTimeout(timer); + sock.destroy(); + if (err) reject(err); + else { + const tls = sock as Socket & { authorized?: boolean; authorizationError?: Error }; + resolve({ + data, + certError: + opts.tls && !tls.authorized ? String(tls.authorizationError ?? 'not valid') : null, + }); + } + }; + const timer = setTimeout(() => finish(new Error('no answer')), TIMEOUT_MS); + sock.setEncoding('utf8'); + sock.on('data', (d: string) => { + data += d; + if (opts.done.test(data)) finish(); + }); + sock.on('error', (e) => finish(e)); + sock.on('close', () => (opts.done.test(data) ? finish() : finish(new Error('closed')))); + }); +} + +const sipOptions = (transport: string, host: string, port: number) => + [ + `OPTIONS sip:${host}:${port};transport=${transport.toLowerCase()} SIP/2.0`, + // rport: answer the address and port this came from (RFC 3581), not the made-up host. + `Via: SIP/2.0/${transport} probe.invalid;branch=z9hG4bK${rand()};rport`, + 'Max-Forwards: 70', + `From: ;tag=${rand()}`, + `To: `, + `Call-ID: ${rand()}@probe.invalid`, + 'CSeq: 1 OPTIONS', + 'User-Agent: selfphoneng-probe', + 'Content-Length: 0', + '', + '', + ].join('\r\n'); + +/** OPTIONS over UDP, sent three times (UDP drops); resolves with the first answer's status line. */ +function sipUdp(ip: string, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4'); + const msg = Buffer.from(sipOptions('UDP', host, port)); + const timers = [0, 500, 1500].map((ms) => + setTimeout(() => sock.send(msg, port, ip, () => {}), ms), + ); + const done = (fn: () => void) => { + timers.forEach(clearTimeout); + clearTimeout(timer); + sock.close(); + fn(); + }; + const timer = setTimeout(() => done(() => reject(new Error('no answer'))), TIMEOUT_MS); + sock.on('message', (m) => { + const line = m.toString('utf8').split('\r\n')[0]!; + if (line.startsWith('SIP/2.0 ')) done(() => resolve(line)); + }); + sock.on('error', (e) => done(() => reject(e))); + }); +} + +function sendUdp(ip: string, port: number, token: string): Promise { + return new Promise((resolve) => { + const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4'); + const msg = Buffer.from(`selfphoneng-probe ${token}`); + let sent = 0; + const one = () => + sock.send(msg, port, ip, () => { + if (++sent === 3) { + sock.close(); + resolve(); + } + }); + one(); + setTimeout(one, 300); + setTimeout(one, 600); + }); +} + +const HTTP_STATUS = /^HTTP\/1\.[01] (\d{3})[^\r\n]*\r\n/; + +export async function runTest(ip: string, host: string, t: Test): Promise { + const start = Date.now(); + const result = (ok: boolean, detail: string): Result => ({ + id: t.id, + ok, + detail, + ms: Date.now() - start, + }); + try { + switch (t.kind) { + case 'tcp': + await new Promise((resolve, reject) => { + const s = tcpConnect({ host: ip, port: t.port }, () => (s.destroy(), resolve())); + s.setTimeout(TIMEOUT_MS, () => (s.destroy(), reject(new Error('no answer')))); + s.on('error', reject); + }); + return result(true, 'connected'); + case 'http': + case 'https': { + const upgrade = t.websocket + ? [ + 'Connection: Upgrade', + 'Upgrade: websocket', + `Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}`, + 'Sec-WebSocket-Version: 13', + 'Sec-WebSocket-Protocol: sip', + ] + : ['Connection: close']; + const request = [ + `GET ${t.path ?? '/'} HTTP/1.1`, + `Host: ${host}`, + 'User-Agent: selfphoneng-probe', + ...upgrade, + '', + '', + ].join('\r\n'); + const r = await exchange(ip, t.port, { + tls: t.kind === 'https', + host, + request, + done: HTTP_STATUS, + }); + const status = Number(HTTP_STATUS.exec(r.data)?.[1]); + const cert = r.certError + ? `; the certificate isn't valid for ${host} (${r.certError})` + : ''; + if (t.websocket) + return result(status === 101, `${status === 101 ? 'upgraded' : `HTTP ${status}`}${cert}`); + return result(true, `HTTP ${status}${cert}`); + } + case 'sip-udp': + return result(true, await sipUdp(ip, t.port, host)); + case 'sip-tcp': + case 'sip-tls': { + const tls = t.kind === 'sip-tls'; + const r = await exchange(ip, t.port, { + tls, + host, + request: sipOptions(tls ? 'TLS' : 'TCP', host, t.port), + done: /^SIP\/2\.0 \d{3}[^\r\n]*\r\n/, + }); + return result(true, r.data.split('\r\n')[0]!); + } + case 'udp': + await sendUdp(ip, t.port, t.token); + return result(true, 'sent'); + } + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + return result( + false, + code === 'ECONNREFUSED' ? 'refused' : code ? code.toLowerCase() : (e as Error).message, + ); + } +} diff --git a/apps/probe/src/index.ts b/apps/probe/src/index.ts new file mode 100644 index 0000000..47654d9 --- /dev/null +++ b/apps/probe/src/index.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL +// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is). +// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For. +import { createProbe } from './server.ts'; + +const port = Number(process.env.PORT ?? 8080); +const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' }); +probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`)); +for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0))); diff --git a/apps/probe/src/probe.test.ts b/apps/probe/src/probe.test.ts new file mode 100644 index 0000000..6bd2252 --- /dev/null +++ b/apps/probe/src/probe.test.ts @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and +// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits. +import { createSocket, type Socket as UdpSocket } from 'node:dgram'; +import { createServer as httpServer, type Server as HttpServer } from 'node:http'; +import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { runTest } from './checks.ts'; +import { createProbe, parseCheck } from './server.ts'; + +const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port; +const listen = (s: T) => + new Promise((r) => s.listen(0, '127.0.0.1', () => r(s))); +const bindUdp = ( + onMessage: (m: Buffer, from: { address: string; port: number }, s: UdpSocket) => void, +) => + new Promise((r) => { + const s = createSocket('udp4'); + s.on('message', (m, from) => onMessage(m, from, s)); + s.bind(0, '127.0.0.1', () => r(s)); + }); +const SIP_OK = (req: string) => + `SIP/2.0 401 Unauthorized\r\n${req + .split('\r\n') + .filter((l) => /^(Via|From|To|Call-ID|CSeq):/.test(l)) + .join('\r\n')}\r\nContent-Length: 0\r\n\r\n`; + +describe('probe checks', () => { + let http: HttpServer; + let sipTcp: Server; + let sipUdp: UdpSocket; + let raw: UdpSocket; + const got: string[] = []; + beforeAll(async () => { + http = await listen(httpServer((_req, res) => res.end('{"ok":true}'))); + http.on('upgrade', (req, sock) => { + const ok = req.headers['sec-websocket-protocol'] === 'sip'; + sock.end( + ok + ? 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n' + : 'HTTP/1.1 400 Bad Request\r\n\r\n', + ); + }); + sipTcp = await listen(tcpServer((s) => s.on('data', (d) => s.write(SIP_OK(d.toString()))))); + sipUdp = await bindUdp((m, from, s) => s.send(SIP_OK(m.toString()), from.port, from.address)); + raw = await bindUdp((m) => got.push(m.toString())); + }); + afterAll(() => { + http.close(); + sipTcp.close(); + sipUdp.close(); + raw.close(); + }); + + it('TCP: connected, or refused', async () => { + expect( + await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: port(http) }), + ).toMatchObject({ ok: true, detail: 'connected' }); + const closed = await listen(tcpServer()); + const p = port(closed); + closed.close(); + expect(await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: p })).toMatchObject( + { ok: false, detail: 'refused' }, + ); + }); + + it('HTTP status, and a SIP WebSocket upgrade', async () => { + expect( + await runTest('127.0.0.1', 'pbx.test', { + id: 'h', + kind: 'http', + port: port(http), + path: '/healthz', + }), + ).toMatchObject({ ok: true, detail: 'HTTP 200' }); + expect( + await runTest('127.0.0.1', 'pbx.test', { + id: 'w', + kind: 'http', + port: port(http), + path: '/ws', + websocket: true, + }), + ).toMatchObject({ ok: true, detail: 'upgraded' }); + }); + + it('SIP OPTIONS over UDP and TCP: any SIP answer', async () => { + expect( + await runTest('127.0.0.1', 'pbx.test', { + id: 'u', + kind: 'sip-udp', + port: sipUdp.address().port, + }), + ).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' }); + expect( + await runTest('127.0.0.1', 'pbx.test', { id: 'c', kind: 'sip-tcp', port: port(sipTcp) }), + ).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' }); + }); + + it('UDP: the token arrives', async () => { + expect( + await runTest('127.0.0.1', 'pbx.test', { + id: 'r', + kind: 'udp', + port: raw.address().port, + token: 'abcd1234ef', + }), + ).toMatchObject({ ok: true }); + expect(got).toContain('selfphoneng-probe abcd1234ef'); + }); +}); + +describe('probe server', () => { + it('validates: a host, 1–20 known tests, no target of its own', () => { + expect(parseCheck({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 443 }] })).toEqual({ + host: 'pbx.test', + tests: [{ id: 'a', kind: 'tcp', port: 443 }], + }); + // A target address in the body is simply not read. + expect( + parseCheck({ + host: 'pbx.test', + ip: '192.0.2.1', + tests: [{ id: 'a', kind: 'tcp', port: 1, ip: '192.0.2.1' }], + }), + ).toEqual({ + host: 'pbx.test', + tests: [{ id: 'a', kind: 'tcp', port: 1 }], + }); + for (const bad of [ + { tests: [{ id: 'a', kind: 'tcp', port: 1 }] }, + { host: 'a b', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }, + { host: 'pbx.test', tests: [] }, + { + host: 'pbx.test', + tests: Array.from({ length: 21 }, (_, i) => ({ id: `a${i}`, kind: 'tcp', port: 1 })), + }, + { host: 'pbx.test', tests: [{ id: 'a', kind: 'exec', port: 1 }] }, + { host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 70000 }] }, + { host: 'pbx.test', tests: [{ id: 'a', kind: 'udp', port: 1, token: 'x' }] }, + { host: 'pbx.test', tests: [{ id: 'a', kind: 'http', port: 1, path: 'http://evil/' }] }, + ]) + expect(typeof parseCheck(bad)).toBe('string'); + }); + + it('tests the address that asked (X-Forwarded-For only behind a proxy); limits checks', async () => { + const target = await listen(tcpServer()); + const probe = await listen(createProbe({ maxChecks: 2 })); + const ask = () => + fetch(`http://127.0.0.1:${port(probe)}/v1/check`, { + method: 'POST', + headers: { 'x-forwarded-for': '192.0.2.1' }, + body: JSON.stringify({ + host: 'pbx.test', + tests: [{ id: 'tcp', kind: 'tcp', port: port(target) }], + }), + }); + const r = await ask(); + expect(r.status).toBe(200); + expect(await r.json()).toMatchObject({ ip: '127.0.0.1', results: [{ id: 'tcp', ok: true }] }); + expect((await ask()).status).toBe(200); + expect((await ask()).status).toBe(429); + expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200); + probe.close(); + target.close(); + }); +}); diff --git a/apps/probe/src/server.ts b/apps/probe/src/server.ts new file mode 100644 index 0000000..39b7cf6 --- /dev/null +++ b/apps/probe/src/server.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// POST /v1/check {host, tests}: runs the tests against the address the request came from and +// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe +// from being aimed at someone else; each address gets a few checks per 10 minutes. +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import { isIP } from 'node:net'; +import { runTest, type Result, type Test } from './checks.ts'; + +export type ProbeOptions = { + /** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */ + trustProxy?: boolean; + /** Checks per address per window. */ + maxChecks?: number; + windowMs?: number; +}; + +const HOST = + /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/; +const KINDS = new Set(['tcp', 'http', 'https', 'sip-udp', 'sip-tcp', 'sip-tls', 'udp']); +const MAX_TESTS = 20; + +/** The tests, or why they're not acceptable. */ +export function parseCheck(body: unknown): { host: string; tests: Test[] } | string { + const b = body as { host?: unknown; tests?: unknown }; + if (typeof b?.host !== 'string' || !(HOST.test(b.host) || isIP(b.host))) + return 'host: a host name'; + if (!Array.isArray(b.tests) || !b.tests.length || b.tests.length > MAX_TESTS) + return `tests: 1 to ${MAX_TESTS}`; + const tests: Test[] = []; + for (const t of b.tests as Record[]) { + if (typeof t?.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(t.id)) return 'tests[].id'; + if (typeof t.kind !== 'string' || !KINDS.has(t.kind)) return `${t.id}: kind`; + if (!Number.isInteger(t.port) || (t.port as number) < 1 || (t.port as number) > 65535) + return `${t.id}: port`; + if (t.path !== undefined && (typeof t.path !== 'string' || !/^\/[\w./~-]{0,100}$/.test(t.path))) + return `${t.id}: path`; + if (t.kind === 'udp' && (typeof t.token !== 'string' || !/^[A-Za-z0-9]{8,64}$/.test(t.token))) + return `${t.id}: token`; + tests.push({ + id: t.id, + kind: t.kind, + port: t.port, + ...(t.path !== undefined && { path: t.path }), + ...(t.websocket === true && { websocket: true }), + ...(t.kind === 'udp' && { token: t.token }), + } as Test); + } + return { host: b.host, tests }; +} + +const clientIp = (req: IncomingMessage, trustProxy: boolean) => { + const xff = req.headers['x-forwarded-for']; + const raw = + trustProxy && typeof xff === 'string' + ? xff.split(',').at(-1)!.trim() + : req.socket.remoteAddress!; + return raw.replace(/^::ffff:/, ''); +}; + +function readJson(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => { + size += c.length; + if (size > 8192) req.destroy(new Error('too big')); + else chunks.push(c); + }); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + +export function createProbe(opts: ProbeOptions = {}): Server { + const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts; + // ponytail: in-process memory; addresses past the window are swept on each check. + const seen = new Map(); + const allow = (ip: string, now = Date.now()) => { + for (const [k, v] of seen) if (now - v.at(-1)! >= windowMs) seen.delete(k); + const list = (seen.get(ip) ?? []).filter((t) => now - t < windowMs); + if (list.length >= maxChecks) return false; + seen.set(ip, [...list, now]); + return true; + }; + const send = (res: import('node:http').ServerResponse, code: number, body: unknown) => + res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(body)); + + return createServer((req, res) => { + if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true }); + if (req.method !== 'POST' || req.url !== '/v1/check') + return send(res, 404, { error: 'not found' }); + const ip = clientIp(req, trustProxy); + void readJson(req) + .then(async (body) => { + const check = parseCheck(body); + if (typeof check === 'string') return send(res, 400, { error: check }); + if (!allow(ip)) + return send(res, 429, { error: 'too many checks; try again in 10 minutes' }); + const results: Result[] = await Promise.all( + check.tests.map((t) => runTest(ip, check.host, t)), + ); + send(res, 200, { ip, results }); + }) + .catch(() => send(res, 400, { error: 'a JSON body' })); + }); +} diff --git a/apps/probe/tsconfig.json b/apps/probe/tsconfig.json new file mode 100644 index 0000000..2322097 --- /dev/null +++ b/apps/probe/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "types": ["node"] }, + "include": ["src"] +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c9b2740..c9c0fe7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -4,6 +4,8 @@ import type { AboutDto, SystemEventDto, SystemStatus, + DiagnosticsDto, + NetworkTestDto, QueueReportRow, ReportRange, ReportSummary, @@ -239,6 +241,9 @@ export const systemApi = { detectPublicIp: () => api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip), tls: () => api('GET', '/api/system/tls'), + diagnostics: () => api('GET', '/api/system/diagnostics'), + networkTest: (mode: 'quick' | 'complete') => + api('POST', '/api/system/diagnostics/network', { mode }), backups: () => api('GET', '/api/system/backups'), backupNow: () => api('POST', '/api/system/backups'), setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'), diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx index 3260c19..a158edd 100644 --- a/apps/web/src/pages/SetupPage.tsx +++ b/apps/web/src/pages/SetupPage.tsx @@ -216,6 +216,7 @@ function NetworkStep({ next }: { next: () => void }) { publicIp={s.data['network.publicIp'] ?? ''} lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''} lanIps={status.data.lanIps} + rtpPorts={status.data.ports.rtp} localNets={s.data['network.localNets'].join(', ')} /> ); @@ -226,6 +227,7 @@ function NetworkForm(p: { publicIp: string; lanIp: string; lanIps: string[]; + rtpPorts: string; localNets: string; }) { const [publicIp, setPublicIp] = useState(p.publicIp); @@ -325,7 +327,7 @@ function NetworkForm(p: { breaks them.
  • - Forward UDP 5060 and UDP 10000–20000 to this server ( + Forward UDP 5060 and UDP {p.rtpPorts} to this server ( {lanIp || 'its LAN IP'}). Behind two routers, do it on both.
  • diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index c495ead..8304094 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -417,13 +417,18 @@ function NetworkTab({ s }: { s: SettingsDto }) { const save = useSave(); const [ip, setIp] = useState(s['network.publicIp'] ?? ''); const [watch, setWatch] = useState(s['network.publicIpWatch']); + const [probe, setProbe] = useState(s['network.probeUrl'] ?? ''); const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp }); return (
    { 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, + }); }} > @@ -449,6 +454,21 @@ function NetworkTab({ s }: { s: SettingsDto }) { When it changes, the new IP is saved and admins get an email asking for a telephony restart. Asks api.ipify.org (or ifconfig.me) for the address.

    + + {(p) => ( + setProbe(e.target.value)} + /> + )} +
    + } + > +

    This server

    + {inside.isPending &&

    Checking…

    } + {inside.error && {inside.error.message}} + {inside.data && } +
    +

    From the internet

    +

    + The network test probe connects back to this server from outside, the way a phone on + mobile data or your SIP provider does. Quick: ports 80, 443 and SIP. Complete: also SIP + over TLS, the browser phone and the call audio ports. +

    +
    + + +
    + {inside.data && !hasProbe && ( +

    + Set a probe first:{' '} + + Settings → Network + + . +

    + )} + {test.isPending && ( +

    + Testing… (up to half a minute) +

    + )} + {test.error && {test.error.message}} +
    + {test.data && ( + + )} + + ); +} + const SEVERITY: Record = { info: ['Info', 'bg-sky-500'], warning: ['Warning', 'bg-amber-500'], @@ -181,6 +280,8 @@ export function SystemPage() {
    + + :8080 +# +# Host networking, so it sees the real address that asks (Docker's published ports can hide it). +# Behind your own HTTPS reverse proxy instead: set PORT to a local port, TRUST_PROXY=true, and have +# the proxy set X-Forwarded-For. The SIP checks connect out from here, so no other ports are needed. +services: + probe: + # Built by each release, like the api and asterisk images (pick your registry and version). + image: ghcr.io/iamdoubz/selfphoneng-probe:latest + restart: unless-stopped + network_mode: host + environment: + PORT: '8080' + TRUST_PROXY: 'false' + logging: + driver: json-file + options: { max-size: 10m, max-file: '3' } diff --git a/docs/admin-guide/README.md b/docs/admin-guide/README.md index d308ed7..2bf7032 100644 --- a/docs/admin-guide/README.md +++ b/docs/admin-guide/README.md @@ -35,7 +35,7 @@ server is behind one; see [Network](network.md)): | 80 | TCP | Only with the bundled Caddy: getting the HTTPS certificate | | 5060 | UDP (+TCP) | SIP: voip.ms and desk phones | | 5061 | TCP | Only if you turn on SIP over TLS | -| 10000–20000 | UDP | Call audio (RTP) | +| 10000–20000 | UDP | Call audio (RTP); another range with `RTP_PORT_START`/`RTP_PORT_END` ([Network](network.md#call-audio-ports)) | Everything else (the database, the api on 8080, Asterisk's 5038 and 8088) listens on 127.0.0.1 only. Don't open them. diff --git a/docs/admin-guide/network.md b/docs/admin-guide/network.md index acf387d..042ba46 100644 --- a/docs/admin-guide/network.md +++ b/docs/admin-guide/network.md @@ -15,7 +15,7 @@ the wrong place. " disable SIP ALG". 2. **Forward ports** to the server's LAN IP: - UDP 5060 (SIP); - - UDP 10000–20000 (audio); + - UDP 10000–20000 (audio; see [Call audio ports](#call-audio-ports) to use another range); - TCP 443 (and 80 with the bundled Caddy); - TCP 5061 only if you use SIP over TLS. 3. **Double NAT** (the ISP's box in front of your own router): forward the ports on both, or put @@ -34,6 +34,58 @@ the wrong place. ![Settings → Network](img/settings-network.png) +## Call audio ports + +Call audio (RTP) uses UDP **10000–20000** unless you set another range in `.env` (or, in a +Portainer stack, in the api's environment): + +```sh +RTP_PORT_START=40000 +RTP_PORT_END=40199 +``` + +Then `docker compose up -d` (the api writes the range into Asterisk's config), and open **the same +range** on the firewall and the router instead of 10000–20000. Each call uses two ports, so 200 +ports is plenty for a small office. Pick a range nothing else on the server uses: System → +Diagnostics counts how many ports in the range are already taken. + +## Diagnostics: is the server reachable? + +**System → Diagnostics** checks what usually stops calls from working outside the office, and +says how to fix each problem it finds. + +**This server** runs every time you open the page: + +- the public IP is set, and is the address the server really reaches the internet from; +- the host name's DNS record points at that address (with Cloudflare, it must be "DNS only": its + proxy carries web pages, not SIP); +- the server's LAN address is in the local networks; +- whether phones may sign in from anywhere (the phone network limit refuses phone apps on mobile + data), and whether phone setup is limited to the local networks; +- Asterisk is listening on the SIP ports; +- how many call audio ports are free. + +**From the internet** needs a **network test probe**: a small program, run on a machine outside +your network, that connects back to the server the way a phone on mobile data or your SIP provider +does. A server can't test this itself: most routers either don't route a connection to their own +public address back in, or route it without going through the port forwards. + +- **Quick network test**: TCP 80 and 443, and SIP on 5060 over UDP (and TCP if it's on). +- **Complete network test**: also SIP over TLS (if on), the browser phone's `/ws`, and a sample + of the call audio ports (the server listens on them while the probe sends to them). + +To run a probe, on any machine outside your network (a small VPS is plenty): + +```sh +docker compose -f deploy/probe/compose.yml up -d +``` + +Then put its address, for example `http://203.0.113.50:8080`, in **Settings → Network → Network +test probe**. The probe only ever tests the address a request comes from, so nobody can use it +against someone else, and each address gets a few tests per 10 minutes. Its SIP test is one +OPTIONS request; if you run fail2ban or CrowdSec, add the probe's address to their allow list so a +test never counts toward a ban. + ## Firewall Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the @@ -46,7 +98,7 @@ ufw allow OpenSSH ufw allow 443/tcp ufw allow 80/tcp # only with the bundled Caddy ufw allow 5060/udp -ufw allow 10000:20000/udp +ufw allow 10000:20000/udp # or your RTP_PORT_START:RTP_PORT_END ufw enable ``` diff --git a/docs/admin-guide/troubleshooting.md b/docs/admin-guide/troubleshooting.md index 0534a4f..0836d91 100644 --- a/docs/admin-guide/troubleshooting.md +++ b/docs/admin-guide/troubleshooting.md @@ -49,11 +49,26 @@ The call connects but one side (or both) hears nothing. It's almost always NAT 1. **SIP ALG** must be off on every router in the path. This is the first suspect. 2. **Public IP:** Settings → Network must show the server's current public IP. If it changed, update it and restart telephony (`docker compose restart asterisk`). -3. **Audio ports:** UDP 10000–20000 must be forwarded to the server and open on its firewall. +3. **Audio ports:** UDP 10000–20000 (or your `RTP_PORT_START`–`RTP_PORT_END`) must be forwarded to the server and open on its firewall. **System → Diagnostics → Complete network test** checks it from outside. 4. **Local networks** (setup wizard, Network step) must list your office networks, and only them. 5. **Calls drop after about 30 seconds:** same causes. The answer never reaches the other side, so it gives up. +## Phones work on the office Wi-Fi, not on mobile data + +A phone app (Linphone) registers and calls on the office network, but not elsewhere. Open +**System → Diagnostics**: it checks each of these and says what to change. + +1. **The phone network limit** (Settings → Security, "Only accept desk phones from the networks + below") refuses phones outside the local networks. Turn it off, or add the networks they use. +2. **Public IP** (Settings → Network) must be set: otherwise phones outside are given the + server's private address. +3. **DNS**: the phone finds the server by its host name, which must point at the public IP. Behind + Cloudflare's proxy (orange cloud) web pages work but SIP doesn't: set it to "DNS only". +4. **Port forwards**: UDP 5060 and the audio ports forwarded on the router, as well as open on the + server's firewall. On the office Wi-Fi the phone reaches the server directly, so missing + forwards only show up outside. The **Quick network test** checks 5060 from outside. + ## The trunk won't register Trunks shows **Rejected** or **Not registered**. diff --git a/docs/spec/01-infrastructure.md b/docs/spec/01-infrastructure.md index 0855c5a..f59aca5 100644 --- a/docs/spec/01-infrastructure.md +++ b/docs/spec/01-infrastructure.md @@ -382,7 +382,10 @@ rtpend = 20000 #include selfphoneng/rtp_selfphoneng.conf ``` (The generator writes `icesupport`, `stunaddr` and `[ice_host_candidates]` into -`rtp_selfphoneng.conf`, see `03-config-generator.md` §4.) +`rtp_selfphoneng.conf`, see `03-config-generator.md` §4. Since 2026-10-21 it also writes +`rtpstart`/`rtpend` there from the api's `RTP_PORT_START`/`RTP_PORT_END`: Asterisk takes a +setting's last value, so they win over the defaults above, which only cover the first boot. +`call-test.ts` checks `rtp show settings` with 31000–31200.) ### 3.7 `pjsip.conf` ```ini diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md index 892b97a..c88e6a2 100644 --- a/docs/spec/05-web-ui.md +++ b/docs/spec/05-web-ui.md @@ -48,7 +48,7 @@ Left sidebar navigation, in this order: | SMS | `/admin/sms` | Same UI as the user one, with all DIDs | | Blocked numbers | `/admin/blocked` | | | Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS | -| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell | +| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell | ### 2.1 First-run setup wizard (`/setup`) diff --git a/docs/spec/08-security-ops.md b/docs/spec/08-security-ops.md index c67b27d..b3d0f3f 100644 --- a/docs/spec/08-security-ops.md +++ b/docs/spec/08-security-ops.md @@ -95,6 +95,29 @@ get the proxy's address only (below). The setting is on Settings → Security, w - `.env` mode 0600 (install.sh sets it). - Recordings and voicemail are served only through authenticated api routes, never as static files. +### 2.6 Network diagnostics (owner, 2026-10-21) +System → Diagnostics (`services/diagnostics.ts`, admin only). Every failed or doubtful line says +how to fix it. +- **This server** (`GET /api/system/diagnostics`): public IP set and equal to what + api.ipify.org sees; `PUBLIC_HOSTNAME`'s A records include it (Cloudflare proxy named in the fix); + `network.lanIp` inside `network.localNets`; the phone ACL (§2.2) and `provisioning.lanOnly` + flagged as limits on phones elsewhere; `pjsip show transports` has the enabled transports; the + RTP range's even ports bound once each, a warning past a quarter taken; whether a probe is set. +- **From the internet** (`POST /api/system/diagnostics/network {mode}`) needs the probe, + `apps/probe` (no dependencies, its own image `selfphoneng-probe`, `deploy/probe/compose.yml`), + run outside the network; `network.probeUrl` points at it. A server can't test its own ports: + hairpin NAT is missing or skips the port forwards, and browsers refuse port 5060. + - `POST /v1/check {host, tests}`: the probe tests **only the address the request came from** (the + last X-Forwarded-For entry with `TRUST_PROXY=true`), so it can't be aimed at anyone else; 20 + tests at most, 10 checks per address per 10 minutes, 4 s per test. + - Quick: HTTP 80, HTTPS 443 `/healthz` (status and certificate), SIP OPTIONS over UDP (and TCP + when on) to `sip.udpPort`. Complete adds SIP over TLS (when on), a WebSocket upgrade on `/ws` + (protocol `sip`), and up to four even ports of the RTP range: the api binds them and the probe + sends a token to each. + - The answer's `ip` is checked too: a private one means the probe is inside the network; another + public one than `network.publicIp` means a VPN or a changed address. +- The RTP range comes from `RTP_PORT_START`/`RTP_PORT_END` (api env, default 10000–20000; 01 §3.6). + ## 3. Operations ### 3.1 Install / update diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index f27ec40..fe63f67 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.ts @@ -20,6 +20,11 @@ export const SettingsSchemas = { .default(['192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12']), 'network.lanIp': z.ipv4().nullable().default(null), 'network.publicIpWatch': z.boolean().default(false), + /** The network test probe (apps/probe), outside this network; null: no outside tests (08 §2.6). */ + 'network.probeUrl': z + .url({ protocol: /^https?$/ }) + .nullable() + .default(null), 'network.stunServer': z .string() .regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/) @@ -128,6 +133,26 @@ export type SystemStatus = { restartRequired: boolean; }; +/** One line of System → Diagnostics (08 §2.6). `fix` says what to do when it isn't ok. */ +export type DiagnosticCheck = { + id: string; + label: string; + status: 'ok' | 'warn' | 'fail' | 'skip'; + detail: string; + fix?: string; +}; + +/** GET /api/system/diagnostics: what the server can check from the inside. */ +export type DiagnosticsDto = { checks: DiagnosticCheck[] }; + +/** POST /api/system/diagnostics/network {mode}: the probe's outside view of the server. */ +export type NetworkTestDto = { + mode: 'quick' | 'complete'; + /** The address the probe tested: the one this server reaches the internet from. */ + testedIp: string | null; + checks: DiagnosticCheck[]; +}; + /** GET /api/system/events: the System page's event log, newest first. */ export type SystemEventDto = { id: number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6d39e5..44156ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,8 @@ importers: specifier: ^0.31.11 version: 0.31.11 + apps/probe: {} + apps/web: dependencies: '@hookform/resolvers': diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 326c32b..ec3440a 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -270,3 +270,9 @@ warning is gone. System information shows the IP and ports, the event log and it in, the login page's link opens About, whose "Code v" downloads `selfphone.tar.gz` byte for byte as built: the runner builds the api image and copies its `VERSION` and archive into the api's working folder (5th argument). About 6 minutes. + +`m11-diagnostics.mjs ` (after `m1-t4.mjs`, on a +database without `network.probeUrl`) checks System → Diagnostics: the server's checks show, the +network tests wait for a probe, then it sets one in Settings → Network (a probe it starts on +127.0.0.1:18090, so the results also warn that it's inside the network) and runs a quick and a +complete test. Asterisk isn't needed. diff --git a/scripts/e2e/m11-diagnostics.mjs b/scripts/e2e/m11-diagnostics.mjs new file mode 100644 index 0000000..e357307 --- /dev/null +++ b/scripts/e2e/m11-diagnostics.mjs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// System → Diagnostics (08 §2.6) in a browser: the server's own checks show, the network tests +// stay off until a probe is set in Settings → Network, then a quick and a complete test run +// against a probe started here (on 127.0.0.1, so it also flags that it's inside the network). +// Runs after m1-t4.mjs; Asterisk isn't needed (the SIP rows then show how to fix them). +// Usage: node scripts/e2e/m11-diagnostics.mjs +import assert from 'node:assert/strict'; +import { launch } from './cdp.mjs'; + +const { createProbe } = await import('../../apps/probe/src/server.ts'); +const [base, email, pw, shotsDir] = process.argv.slice(2); +const probe = createProbe(); +await new Promise((r) => probe.listen(18090, '127.0.0.1', r)); +const p = await launch({ shotsDir }); +const text = (s) => `document.body.innerText.includes(${JSON.stringify(s)})`; +try { + await p.goto(`${base}/login`); + await p.waitFor(`!!document.querySelector('input[type=email]')`); + await p.fill('Email', email); + await p.fill('Password', pw); + await p.click('Sign in'); + await p.waitFor(`location.pathname === '/admin'`, 'signed in'); + + await p.goto(`${base}/admin/system`); + await p.waitFor(`!!document.querySelector('ul[aria-label="Server checks"]')`, 'server checks'); + for (const label of ['Public IP', 'DNS for', 'Where phones can sign in from', 'Call audio ports']) + await p.waitFor(text(label), label); + await p.waitFor(text('Set a probe first'), 'no probe yet'); + assert.equal( + await p.evaluate( + `[...document.querySelectorAll('button')].find(b => b.textContent === 'Quick network test').disabled`, + ), + true, + ); + await p.evaluate(`document.querySelector('ul[aria-label="Server checks"]').scrollIntoView()`); + await p.shot('m11-diag-01-server-checks'); + console.log('server checks shown; network tests wait for a probe'); + + await p.goto(`${base}/admin/settings/network`); + await p.waitFor(text('Network test probe'), 'network settings'); + await p.fill('Network test probe', 'http://127.0.0.1:18090'); + await p.click('Save'); + await p.waitFor(text('Saved.'), 'saved'); + + await p.goto(`${base}/admin/system`); + await p.waitFor(text('Network test probe'), 'probe row'); + await p.waitFor( + `[...document.querySelectorAll('button')].find(b => b.textContent === 'Quick network test')?.disabled === false`, + 'quick enabled', + ); + await p.click('Quick network test'); + await p.waitFor( + `!!document.querySelector('ul[aria-label="Quick network test"]')`, + 'quick results', + 40_000, + ); + for (const label of ['Tested address', 'HTTPS (TCP 443)', 'SIP (UDP 5060)', 'How to fix']) + await p.waitFor(text(label), label); + await p.waitFor(text('a private address'), 'inside-the-network warning'); + await p.evaluate( + `document.querySelector('ul[aria-label="Quick network test"]').scrollIntoView()`, + ); + await p.shot('m11-diag-02-quick'); + console.log('quick test: results with fixes'); + + await p.click('Complete network test'); + await p.waitFor( + `!!document.querySelector('ul[aria-label="Complete network test"]')`, + 'complete results', + 40_000, + ); + await p.waitFor(text('Browser phone (/ws)'), '/ws row'); + await p.waitFor(text('Call audio (UDP'), 'audio row'); + await p.evaluate( + `document.querySelector('ul[aria-label="Complete network test"]').scrollIntoView()`, + ); + await p.shot('m11-diag-03-complete'); + console.log('complete test: /ws and call audio too'); +} finally { + await p.close(); + probe.close(); +}