security: a key for the network test probe (08 §7 #9)
ci / check (push) Successful in 1m13s
ci / images (push) Successful in 4m2s
ci / check (pull_request) Successful in 1m42s
ci / images (pull_request) Successful in 28s

Settings → Network makes a key, shown once and stored encrypted; the api sends
it to the probe as a Bearer token, and a new probe address drops it. A probe
with PROBE_KEY only answers that key. Without one it stays open, and
Diagnostics strongly recommends setting it.
This commit is contained in:
iamdoubz committed 2026-10-28 16:28:23 -05:00
1 parent 958405651b
commit 60348cf0c5
14 files changed
+233 -14

No files matched your search

+26
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
import { randomBytes } from 'node:crypto';
import { eq, inArray, isNotNull } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
@@ -63,6 +64,7 @@ const READ_ONLY = new Set<string>([
'spam.state',
'sms.webhookToken',
'setup.completed',
'network.probeKeyEnc',
]);
async function toDto(db: Db): Promise<SettingsDto> {
@@ -72,10 +74,12 @@ async function toDto(db: Db): Promise<SettingsDto> {
'auth.oidc': oidc,
'auth.ldap': ldap,
spam,
'network.probeKeyEnc': probeKey,
...rest
} = await getAllSettings(db);
return {
...rest,
'network.hasProbeKey': probeKey !== null,
spam: {
...spam,
ipqs: spam.ipqs && { hasKey: true },
@@ -356,6 +360,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const cert = await readTlsCert(app.config.TLS_DIR);
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
}
// The probe's key was made for its address: another probe doesn't get it (08 §7 #9).
if ('network.probeUrl' in values && values['network.probeUrl'] !== before['network.probeUrl'])
values['network.probeKeyEnc'] = null;
for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never);
// People's own Contrack tokens were for the old address: they connect again (08 §7 #7).
@@ -487,6 +494,25 @@ export async function settingsRoutes(app: FastifyInstance) {
},
);
/**
* A new key for the network test probe (08 §7 #9): shown this once, for its PROBE_KEY. Made for
* the probe address set now; the old key stops working on the next test.
*/
app.post('/api/settings/network/probe-key', async (req) => {
if (!(await getSetting(db, 'network.probeUrl')))
throw badRequest('Set the probe address first');
const key = randomBytes(32).toString('base64url');
await setSetting(db, 'network.probeKeyEnc', encrypt(key));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'settings',
summary: 'Made a new network test probe key',
});
return { key };
});
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
+23 -6
View File
@@ -9,6 +9,7 @@ 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 { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { detectPublicIp } from '../jobs.ts';
import { getAllSettings } from './settings.ts';
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
);
out.push(
s['network.probeUrl']
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
: check(
!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.',
),
)
: s['network.probeKeyEnc']
? check('probe', 'Network test probe', 'ok', `${s['network.probeUrl']}, with its key`)
: check(
'probe',
'Network test probe',
'warn',
`${s['network.probeUrl']} has no key: anyone who finds it can use it.`,
'Settings → Network → Generate key, then set it as PROBE_KEY on the probe and restart it (strongly recommended).',
),
);
return out;
}
@@ -289,14 +298,20 @@ export async function networkTest(
let ip: string | null = null;
let results: ProbeResult[] = [];
let probeError: string | null = null;
let keyRefused = false;
const key = s['network.probeKeyEnc'];
try {
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: {
'content-type': 'application/json',
...(key && { authorization: `Bearer ${decrypt(key)}` }),
},
body: JSON.stringify({ host, tests }),
signal: AbortSignal.timeout(30_000),
});
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
keyRefused = r.status === 401;
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.
@@ -316,7 +331,9 @@ export async function networkTest(
'Network test probe',
'fail',
`${probe} didn't answer: ${probeError}`,
'Check the probe runs and is reachable from this server, then try again.',
keyRefused
? "The probe's PROBE_KEY isn't this server's key. Settings → Network → Generate key, set it as PROBE_KEY on the probe and restart it."
: 'Check the probe runs and is reachable from this server, then try again.',
),
],
};
+51
View File
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
401,
);
});
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
const cookie = await t.login(ADMIN.email, ADMIN.password);
const as = (method: string, url: string, payload?: object) =>
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
// No key yet: the inside checks say to make one.
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
const g = await as('POST', '/api/settings/network/probe-key');
expect(g.statusCode, g.body).toBe(200);
const { key } = g.json() as { key: string };
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
const s = await as('GET', '/api/settings');
expect(s.json()['network.hasProbeKey']).toBe(true);
expect(s.body).not.toContain(key);
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
400,
);
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
let auth: string | null = null;
await networkTest(
{
...deps,
fetchProbe: (async (_u: URL, init: RequestInit) => {
auth = new Headers(init.headers).get('authorization');
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
}) as unknown as typeof fetch,
},
'quick',
);
expect(auth).toBe(`Bearer ${key}`);
// The probe refusing it says what to do.
const refused = await networkTest(
{
...deps,
fetchProbe: (async () =>
new Response(JSON.stringify({ error: 'a key is needed' }), {
status: 401,
})) as unknown as typeof fetch,
},
'quick',
);
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
// Another address: the key was for the old probe, so it's gone.
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
});
});
+9 -2
View File
@@ -3,12 +3,19 @@
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy on this machine that sets
// X-Forwarded-For. HOST: where to listen; with TRUST_PROXY=true 127.0.0.1 (only the proxy may
// reach it), else every address.
// reach it), else every address. PROBE_KEY: the key from SelfPhoneNG's Settings → Network;
// without one anyone who finds the probe can use it.
import { createProbe } from './server.ts';
const port = Number(process.env.PORT ?? 8080);
const trustProxy = process.env.TRUST_PROXY === 'true';
const host = process.env.HOST ?? (trustProxy ? '127.0.0.1' : '::');
const probe = createProbe({ trustProxy });
const key = process.env.PROBE_KEY || undefined;
const probe = createProbe({ trustProxy, key });
probe.listen(port, host, () => console.log(`selfphoneng-probe listening on ${host} ${port}`));
if (!key)
console.warn(
'No PROBE_KEY: anyone who finds this probe can use it. Make one in SelfPhoneNG ' +
'(Settings → Network → Generate key) and set PROBE_KEY.',
);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
+21 -1
View File
@@ -1,7 +1,8 @@
// 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,
// and believes X-Forwarded-For only from a proxy on this machine (08 §7 #4).
// believes X-Forwarded-For only from a proxy on this machine (08 §7 #4), and with PROBE_KEY only
// answers who sends it (#9).
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
import {
createServer as httpServer,
@@ -171,6 +172,25 @@ describe('probe server', () => {
});
});
describe('the probe key', () => {
it('with a key, only a request that sends it gets tested (08 §7 #9)', async () => {
const key = 'k3y'.repeat(15);
const probe = await listen(createProbe({ key }));
const post = (authorization?: string) =>
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(authorization && { authorization }) },
body: JSON.stringify({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }),
});
expect((await post()).status).toBe(401);
expect((await post('Bearer wrong')).status).toBe(401);
expect((await post(`Bearer ${key}x`)).status).toBe(401);
expect((await post(`Bearer ${key}`)).status).toBe(200);
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
probe.close();
});
});
describe('the address that asked', () => {
const req = (peer: string, xff?: string) =>
({
+12 -2
View File
@@ -1,7 +1,9 @@
// 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.
// from being aimed at someone else; each address gets a few checks per 10 minutes. With a key
// (PROBE_KEY, made in SelfPhoneNG's Settings → Network), only who sends it is tested (08 §7 #9).
import { createHash, timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import { isIP } from 'node:net';
import { runTest, type Result, type Test } from './checks.ts';
@@ -9,6 +11,8 @@ 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;
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
key?: string;
/** Checks per address per window. */
maxChecks?: number;
windowMs?: number;
@@ -65,6 +69,10 @@ export const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
: peer;
};
/** Equal, in the same time whatever was sent (hashes, so lengths don't show either). */
const sameKey = (a: string, b: string) =>
timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let size = 0;
@@ -86,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
}
export function createProbe(opts: ProbeOptions = {}): Server {
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
const { trustProxy = false, key, 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()) => {
@@ -103,6 +111,8 @@ export function createProbe(opts: ProbeOptions = {}): Server {
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' });
if (key && !sameKey(req.headers.authorization ?? '', `Bearer ${key}`))
return send(res, 401, { error: 'this probe needs its key' });
const ip = clientIp(req, trustProxy);
void readJson(req)
.then(async (body) => {
+3
View File
@@ -295,6 +295,9 @@ export const systemApi = {
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
detectPublicIp: () =>
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
/** A new key for the network test probe, shown this once (08 §7 #9). */
probeKey: () =>
api<{ key: string }>('POST', '/api/settings/network/probe-key').then((r) => r.key),
tls: () => api<TlsCert>('GET', '/api/system/tls'),
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
networkTest: (mode: 'quick' | 'complete') =>
+54 -1
View File
@@ -505,6 +505,11 @@ function NetworkTab({ s }: { s: SettingsDto }) {
const [watch, setWatch] = useState(s['network.publicIpWatch']);
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
const qc = useQueryClient();
const makeKey = useMutation({
mutationFn: systemApi.probeKey,
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
});
return (
<form
className="flex flex-col gap-4"
@@ -555,13 +560,61 @@ function NetworkTab({ s }: { s: SettingsDto }) {
/>
)}
</Field>
{s['network.probeUrl'] && (
<section
aria-labelledby="probe-key-h"
className="flex flex-col gap-2 rounded-lg border border-line p-3 text-sm"
>
<h3 id="probe-key-h" className="font-medium">
Probe key
</h3>
{s['network.hasProbeKey'] ? (
<p className="text-muted">Set: the probe only answers this server.</p>
) : (
<p className="text-amber-800 dark:text-amber-200">
Not set: anyone who finds your probe can use it. Strongly recommended: generate a key
and set it on the probe.
</p>
)}
{makeKey.data ? (
<>
<p>
Set this on the probe (its compose file or service), then restart it. It&rsquo;s
shown only now.
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 rounded bg-subtle p-2 font-mono text-xs break-all select-all">
PROBE_KEY={makeKey.data}
</code>
<Button
type="button"
onClick={() => void navigator.clipboard.writeText(makeKey.data)}
>
Copy
</Button>
</div>
</>
) : (
<div>
<Button type="button" onClick={() => makeKey.mutate()} disabled={makeKey.isPending}>
{s['network.hasProbeKey'] ? 'Generate a new key' : 'Generate key'}
</Button>
</div>
)}
<p className="text-xs text-muted">
A new probe address drops the key: generate one for the new probe.
</p>
</section>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
{(save.error ?? detect.error ?? makeKey.error) && (
<Alert>{(save.error ?? detect.error ?? makeKey.error)!.message}</Alert>
)}
</form>
);
}
+3
View File
@@ -18,6 +18,9 @@ services:
environment:
PORT: '8080'
TRUST_PROXY: 'false'
# Strongly recommended: SelfPhoneNG → Settings → Network → Generate key, paste it here, and
# the probe only answers your server. Empty: anyone who finds the probe can use it.
PROBE_KEY: ''
logging:
driver: json-file
options: { max-size: 10m, max-file: '3' }
+3
View File
@@ -12,6 +12,9 @@ Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/selfphoneng-probe
Environment=PORT=8080
# Strongly recommended: SelfPhoneNG → Settings → Network → Generate key, then uncomment and paste
# it; the probe then only answers your server.
#Environment=PROBE_KEY=
# Behind your own HTTPS reverse proxy on this machine that sets X-Forwarded-For: PORT to a local
# one, and TRUST_PROXY=true (it then listens on 127.0.0.1 only; HOST changes that).
#Environment=TRUST_PROXY=true
+8
View File
@@ -118,6 +118,14 @@ against someone else, and each address gets a few tests per 10 minutes. Its SIP
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.
**Give the probe a key (strongly recommended).** Without one, anyone who finds your probe can use
it to test their own address, and spend its few tests per 10 minutes. After saving the address,
click **Generate key** under it, copy the `PROBE_KEY=…` line, and add it to the probe: under
`environment:` in `deploy/probe/compose.yml` (`PROBE_KEY: '…'`), or as an `Environment=PROBE_KEY=…`
line in the service file (`sudo systemctl edit selfphoneng-probe`). Restart the probe. From then on
it only answers this server, and System → Diagnostics stops warning about it. The key is shown only
once; a new probe address drops it, so generate one for the new probe.
## Firewall
Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the
+1
View File
@@ -445,6 +445,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
| POST | `/api/system/warnings/spam_quota/dismiss` | hides `spam_quota` until IPQualityScore's credits run out again (14 §3) |
| POST | `/api/settings/network/detect-public-ip` | |
| POST | `/api/settings/network/probe-key` | a new key for the network test probe, `{ key }` shown this once, stored encrypted in `network.probeKeyEnc` (never in GET: `network.hasProbeKey`); 400 without a probe address. Diagnostics sends it as `Authorization: Bearer`; a new `network.probeUrl` drops it (08 §7 #9) |
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
| GET | `/api/system/generated-files` | names + contents of generated files, **passwords masked** (debug view) |
+6 -1
View File
@@ -117,6 +117,9 @@ how to fix it.
works on Gitea's runner, where the job's checkout can't be bind-mounted).
`deploy/probe/selfphoneng-probe.service` runs it under systemd with `DynamicUser` and a
read-only system. About 45 MB of memory.
- With `PROBE_KEY` set the probe only answers `Authorization: Bearer <key>` (401 otherwise,
compared in constant time); without it, it answers anyone and logs a warning at start. The key
is made in Settings → Network and sent by the api only, never the browser (08 §7 #9).
- `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`, believed only from a proxy on 127.0.0.1
or ::1, where the probe then listens by default; 08 §7 #4), so it can't be aimed at anyone else; 20
@@ -257,8 +260,10 @@ High.
| 6 | Low, privacy | The `voicemail.new` webhook carried the transcript, though webhooks leave the server and 10 §1 says transcripts don't | the webhook has no `transcript` (04 §9) | `voicemail.test.ts` |
| 7 | Low | Contrack's saved company token went to any address: Test with another URL, or saving a new URL, sent it there (SMTP, LDAP and OpenID already refused) | the saved token only for the saved address; a new address needs it typed again and clears everyone's own token (11 §4.3) | `contrack.test.ts` |
| 8 | Low | The OpenID connect callback wasn't tied to the browser that asked: a planted `sp_oidc` (a sibling subdomain can set it) connected the victim's provider account to the attacker's user | the callback's own session must be the one in `sp_oidc`, else `expired` (12 §3.3) | `oidc.test.ts`, both key types |
| 9 | Low | Anyone who found a probe could use it (owner's idea after #4): to test their own address, and spend its per-address limits | an optional key: Settings → Network → Generate key, set as the probe's `PROBE_KEY`; the api sends it as a Bearer token, encrypted at rest, dropped when the probe address changes. Without a key the probe stays open (owner, 2026-10-28) and Diagnostics warns, strongly recommending one (§2.6) | `probe.test.ts`, `diagnostics.test.ts` |
The independent agent found 1, 2, 4, 5 and 8, the author 3; both found 6 and 7. The agent had
The independent agent found 1, 2, 4, 5 and 8, the author 3; both found 6 and 7; 9 was the
owner's idea. The agent had
checked the recording rule and called it sound: it's the same rule as the audio, which §6 also
passed.
+13 -1
View File
@@ -42,6 +42,11 @@ export const SettingsSchemas = {
.url({ protocol: /^https?$/ })
.nullable()
.default(null),
/**
* The probe's key (its PROBE_KEY), encrypted: made by POST /api/settings/network/probe-key,
* sent as a Bearer token, dropped when the address changes (08 §7 #9). Never in GET.
*/
'network.probeKeyEnc': z.string().nullable().default(null),
'network.stunServer': z
.string()
.regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/)
@@ -327,10 +332,17 @@ export type SettingsDto = {
[
K in Exclude<
SettingKey,
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap' | 'spam'
| 'smtp'
| 'sms.webhookToken'
| 'contacts.contrack'
| 'auth.oidc'
| 'auth.ldap'
| 'spam'
| 'network.probeKeyEnc'
>
]: SettingValue<K>;
} & {
'network.hasProbeKey': boolean;
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
spam: SpamSettingsDto;