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:
1 parent
90b3081368
commit
65a0438dde
50 files changed
+1704
-59
No files matched your search
@@ -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
|
||||
@@ -1,8 +1,8 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Container images, built only when a release is published (08 §3.1). Runs on GitHub Actions and
|
||||
# Gitea Actions (both read .github/workflows); each pushes to its own registry:
|
||||
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN)
|
||||
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_TOKEN:
|
||||
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
|
||||
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk,probe} (secrets REGISTRY_USER + REGISTRY_TOKEN:
|
||||
# a personal access token with write:package; Gitea's job token can't push packages)
|
||||
# Tags: the version (0.0.1), plus <major>.<minor> and latest; a pre-release gets the version and dev.
|
||||
name: release
|
||||
@@ -79,6 +79,7 @@ jobs:
|
||||
)
|
||||
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
|
||||
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
|
||||
docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" .
|
||||
- name: Check
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -88,15 +89,16 @@ jobs:
|
||||
docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')"
|
||||
test "$(docker run --rm --entrypoint cat "$p-api:build" /app/VERSION)" = "${{ steps.v.outputs.version }}"
|
||||
docker run --rm --entrypoint asterisk "$p-asterisk:build" -V
|
||||
docker run --rm "$p-probe:build" node --input-type=module -e "await import('/app/src/server.ts')"
|
||||
- name: Push
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
p="${{ steps.v.outputs.prefix }}"
|
||||
for image in api asterisk; do
|
||||
for image in api asterisk probe; do
|
||||
for t in ${{ steps.v.outputs.tags }}; do
|
||||
docker tag "$p-$image:build" "$p-$image:$t"
|
||||
docker push "$p-$image:$t"
|
||||
done
|
||||
done
|
||||
echo "pushed: $p-{api,asterisk}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
echo "pushed: $p-{api,asterisk,probe}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
@@ -5,6 +5,13 @@ tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **System → Diagnostics:** checks what usually keeps phones off the network (public IP, DNS,
|
||||
local networks, the phone network limit, SIP listening, free audio ports), with how to fix each.
|
||||
A **Quick** or **Complete network test** checks the ports from the internet through a network
|
||||
test probe (`apps/probe`, image `selfphoneng-probe`) you run outside your network; set its URL
|
||||
in Settings → Network.
|
||||
- The call audio port range is configurable: `RTP_PORT_START` and `RTP_PORT_END` (default
|
||||
10000–20000).
|
||||
- Updating (Docker only): a new version backs up before it changes the database, and stops
|
||||
instead if the backup fails. `scripts/update.sh` updates a compose install to a release, checks
|
||||
the result and goes back by itself if the check fails. The admin guide's "Updating" section
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ declare module 'fastify' {
|
||||
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
|
||||
generator: Generator;
|
||||
/** Decorated by the caller too (tests pass a fake). */
|
||||
ami: Pick<AmiClient, 'action' | 'isConnected'>;
|
||||
ami: Pick<AmiClient, 'action' | 'command' | 'isConnected'>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
+45
-37
@@ -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/<id> (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/<uniqueid>.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/<id> (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/<uniqueid>.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<typeof EnvSchema>;
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,4 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
stunaddr = stun.example.org:3478
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -101,6 +101,8 @@ export type GeneratorDeps = {
|
||||
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
|
||||
onApplied?: (r: ApplyResult) => void;
|
||||
debounceMs?: number;
|
||||
/** RTP_PORT_START/END (config.ts). */
|
||||
rtpPorts?: { start: number; end: number };
|
||||
};
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
@@ -165,7 +167,7 @@ export function createGenerator(deps: GeneratorDeps) {
|
||||
restartRequired: false,
|
||||
};
|
||||
try {
|
||||
const snap = await loadSnapshot(db);
|
||||
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
|
||||
const files = render(snap);
|
||||
await mkdir(join(dir, '.prev'), { recursive: true });
|
||||
const current = new Map<GeneratedFileName, string | null>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -5,7 +5,13 @@ import { networkInterfaces } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { desc, lt } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared';
|
||||
import type {
|
||||
DiagnosticsDto,
|
||||
ListResponse,
|
||||
NetworkTestDto,
|
||||
SystemEventDto,
|
||||
SystemStatus,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
@@ -14,7 +20,8 @@ import { systemEvents } from '../db/schema.ts';
|
||||
import { appVersion } from './about.ts';
|
||||
import { csvCell } from './calls.ts';
|
||||
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts';
|
||||
import { HttpError, notFound } from '../errors.ts';
|
||||
import { badRequest, HttpError, notFound } from '../errors.ts';
|
||||
import { insideChecks, networkTest } from '../services/diagnostics.ts';
|
||||
import { AmiDisconnectedError } from '../ami/client.ts';
|
||||
import { GENERATED_FILES } from '../generator/index.ts';
|
||||
|
||||
@@ -67,7 +74,7 @@ export async function systemRoutes(app: FastifyInstance) {
|
||||
sip: s['sip.udpPort'],
|
||||
sipTcp: s['sip.tcpEnabled'],
|
||||
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null,
|
||||
rtp: '10000–20000', // asterisk/conf/rtp.conf
|
||||
rtp: `${app.config.RTP_PORT_START}–${app.config.RTP_PORT_END}`, // RTP_PORT_START/END
|
||||
https: 443,
|
||||
},
|
||||
ami: { connected: app.ami.isConnected() },
|
||||
@@ -163,6 +170,20 @@ export async function systemRoutes(app: FastifyInstance) {
|
||||
return reply.sendFile(name, dir);
|
||||
});
|
||||
|
||||
// Diagnostics (08 §2.6): the inside checks; the probe's outside test, quick or complete.
|
||||
const diag = () => ({ db: app.db, config: app.config, ami: app.ami });
|
||||
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
|
||||
checks: await insideChecks(diag()),
|
||||
}));
|
||||
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
|
||||
const { mode } = z
|
||||
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
|
||||
.parse(req.body ?? {});
|
||||
if (!(await getAllSettings(app.db))['network.probeUrl'])
|
||||
throw badRequest('No network test probe set (Settings → Network)');
|
||||
return networkTest(diag(), mode);
|
||||
});
|
||||
|
||||
app.get('/api/system/generated-files', async () => ({
|
||||
files: await Promise.all(
|
||||
GENERATED_FILES.map(async (name) => ({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true, "types": ["node"] },
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
AboutDto,
|
||||
SystemEventDto,
|
||||
SystemStatus,
|
||||
DiagnosticsDto,
|
||||
NetworkTestDto,
|
||||
QueueReportRow,
|
||||
ReportRange,
|
||||
ReportSummary,
|
||||
@@ -239,6 +241,9 @@ export const systemApi = {
|
||||
detectPublicIp: () =>
|
||||
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
|
||||
tls: () => api<TlsCert>('GET', '/api/system/tls'),
|
||||
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
|
||||
networkTest: (mode: 'quick' | 'complete') =>
|
||||
api<NetworkTestDto>('POST', '/api/system/diagnostics/network', { mode }),
|
||||
backups: () => api<BackupDto[]>('GET', '/api/system/backups'),
|
||||
backupNow: () => api<BackupDto>('POST', '/api/system/backups'),
|
||||
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
|
||||
|
||||
@@ -216,6 +216,7 @@ function NetworkStep({ next }: { next: () => void }) {
|
||||
publicIp={s.data['network.publicIp'] ?? ''}
|
||||
lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''}
|
||||
lanIps={status.data.lanIps}
|
||||
rtpPorts={status.data.ports.rtp}
|
||||
localNets={s.data['network.localNets'].join(', ')}
|
||||
/>
|
||||
);
|
||||
@@ -226,6 +227,7 @@ function NetworkForm(p: {
|
||||
publicIp: string;
|
||||
lanIp: string;
|
||||
lanIps: string[];
|
||||
rtpPorts: string;
|
||||
localNets: string;
|
||||
}) {
|
||||
const [publicIp, setPublicIp] = useState(p.publicIp);
|
||||
@@ -325,7 +327,7 @@ function NetworkForm(p: {
|
||||
breaks them.
|
||||
</li>
|
||||
<li>
|
||||
Forward <strong>UDP 5060</strong> and <strong>UDP 10000–20000</strong> to this server (
|
||||
Forward <strong>UDP 5060</strong> and <strong>UDP {p.rtpPorts}</strong> to this server (
|
||||
{lanIp || 'its LAN IP'}). Behind two routers, do it on both.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -417,13 +417,18 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const [ip, setIp] = useState(s['network.publicIp'] ?? '');
|
||||
const [watch, setWatch] = useState(s['network.publicIpWatch']);
|
||||
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
|
||||
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({ 'network.publicIp': ip.trim() || null, 'network.publicIpWatch': watch });
|
||||
save.mutate({
|
||||
'network.publicIp': ip.trim() || null,
|
||||
'network.publicIpWatch': watch,
|
||||
'network.probeUrl': probe.trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Field label="Public IP" hint="Changing it needs a telephony restart (see System)">
|
||||
@@ -449,6 +454,21 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
When it changes, the new IP is saved and admins get an email asking for a telephony restart.
|
||||
Asks api.ipify.org (or ifconfig.me) for the address.
|
||||
</p>
|
||||
<Field
|
||||
label="Network test probe"
|
||||
hint="A probe running outside this network (apps/probe), for System → Diagnostics. It only ever tests the address that asks it. Empty: no tests from outside."
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="url"
|
||||
placeholder="https://probe.example.com"
|
||||
className={inputClass}
|
||||
value={probe}
|
||||
onChange={(e) => setProbe(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// System (05 §2): system information, how to restart telephony, and the event log.
|
||||
// System (05 §2): system information, how to restart telephony, diagnostics, and the event log.
|
||||
// ponytail: the spec's generator runs, generated files, CLI views and audit log aren't here yet;
|
||||
// the admin guide's troubleshooting page covers them from the server's shell.
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import type { SystemEventDto } from '@selfphoneng/shared';
|
||||
import type { DiagnosticCheck, SystemEventDto } from '@selfphoneng/shared';
|
||||
import { systemApi } from '../../api/client.ts';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
|
||||
@@ -77,6 +77,105 @@ function Item({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PULSE = 'M22 12h-4l-3 9L9 3l-3 9H2';
|
||||
const STATUS: Record<DiagnosticCheck['status'], [string, string]> = {
|
||||
ok: ['OK', 'bg-green-500'],
|
||||
warn: ['Check this', 'bg-amber-500'],
|
||||
fail: ['Problem', 'bg-red-500'],
|
||||
skip: ['Not tested', 'bg-zinc-400'],
|
||||
};
|
||||
|
||||
function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) {
|
||||
return (
|
||||
<ul aria-label={label} className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{checks.map((c) => (
|
||||
<li key={c.id} className="flex gap-3 px-4 py-2 text-sm">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${STATUS[c.status][1]}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>
|
||||
<span className="font-medium">{c.label}</span>
|
||||
<span className="text-zinc-500"> · {STATUS[c.status][0]}</span>
|
||||
</p>
|
||||
<p className="break-words text-zinc-600 dark:text-zinc-400">{c.detail}</p>
|
||||
{c.fix && (
|
||||
<p className="mt-1 rounded-md bg-amber-50 px-2 py-1 break-words text-amber-900 dark:bg-amber-950/50 dark:text-amber-100">
|
||||
<strong>How to fix:</strong> {c.fix}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Diagnostics (08 §2.6): the server's own checks, and the probe's test from outside. */
|
||||
function Diagnostics() {
|
||||
const inside = useQuery({ queryKey: ['diagnostics'], queryFn: systemApi.diagnostics });
|
||||
const test = useMutation({ mutationFn: systemApi.networkTest });
|
||||
const hasProbe = inside.data?.checks.some((c) => c.id === 'probe' && c.status === 'ok');
|
||||
return (
|
||||
<Panel
|
||||
title="Diagnostics"
|
||||
path={PULSE}
|
||||
action={
|
||||
<Button onClick={() => void inside.refetch()} disabled={inside.isFetching}>
|
||||
Check again
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h3 className="px-4 pt-3 text-sm font-medium">This server</h3>
|
||||
{inside.isPending && <p className="px-4 py-2 text-sm text-zinc-500">Checking…</p>}
|
||||
{inside.error && <Alert>{inside.error.message}</Alert>}
|
||||
{inside.data && <Checks label="Server checks" checks={inside.data.checks} />}
|
||||
<div className="flex flex-col gap-2 border-t border-zinc-200 px-4 py-3 text-sm dark:border-zinc-800">
|
||||
<h3 className="font-medium">From the internet</h3>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
The network test probe connects back to this server from outside, the way a phone on
|
||||
mobile data or your SIP provider does. Quick: ports 80, 443 and SIP. Complete: also SIP
|
||||
over TLS, the browser phone and the call audio ports.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => test.mutate('quick')}
|
||||
disabled={!hasProbe || test.isPending}
|
||||
>
|
||||
Quick network test
|
||||
</Button>
|
||||
<Button onClick={() => test.mutate('complete')} disabled={!hasProbe || test.isPending}>
|
||||
Complete network test
|
||||
</Button>
|
||||
</div>
|
||||
{inside.data && !hasProbe && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
Set a probe first:{' '}
|
||||
<Link to="/admin/settings/network" className="text-sky-700 underline dark:text-sky-400">
|
||||
Settings → Network
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{test.isPending && (
|
||||
<p role="status" className="text-zinc-500">
|
||||
Testing… (up to half a minute)
|
||||
</p>
|
||||
)}
|
||||
{test.error && <Alert>{test.error.message}</Alert>}
|
||||
</div>
|
||||
{test.data && (
|
||||
<Checks
|
||||
label={`${test.data.mode === 'quick' ? 'Quick' : 'Complete'} network test`}
|
||||
checks={test.data.checks}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const SEVERITY: Record<SystemEventDto['severity'], [string, string]> = {
|
||||
info: ['Info', 'bg-sky-500'],
|
||||
warning: ['Warning', 'bg-amber-500'],
|
||||
@@ -181,6 +280,8 @@ export function SystemPage() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Diagnostics />
|
||||
|
||||
<Panel
|
||||
title="Event log"
|
||||
path={LIST}
|
||||
|
||||
@@ -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' }
|
||||
@@ -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.
|
||||
|
||||
@@ -15,7 +15,7 @@ the wrong place.
|
||||
"<router model> disable SIP ALG".
|
||||
2. **Forward ports** to the server's LAN IP:
|
||||
- UDP 5060 (SIP);
|
||||
- UDP 10000–20000 (audio);
|
||||
- UDP 10000–20000 (audio; see [Call audio ports](#call-audio-ports) to use another range);
|
||||
- TCP 443 (and 80 with the bundled Caddy);
|
||||
- TCP 5061 only if you use SIP over TLS.
|
||||
3. **Double NAT** (the ISP's box in front of your own router): forward the ports on both, or put
|
||||
@@ -34,6 +34,58 @@ the wrong place.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+2
@@ -88,6 +88,8 @@ importers:
|
||||
specifier: ^0.31.11
|
||||
version: 0.31.11
|
||||
|
||||
apps/probe: {}
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
|
||||
@@ -270,3 +270,9 @@ warning is gone. System information shows the IP and ports, the event log and it
|
||||
in, the login page's link opens About, whose "Code v<version>" downloads `selfphone.tar.gz` byte for
|
||||
byte as built: the runner builds the api image and copies its `VERSION` and archive into the api's
|
||||
working folder (5th argument). About 6 minutes.
|
||||
|
||||
`m11-diagnostics.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>` (after `m1-t4.mjs`, on a
|
||||
database without `network.probeUrl`) checks System → Diagnostics: the server's checks show, the
|
||||
network tests wait for a probe, then it sets one in Settings → Network (a probe it starts on
|
||||
127.0.0.1:18090, so the results also warn that it's inside the network) and runs a quick and a
|
||||
complete test. Asterisk isn't needed.
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user