From 39ce8005c0099da8925eb1d3c7ff16f8c1b048e0 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 19:26:17 +0200 Subject: [PATCH] feat(docker): image, compose, and env for remote-HTTP deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/Dockerfile: multi-stage uv wheel build; runtime runs `familiar serve` as a non-root user (uid 1000) on port 8080, /data volume for the encrypted token store, and a HEALTHCHECK against /healthz. No token is baked in — serve refuses to start without FAMILIAR_HTTP_TOKEN, so it's supplied at run time. - docker-compose.yml: joins an external `proxy` network (Traefik labels for TLS, nginx/Caddy documented as alternatives), read-only rootfs, tmpfs /tmp, no-new-privileges, cap_drop ALL, non-root, /healthz healthcheck. Pulls the published FAMILIAR_IMAGE with a local build as fallback; writes default off. - .env.example: every FAMILIAR_* knob incl. the bearer token, auth mode, the OAuth resource-server settings, and the insecure-bind escape hatch. - .dockerignore: keep local state (data/, *.enc, key) and dev files out. --- .dockerignore | 26 +++++++++++++++ .env.example | 46 ++++++++++++++++++++++++++ docker-compose.yml | 80 ++++++++++++++++++++++++++++++++++++++++++++++ docker/Dockerfile | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c0fdfa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Keep the build context small and never ship local state or secrets. +.git +.gitea +.venv +**/__pycache__/ +*.pyc +.pytest_cache +.ruff_cache +.coverage +htmlcov +dist +build +*.egg-info + +# Local EVE token store / data dir — must never end up in an image layer. +data +*.enc +key + +# Docs, tests, and dev tooling aren't needed at runtime. +tests +doc +docker-compose.yml +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..96527dc --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# familiar — remote HTTP / Docker deployment settings. +# Copy to .env and fill in. .env is gitignored; never commit real secrets. + +# --- Image (published to Gitea by .gitea/workflows/docker.yml) -------------- # +# Prod channel: :latest or :main. Dev channel: :dev. Or pin :sha-<12>. +FAMILIAR_IMAGE=git.hiddenden.cafe/hiddenden/familiar:latest + +# --- Bind + path ----------------------------------------------------------- # +# The image binds 0.0.0.0:8080 internally; only the reverse proxy reaches it. +# FAMILIAR_HTTP_PATH must match your proxy's location block. +FAMILIAR_HTTP_PATH=/mcp + +# --- Auth mode ------------------------------------------------------------- # +# bearer (default) — a single static token (below). Works with every client. +# oauth — validate external-issuer JWTs; needed only for the Claude web +# connector (requires an external IdP with DCR). See doc/clients/claude-web.md. +FAMILIAR_AUTH_MODE=bearer + +# --- Bearer auth (REQUIRED when FAMILIAR_AUTH_MODE=bearer) ------------------ # +# Token clients send as `Authorization: Bearer `. Generate a strong one: +# openssl rand -hex 32 +FAMILIAR_HTTP_TOKEN= + +# Escape hatch: allow a public (0.0.0.0) bind with NO token. Leave false unless +# authentication is handled entirely upstream. +# FAMILIAR_ALLOW_INSECURE_BIND=false + +# --- OAuth 2.1 resource server (only when FAMILIAR_AUTH_MODE=oauth) --------- # +# FAMILIAR_OAUTH_ISSUER=https://idp.example.com/application/o/familiar/ +# FAMILIAR_OAUTH_RESOURCE=https://familiar.example.com # this server's public URL +# FAMILIAR_OAUTH_AUDIENCE=https://familiar.example.com # defaults to RESOURCE +# FAMILIAR_OAUTH_JWKS_URI= # optional; else discovered from issuer + +# --- Data / writes --------------------------------------------------------- # +# Host path holding the encrypted token store (token.enc + key), audit log, and +# write toggle. Produced by `familiar login` on a machine with a browser (or +# `familiar login --no-browser` on the host). +FAMILIAR_DATA_DIR_HOST=./data + +# Writes off by default on a public instance. Set true to allow guarded writes. +FAMILIAR_ENABLE_WRITES=false + +# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ # +# Only used if you run Traefik. For nginx/Caddy, see doc/deployment.md. +FAMILIAR_DOMAIN=familiar.example.com +TRAEFIK_CERTRESOLVER=letsencrypt diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5318e68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +services: + familiar: + # Pull the image published to Gitea by .gitea/workflows/docker.yml, or build + # locally with `docker compose build`. Pick the channel via .env: + # FAMILIAR_IMAGE (:latest / :main for prod, :dev for the dev channel). + image: ${FAMILIAR_IMAGE:-git.hiddenden.cafe/hiddenden/familiar:latest} + build: + context: . + dockerfile: docker/Dockerfile + container_name: familiar + restart: unless-stopped + env_file: + - .env + environment: + FAMILIAR_HTTP_HOST: 0.0.0.0 + FAMILIAR_HTTP_PORT: "8080" + FAMILIAR_HTTP_PATH: ${FAMILIAR_HTTP_PATH:-/mcp} + FAMILIAR_DATA_DIR: /data + # bearer (default) or oauth. In bearer mode FAMILIAR_HTTP_TOKEN is required. + FAMILIAR_AUTH_MODE: ${FAMILIAR_AUTH_MODE:-bearer} + # Bearer token clients send as `Authorization: Bearer `. REQUIRED in + # bearer mode — `familiar serve` refuses to start on 0.0.0.0 without it + # (unless FAMILIAR_ALLOW_INSECURE_BIND=true). Leave blank for oauth mode. + FAMILIAR_HTTP_TOKEN: ${FAMILIAR_HTTP_TOKEN:-} + # OAuth 2.1 resource-server settings (only used when AUTH_MODE=oauth). + FAMILIAR_OAUTH_ISSUER: ${FAMILIAR_OAUTH_ISSUER:-} + FAMILIAR_OAUTH_RESOURCE: ${FAMILIAR_OAUTH_RESOURCE:-} + FAMILIAR_OAUTH_AUDIENCE: ${FAMILIAR_OAUTH_AUDIENCE:-} + FAMILIAR_OAUTH_JWKS_URI: ${FAMILIAR_OAUTH_JWKS_URI:-} + # Writes are off by default over HTTP; set true in .env to allow them. + FAMILIAR_ENABLE_WRITES: ${FAMILIAR_ENABLE_WRITES:-false} + # The encrypted token store (token.enc + key), audit log, and write toggle + # live under /data. Run `familiar login` (or `login --no-browser`) once, then + # point this at that data dir (or copy token.enc + key into ./data). + volumes: + - ${FAMILIAR_DATA_DIR_HOST:-./data}:/data + # Not published to the host — only reachable on the internal proxy network, + # where your TLS reverse proxy terminates HTTPS and forwards to :8080. + expose: + - "8080" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + user: "1000:1000" + networks: + - proxy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5).raise_for_status()", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + labels: + # --- Traefik TLS termination (Let's Encrypt) -------------------------- # + # Enable if your reverse proxy is Traefik on the external `proxy` network. + # Set FAMILIAR_DOMAIN and TRAEFIK_CERTRESOLVER in .env. For nginx/Caddy + # instead, ignore these labels — see doc/deployment.md. + traefik.enable: "true" + traefik.docker.network: proxy + traefik.http.routers.familiar.rule: Host(`${FAMILIAR_DOMAIN:-familiar.example.com}`) + traefik.http.routers.familiar.entrypoints: websecure + traefik.http.routers.familiar.tls: "true" + traefik.http.routers.familiar.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-letsencrypt} + traefik.http.services.familiar.loadbalancer.server.port: "8080" + +networks: + # Shared reverse-proxy network (Traefik/Caddy). Create once with: + # docker network create proxy + proxy: + external: true diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..0024c7c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- # +# Build stage: build a wheel from the source tree with uv. +# --------------------------------------------------------------------------- # +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN pip install --no-cache-dir uv + +# Files needed to build the wheel (pyproject references README.md + LICENSE). +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src + +RUN uv build --wheel --out-dir /dist + +# --------------------------------------------------------------------------- # +# Runtime stage: minimal, non-root image that runs `familiar serve` (HTTP). +# --------------------------------------------------------------------------- # +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + # Bind on all interfaces inside the container; a TLS reverse proxy fronts it. + FAMILIAR_HTTP_HOST=0.0.0.0 \ + FAMILIAR_HTTP_PORT=8080 \ + FAMILIAR_HTTP_PATH=/mcp \ + # Token store + audit log + write toggle live here — mount a persistent volume. + FAMILIAR_DATA_DIR=/data + # FAMILIAR_HTTP_TOKEN is intentionally NOT baked in: `familiar serve` refuses + # to start without it, so it must be supplied at run time (docker run -e ...). + # Writes stay off by default over HTTP; set FAMILIAR_ENABLE_WRITES=true to opt in. + +WORKDIR /app + +# Non-root runtime user; /data owned by it so token refresh can write. +RUN useradd -m -u 1000 -s /usr/sbin/nologin familiar \ + && mkdir -p /data \ + && chown -R familiar:familiar /data + +COPY --from=builder /dist/*.whl /tmp/ +RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl + +USER familiar + +EXPOSE 8080 +VOLUME ["/data"] + +# Liveness against the unauthenticated /healthz route (no token needed). +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import os,httpx; httpx.get(f\"http://127.0.0.1:{os.environ.get('FAMILIAR_HTTP_PORT','8080')}/healthz\", timeout=5).raise_for_status()" || exit 1 + +CMD ["familiar", "serve"]