From 1d9e7e0fab391518c11058e1d90b070ce1acabd4 Mon Sep 17 00:00:00 2001 From: chelsea Date: Sat, 4 Jul 2026 05:09:43 +0000 Subject: [PATCH] =?UTF-8?q?Add=20static=20API-key=20auth,=20dockerize=20se?= =?UTF-8?q?rvice,=20publish=20fa=C3=A7ade=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 15 ++++ .env.example | 14 ++++ Dockerfile | 20 +++++ docker-compose.yml | 22 +++++ sms/README.md | 54 ++++++++++++ sms/app.py | 26 ++++-- sms/auth.py | 69 +++++++++++++++ sms/config.py | 9 ++ sms/docs/sms-facade.dokuwiki.txt | 139 +++++++++++++++++++++++++++++++ 9 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 sms/auth.py create mode 100644 sms/docs/sms-facade.dokuwiki.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ae5d75a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Keep secrets and bloat out of the image build context. +.env +.env.* +!.env.example +.venv/ +venv/ +__pycache__/ +*.py[cod] +.git/ +.claude/ +# Reference docs aren't needed at runtime. +sms/docs/ +Dockerfile +docker-compose.yml +.dockerignore \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0540b2d --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Template — copy to .env (chmod 600, gitignored) and fill in real values. +# Used by sms/config.py (Settings, env_prefix=voipms_). + +# voip.ms REST API credentials (portal login email + API password from API Security) +VOIPMS_API_USERNAME=you@example.com +VOIPMS_API_PASSWORD=your-api-password + +# SHA-256 hash of the accepted client API key (raw key is NOT stored here). +# Generate: python3 -c "import secrets,hashlib; k=secrets.token_urlsafe(32); print('KEY:',k); print('HASH:',hashlib.sha256(k.encode()).hexdigest())" +VOIPMS_SMS_API_KEY_HASH= + +# Optional overrides +VOIPMS_DIALING_MODE=nanpa +VOIPMS_DAILY_LIMIT=100 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..16f535f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# voip.ms SMS/MMS API façade — container image. +# Secrets are injected at runtime via env_file (docker-compose) / -e; they are +# NEVER copied into the image (see .dockerignore). +FROM python:3.13-slim + +WORKDIR /app + +# Install deps first for better layer caching. +COPY sms/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Application package only. +COPY sms/ ./sms/ + +EXPOSE 8000 + +# Bind 0.0.0.0 inside the container; the compose port mapping restricts host +# exposure to 127.0.0.1. Adjust the mapping if you need broader exposure +# (the API key in VOIPMS_SMS_API_KEY_HASH gates access either way). +CMD ["uvicorn", "sms.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1b89aea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# voip.ms SMS/MMS API façade. +# Secrets live in the host .env (gitignored) and are injected here via env_file; +# they are never baked into the image. +services: + sms: + build: . + image: sms-api-wrapper:latest + container_name: sms-api + # Host-localhost only. To expose on the LAN, change to "8000:8000" + # (access is still gated by the VOIPMS_SMS_API_KEY_HASH API key). + # Override the host port with SMS_PORT (e.g. SMS_PORT=8001 in .env) if 8000 is taken. + ports: + - "127.0.0.1:${SMS_PORT:-8000}:8000" + env_file: + - .env + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s \ No newline at end of file diff --git a/sms/README.md b/sms/README.md index e7352a3..042409c 100644 --- a/sms/README.md +++ b/sms/README.md @@ -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 +X-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) | diff --git a/sms/app.py b/sms/app.py index 9228118..b06f386 100644 --- a/sms/app.py +++ b/sms/app.py @@ -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" \ No newline at end of file + return "ok" + + +app.include_router(router) \ No newline at end of file diff --git a/sms/auth.py b/sms/auth.py new file mode 100644 index 0000000..ef917ed --- /dev/null +++ b/sms/auth.py @@ -0,0 +1,69 @@ +"""Static API-key authentication for the sms façade. + +Clients present a key in one of two headers: + + Authorization: Bearer + X-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"}, + ) \ No newline at end of file diff --git a/sms/config.py b/sms/config.py index 0bf5f57..add444e 100644 --- a/sms/config.py +++ b/sms/config.py @@ -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) \ No newline at end of file diff --git a/sms/docs/sms-facade.dokuwiki.txt b/sms/docs/sms-facade.dokuwiki.txt new file mode 100644 index 0000000..b791074 --- /dev/null +++ b/sms/docs/sms-facade.dokuwiki.txt @@ -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 '' + * ''X-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: + + +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. **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": }''. ''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": }''. 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.// \ No newline at end of file