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

@@ -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)