diff --git a/admin-frontend/sapot-admin/Dockerfile b/admin-frontend/sapot-admin/Dockerfile index 982865fb..c881b681 100644 --- a/admin-frontend/sapot-admin/Dockerfile +++ b/admin-frontend/sapot-admin/Dockerfile @@ -1,13 +1,22 @@ # syntax=docker/dockerfile:1 -FROM node:22-slim AS builder +FROM node:22-slim AS base RUN corepack enable WORKDIR /app + +FROM base AS dependencies + COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile +FROM dependencies AS development +ENV NODE_ENV=development +COPY . . + +FROM dependencies AS builder + COPY . . RUN pnpm build diff --git a/docker-compose.yml b/docker-compose.yml index b4142ae1..834dd983 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: restart: unless-stopped env_file: - ./server/.env + environment: + GSM_GATEWAY_URL: http://gsm-fastapi:8001 depends_on: db: condition: service_healthy @@ -78,6 +80,7 @@ services: admin: build: context: ./admin-frontend/sapot-admin + target: development command: pnpm dev restart: unless-stopped env_file: @@ -125,7 +128,9 @@ services: - ./GSM-module/GSM-fastapi/.env environment: HOST: 0.0.0.0 - SAPOT_API_URL: https://nginx + # The callback stays on Docker's private network. Going through nginx + # would require the gateway image to trust the development TLS CA. + SAPOT_API_URL: http://api:8000 # No /dev/ttyACM0 device passthrough here — the modem isn't present on # most dev machines, and Compose has no "optional device" syntax, so # declaring it here would abort the whole `docker compose up` (nginx/ diff --git a/docs/deployment/environment-config.md b/docs/deployment/environment-config.md index c1c826a6..61521a69 100644 --- a/docs/deployment/environment-config.md +++ b/docs/deployment/environment-config.md @@ -16,6 +16,7 @@ All SAPOT components are configured via environment variables. This document lis | `REDIS_URL` | `redis://localhost:6379` | Set if Redis is on a non-default host/port | | `SERVER_ED25519_SEED` | `None` (server key signing disabled if unset) | Set to enable server-signed peer keys | | `GSM_SECRET` | None — required, raises `RuntimeError` at import if unset | **MUST** be set — shared secret for GSM module webhooks | +| `GSM_GATEWAY_URL` | `http://localhost:8001` | Base URL of the deployed GSM FastAPI gateway. Set `http://gsm-fastapi:8001` in Docker Compose. | See [SECURITY.md](../../SECURITY.md) for why `DATABASE_URL`, `JWT_SECRET_KEY`, `CORS_ALLOWED_ORIGINS`, and `GSM_SECRET` are required. @@ -37,6 +38,7 @@ ENVIRONMENT=production REDIS_URL=redis://127.0.0.1:6379/0 SERVER_ED25519_SEED= GSM_SECRET= +GSM_GATEWAY_URL=http://127.0.0.1:8001 ``` --- diff --git a/docs/getting-started/docker-setup.md b/docs/getting-started/docker-setup.md index 0c092eae..bd68e6d4 100644 --- a/docs/getting-started/docker-setup.md +++ b/docs/getting-started/docker-setup.md @@ -59,6 +59,10 @@ cp GSM-module/GSM-fastapi/.env.example GSM-module/GSM-fastapi/.env `gsm-fastapi`'s `GSM_SECRET` must match `server/.env`'s `GSM_SECRET` — they authenticate the webhook calls between the two services (see [environment-config.md](../deployment/environment-config.md)). + +Compose sets the server's `GSM_GATEWAY_URL` to `http://gsm-fastapi:8001`, which resolves through the +internal Docker network. Do not replace it with `localhost`: inside the `api` container, that address +refers to the API container rather than the separate GSM gateway container. The `gsm-fastapi` container passes through the GSM modem at `/dev/ttyACM0`, but only when `docker-compose.gsm-hardware.yml` is explicitly merged in (Compose has no "optional device" syntax, so this stays out of the base `docker-compose.yml`/`docker-compose.override.yml` — otherwise the diff --git a/server/.env.example b/server/.env.example index 5746eeca..ffeaf79d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,6 +11,7 @@ TLS_KEY=~/server.key # so `cp .env.example .env` works out of the box for docs/getting-started/docker-setup.md. # Running bare-metal instead (docs/getting-started/server-setup.md)? Change both to 127.0.0.1/localhost. REDIS_URL=redis://redis:6379 +GSM_GATEWAY_URL=http://gsm-fastapi:8001 DATABASE_URL=mysql+pymysql://sapot:sapot@db:3306/sapot_dev diff --git a/server/app/api/gsm.py b/server/app/api/gsm.py index e630c72a..207cba9f 100644 --- a/server/app/api/gsm.py +++ b/server/app/api/gsm.py @@ -31,20 +31,21 @@ GSM_PROXY_MAX_CONNECTIONS = GSM_GATEWAY_MAX_ADMITTED_REQUESTS + 1 GSM_PROXY_POOL_TIMEOUT_SECONDS = 1.0 -# Module-level client reuses TCP connections to localhost:8001 across requests. +# Module-level client reuses TCP connections to the configured GSM gateway. _gsm_http_client: httpx.AsyncClient | None = None logger = logging.getLogger("app") GSM_SECRET = os.environ.get("GSM_SECRET") if not GSM_SECRET: raise RuntimeError("GSM_SECRET environment variable is not set") +GSM_GATEWAY_URL = os.environ.get("GSM_GATEWAY_URL", "http://localhost:8001").rstrip("/") def _get_gsm_client() -> httpx.AsyncClient: global _gsm_http_client if _gsm_http_client is None or _gsm_http_client.is_closed: _gsm_http_client = httpx.AsyncClient( - base_url="http://localhost:8001", + base_url=GSM_GATEWAY_URL, timeout=httpx.Timeout( connect=5.0, read=GSM_PROXY_READ_TIMEOUT_SECONDS, diff --git a/server/app/tests/test_gsm_proxy.py b/server/app/tests/test_gsm_proxy.py index 65738769..81eaea62 100644 --- a/server/app/tests/test_gsm_proxy.py +++ b/server/app/tests/test_gsm_proxy.py @@ -69,7 +69,7 @@ def _authenticated_user(session): return session.exec(select(User)).first() -def test_proxy_capacity_and_timeouts_cover_gateway_contract(monkeypatch): +def test_proxy_capacity_timeouts_and_gateway_url_cover_gateway_contract(monkeypatch): captured = {} class CapturingClient: @@ -90,6 +90,25 @@ def __init__(self, **kwargs): assert timeout.pool == gsm.GSM_PROXY_POOL_TIMEOUT_SECONDS assert limits.max_connections == gsm.GSM_PROXY_MAX_CONNECTIONS assert limits.max_connections > gsm.GSM_GATEWAY_MAX_ADMITTED_REQUESTS + assert captured["base_url"] == gsm.GSM_GATEWAY_URL + + +def test_proxy_uses_configured_gateway_url(monkeypatch): + captured = {} + + class CapturingClient: + is_closed = False + + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(gsm, "_gsm_http_client", None) + monkeypatch.setattr(gsm, "GSM_GATEWAY_URL", "http://gsm-fastapi:8001") + monkeypatch.setattr(gsm.httpx, "AsyncClient", CapturingClient) + + gsm._get_gsm_client() + + assert captured["base_url"] == "http://gsm-fastapi:8001" def test_send_to_module_authenticates_with_shared_secret(monkeypatch):