security: fix the review leftovers (owner, 08 §6)

- Asterisk doesn't load app_system, func_shell, app_disa, res_agi, app_externalivr.
- Turning two-step sign-in on asks for the password again.
- *97/*98: one voicemail login at a time per phone, 10 an hour (sp-sub-vmlimit).
- /prov: an unknown MAC gets the same 401 as a known one.
- The login limiter sweeps keys past every window.
Also the v1 checklist with the owner's results from 2026-10-21.
This commit is contained in:
iamdoubz committed 2026-10-21 10:54:44 -05:00
1 parent 3c8828eedb
commit 8d5475cfbf
32 files changed
+501 -61

No files matched your search

+10
View File
@@ -3,6 +3,16 @@
Versions follow [semver](https://semver.org). Each release's container images are built from its
tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on the Gitea registry.
## Unreleased
- Security leftovers from the review (08 §6), all fixed:
- Asterisk no longer loads the modules that can run host commands (`app_system`, `func_shell`,
`app_disa`, `res_agi`, `app_externalivr`).
- Turning two-step sign-in on asks for your password again.
- `*97`/`*98` voicemail logins: one at a time per phone, 10 an hour.
- Phone setup files: an unknown MAC gets the same answer as a known one.
- The login limiter no longer keeps every email ever tried until a restart.
## v0.0.2 — 2026-10-21
- Fix: calls to a user with Linphone on an iPhone registered dropped at once, for every device of
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { expect, it } from 'vitest';
import {
isLoginBlocked,
loginLimiterSize,
recordLoginFailure,
resetLoginLimiter,
} from './login-limiter.ts';
it('forgets emails tried long ago instead of growing until restart', () => {
resetLoginLimiter();
const t0 = 1_000_000_000;
for (let i = 0; i < 100; i++) recordLoginFailure(`10.0.0.${i}`, `user${i}@x.test`, t0);
expect(loginLimiterSize()).toBe(200);
for (let i = 0; i < 5; i++) recordLoginFailure('10.9.9.9', 'kept@x.test', t0 + 61 * 60_000);
expect(loginLimiterSize()).toBe(2);
expect(isLoginBlocked('10.9.9.9', 'other@x.test', t0 + 61 * 60_000)).toBe(true);
});
+14
View File
@@ -3,8 +3,10 @@
// ponytail: in-process memory, resets on restart; fine for one api process (v1 has no HA).
const MIN = 60_000;
const RULES = { ip: { max: 5, windowMs: 15 * MIN }, email: { max: 10, windowMs: 60 * MIN } };
const LONGEST = Math.max(RULES.ip.windowMs, RULES.email.windowMs);
const failures = new Map<string, number[]>();
let lastSweep = 0;
function recent(key: string, windowMs: number, now: number): number[] {
const list = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -21,12 +23,24 @@ export function isLoginBlocked(ip: string, email: string, now = Date.now()): boo
}
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
// Forget keys past every window, at most once a minute: emails tried once and never again
// would otherwise stay until restart (08 §6).
if (now - lastSweep > MIN) {
lastSweep = now;
for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key);
}
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
failures.set(key, [...(failures.get(key) ?? []), now]);
}
}
/** Tests only. */
export function loginLimiterSize(): number {
return failures.size;
}
/** Tests only. */
export function resetLoginLimiter(): void {
failures.clear();
lastSweep = 0;
}
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
+21
View File
@@ -112,14 +112,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(\${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=\${SP_EXT})
same => n,GotoIf($[\${GROUP_COUNT(\${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=\${STRFTIME(\${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=\${DB(SP/VMLOGIN/\${SP_EXT})})
same => n,Set(LOCAL(N)=\${IF($["\${CUT(V,:,1)}" = "\${H}"]?\${CUT(V,:,2)}:0)})
same => n,GotoIf($[\${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/\${SP_EXT})=\${H}:$[\${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(\${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/\${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 1)
+6 -3
View File
@@ -114,8 +114,11 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
adminOnly(req.user!);
const { secret, code } = TotpEnableSchema.parse(req.body);
if ((await myRow(req.user!.id)).totpSecretEnc) throw conflict('Two-step sign-in is already on');
const { secret, code, password } = TotpEnableSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -135,7 +138,7 @@ export async function authRoutes(app: FastifyInstance) {
return { recoveryCodes: codes };
});
/** Turning it off or making new recovery codes needs the password again. */
/** Turning it off or making new recovery codes needs the password again (on does too). */
async function confirmPassword(id: string, body: unknown) {
const { password } = PasswordConfirmSchema.parse(body);
const u = await myRow(id);
+19 -15
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// GET /prov/... (07 §2): phones fetch their config with HTTP Basic auth (user = MAC, password =
// the device's provisioning password). No session. Unknown MAC → 404, bad auth → 401; both are
// logged with the IP and count toward an IP block (provisioning/limiter.ts).
// the device's provisioning password). No session. Unknown MAC or bad auth → 401, alike; wrong
// credentials are logged with the IP and count toward an IP block (provisioning/limiter.ts).
import { timingSafeEqual } from 'node:crypto';
import { BlockList, isIPv4 } from 'node:net';
import { eq, sql } from 'drizzle-orm';
@@ -113,24 +113,28 @@ export async function provisioningRoutes(app: FastifyInstance) {
if (!kind) return deny(reply, 404, 'Not found');
const mac = kind.m![1]!;
const found = await loadContext(db, app.config, mac);
if (
!found ||
found.ctx.vendor === 'generic' ||
(kind.f.vendor && found.ctx.vendor !== kind.f.vendor)
) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip, mac }, 'provisioning: unknown MAC');
return deny(reply, 404, 'Not found');
}
const loaded = await loadContext(db, app.config, mac);
const found =
loaded &&
loaded.ctx.vendor !== 'generic' &&
(!kind.f.vendor || loaded.ctx.vendor === kind.f.vendor)
? loaded
: null;
const auth = basicAuth(req);
const ok =
auth !== null && auth.user.toLowerCase() === mac && same(auth.pass, found.ctx.provPassword);
found !== null &&
auth !== null &&
auth.user.toLowerCase() === mac &&
same(auth.pass, found.ctx.provPassword);
if (!ok) {
// A phone asks without credentials first and retries after the 401: not a failure.
// An unknown MAC gets the same 401 as a known one, so the answers don't tell which MACs
// exist (08 §6). A phone asks without credentials first and retries: not a failure.
if (auth) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip, mac }, 'provisioning: wrong credentials');
req.log.warn(
{ ip: req.ip, mac },
found ? 'provisioning: wrong credentials' : 'provisioning: unknown MAC',
);
}
return reply
.code(401)
+25
View File
@@ -285,6 +285,31 @@ async function main() {
await sleep(1000);
}
// Voicemail logins: *97 above counted once; with 10 this hour, *98 doesn't reach a PIN prompt.
const hour = new Date().toISOString().replace(/\D/g, '').slice(0, 10);
const counted = await ami.command('database get SP/VMLOGIN 101');
if (!counted.includes(`${hour}:1`)) problems.push(`*97 not counted: ${counted}`);
await ami.command(`database put SP/VMLOGIN 101 ${hour}:10`);
await ami.action({
Action: 'Originate',
Channel: 'Local/*98@sp-from-ext',
Application: 'Wait',
Data: '5',
Variable: '__SP_EXT=101',
Async: 'true',
});
let refused = false;
for (let i = 0; i < 20 && !refused; i++) {
await sleep(500);
refused = /Playing 'an-error-has-occurred/.test(docker('logs', AST).out);
}
if (!refused) problems.push('*98 past the hourly voicemail login limit was not refused');
else if (/VoiceMailMain\("[^"]*", "@default"\)/.test(docker('logs', AST).out))
problems.push('*98 reached VoiceMailMain past the limit');
else log('*98 past 10 logins an hour: refused');
await ami.command('channel request hangup all');
await waitIdle();
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
const plainLog = docker('logs', AST).out.replace(/\x1b\[[0-9;]*m/g, '');
problems.push(
+20 -5
View File
@@ -105,10 +105,25 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
expect((await prov('y000000000044.cfg')).body).toBe('#!version:1.0.0.1\n');
});
it('unknown MAC and the other vendor’s file are 404; ten failures block the IP', async () => {
expect((await prov('001565000000.cfg')).statusCode).toBe(404);
expect((await prov(`cfg${device.mac}.xml`)).statusCode).toBe(404); // a Yealink here
it('unknown MAC and the other vendor’s file answer like a known MAC; ten failures block the IP', async () => {
// 08 §6: the answers mustn't tell which MACs exist.
const known = await prov(`${device.mac}.cfg`);
for (const f of ['001565000000.cfg', `cfg${device.mac}.xml`]) {
const r = await prov(f);
expect([r.statusCode, r.headers['www-authenticate'], r.body]).toEqual([
401,
known.headers['www-authenticate'],
known.body,
]);
}
expect((await prov('anything.boot')).statusCode).toBe(404);
// with credentials they're failures, like a wrong password
expect(
(await prov('001565000000.cfg', '192.168.1.20', 'guess', '001565000000')).statusCode,
).toBe(401);
expect(
(await prov(`cfg${device.mac}.xml`, '192.168.1.20', device.provisioningPassword)).statusCode,
).toBe(401); // a Yealink here
for (let i = 0; i < 8; i++)
expect((await prov(`${device.mac}.cfg`, '192.168.1.20', 'guess')).statusCode).toBe(401);
// 10 failures (2 unknown MACs, 8 wrong passwords): even the right password is refused now
@@ -181,7 +196,7 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
expect(
(await prov('112233445566.cfg', '192.168.1.40', generic.provisioningPassword, '112233445566'))
.statusCode,
).toBe(404);
).toBe(401);
});
it('M10-T2: BLF keys named after their users, the time zone, and the phonebook', async () => {
@@ -256,6 +271,6 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.50', device.provisioningPassword)).statusCode,
).toBe(404);
).toBe(401);
});
});
+12 -6
View File
@@ -44,12 +44,18 @@ describe.skipIf(!hasTestDb)('two-step sign-in', () => {
secret = setup.secret;
expect(setup.otpauthUrl).toMatch(/^otpauth:\/\/totp\/.+\?secret=[A-Z2-7]{32}&issuer=/);
expect(setup.qr).toMatch(/^data:image\/png;base64,/);
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000' });
const password = ADMIN.password;
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000', password });
expect(bad.statusCode).toBe(400);
const ok = await req('POST', '/api/auth/totp/enable', {
secret,
code: totpCode(secret, stepAt()),
});
// A session alone isn't enough: the password again, like turning it off.
const code = totpCode(secret, stepAt());
for (const payload of [
{ secret, code },
{ secret, code, password: 'not-it' },
])
expect((await req('POST', '/api/auth/totp/enable', payload)).statusCode).toBe(400);
expect((await req('GET', '/api/auth/totp')).json().enabled).toBe(false);
const ok = await req('POST', '/api/auth/totp/enable', { secret, code, password });
expect(ok.statusCode, ok.body).toBe(200);
recovery = ok.json().recoveryCodes;
expect(recovery).toHaveLength(10);
@@ -58,7 +64,7 @@ describe.skipIf(!hasTestDb)('two-step sign-in', () => {
recoveryCodesLeft: 10,
});
expect(
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456' })).statusCode,
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456', password })).statusCode,
).toBe(409);
});
+2 -2
View File
@@ -143,8 +143,8 @@ export const authApi = {
}).then((r) => r.user),
totp: () => api<TotpStatus>('GET', '/api/auth/totp'),
totpSetup: () => api<TotpSetup>('POST', '/api/auth/totp/setup', {}),
totpEnable: (secret: string, code: string) =>
api<{ recoveryCodes: string[] }>('POST', '/api/auth/totp/enable', { secret, code }),
totpEnable: (secret: string, code: string, password: string) =>
api<{ recoveryCodes: string[] }>('POST', '/api/auth/totp/enable', { secret, code, password }),
totpDisable: (password: string) =>
api<{ ok: true }>('POST', '/api/auth/totp/disable', { password }),
totpNewCodes: (password: string) =>
+4 -2
View File
@@ -30,11 +30,12 @@ function TwoStep() {
const [password, setPassword] = useState('');
const setup = useMutation({ mutationFn: authApi.totpSetup });
const enable = useMutation({
mutationFn: () => authApi.totpEnable(setup.data!.secret, code.trim()),
mutationFn: () => authApi.totpEnable(setup.data!.secret, code.trim(), password),
onSuccess: async (r) => {
setCodes(r.recoveryCodes);
setup.reset();
setCode('');
setPassword('');
await refresh();
},
});
@@ -131,8 +132,9 @@ function TwoStep() {
/>
)}
</Field>
{passwordField}
<div className="flex gap-2">
<Button type="submit" variant="primary" disabled={enable.isPending}>
<Button type="submit" variant="primary" disabled={!password || enable.isPending}>
Turn on
</Button>
<Button onClick={() => setup.reset()}>Cancel</Button>
+6
View File
@@ -51,3 +51,9 @@ noload = chan_websocket.so
noload = res_adsi.so
noload = app_getcpeid.so
noload = app_adsiprog.so
; can run host commands; nothing of ours uses them, but a dialplan injection would reach for them (08 §6)
noload = app_system.so
noload = func_shell.so
noload = app_disa.so
noload = res_agi.so
noload = app_externalivr.so
+8
View File
@@ -849,11 +849,19 @@ Default routes, created with the first trunk (06 §4), in this order. Toll-free
```ini
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1) ; 1 at a time, 10/hour per phone (08 §6)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
+5 -3
View File
@@ -40,7 +40,8 @@ type ProvisionContext = {
| `GET /prov/linphone/<token>.xml` | Linphone (§5) | token auth instead of Basic |
Rules:
- Unknown MAC → 404, wrong/missing Basic auth → 401 with `WWW-Authenticate: Basic realm="selfphoneng"`.
- Unknown MAC or wrong/missing Basic auth → the same 401 with `WWW-Authenticate: Basic realm="selfphoneng"`
(an unknown MAC was a 404 until 2026-10-21: the difference told which MACs exist, 08 §6).
Both get logged with IP. Rate limit: 20 requests / 10 min per IP; after 10 failures, block that IP for 1 h.
- If `provisioning.lanOnly` is true and the client IP isn't in `network.localNets` → 403.
- On success: update `last_provisioned_at`, `last_provisioned_ip`. Set `Cache-Control: no-store`.
@@ -48,8 +49,9 @@ Rules:
As built (M10-T1), `routes/provisioning.ts` + `provisioning/limiter.ts`:
- A request **without** an `Authorization` header gets the 401 but isn't a failure: phones ask without
credentials first and retry after the challenge. Failures are unknown MACs (or a file for another
vendor) and wrong credentials; 10 within an hour block the IP for an hour (429).
credentials first and retry after the challenge. Failures are requests with credentials that
don't match: a wrong password, an unknown MAC, or a file for another vendor; 10 within an hour
block the IP for an hour (429).
- The 20 requests / 10 min limit applies to clients **outside** `network.localNets`: an office's
phones rebooting together behind one address would pass it in a minute. Failures count for all.
- The Basic auth user name is the 12-hex MAC (either case); it can't carry colons (Basic auth splits
+13 -12
View File
@@ -83,8 +83,8 @@ get the proxy's address only (below). The setting is on Settings → Security, w
code, get 10 recovery codes once (`k7m3q-x9tpw`; case and dashes don't matter). Login asks for
the code only after the password is right (401 `totp_required`, no session); a wrong code is a
failed login (rate limit, audit). Each 30 s step signs in once (`users.totp_last_step`, updated
atomically) and each recovery code once (`array_remove` with an `any()` guard). Turning it off or
making new codes needs the password. Another admin can turn it off for a lost phone
atomically) and each recovery code once (`array_remove` with an `any()` guard). Turning it on or
off, or making new codes, needs the password. Another admin can turn it off for a lost phone
(`POST /api/users/:id/reset-two-step`, signs that user out). `scripts/e2e/m11-t1-totp.mjs`.
- As built (M11-T1): the headers come from an `onSend` hook on every response (UI, API, `/prov`);
`connect-src` also allows `WEBRTC_WS_URL`'s origin when it's set. The dark-mode bootstrap moved
@@ -181,13 +181,14 @@ without a shell; CSV export escapes formulas; no `dangerouslySetInnerHTML`; sess
stored as sha256; AES-256-GCM with a random IV per value; AMI, Asterisk HTTP and the api listen on
127.0.0.1 only; webhooks check addresses at connect time (no DNS rebinding), follow no redirects.
To do (owner, 2026-10-20, for the next image): `noload` in `asterisk/conf/modules.conf` for
`app_system.so`, `func_shell.so`, `app_disa.so` and `res_agi.so`. Nothing uses them, but they are
what a dialplan injection would reach for (`SHELL()` runs host commands). Check with
`pnpm test:asterisk` before releasing.
Left as they are, for the owner:
- Turning two-step sign-in on doesn't ask for the password again (turning it off does).
- `*98` allows 3 PIN tries per call, with no limit on calls; only internal phones reach it.
- A known MAC answers `/prov` with 401, an unknown one with 404 (07 §2 accepts this).
- The login limiter's memory grows with the number of distinct emails tried until restart.
Fixed after the review (owner, 2026-10-21):
- `asterisk/conf/modules.conf` doesn't load `app_system`, `func_shell`, `app_disa`, `res_agi` or
`app_externalivr`. Nothing uses them, but they are what a dialplan injection would reach for
(`SHELL()` runs host commands). `pnpm test:asterisk` passes without them.
- Turning two-step sign-in on asks for the password again, like turning it off (`totp.test.ts`).
- `*97`/`*98`: one voicemail login at a time per phone and 10 an hour (`sp-sub-vmlimit`, AstDB
`SP/VMLOGIN/<ext>` = `<UTC hour>:<count>`), each with 3 PIN tries (`voicemail-test.ts`).
- `/prov`: an unknown MAC gets the same 401 as a known one, so the answers don't tell which MACs
exist; with credentials it counts as a failure (`devices.test.ts`).
- The login limiter forgets keys past every window, swept at most once a minute
(`login-limiter.test.ts`).
+22 -13
View File
@@ -11,32 +11,39 @@ Tick a box only with proof: a date and what was seen.
## Real voip.ms account (M3)
- [ ] The trunk shows **Registered** in the UI.
- [x] The trunk shows **Registered** in the UI (2026-10-21: green, voip.ms, the sub-account at
its POP).
- [x] A mobile → DID call rings a phone. 2026-10-21: DID → digital receptionist → browser
phone; the DID came in the Request-URI. Still to do: write that down in 06 §3.
- [ ] An outbound call shows the DID as caller ID on the mobile. Write down in 06 §4 whether
per-call caller ID (Remote-Party-ID) is honored.
- [x] An outbound call shows the DID as caller ID on the mobile (2026-10-21, owner).
- [ ] Write down in 06 §4 whether per-call caller ID (Remote-Party-ID) is honored.
## Real phones (M8, M10)
- [x] Linphone on a real phone registers by scanning the QR (2026-10-20, iPhone).
- [ ] An incoming call rings that Linphone while the app is open (fixed in v0.0.2, needs
checking on the server).
- [x] An incoming call rings that Linphone while the app is open (2026-10-21, v0.0.2: DID →
receptionist → 100 rings the iPhone).
- [ ] A factory-reset Yealink pointed at the setup URL registers with the right name and BLF keys.
- [ ] A Grandstream gets its keys the same way.
- [ ] Paging auto-answers on a real Yealink or Grandstream.
On hold until the owner has the phones (2026-10-21).
## Someone else installs it (M11)
- [ ] An outside tester follows only the admin guide and gets inbound and outbound calls working
on a fresh VM.
- [ ] A backup restores on a second VM.
On hold (2026-10-21).
## A week on the owner's server
- [ ] 7 days handling real calls with no crash or restart of the api or Asterisk.
- [ ] Nightly backups restore cleanly.
In progress on the owner's server (v0.0.2, from 2026-10-21).
## Updating
- [ ] `scripts/update.sh` written, reviewed and tested on a real server: updating a running
@@ -45,18 +52,20 @@ Tick a box only with proof: a date and what was seen.
## Security leftovers (08 §6)
- [ ] Asterisk no longer loads `app_system`, `func_shell`, `app_disa` and `res_agi`; checked with
`pnpm test:asterisk`.
- [ ] Owner decision, fix or accept each:
- [ ] Turning two-step sign-in on doesn't ask for the password again.
- [ ] `*98` allows 3 PIN tries per call, with no limit on calls.
- [ ] A known MAC answers `/prov` with 401, an unknown one with 404.
- [ ] The login limiter's memory grows with the number of distinct emails tried.
Owner's decision (2026-10-21): fix all of them. Each is in 08 §6 with its test.
- [x] Asterisk no longer loads `app_system`, `func_shell`, `app_disa`, `res_agi` or
`app_externalivr`; `pnpm test:asterisk` passes.
- [x] Turning two-step sign-in on asks for the password again.
- [x] `*97`/`*98`: one voicemail login at a time per phone, 10 an hour.
- [x] An unknown MAC answers `/prov` like a known one (401).
- [x] The login limiter forgets old entries.
## The release
- [ ] Version 1.0.0 in every `package.json`, and a v1.0.0 entry in the [changelog](../CHANGELOG.md).
- [ ] The release builds and pushes images to both the Gitea registry and ghcr.io.
- [ ] The release builds and pushes images to both the Gitea registry and ghcr.io. (Gitea works.
ghcr.io: the api image pushes; the asterisk package exists, empty, and refuses the push.)
## Not needed for v1
+1
View File
@@ -40,5 +40,6 @@ export const TotpEnableSchema = z.object({
.string()
.trim()
.regex(/^\d{6}$/, 'The 6-digit code from the app'),
password: z.string().min(1).max(200),
});
export const PasswordConfirmSchema = z.object({ password: z.string().min(1).max(200) });
+1
View File
@@ -43,6 +43,7 @@ try {
`[...document.querySelectorAll('span.font-mono')].map(s => s.textContent).find(t => /^[A-Z2-7]{32}$/.test(t))`,
);
assert.match(secret, /^[A-Z2-7]{32}$/);
await p.fill('Your password', pw); // turning it on asks for the password too (08 §6)
await p.fill('2. Enter the 6-digit code it shows', '000000');
await p.click('Turn on');
await p.waitFor(`document.body.innerText.includes('doesn’t match')`, 'wrong code refused');