diff --git a/CHANGELOG.md b/CHANGELOG.md index 5849258..ba8ed2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ Changes in this section will be promoted to a dated release entry on merge to `m ### Added +- **anythingllm-docker — `cloudflare_proxied` mode: tenants can live behind Cloudflare's proxy (2026-07-23)** — the per-brand `.weown.chat` hosting model (Jason 2026-07-21) puts customer instances behind Cloudflare Pro (orange cloud), where the template's ACME HTTP-01 cannot complete — exactly the 525 `f1visa.weown.chat` shows today. New copier bool `cloudflare_proxied` (default false — existing direct-DNS sites render identically): Caddy serves a **Cloudflare Origin CA certificate** instead of running ACME (`tls /certs/origin.crt /certs/origin.key`; zone SSL = Full (strict)). The PEMs arrive as `CF_ORIGIN_CERT`/`CF_ORIGIN_KEY` through the same ADR-006 Infisical entrypoint wrapper as every other secret — the caddy container gains the wrapper, and its command materialises them as 0600 files inside the container at start (never on host disk; re-fetched on restart). Origin-CA over DNS-01 was deliberate: no custom Caddy build and no DNS-edit API token sitting on customer droplets — one wildcard cert for `*.weown.chat`, readable only by operators at the initiative-project root, copied per-tenant-folder at provision time (droplet MIs stay path-scoped). **Verification**: copier renders clean in both modes and the rendered compose parses; the cert-materialise command proven against a multi-line PEM in env (content byte-identical, 0600 perms); fleet wiring (registry flag → render-deploy/provision) landed in weown-fleet. + - **`gitea-docker/` — new copier template: Gitea with Keycloak SSO (2026-07-17)** — self-hosted git for WeOwn internal use (Jason-approved Gitea + Keycloak), cloned from `keycloak-docker/` per the repo template standard and keeping its full stack: ADR-006 in-container Infisical injection, Path-C thin cloud-init + ansible app layer, Layer-2 bootstrap-secret rotation, skinny backups with DO Spaces offload, DO monitoring alerts. Gitea-specific: **all logins via Keycloak OIDC** (confidential client, `gitea admin auth add-oauth` bootstrap documented in the rendered README with a stdin-only secret prompt; local registration disabled, SSO auto-registration on, `USERNAME=email`, `ACCOUNT_LINKING=auto`), git-over-SSH on a dedicated firewall port (`gitea_ssh_port`, default 2222; inline-justified `trivy:ignore:AVD-DIG-0001`), `GITEA__*` env-style config with only non-secret values in compose (`SECRET_KEY`/`INTERNAL_TOKEN`/`JWT_SECRET` from Infisical in prod), `INSTALL_LOCK=true` (env-only config, no web installer), no admin vhost (avoids keycloak-docker's D437 exposure), health via `/api/healthz`. **Verification**: template renders clean via copier; rendered compose/ansible YAML parse; `tofu validate` passes; all scripts `bash -n` clean; **local end-to-end SSO proven** — rendered `compose.local.yaml` + local Keycloak 24 (realm `weown`, confidential `gitea` client with `email profile offline_access` scopes) → browser-equivalent OIDC dance → Keycloak login → callback → user auto-registered active → authenticated session. Reference configs: `mcp/infra` gitea + keycloak_client roles. - **anythingllm-docker — customer dashboard (the product surface) + Simple-SSO + two-workspace bootstrap (2026-07-15)** — the customer-facing managed-service layer. New `template/dashboard/` is a **zero-npm-dependency Node server** (bind-mounted read-only into a `node:20-alpine` container, no build/registry image for v0) that is the customer's ONLY surface: they authenticate to the **dashboard** (HMAC session cookie, sha256 password hash from Infisical, per-IP login throttle, `X-Dashboard` CSRF header, `SameSite=Strict; Secure`), and the server proxies scoped actions to the AnythingLLM API with the admin Developer API key held **server-side** — the customer never receives ALLM credentials. Two sections: **Public** (upload public docs → `ws-public`, embed-snippet screen) and **Private** (upload business docs → `ws-private`, authenticated chat proxied with `userId` attribution). Caddy routes `/app*` → dashboard, everything else → ALLM (embed widget + WeOwn-operated admin UI). `SIMPLE_SSO_ENABLED` added to compose so the dashboard can mint single-use 1-hour login links (`/api/v1/users/{id}/issue-auth-token` → `/sso/simple`) for a UI hop. New `scripts/bootstrap-product.sh` provisions the two workspaces + domain-allowlisted embed widget + all dashboard secrets in-process (nothing on disk/history). ansible uploads the dashboard app; docs updated. **Verification**: `node --check` on the server; **live 8-check smoke test** (healthz, unauth-reject, wrong-pw-reject, login, CSRF-reject, authed doc-list, bearer-authenticated chat proxy, logout-clears-session) all pass against a running server + stub upstream; compose YAML parses with all three services; rendered ansible parses. - **Per-customer LLM key provisioning + single-tenant instance lifecycle doc (2026-07-15)** — `scripts/provision-openrouter-key.sh` mints a **per-customer, budget-capped** OpenRouter key via the OpenRouter Management API (`POST /api/v1/keys`, `limit` + `limit_reset: monthly`) and writes it into that customer's Infisical app project as `OPENROUTER_API_KEY` — the value never touches the terminal, disk, or shell history; the provisioning key is sourced in-process from the operator Infisical project (`OPENROUTER_PROVISIONING_KEY`, `read -rs` fallback). Refuses to overwrite a live key without `--force` (no silent orphaning); `--dry-run` for shared hosts. This **automates the previously-manual** "create a key in the OpenRouter dashboard and paste it" step in the per-site `bootstrap-*-infisical.sh` scripts, and gives each dedicated instance blast-radius isolation + a hard monthly spend cap. New [`docs/CUSTOMER_INSTANCE_PROVISIONING.md`](docs/CUSTOMER_INSTANCE_PROVISIONING.md) sequences the existing deploy pieces into a per-customer provision→deploy→operate→deprovision lifecycle (technical only; commercial/sales flow kept out of this public repo). **Verification**: `bash -n` + `shellcheck` clean. **Compliance**: NIST CSF 2.0 PR.AC-4 (least privilege per tenant), PR.DS-1; CIS v8 3.11. diff --git a/anythingllm-docker/copier.yaml b/anythingllm-docker/copier.yaml index ef4eac7..fed675b 100644 --- a/anythingllm-docker/copier.yaml +++ b/anythingllm-docker/copier.yaml @@ -86,6 +86,11 @@ caddy_image: help: "Caddy Docker image" default: "reg.mini.dev/caddy:2" +cloudflare_proxied: + type: bool + help: "Domain sits behind Cloudflare's proxy (orange cloud). Caddy then serves a Cloudflare Origin CA certificate (CF_ORIGIN_CERT/CF_ORIGIN_KEY from Infisical) instead of running ACME — HTTP-01 cannot complete through the proxy, and the zone should be set to SSL mode 'Full (strict)'. Leave false for grey-cloud/direct-DNS domains (e.g. chat.weown.dev), which keep normal auto-HTTPS." + default: false + # ============================================================================= # LLM Configuration # ============================================================================= diff --git a/anythingllm-docker/template/docker/Caddyfile.jinja b/anythingllm-docker/template/docker/Caddyfile.jinja index 540a325..ad7b5fb 100644 --- a/anythingllm-docker/template/docker/Caddyfile.jinja +++ b/anythingllm-docker/template/docker/Caddyfile.jinja @@ -1,5 +1,14 @@ {{ domain }} { +{% if cloudflare_proxied %} + # Behind Cloudflare's proxy (orange cloud): ACME HTTP-01 can't complete + # through the proxy, so Caddy serves the Cloudflare Origin CA certificate + # instead (trusted by Cloudflare only — which is the entire audience of + # this listener). The PEMs are written by the container entrypoint from + # CF_ORIGIN_CERT / CF_ORIGIN_KEY (Infisical). Zone SSL mode must be + # "Full (strict)". + tls /certs/origin.crt /certs/origin.key +{% endif %} # Customer dashboard — the customer-facing surface (auth + doc manager + # private chat). Everything under /app is the dashboard container; the # dashboard proxies scoped calls to AnythingLLM server-side. diff --git a/anythingllm-docker/template/docker/compose.prod.yaml.jinja b/anythingllm-docker/template/docker/compose.prod.yaml.jinja index 2233509..dc38f08 100644 --- a/anythingllm-docker/template/docker/compose.prod.yaml.jinja +++ b/anythingllm-docker/template/docker/compose.prod.yaml.jinja @@ -5,6 +5,8 @@ # # Required Infisical secrets: # ANYTHINGLLM_IMAGE, OPENROUTER_API_KEY, JWT_SECRET, ADMIN_EMAIL, EMBEDDING_ENGINE +# + CF_ORIGIN_CERT / CF_ORIGIN_KEY when cloudflare_proxied (Origin CA PEMs, +# copied into the tenant folder by weown-fleet provisioning) # Optional Infisical secrets: # OPENROUTER_MODEL_PREF, OPENROUTER_TIMEOUT_MS, # AGENT_SEARCH_PROVIDER, AGENT_SEARXNG_API_URL, @@ -176,6 +178,16 @@ services: restart: unless-stopped depends_on: - anythingllm +{% if cloudflare_proxied %} + # Cloudflare-proxied domain: the entrypoint wrapper injects CF_ORIGIN_CERT / + # CF_ORIGIN_KEY (the Cloudflare Origin CA PEMs, from Infisical) and the + # command materialises them as 0600 files inside the container before caddy + # starts — never on the host disk, re-fetched on every restart + # (bounce-to-refresh, same as every other secret). `$$` is compose escaping + # for a literal `$` reaching the shell. + entrypoint: ["/opt/{{ project_name | replace('-', '_') }}/entrypoint-infisical.sh"] + command: ["/bin/sh", "-c", "umask 077 && mkdir -p /certs && printf '%s\n' \"$$CF_ORIGIN_CERT\" > /certs/origin.crt && printf '%s\n' \"$$CF_ORIGIN_KEY\" > /certs/origin.key && exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"] +{% endif %} environment: DOMAIN: {{ domain }} ports: @@ -184,6 +196,11 @@ services: - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro +{% if cloudflare_proxied %} + - /usr/bin/infisical:/usr/bin/infisical:ro + - /opt/{{ project_name | replace('-', '_') }}/.infisical-auth.env.container:/.infisical-auth.env:ro + - /opt/{{ project_name | replace('-', '_') }}/entrypoint-infisical.sh:/opt/{{ project_name | replace('-', '_') }}/entrypoint-infisical.sh:ro +{% endif %} - caddy_data:/data - caddy_config:/config # Bind-mount access logs to host so otel-agent's filelog/caddy receiver