Update all documentation to current MySQL + React SPA + vm_api.py architecture

README, docs/{ARCHITECTURE,CONFIGURATION,INSTALL,OPERATIONS,SECURITY,TESTING}
and CHANGELOG were stale: they described the original SQLite + CardDAV +
zero-JS vm_web portal. Brought them in line with the actual code:
- MySQL (PyMySQL) as the default store; SQLite fallback only
- vm_contacts resolves from the MySQL contacts table (file/google/carddav removed)
- React SPA frontend + vm_api.py JSON API (:8098); vm_web.py now serves /audio/
- Apache vhost proxies /api/ -> :8098, /audio/ -> :8099, serves the SPA
- CSP relaxed to script-src 'self' 'unsafe-inline' (UI is now JavaScript)
- Added CHANGELOG 1.2.0 entry for the React/vm_api frontend work
This commit is contained in:
jp
2026-08-13 20:20:39 +01:00
parent 3155020f67
commit b8c60605fd
8 changed files with 553 additions and 290 deletions

View File

@ -1,9 +1,8 @@
# Configuration
All config lives in `/opt/vm-transcribe/`. Templates are committed as
`*.example`; the live files are created by `install.sh` or by you. Secret-bearing
files (`telegram.conf`, `contacts.conf`) are mode `640 root:asterisk` and git-
ignored.
The backend services run from `/opt/vm-transcribe/`. Live secret-bearing files
live there (some git-ignored). The repo ships `*.example` templates under
`config/`.
Most settings can also be set via environment variables, which is how the systemd
unit wires production values. Env vars override file values when both are present
@ -11,17 +10,47 @@ unless noted.
---
## Environment variables (shared)
## Environment variables
| Variable | Default | Used by | Meaning |
|---|---|---|---|
| `VM_DB` | `/var/lib/vm-transcribe/voicemail.db` | store, web, import | SQLite path. Created with WAL mode if missing. |
| `VM_AUDIO_DIR` | `/var/lib/vm-transcribe/audio` | store, web, import | Where copied recordings live (`<sha[:2]>/<sha>.wav`). |
| `VM_LOG` | `/var/log/asterisk/vm_mailcmd.log` | mailcmd | Log path. |
| `VM_MODEL_CACHE` | `/opt/vm-transcribe/models` | mailcmd, import | Whisper model download/load dir. |
| `VM_WHISPER_MODEL` | `base.en` | mailcmd, import | Model name. `tiny`/`base`/`small` trade speed for accuracy. |
| `VM_SPOOL` | `/var/spool/asterisk/voicemail` | import | Spool root to scan. |
| `VM_ASTERISK_CONF` | `/etc/asterisk/voicemail.conf` | web, auth | For PIN lookup. |
### Shared / store (`vm_store.py`, used by mailcmd, web, api, import)
| Variable | Default | Meaning |
|---|---|---|
| `VM_DB` | `/var/lib/vm-transcribe/voicemail.db` | SQLite path — **used only as a fallback** when no MySQL is configured. |
| `VM_AUDIO_DIR` | `/var/lib/vm-transcribe/audio` | Where copied recordings live (`<sha[:2]>/<sha>.wav`). |
| `VM_LOG` | `/var/log/asterisk/vm_mailcmd.log` | Log path. |
| `VM_MODEL_CACHE` | `/opt/vm-transcribe/models` | Whisper model download/load dir. |
| `VM_WHISPER_MODEL` | `base.en` | Model name. `tiny`/`base`/`small` trade speed for accuracy. |
| `VM_SPOOL` | `/var/spool/asterisk/voicemail` | Spool root to scan (import). |
| `VM_ASTERISK_CONF` | `/etc/asterisk/voicemail.conf` | For PIN lookup. |
### MySQL (default store + contacts)
Set these as env vars (the API unit reads `/opt/vm-transcribe/api.env`) **or**
put them in `/opt/vm-transcribe/db_secret` (mode `640 root:root`,
`KEY=VALUE` lines). When a credentials source exists, `vm_store` uses MySQL;
otherwise it falls back to SQLite.
| Variable | Default | Meaning |
|---|---|---|
| `VM_MYSQL_HOST` | `localhost` | MySQL host. |
| `VM_MYSQL_USER` | `asterisk` | Least-privilege DB user (owns the `asterisk` DB; not root). |
| `VM_MYSQL_PASSWORD` | (from `db_secret`) | DB password. |
| `VM_MYSQL_DB` | `asterisk` | Database name — the **same** DB Asterisk's CDR uses. |
`db_secret` key names: `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`,
`MYSQL_DB`.
### Portal / API hardening
| Var | Default | Meaning |
|---|---|---|
| `VM_SESSION_HOURS` | `12` | Session lifetime. |
| `VM_MAX_FAILS` | `5` | Failed attempts per (mailbox, IP) before locking. |
| `VM_LOCK_MINUTES` | `15` | Lock duration. |
| `VM_INSECURE_COOKIE` | unset | Set to `1`/`yes`/`true` only for plain-HTTP testing; normally cookies are `Secure`. |
| `VM_BASE_PATH` | `""` | Set to e.g. `/voicemail` if served under a sub-path. |
| `VM_CORS_ORIGINS` | `*` | Comma list of allowed CORS origins for the JSON API. |
---
@ -88,41 +117,38 @@ file still delivers the summary.
```ini
[contacts]
backends = file ; space/comma separated: file google carddav
cache_ttl = 86400 ; seconds; cache hits AND misses
cache_file = /var/lib/vm-transcribe/contacts-cache.json
enabled = yes
backends = mysql ; ONLY 'mysql' is implemented (MySQL contacts table).
# The schema still parses file/google/carddav entries,
# but the registry is {"mysql": lookup_mysql} so any
# other value is ignored / logged as unknown.
cache_path = /var/lib/vm-transcribe/contacts_cache.json
cache_ttl = 86400
# --- 'file' backend (recommended): a vCard or CSV export -------------
[file]
path = /var/lib/vm-transcribe/contacts.vcf
# CSV (Google export "All contact data") is auto-detected:
# path = /var/lib/vm-transcribe/contacts.csv
# --- 'google' backend: People API via OAuth ---------------------------
[google]
token_file = /var/lib/vm-transcribe/google-token.json
# Obtain a token with the People API (contacts.readonly) scope. See
# docs/SECURITY.md. No app password here — OAuth only.
# --- 'carddav' backend: app password, Nextcloud/Fastmail/iCloud --------
[carddav]
# Google rejects app passwords (basic auth disabled 2024-09-30); a Google
# URL here is refused with a log warning and the backend is skipped.
url = https://contacts.fastmail.com/dav/addressbooks/user/xxx/Default
username = jp@txt3.com
app_password = ; app-specific password, not your login password
[file] ; NOT USED — kept for reference only
[google] ; NOT USED — kept for reference only
[carddav] ; NOT USED — kept for reference only
```
**Matching** is on the **last 9 digits** of the caller ID, so `+447700900123`,
`07700900123` and `447700900123` all collapse to the same contact. Names resolve
best-effort; on any backend error the caller ID is used as-is.
**Contacts are resolved exclusively from the MySQL `contacts` table** on the
`asterisk` DB. Numbers are stored canonical E.164; `normalize_uk()` collapses
`+44…` and `0…` (and `0044…`) so both forms match. Matching uses the full digit
string (up to 15 digits), not just last-9. Creating/updating a contact via the
API backfills `messages.contact_name`/`contact_email` for matching callerids.
Load the table from a vCard/CSV export with:
```bash
sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 \
/opt/vm-transcribe/vm_import_contacts.py /path/to/contacts.vcf
```
---
## Portal settings (per mailbox, in the SQLite store)
## Portal settings (per mailbox, in MySQL)
These are edited from the portal's **Settings** page by each user; they are not
config files. Every value defaults to `yes`.
These are edited from the React portal's **Settings** page by each user; they are
not config files. Every value defaults to `yes`.
| Setting | Effect |
|---|---|
@ -134,26 +160,33 @@ config files. Every value defaults to `yes`.
---
## vm-portal.service
## systemd units
Production values are passed as environment in the unit file:
Two units, both running as `asterisk` and binding loopback only.
### vm-api.service (:8098 JSON API)
```ini
Environment=VM_SESSION_HOURS=12
EnvironmentFile=-/opt/vm-transcribe/api.env ; MySQL creds + VM_* overrides
# Hardening: ProtectSystem=full, ProtectHome=read-only, NoNewPrivileges,
# PrivateTmp, IPAddressAllow=127.0.0.1
ExecStart=/opt/vm-transcribe/venv/bin/uvicorn vm_api:app --host 127.0.0.1 --port 8098 --workers 2
```
### vm-portal.service (:8099 legacy HTML / audio)
```ini
Environment=VM_DB=/var/lib/vm-transcribe/voicemail.db
Environment=VM_AUDIO_DIR=/var/lib/vm-transcribe/audio
Environment=VM_ASTERISK_CONF=/etc/asterisk/voicemail.conf
Environment=VM_SESSION_HOURS=12
ExecStart=/opt/vm-transcribe/venv/bin/python -m uvicorn vm_web:app \
--host 127.0.0.1 --port 8099 --proxy-headers --forwarded-allow-ips 127.0.0.1
# Hardening: NoNewPrivileges, PrivateTmp, ProtectSystem=full, ProtectHome,
# ReadWritePaths=/var/lib/vm-transcribe /var/log/asterisk,
# IPAddressAllow=localhost, IPAddressDeny=any
```
Login hardening (in-process):
| Var | Default | Meaning |
|---|---|---|
| `VM_MAX_FAILS` | `5` | Failed attempts per (mailbox, IP) before locking. |
| `VM_LOCK_MINUTES` | `15` | Lock duration. |
| `VM_SESSION_HOURS` | `12` | Session lifetime. |
| `VM_INSECURE_COOKIE` | unset | Set to `1` only for plain-HTTP testing; normally cookies are `Secure`. |
| `VM_BASE_PATH` | `""` | Set to e.g. `/voicemail` if the app is served under a sub-path. |
> A service restart clears the in-memory lockout counters. Testing lockout will
> lock you out of your own verification for `VM_LOCK_MINUTES`.