Commit Graph
15 Commits
Author SHA1 Message Date
iamdoubz 97e67e347d auth: security audit fixes, rate limits on every route
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s
Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
2026-10-26 09:46:23 -05:00
iamdoubz b06335586b auth: sign in with LDAP/LDAPS, directory settings and Test (S4)
Spec 12 S4: the login form checks the SelfPhoneNG password first, then the directory (ldapts,
service-account search, the user's own bind on a second connection). Presets for lldap, AD,
FreeIPA and OpenLDAP; Test with an optional test login. A directory account connects by email,
never makes a user; adminFilter sets the role (applyRole shared with OpenID). One Disconnect
route per kind (oidc, ldap). Live test against lldap 0.6.3 over LDAPS.
2026-10-25 18:39:52 -05:00
iamdoubz 06b3a73edc auth: sign in with OpenID Connect, connect and disconnect, sign-in modes (S2)
Spec 12 S2: openid-client 6.8 (decision 0004). Discovery, PKCE + state + nonce, ID token
signatures checked, userinfo always read. Users connect their OpenID account from My account
and sign in either way; no accounts are made; a user without a password is matched once by
verified email. auth.mode mixed and oidc (admins keep a password way in), role from adminGroups,
allowedGroups, signing out at the provider. Settings -> Sign-in with Test. Tested against a fake
provider with RS256 and ES256 keys.
2026-10-25 14:13:27 -05:00
iamdoubz 4143f6d064 probe: downloadable executables for machines without Docker
ci / check (push) Successful in 59s
ci / images (push) Successful in 49s
Each release attaches selfphoneng-probe-linux-x64 and -arm64 (Node's single executable
applications: esbuild bundle + postject into the official Node binary, checked against
nodejs.org's SHASUMS256.txt), the bundle as selfphoneng-probe.cjs (Node 22+) and SHA256SUMS.
Built in binary.Dockerfile with BuildKit's local output, so Gitea's runner can build it too.
deploy/probe/selfphoneng-probe.service runs it under systemd, sandboxed.
2026-10-21 12:57:04 -05:00
iamdoubz 65a0438dde 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).
2026-10-21 12:37:16 -05:00
iamdoubz c1f8b04e7e provisioning: Linphone setup QR, "Set up a phone app"
ci / check (push) Successful in 53s
ci / images (push) Successful in 24s
GET /api/extensions/:id/softphone-qr (admin) and /api/me/softphone-qr
return {url, qr, expiresAt}: the link Linphone fetches and its QR as a
PNG data URL (qrcode), valid 24 h. Both are audited like revealing the
SIP password. JSON rather than a bare PNG, so the page can show the
link for desktop Linphone too.

The token is stateless per 07 §5: base64url(extensionId.expiry.hmac),
HMAC-SHA256 with APP_SECRET, checked timing-safe. /prov/linphone/
<token>.xml matches it before the other file names are lower-cased
(the token is case-sensitive); a bad or expired token is a 404 and a
counted failure. auth_info keys checked against liblinphone's config
reader; proxy keys stay (verify).

Web: a "Set up a phone app" section on My settings and in the admin
Credentials tab: Linphone with the QR, MicroSIP/Baresip by hand, and
the plain warning about mobile apps without push. e2e m10-t3: as a
user, the QR's link gives the XML and a baresip phone registers with
only what it holds.

Task: M10-T3
2026-10-19 14:16:28 -05:00
iamdoubz ae3a723a3c softphone: SIP.js engine, /api/me/webrtc, minimal dock
GET /api/me/webrtc returns the <ext>-web identity, WebSocket URL
(WEBRTC_WS_URL overrides wss://PUBLIC_HOSTNAME/ws) and STUN/TURN servers.
SoftphoneEngine wraps SIP.js 0.21.2: register with backoff reconnect,
call, answer, decline, hang up, remote audio. A minimal dock on /me
enables it (asks for the microphone on the click) and dials/answers;
M5-T2 grows it into the full panel.

Generator: ice_host_candidates keeps the LAN address
(include_local_address), so LAN browsers don't need hairpin NAT, and -web
endpoints set from_domain = selfphoneng.invalid because SIP.js drops
INVITEs whose From host starts with a digit (Docker/VPS hostnames).

e2e m5-t1 (Docker): browser registers as 170-web and has two-way audio
with a baresip phone both ways. The LAN and hotspot check on the owner's
server is still open (roadmap note).

Task: M5-T1
2026-10-18 09:27:45 -05:00
iamdoubz 409724bc55 emergency: 911 dialplan, notifications, test button, settings, warnings
Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext;
sp-emergency has no class, channel-limit or DND check, dials every
enabled trunk in name order with the E911 caller ID, and raises
UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches)
for the ring-out.

api: SpEmergency -> emergency_events row, live emergency.call (admins
and notify users only), email via nodemailer (SMTP from settings) and
an Originate to every notify extension, each outcome recorded. Test
button runs the same with TEST in front, records and dials nothing.
GET/PATCH /api/settings (smtp password write-only, references
checked, read-only system keys), SMTP test, /api/system/warnings.
Fix: clearing a nullable setting stored SQL NULL (now JSON null).

UI: Settings page (Email, Emergency with the 06 §6 notice), user
Emergency tab, dashboard warnings banner, red 911 banner.

Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a
caller with no class to the fake provider (E911 caller ID in RPID,
SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs
(SMTP sink receives the test email, 101 rings, banner shows).
nodemailer pinned to 10.0.11 (newest release over two weeks old).

Task: M3-T4
2026-10-17 20:52:50 -05:00
iamdoubz c0c4036887 live: AMI-fed live state, /api/live WebSocket, dashboard and status dots
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s
LiveState loads extension states and registered devices from Asterisk on
every AMI connect and follows ExtensionStatus/ContactStatus; the hub sends
a snapshot then deltas batched every 250 ms (devices for admins only),
pings every 30 s and closes unauthenticated sockets with 4401. The web UI
keeps one reconnecting socket in a zustand store, shows a Reconnecting
banner, a Dashboard (online, on a call, telephony engine) and status dots.

Fix: Asterisk 24 has no 'pjsip reload'; reload with
'module reload res_pjsip.so'. AMI errors now carry the CLI output.

Milestone check, automated: call-test.ts registers two baresip phones and
holds a 30 s call with RTP both ways (pnpm test:asterisk); the recorded
session drives the live-state tests. scripts/e2e/m2-live.mjs runs api +
Asterisk + phones + browser on a private Docker network: a new extension
is live in Asterisk in ~1.3 s, the dashboard shows 2/2 online and red dots
during the call.

Task: M2-T6
2026-10-17 16:49:25 -05:00
iamdoubz 9a81e37725 users: admin CRUD API and admin shell UI
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s
Login, forced password change, admin layout with every 05 §2 page in the
sidebar (unbuilt ones name their milestone), Users table/cards + drawer
(General tab), one-time temporary passwords, reset password. The last
active admin can't be demoted, deactivated or deleted. Audit diffs only
mask secret strings. scripts/e2e has a dependency-free CDP browser check
for this task's acceptance flow.

Task: M1-T4
2026-10-17 15:18:08 -05:00
iamdoubz 13733ac262 auth: sessions, login/logout/me/change-password, CSRF, guards, audit
argon2id passwords with a min length and a common-password list (SecLists,
MIT). Login failures are limited per IP and per email; must_change_password
blocks everything outside /api/auth/*. The first admin is created from env
on startup. pnpm dev derives DATABASE_URL from .env.

Task: M1-T3
2026-10-17 15:08:44 -05:00
iamdoubz 15b8a20751 db: drizzle schema matching spec 02, initial migrations, settings service
Migration 0000 enables citext; 0001 creates all 39 tables. DB tests use a
throwaway database per file (TEST_DATABASE_URL, derived from .env in dev,
a postgres service in CI). Decision 0002: pg driver and @fastify/cookie.

Task: M1-T1
2026-10-17 15:04:18 -05:00
iamdoubz d12795ec8b web: vite + react + tailwind hello page with dark-mode toggle
Workspace packages export TS source under a 'source' condition used by
vite, node (--conditions=source) and tsc.

Task: M0-T4
2026-10-17 14:23:15 -05:00
iamdoubz 08bd33eefa api: env config, fastify with /healthz and SPA static serving
Task: M0-T3
2026-10-17 14:20:52 -05:00
iamdoubz aa99622a24 workspace: pnpm monorepo with api, web, shared
TypeScript 7 strict, ESLint + Prettier, vitest, AGPL-3.0 license.
Decision 0001 records the latest-majors choice and the TS 6 alias.

Task: M0-T1
2026-10-17 14:18:02 -05:00