feat(serve): HTTP transport + hardened Docker image for secure public serving
Add an opt-in streamable-HTTP transport alongside the default stdio, so familiar can be reached from a home lab over HTTPS behind a TLS reverse proxy. - config.http_settings(): env-driven transport/host/port/path/token settings. - serve.run(): dispatch stdio vs http; fail closed on a non-loopback bind without a bearer token (FAMILIAR_ALLOW_INSECURE_BIND escape hatch). - server.build_http_auth(): FastMCP StaticTokenVerifier bearer gate; add an unauthenticated /healthz route for container/proxy probes. - `familiar serve` CLI alias; clean exit on InsecureBind. - docker/Dockerfile (multi-stage, non-root) + hardened docker-compose.yml (read-only rootfs, cap_drop ALL, no-new-privileges, Traefik TLS labels), .dockerignore, .env.example. Token store mounts at /data; writes off by default. - .gitea/workflows/docker.yml: build + smoke-test, opt-in image publish. - docs/deployment.md + README/CLAUDE.md notes; tests for settings, fail-closed validation, and the bearer verifier.
This commit is contained in:
13 files changed
+727
-4
No files matched your search
@@ -16,7 +16,8 @@ uv run pytest # full suite; FAILS under 80% coverage (configured
|
||||
uv run pytest tests/test_market.py::test_get_market_price # single test
|
||||
uv run ruff format . # format
|
||||
uv run ruff check . # lint (run both before committing; CI enforces them)
|
||||
uv run familiar # run the MCP server over stdio (how Claude launches it)
|
||||
uv run familiar # run the MCP server (stdio by default; HTTP via FAMILIAR_TRANSPORT)
|
||||
uv run familiar serve # explicit alias for the above (used as the Docker CMD)
|
||||
uv run familiar login # browser EVE SSO login (adds a character; run once per character)
|
||||
uv run familiar status # list logged-in characters
|
||||
uv run familiar writes disable # block write actions (default: enabled)
|
||||
@@ -36,6 +37,8 @@ There is no separate build step; `uv build` produces the wheel (CI does this on
|
||||
|
||||
**Write safety — the choke point.** [writes.py](src/familiar/writes.py) `guard(action, params, confirm, execute)` is the single gate every mutating tool calls. `confirm=False` → dry-run preview (nothing happens); `confirm=True` but writes disabled → `WriteDisabled`; `confirm=True` + enabled → execute + append to an audit log. Writes are **on by default** (opt out via `familiar writes disable`; toggle via `familiar writes enable`/`disable` or `FAMILIAR_ENABLE_WRITES`). Write tools take a *required* `character` and a `confirm` flag; reads take an *optional* `character`. When adding a write tool, route it through `writes.guard` — never call a mutating ESI endpoint directly.
|
||||
|
||||
**Transport selection — the serving choke point.** [serve.py](src/familiar/serve.py) `run()` reads `config.http_settings()` (all `FAMILIAR_*` env, no I/O) and dispatches: `stdio` (default → `mcp.run()`) or `http` (streamable HTTP via `mcp.run(transport="http", ...)`, for Docker behind a TLS reverse proxy). It **fails closed** — a non-loopback bind without `FAMILIAR_HTTP_AUTH_TOKEN` (and without the `FAMILIAR_ALLOW_INSECURE_BIND=true` escape hatch) raises `InsecureBind`. HTTP auth is a FastMCP `StaticTokenVerifier` bearer token attached in `server.build_http_auth` at construction (ignored by stdio). `server.py` also adds an unauthenticated `/healthz` route for container/proxy probes. The image + `docker-compose.yml` (hardened: non-root, read-only rootfs, `cap_drop: ALL`) mount the encrypted token store at `/data` (`FAMILIAR_DATA_DIR`) and default `FAMILIAR_ENABLE_WRITES=false`; login still happens on a host with a browser. See [docs/deployment.md](docs/deployment.md).
|
||||
|
||||
## Testing conventions
|
||||
|
||||
Tests use [tests/conftest.py](tests/conftest.py): the `mock_esi` fixture installs a mock ESI client driven by a request handler, and `route(req, {substring: body})` dispatches by path substring. Async tools are driven with `asyncio.run(...)` (no async-test plugin). Authenticated-tool tests monkeypatch `auth.list_characters` / `auth.get_auth` / `auth.resolve_character` rather than touching the real token store; write tests set `FAMILIAR_DATA_DIR` to a tmp dir and `FAMILIAR_ENABLE_WRITES`. `make_jwt(claims)` builds fake access tokens.
|
||||
|
||||
Reference in new issue
Block a user