Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion admin-frontend/sapot-admin/Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
7 changes: 6 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +80,7 @@ services:
admin:
build:
context: ./admin-frontend/sapot-admin
target: development
command: pnpm dev
restart: unless-stopped
env_file:
Expand Down Expand Up @@ -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/
Expand Down
2 changes: 2 additions & 0 deletions docs/deployment/environment-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -37,6 +38,7 @@ ENVIRONMENT=production
REDIS_URL=redis://127.0.0.1:6379/0
SERVER_ED25519_SEED=<generate with: openssl rand -hex 32>
GSM_SECRET=<shared secret with GSM module>
GSM_GATEWAY_URL=http://127.0.0.1:8001
```

---
Expand Down
4 changes: 4 additions & 0 deletions docs/getting-started/docker-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions server/app/api/gsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion server/app/tests/test_gsm_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down
Loading