From b11d2646e50244fb7ca99e82ecf3ab2f79106212 Mon Sep 17 00:00:00 2001 From: NotYuSheng Date: Tue, 28 Jul 2026 09:53:00 +0800 Subject: [PATCH 1/2] fix: stop production overlays defaulting to third-party LLM and online GeoIP (#607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a deploy box with no .env, the production overlay resolved to: LLM_API_BASE_URL: https://api.openai.com/v1 GEO_FORCE_OFFLINE: "false" Neither prod overlay pinned these, so both inherited the base file's local-dev convenience defaults. AI features build their prompts from analysed capture content, so that is a data egress path — in the deployment most likely to hold real operational data, and in direct conflict with the offline requirement. docker-compose.offline.yml already handled this correctly (MMDB-only geo, a localhost LLM). The online production path never received the same treatment. The failure mode is quiet: a developer .env typically overrides LLM_API_BASE_URL to a local or Tailscale host, so it works on the developer's machine and only breaks the guarantee on a fresh deploy box. docker-compose.prod.yml LLM_API_BASE_URL is now REQUIRED (${VAR:?}) — the endpoint must be a deliberate choice, following the same fail-fast pattern as the credentials in #595 rather than trusting an operator to read a doc. GEO_FORCE_OFFLINE defaults to true, matching docker-compose.offline.yml. docker-compose.offline-prod.yml Both restated explicitly, so an air-gapped deployment's guarantee doesn't rest on a default in the file below it. Base docker-compose.yml is unchanged — local dev and CI depend on those defaults, and the base stack is the open testing path. Verified: prod aborts with an actionable message when LLM_API_BASE_URL is unset; prod with it set resolves geo offline and contains zero api.openai.com references; offline-prod still starts air-gapped with no .env; base config unchanged; stack builds and API returns 200; docs build clean. Closes #607 Co-Authored-By: Claude Opus 5 --- .env.example | 5 ++++- docker-compose.offline-prod.yml | 5 +++++ docker-compose.prod.yml | 9 +++++++++ docs/operations/production-hardening.rst | 19 +++++++++++++++++-- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 9ad97840..e3887328 100644 --- a/.env.example +++ b/.env.example @@ -141,7 +141,10 @@ STUCK_FILE_RECONCILIATION_ENABLED=true STUCK_FILE_TIMEOUT_MINUTES=30 # LLM Configuration (Local LLM Server) -# Use a local LLM server (LM Studio, Ollama) or OpenAI API +# Use a local LLM server (LM Studio, Ollama) or OpenAI API. +# REQUIRED by the production overlays — they abort rather than inherit the base file's +# api.openai.com default, because prompts are built from analysed capture content and the +# endpoint is therefore a data egress path. See docs/operations/production-hardening.rst. LLM_API_BASE_URL=http://localhost:1234/v1 LLM_API_KEY= LLM_MODEL=Qwen2.5-14B-Coder-Instruct diff --git a/docker-compose.offline-prod.yml b/docker-compose.offline-prod.yml index d10a5657..1528acc7 100644 --- a/docker-compose.offline-prod.yml +++ b/docker-compose.offline-prod.yml @@ -88,6 +88,11 @@ services: DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example} MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env — see .env.example} MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env — see .env.example} + # Data egress. docker-compose.offline.yml already defaults these safely (MMDB-only geo, a + # localhost LLM); restated here so an air-gapped deployment's guarantee doesn't rest on a + # default in the file below it, and stays aligned with docker-compose.prod.yml. + LLM_API_BASE_URL: ${LLM_API_BASE_URL:-http://localhost:1234/v1} + GEO_FORCE_OFFLINE: ${GEO_FORCE_OFFLINE:-true} # Public issuer (matches the token `iss`). Must equal PUBLIC_URL + /realms/tracepcap. KEYCLOAK_ISSUER_URI: ${PUBLIC_URL:-http://localhost:8888}/realms/tracepcap # Backend-reachable JWKS endpoint (internal docker network), fetched lazily so the diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 91001dab..dad56e4a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -85,6 +85,15 @@ services: DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example} MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env — see .env.example} MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env — see .env.example} + # Data egress. The base file defaults the LLM to api.openai.com for convenience in local + # dev; inheriting that here would send capture-derived prompts (Story mode, Network + # Insights) to a third party from the deployment most likely to hold real operational data. + # REQUIRED, so the endpoint is always a deliberate choice — point it at a local inference + # server (LM Studio, Ollama, vLLM) for an offline deployment. + LLM_API_BASE_URL: ${LLM_API_BASE_URL:?set LLM_API_BASE_URL in .env (e.g. http://:1234/v1) — see .env.example} + # Resolve GeoIP from the bundled MMDB instead of probing ipinfo.io at runtime, matching + # docker-compose.offline.yml. Set GEO_FORCE_OFFLINE=false to opt back into online lookups. + GEO_FORCE_OFFLINE: ${GEO_FORCE_OFFLINE:-true} # Public issuer (matches the token `iss`). Must equal PUBLIC_URL + /realms/tracepcap. KEYCLOAK_ISSUER_URI: ${PUBLIC_URL:-http://localhost:8888}/realms/tracepcap # Backend-reachable JWKS endpoint (internal docker network). Decouples key fetch from the diff --git a/docs/operations/production-hardening.rst b/docs/operations/production-hardening.rst index 9be3f182..ea83ce24 100644 --- a/docs/operations/production-hardening.rst +++ b/docs/operations/production-hardening.rst @@ -242,8 +242,23 @@ To stop a service and have it *stay* stopped, use ``docker compose stop ``; Configure LLM Privacy --------------------- -If you use AI features, ensure ``LLM_API_BASE_URL`` points to a locally-hosted -model. Do **not** configure a cloud API endpoint if your PCAP data is sensitive. +AI features (Story mode, Network Insights) build their prompts from analysed +capture content, so the LLM endpoint is a **data egress path**. + +The production overlays **require** ``LLM_API_BASE_URL`` — they abort rather than +falling back to the base file's convenience default of ``api.openai.com``. Point +it at a locally-hosted inference server (LM Studio, Ollama, vLLM): + +.. code-block:: bash + + LLM_API_BASE_URL=http://:1234/v1 + +Do **not** configure a cloud API endpoint if your PCAP data is sensitive. + +The production overlays also default ``GEO_FORCE_OFFLINE`` to ``true``, resolving +geolocation from the bundled DB-IP Lite MMDB instead of probing ``ipinfo.io`` at +runtime. Set ``GEO_FORCE_OFFLINE=false`` only if outbound lookups are acceptable +and the deployment has internet access. Restrict MinIO Console Access ------------------------------- From 648bbe0607897a41156131da6a512dc82503e6c3 Mon Sep 17 00:00:00 2001 From: NotYuSheng Date: Tue, 28 Jul 2026 10:00:04 +0800 Subject: [PATCH 2/2] fix/offline-prod-llm-required-not-localhost-fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs and .env.example claimed both production overlays abort when LLM_API_BASE_URL is unset, but offline-prod silently fell back to http://localhost:1234/v1 — which resolves to the backend container itself, not to any inference host on the network. An air-gapped operator who trusted that wording would get a stack that starts fine and then fails at request time. Makes offline-prod require the variable, matching prod, so the documented behaviour is true. Also stops .env.example presenting OpenAI as a valid option (it contradicts the offline requirement) and warns that localhost resolves inside the container. Co-Authored-By: Claude Opus 5 --- .env.example | 15 ++++++++++----- docker-compose.offline-prod.yml | 11 +++++++---- docs/operations/production-hardening.rst | 15 ++++++++++++--- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index e3887328..5f52f665 100644 --- a/.env.example +++ b/.env.example @@ -141,11 +141,16 @@ STUCK_FILE_RECONCILIATION_ENABLED=true STUCK_FILE_TIMEOUT_MINUTES=30 # LLM Configuration (Local LLM Server) -# Use a local LLM server (LM Studio, Ollama) or OpenAI API. -# REQUIRED by the production overlays — they abort rather than inherit the base file's -# api.openai.com default, because prompts are built from analysed capture content and the -# endpoint is therefore a data egress path. See docs/operations/production-hardening.rst. -LLM_API_BASE_URL=http://localhost:1234/v1 +# Point this at a locally-hosted inference server (LM Studio, Ollama, vLLM). Prompts are built +# from analysed capture content, so this endpoint is a data egress path — TracePcap is intended +# to run fully offline and should not be pointed at an internet-hosted provider. +# +# REQUIRED by both production overlays: they abort rather than inherit a default, so the endpoint +# is always a deliberate choice. See docs/operations/production-hardening.rst. +# +# Use the inference host's address as reachable FROM THE BACKEND CONTAINER — `localhost` resolves +# to the container itself, not to a server on the host or LAN. +LLM_API_BASE_URL=http://:1234/v1 LLM_API_KEY= LLM_MODEL=Qwen2.5-14B-Coder-Instruct LLM_TEMPERATURE=0.7 diff --git a/docker-compose.offline-prod.yml b/docker-compose.offline-prod.yml index 1528acc7..289922e7 100644 --- a/docker-compose.offline-prod.yml +++ b/docker-compose.offline-prod.yml @@ -88,10 +88,13 @@ services: DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example} MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env — see .env.example} MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env — see .env.example} - # Data egress. docker-compose.offline.yml already defaults these safely (MMDB-only geo, a - # localhost LLM); restated here so an air-gapped deployment's guarantee doesn't rest on a - # default in the file below it, and stays aligned with docker-compose.prod.yml. - LLM_API_BASE_URL: ${LLM_API_BASE_URL:-http://localhost:1234/v1} + # Data egress. REQUIRED, matching docker-compose.prod.yml. The base offline file defaults + # this to http://localhost:1234/v1, which resolves to the *backend container itself*, not to + # any inference host on the network — so inheriting it would leave Story mode and Network + # Insights failing at request time with a connection error instead of at startup. + LLM_API_BASE_URL: ${LLM_API_BASE_URL:?set LLM_API_BASE_URL in .env (e.g. http://:1234/v1) — see .env.example} + # Restated so an air-gapped deployment's guarantee doesn't rest on a default in the file + # below it. Left overridable: a deployment with internet access may opt back in. GEO_FORCE_OFFLINE: ${GEO_FORCE_OFFLINE:-true} # Public issuer (matches the token `iss`). Must equal PUBLIC_URL + /realms/tracepcap. KEYCLOAK_ISSUER_URI: ${PUBLIC_URL:-http://localhost:8888}/realms/tracepcap diff --git a/docs/operations/production-hardening.rst b/docs/operations/production-hardening.rst index ea83ce24..a5f958b2 100644 --- a/docs/operations/production-hardening.rst +++ b/docs/operations/production-hardening.rst @@ -245,14 +245,23 @@ Configure LLM Privacy AI features (Story mode, Network Insights) build their prompts from analysed capture content, so the LLM endpoint is a **data egress path**. -The production overlays **require** ``LLM_API_BASE_URL`` — they abort rather than -falling back to the base file's convenience default of ``api.openai.com``. Point -it at a locally-hosted inference server (LM Studio, Ollama, vLLM): +Both production overlays **require** ``LLM_API_BASE_URL`` and abort if it is +unset, rather than falling back to a default — the online overlay would otherwise +inherit ``api.openai.com`` from the base file, and the offline overlay +``localhost``. Point it at a locally-hosted inference server (LM Studio, Ollama, +vLLM): .. code-block:: bash LLM_API_BASE_URL=http://:1234/v1 +.. warning:: + + Use the address of the inference host **as reachable from the backend + container**. ``localhost`` resolves to the container itself, not to a server + running on the Docker host or elsewhere on the LAN, so it will fail at request + time rather than at startup. + Do **not** configure a cloud API endpoint if your PCAP data is sensitive. The production overlays also default ``GEO_FORCE_OFFLINE`` to ``true``, resolving