Add static API-key auth, dockerize service, publish façade docs

- sms/auth.py: verify_api_key dependency (Bearer/X-API-Key, SHA-256 hash
  compare via secrets.compare_digest, fail-closed 503 if no hash). All routes
  gated via a protected router; /health stays open for probes.
- config.py: new sms_api_key_hash setting (VOIPMS_SMS_API_KEY_HASH).
- Dockerfile + .dockerignore + docker-compose.yml: lean python:3.13-slim
  image; secrets injected via env_file, never baked in; host-localhost-only
  port mapping (SMS_PORT override); /health healthcheck.
- .env.example: committed template (placeholders only, no secrets).
- sms/docs/sms-facade.dokuwiki.txt: abstraction API reference (published to
  the apidoc wiki at voipms:sms-facade).
- README: Authentication section, Docker section, 401/503 error rows.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chelsea
2026-07-04 05:09:43 +00:00
parent db5bde0a70
commit 1d9e7e0fab
9 changed files with 359 additions and 9 deletions

View File

@@ -0,0 +1,139 @@
====== apifrontend SMS façade ======
A small **FastAPI service** that wraps the [[voipms:sms|voip.ms SMS/MMS REST API]] behind typed Python + clean REST endpoints, with a per-day outbound send guard and static API-key auth. This page documents the **façade's own API** (what callers see); the upstream method reference lives at [[voipms:sms]].
Source: private Gitea repo ''sms-api-wrapper''. Sanitized — no account credentials, phone numbers, or API keys appear here; those are supplied at runtime via environment variables and kept local.
===== Architecture =====
^ File ^ Purpose ^
| ''config.py'' | ''Settings'' — env-loaded creds, dialing mode, daily limit, API-key hash, timeout |
| ''exceptions.py'' | ''VoipMsError'' hierarchy: ''Auth'', ''Api'', ''RateLimit'' |
| ''models.py'' | Pydantic request/response models |
| ''client.py'' | ''VoipMsSMSClient'' — async ''httpx'' wrappers for the 7 upstream methods |
| ''guard.py'' | ''DailySendCounter'' — UTC-day counter + send logging |
| ''auth.py'' | Static API-key dependency (SHA-256 hash compare, fail-closed) |
| ''app.py'' | FastAPI app + router exposing the operations as REST endpoints |
The service is single-process: one shared ''VoipMsSMSClient'' and one ''DailySendCounter'' live in ''app.state'' for the lifetime of the app.
===== Configuration =====
Loaded by ''Settings'' (pydantic-settings, env prefix ''voipms_'', also reads a ''.env'' file in the working directory).
^ Env var ^ Req ^ Notes ^
| ''VOIPMS_API_USERNAME'' | yes | voip.ms portal login **email** (not the account ID) |
| ''VOIPMS_API_PASSWORD'' | yes | API password from **API Security** (distinct from portal login password) |
| ''VOIPMS_SMS_API_KEY_HASH'' | yes* | SHA-256 hex hash of the accepted client API key; comma-separated list allowed for rotation. Empty = **fail-closed** (503) |
| ''VOIPMS_DIALING_MODE'' | no | ''nanpa'' (default, 10 digits) or ''e164'' (''+1'' + 10 digits) |
| ''VOIPMS_DAILY_LIMIT'' | no | Per-UTC-day outbound cap, default ''100'' (matches upstream API limit) |
| ''VOIPMS_TIMEOUT'' | no | HTTP timeout seconds, default ''30'' |
| ''VOIPMS_BASE_URL'' | no | Default ''https://voip.ms/api/v1/rest.php'' (avoid ''www.voip.ms'') |
* ''VOIPMS_SMS_API_KEY_HASH'' is optional in code but the service is fail-closed without it — set it for any real deployment.
Upstream prerequisites (on the voip.ms side): enable the REST API + set an API password, **whitelist this machine's public IP** under API Security, and **enable SMS on the sending DID**.
===== Authentication =====
Every endpoint except ''/health'' requires a static API key. Send it as either:
* ''Authorization: Bearer <your-api-key>''
* ''X-API-Key: <your-api-key>''
The server stores only the **SHA-256 hash** of accepted keys (''VOIPMS_SMS_API_KEY_HASH''); the raw key is never persisted. Comparison is constant-time (''secrets.compare_digest'').
Generate a key + hash:
<code sh>
python3 -c "import secrets,hashlib; k=secrets.token_urlsafe(32); print('KEY:',k); print('HASH:',hashlib.sha256(k.encode()).hexdigest())"
</code>
Put the ''HASH:'' value in ''VOIPMS_SMS_API_KEY_HASH''; hand the ''KEY:'' value to your calling services. **Rotation:** add the new hash alongside the old, switch callers, then remove the old hash.
If ''VOIPMS_SMS_API_KEY_HASH'' is empty/unset, protected routes return ''503'' (fail-closed). ''/health'' stays open for liveness probes.
===== Endpoints =====
^ Method ^ Path ^ Upstream ^ Send-guarded ^
| POST | ''/sms/send'' | ''sendSMS'' | ✅ |
| POST | ''/mms/send'' | ''sendMMS'' | ✅ |
| GET | ''/sms'' | ''getSMS'' | — |
| GET | ''/mms'' | ''getMMS'' | — |
| GET | ''/mms/{id}/media'' | ''getMediaMMS'' | — |
| DELETE | ''/sms/{id}'' | ''deleteSMS'' | — |
| DELETE | ''/mms/{id}'' | ''deleteMMS'' | — |
| GET | ''/health'' | — | — (no auth) |
==== POST /sms/send ====
^ Field ^ Req ^ Notes ^
| did | yes | Sender DID |
| dst | yes | Destination number |
| message | yes | Max **160** chars |
Returns ''{"id": <new sms id>}''. ''did''/''dst'' are normalized per ''VOIPMS_DIALING_MODE'' before sending.
==== POST /mms/send ====
^ Field ^ Req ^ Notes ^
| did | yes | Sender DID |
| dst | yes | Destination |
| message | yes | Max **2048** chars |
| media1 | no | URL to a media file |
| media2 | no | Base64 image (''data:image/png;base64,...'') |
| media3 | no | Reserved |
Returns ''{"id": <new mms id>}''. See [[voipms:sms|upstream reference]] for media type/size limits.
==== GET /sms ====
Query params: ''sms'' (specific id), ''from''/''to'' (''YYYY-MM-DD''), ''type'' (1=received / 0=sent), ''did'', ''contact'', ''limit'', ''timezone'' (-12..13), ''all_messages'' (1=SMS+MMS combined, 0=single type; the id must be ''0'' when set).
Returns a list of SMS records. Empty upstream results (''status: no_sms'') are normalized to ''[]'' — not an error.
==== GET /mms ====
Same shape as ''/sms'' but the id query param is ''id''. ''all_messages=1'' returns MMS+SMS combined (id must be ''0'').
==== GET /mms/{id}/media ====
Query param: ''media_as_array'' (bool, default false). Returns ''{"id","date","media":[url,...]}''.
==== DELETE /sms/{id} · DELETE /mms/{id} ====
Returns ''{"status": "success"}'' on success.
==== GET /health ====
Unauthenticated. Returns the literal text ''ok'' (200). Use for liveness probes.
===== Error mapping =====
^ Condition ^ HTTP ^ Body ^
| Missing API key | 401 | ''{"detail":"missing api key"}'' |
| Invalid API key | 401 | ''{"detail":"invalid api key"}'' |
| No API-key hash configured | 503 | ''{"detail":"api key auth not configured"}'' |
| Daily send limit exceeded | 429 | ''{detail, limit, used_today}'' |
| Upstream returns non-success | 502 | upstream message |
| Upstream auth / IP-whitelist failure | 500 | ''upstream authentication failure'' (details logged server-side, never leaked to caller) |
===== Outbound send guard =====
''DailySendCounter'' caps outbound sends (''/sms/send'', ''/mms/send'') at ''VOIPMS_DAILY_LIMIT'' per **UTC day**. The counter is checked **before** the upstream call and incremented in-process; every successful send is logged. Resets automatically on UTC-day rollover.
**Per-process only:** for multiple instances, replace ''DailySendCounter'' with a shared store (Redis, etc.).
===== Dialing mode =====
''nanpa'' → numbers sent as 10 digits. ''e164'' → ''+1'' + 10 digits. Applied to ''did''/''dst'' on send. Pick the mode that matches what your DID/carrier expects; normalize on your side for inbound.
===== Notes & limitations =====
* No ''markSMSRead'' exists upstream; read state is not manageable via this façade.
* Use ''https://voip.ms'' (not ''www.voip.ms'') — the ''www'' host 302-redirects and drops POST bodies.
* Inbound URL Callback receiver is **not** included; it can be added as a separate route set that replies with the literal ''ok'' and persists ''{TO}/{FROM}/{MESSAGE}/{ID}/{TIMESTAMP}/{MEDIA}''.
* No automated test suite; verify manually per the README.
----
//Façade over the [[voipms:sms|voip.ms SMS/MMS REST API]]. Upstream behavior, limits, and gotchas are documented there.//