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

@@ -13,6 +13,7 @@ with a per-day outbound send guard.
| `models.py` | Pydantic request/response models |
| `client.py` | `VoipMsSMSClient` — async `httpx` wrappers for the 7 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 exposing the operations as REST endpoints |
| `requirements.txt` | Dependencies |
@@ -30,6 +31,7 @@ with a per-day outbound send guard.
```sh
export VOIPMS_API_USERNAME=...
export VOIPMS_API_PASSWORD=...
export VOIPMS_SMS_API_KEY_HASH=$(python3 -c "import secrets,hashlib;print(hashlib.sha256(secrets.token_urlsafe(32).encode()).hexdigest())")
# optional overrides:
# export VOIPMS_BASE_URL=https://voip.ms/api/v1/rest.php
# export VOIPMS_DIALING_MODE=nanpa # or e164
@@ -39,6 +41,32 @@ export VOIPMS_API_PASSWORD=...
(`Settings` also reads a `.env` file in the working directory.)
## 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 in
`VOIPMS_SMS_API_KEY_HASH` (comma-separated list allowed, for rotation). The raw
key is never persisted — generate one, hash it, and keep the raw key wherever
your clients live:
```sh
python3 -c "import secrets,hashlib; k=secrets.token_urlsafe(32); print('KEY:',k); print('HASH:',hashlib.sha256(k.encode()).hexdigest())"
```
Put the `HASH:` value in `VOIPMS_SMS_API_KEY_HASH`; hand the `KEY:` value to
your calling services. To rotate, add the new hash alongside the old, switch
callers over, then remove the old hash.
If `VOIPMS_SMS_API_KEY_HASH` is empty/unset, the service is **fail-closed**:
protected routes return `503` until a hash is configured. `/health` stays open
for liveness probes.
## Run
```sh
@@ -48,6 +76,29 @@ uvicorn sms.app:app --reload
Open `http://127.0.0.1:8000/docs` for the Swagger UI.
## Run with Docker
A `Dockerfile` and `docker-compose.yml` live at the repo root. The compose
stack builds the image and runs it, injecting secrets from the local `.env` via
`env_file`**secrets are never baked into the image** (`.dockerignore` excludes
`.env`, `.venv`, `.git`, `sms/docs/`).
```sh
docker compose up -d # build + start
docker compose logs -f sms # follow logs
docker compose ps # status / healthcheck
docker compose down # stop
```
The container binds `0.0.0.0:8000` internally; compose maps it to
**`127.0.0.1:${SMS_PORT:-8000}`** on the host — localhost-only by default. To
expose on the LAN, set the mapping to `8000:8000` (access is still gated by the
`VOIPMS_SMS_API_KEY_HASH` API key). If host port 8000 is taken, set
`SMS_PORT=8001` (or any free port) in `.env`.
A healthcheck polls `/health` every 30s. The image is lean — reference docs and
the virtualenv are excluded; only `sms/` + dependencies are inside.
## Endpoints
| Method | Path | Upstream | Guarded |
@@ -72,6 +123,9 @@ type; the message id must be 0 when set). For `/sms` the id query param is
| 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 limit exceeded | 429 | `{detail, limit, used_today}` |
| voip.ms returns non-success status | 502 | upstream message |
| Auth / IP-whitelist failure | 500 | `upstream authentication failure` (details logged server-side) |

View File

@@ -12,9 +12,10 @@ import logging
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, Query, Request, status
from fastapi import APIRouter, Depends, FastAPI, Query, Request, status
from fastapi.responses import JSONResponse, PlainTextResponse
from .auth import verify_api_key
from .client import VoipMsSMSClient
from .config import Settings
from .exceptions import VoipMsApiError, VoipMsAuthError, VoipMsError, VoipMsRateLimitError
@@ -51,6 +52,10 @@ app = FastAPI(
lifespan=lifespan,
)
# Every route on this router requires a valid static API key. /health is the
# only route kept on `app` directly so liveness probes stay unauthenticated.
router = APIRouter(dependencies=[Depends(verify_api_key)])
def _client(request: Request) -> VoipMsSMSClient:
return request.app.state.client
@@ -79,7 +84,7 @@ def _map_error(exc: VoipMsError) -> JSONResponse:
# --- outbound (guarded) ----------------------------------------------------
@app.post("/sms/send", response_model=SendResult)
@router.post("/sms/send", response_model=SendResult)
async def send_sms(body: SendSmsRequest, request: Request) -> SendResult:
guard = _guard(request)
client = _client(request)
@@ -92,7 +97,7 @@ async def send_sms(body: SendSmsRequest, request: Request) -> SendResult:
return result
@app.post("/mms/send", response_model=SendResult)
@router.post("/mms/send", response_model=SendResult)
async def send_mms(body: SendMmsRequest, request: Request) -> SendResult:
guard = _guard(request)
client = _client(request)
@@ -115,7 +120,7 @@ async def send_mms(body: SendMmsRequest, request: Request) -> SendResult:
# --- history / retrieval ---------------------------------------------------
@app.get("/sms", response_model=list[SmsRecord])
@router.get("/sms", response_model=list[SmsRecord])
async def list_sms(
request: Request,
sms: Annotated[int | None, Query(description="Specific SMS id")] = None,
@@ -138,7 +143,7 @@ async def list_sms(
return _map_error(exc)
@app.get("/mms", response_model=list[MmsRecord])
@router.get("/mms", response_model=list[MmsRecord])
async def list_mms(
request: Request,
id: Annotated[int | None, Query(description="Specific MMS id")] = None,
@@ -161,7 +166,7 @@ async def list_mms(
return _map_error(exc)
@app.get("/mms/{id}/media", response_model=MediaResult)
@router.get("/mms/{id}/media", response_model=MediaResult)
async def get_mms_media(
request: Request,
id: int,
@@ -177,7 +182,7 @@ async def get_mms_media(
# --- delete ----------------------------------------------------------------
@app.delete("/sms/{id}", response_model=DeleteResult)
@router.delete("/sms/{id}", response_model=DeleteResult)
async def delete_sms(request: Request, id: int) -> DeleteResult:
client = _client(request)
try:
@@ -186,7 +191,7 @@ async def delete_sms(request: Request, id: int) -> DeleteResult:
return _map_error(exc)
@app.delete("/mms/{id}", response_model=DeleteResult)
@router.delete("/mms/{id}", response_model=DeleteResult)
async def delete_mms(request: Request, id: int) -> DeleteResult:
client = _client(request)
try:
@@ -200,4 +205,7 @@ async def delete_mms(request: Request, id: int) -> DeleteResult:
@app.get("/health", response_class=PlainTextResponse)
async def health() -> str:
return "ok"
return "ok"
app.include_router(router)

69
sms/auth.py Normal file
View File

@@ -0,0 +1,69 @@
"""Static API-key authentication for the sms façade.
Clients present a key in one of two headers:
Authorization: Bearer <raw-api-key>
X-API-Key: <raw-api-key>
The server stores only the **SHA-256 hex hash(es)** of accepted keys in the
``SMS_API_KEY_HASH`` setting (comma-separated). The raw key is never persisted;
losing it means generating a new one and updating the hash.
Fail-closed: if no hash is configured, every protected route returns 503 rather
than silently admitting traffic. ``/health`` is exempt (see ``app.py``).
"""
from __future__ import annotations
import hashlib
import logging
import secrets
from fastapi import HTTPException, Request, status
logger = logging.getLogger("voipms.sms.auth")
def _hash_key(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _allowed_hashes(request: Request) -> set[str]:
raw = request.app.state.settings.sms_api_key_hash or ""
return {h.strip().lower() for h in raw.split(",") if h.strip()}
def _extract_token(request: Request) -> str | None:
auth = request.headers.get("authorization")
if auth:
parts = auth.split(None, 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip() or None
return None
return request.headers.get("x-api-key")
def verify_api_key(request: Request) -> None:
"""FastAPI dependency gating every protected route."""
allowed = _allowed_hashes(request)
if not allowed:
logger.error("SMS_API_KEY_HASH not configured; refusing request (fail-closed).")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="api key auth not configured",
)
token = _extract_token(request)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing api key",
headers={"WWW-Authenticate": "Bearer"},
)
digest = _hash_key(token)
if not any(secrets.compare_digest(digest, h) for h in allowed):
logger.warning("rejected api key from %s", request.client.host if request.client else "?")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid api key",
headers={"WWW-Authenticate": "Bearer"},
)

View File

@@ -44,4 +44,13 @@ class Settings(BaseSettings):
ge=0,
)
sms_api_key_hash: str = Field(
"",
description=(
"Comma-separated SHA-256 hex hashes of accepted client API keys. "
"Generate a key, hash it (sha256), and store only the hash here — "
"the raw key is never persisted. Empty = fail-closed (503)."
),
)
timeout: float = Field(30.0, description="HTTP timeout in seconds.", ge=1.0)

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.//