Files
serverAppConfigs/serverAppConfigs-docs/Dankclaw/OpenWebUI.md
T
Frank 33eae71d33 docs: distill the managed skill library into the vault (waves 1-7)
All ~173 managed skills read in full and merged, one polished reference
page per skill-cluster:

- LLM cross-cutting (17 pages): GGUF Compatibility, Llama Cpp Serving
  Sizing, Llama Cpp Multi-Device Splits, PCIe Link and Atomics, RDNA4
  Container Serving, RDNA4 MoE Expert Offload VRAM Budget, vLLM Boot
  Forensics, vLLM Porting and Compile Triage, LLM Bench Windows, LLM
  Performance Diagnosis, Model Swap and Rollback, Tcclaviger Fork Ops,
  Heterogeneous Split Triage, FreeToken Builds + deltas folded into the
  retired runbooks (GLM DFlash2 port laws, Lucebox dual-R9700, R9V
  qualification pipeline, FLM pack registration, ik hybrid envelope,
  frank-medium split rebalance, llama.cpp GGUF preflight, batch-ubatch
  split section, bench-window D-state/stale-read notes, second-endpoint
  bring-up).
- Architecture (3): Stale Signal Audit, Quadlet Lifecycle, Deploy State
  Triage.
- Operations (6 + index): Remote Job Runner, Payload and Disk Reclaim,
  Container Host Reachability, Fleet Ops Playbook, Fleet Audit and
  Posture, SSH and Remote Access.
- Infrastructure: Observability Provisioning + Homepage Status Probes;
  updates to Authentik, Anubis, LAN-DNS (per-client DoH bypass, Porkbun
  zone ops), Swap Configuration, OpenWebUI, Hermes (pre-existing page
  preserved and extended).
- FrankBook-Pro: ARR-Stack filled (API split, schema-first probing,
  auth-posture, auth-config trap); qBittorrent E2E verification.
- Desktop/Agent/Research: Noctalia and Niri, OMP Harness, Agent Docs and
  Edits, Command Center Dotfiles, PICO-8 Asset Standards.
- Indexes: LLM Cross-Cutting References section, Operations/ and Agent/
  index pages, root-index Skill Distillation section, AGENTS.md pointer.
- log.md carries the wave plan.

check-vault-links.py 0 unresolved (185 pages); ./tests/run.sh 366/366.
2026-10-05 21:19:15 -07:00

239 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: reference
description: "OpenWebUI is a self-hosted chat interface for LLMs."
tags:
- dankclaw
- openwebui
timestamp: 2026-09-21
---
# OpenWebUI
OpenWebUI is a self-hosted chat interface for LLMs. It connects to OpenAI-compatible endpoints (vLLM, llama.cpp, Ollama) and adds RAG, plugins, tool calling, and multi-user support.
**Host:** Dankclaw — **Port:** 3005 (host) → 8080 (container) — **Vhost:**
`ai.franksdank.cloud` routes straight to `192.168.0.143:3005` (unguarded —
OpenWebUI enforces its own auth) — **Boot-persistent:** Quadlet — **Docs:**
https://github.com/open-webui/open-webui
## Facts
| Item | Value |
|------|-------|
| Image | `ghcr.io/open-webui/open-webui:main` (verified from the live quadlet unit) |
| Upstream latest | 0.11.4 (2026-09-21) — security/access-control advisory; `:main` is the rolling image, not a stable pin. Imagegen endpoint is `POST /api/v1/images/generations` (the `comfyui` engine is still upstream). |
| Port | 3005 (host) → 8080 (container). Port 3000 is Gitea's HTTP, so it is not usable. |
| Data volume | `/home/dank/Containers/open-webui/data` → `/app/backend/data` |
| Backends | Three fleet endpoints: `frank-large` (host.containers.internal:7770), `frank-medium` (192.168.0.235:7770), `frank-small` (host.containers.internal:7771) — see [The LLM backends](#the-llm-backends) |
| Network | Pinned bridge `systemd-open-webui` (`10.89.3.0/24`, gateway `10.89.3.1`) — see [History](#history) |
| Persistence | Quadlet unit `open-webui.service`; `[Install] WantedBy=default.target` |
| Deploy | `scripts/deploy-open-webui-quadlet.sh dankclaw` |
## The LLM backends
OpenWebUI serves the three fleet LLM endpoints. The model picker is built from
the `model` table in `webui.db` (one row per model), NOT from live connection
discovery — so the picker shows exactly the models seeded there.
| Picker model | Connection URL | Runtime | Host |
|--------------|----------------|---------|------|
| `frank-large` | `http://host.containers.internal:7770/v1` | unsloth llama.cpp (Strix Halo iGPU, 200k ctx) | Dankclaw |
| `frank-medium` | `http://192.168.0.235:7770/v1` | vllm-radiance TP=2 (FP8, DFlash2, 262k ctx) | Dankserver |
| `frank-small` | `http://host.containers.internal:7771/v1` | llama.cpp (RTX 3090, Gemma4-12B, 262k ctx) | Dankclaw |
Two gotchas make the wiring non-obvious:
- **llama.cpp `/v1/models` is Ollama-shaped.** llama.cpp returns
`{"models":[{"name":...}]}`; OpenWebUI's `extract_data()` only reads the
OpenAI `{"data":[{"id":...}]}` shape, so connection discovery silently misses
the llama.cpp endpoints (frank-large, frank-small). The fix is to set
`model_ids` in each connection's `api_config` — when non-empty, OpenWebUI
builds the model list directly and never fetches `/v1/models`. frank-medium
(vLLM) serves the OpenAI shape and would discover fine, but uses `model_ids`
too for consistency.
- **The host's own services are reached via `host.containers.internal`.** The
container sits on a netavark bridge; it can hairpin to a *different* host's
LAN IP (dankserver `192.168.0.235`) but not to its *own* host's non-published
ports via the host LAN IP (`192.168.0.143:7770/7771` is refused).
`host.containers.internal` (169.254.1.2) resolves to the host on the bridge
and reaches both llama.cpp services.
The Ollama connection is disabled (`ollama.enable=false`).
> All three endpoints are bare-metal systemd services managed by this
> repository. Model swaps, context sizing, and spec tuning happen there. See
> the [LLM knowledge base](../LLM/index.md).
## Container introspection (DB and backend python)
- Working dir for imports: `/app/backend`; package name `open_webui`
(underscore), NOT `openwebui`. `os.environ["WEBUI_SECRET_KEY"]` MUST be set
BEFORE importing any `open_webui` module (import-time hard requirement);
the secret lives at `/app/backend/.webui_secret_key`.
- **The `sqlite3` CLI is NOT in the container** — use Python's `sqlite3`
module (`podman exec open-webui python3 -c "import sqlite3;
conn = sqlite3.connect('/app/backend/data/webui.db'); …"`). For config
keys use parameterized LIKE queries (avoids shell quoting).
- Backend python: `podman exec -w /app/backend open-webui python3 -c "…"` —
`Models.get_all_models()` is a coroutine (await it); model objects are
pydantic `ModelModel` instances (attribute access, not `.get()`), with NO
`status`/`enabled` attribute — use `is_active` from the DB.
- **API auth for verification:** `create_token` lives in
`open_webui.utils.auth` (NOT `open_webui.utils.utils`);
`get_verified_user` requires an `id` claim (not just `sub`);
`SESSION_SECRET = WEBUI_SECRET_KEY`. Mint an admin token inside the
container:
```
podman exec -i open-webui sh -c 'export WEBUI_SECRET_KEY=$(cat /app/backend/.webui_secret_key); \
python -c "from open_webui.utils.auth import create_token; \
print(create_token({\"id\":\"<UID>\",\"sub\":\"<UID>\",\"email\":\"…\",\"role\":\"admin\"}))"'
```
(admin UID from the `user` table).
## Verification battery
1. `GET /api/v1/models/list?limit=100` → returns EXACTLY the intended
models. Response key is `items`, not `data`.
2. `POST /openai/chat/completions` per model with a trivial prompt ("Reply
with exactly one word: pong") → each returns content `pong`. Reasoning
models need `max_tokens>=100` — the thinking block eats the budget.
3. A 500 "Server Connection Error" → `podman logs open-webui` for
`ClientConnectorError`: a network-reachability problem (host LAN IP vs
`host.containers.internal`), not a model problem.
**Durability:** the model table + connections are DB-only (not git-managed);
a fresh data volume loses them. For reproducibility, add an idempotent
first-start seed script or document the setup in the service doc.
## Web search
Chat web search is served by [[SearXNG]] on this host, wired in the quadlet
(`ENABLE_WEB_SEARCH`, `WEB_SEARCH_ENGINE=searxng`, `SEARXNG_QUERY_URL`).
- **Access path:** the backend calls `http://192.168.0.143:8081/search?format=json`
directly — gluetun's published port, NOT the `search.franksdank.cloud` vhost.
The Anubis→Authentik guard chain is a browser mechanism (proof-of-work +
session flow); a plain HTTP client cannot solve it, and the internal port
bypasses Caddy entirely.
- **Limiter:** SearXNG caps non-HTML (JSON) requests at 4/IP/hour (`API_MAX`,
a hardcoded Python constant — no config knob). The `pass_ip` list is checked
FIRST and short-circuits the whole botdetection chain, and OpenWebUI's bridge
egress (`10.89.x`) is in `pass_ip`, so the cap never applies. Verified: an
8-request burst from the container returned 8×200.
- **Config storage:** OpenWebUI keeps web-search settings per-key in its SQLite
config table (`webui.db`, `web.search.*`). The quadlet env vars seed that
table on first start (fill-missing only); afterwards the DB is authoritative
and Admin-UI edits persist there.
## SSO (authentik)
Login is authentik via the OIDC authorization-code flow (wired 2026-09-24).
The vhost stays exempt from the Anubis guard chain: the OAuth dance is
browser-driven and only touches the two exempt vhosts (`ai.` and `auth.`).
- **authentik objects** (created via the admin API, 2026-09-24): application
`open-webui` (slug `open-webui`) with the OAuth2 provider `Open WebUI`
(confidential client, `authorization_code` + `refresh_token`, Strict
redirect URI `https://ai.franksdank.cloud/oauth/oidc/callback`, implicit
consent flow). The client ID is in the quadlet; the client secret lives in
the git-ignored host file `/home/dank/Containers/open-webui/env` (mode 600,
loaded via `EnvironmentFile=`).
- **Roles:** the scope mapping `Open WebUI roles` (scope `roles`) reads the
application entitlements `Open WebUI Users` → `user` and `Open WebUI
Admins` → `admin`. The entitlements are bound to the groups
`openwebui_users` / `openwebui_admins`. `akadmin` (frank8465@protonmail.com)
is in `openwebui_admins`; `qa-e2e-admin` is in `openwebui_users`.
- **Account merge:** `OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true` folds an SSO login
into the existing local account with the same email — the pre-SSO admin
(frank8465@protonmail.com) keeps its data. `ENABLE_OAUTH_SIGNUP=true` lets
other `openwebui_users` create accounts on first login.
- **IdP reachability:** the container is on its own bridge (10.89.3.0/24) and
cannot reach `authentik-server:9000` by name. It reaches the IdP over the
public URL `https://auth.franksdank.cloud` — Caddy publishes `0.0.0.0:443`
(netavark DNATs published ports from other containers) and Pi-hole resolves
the name to the host LAN IP. The server-side code exchange rides that path.
- **Config authority:** the OAuth env vars in the quadlet seed the per-key
config DB on first run (fill-missing only); on the live data dir the Admin
Panel is authoritative. To rotate the client secret: create a new token in
authentik, update the host env file, and set the new value in the Admin
Panel (or the DB), then restart the container.
- **SSO-only (2026-09-24):** after the OIDC round-trip was verified, the local
username/password path was retired. `ENABLE_LOGIN_FORM=false` hides the form
and `ENABLE_PASSWORD_AUTH=false` refuses password logins, so the only way in
is the authentik OIDC flow. The `ui.enable_login_form` config-DB override was
flipped to `false` in the same change — the per-key DB wins over env, so both
must agree, and the DB value is the one to change on a live data dir. To
restore the password form: set `ui.enable_login_form` back to `true` in the
Admin Panel (or DB) and drop the two env vars from the quadlet.
## Boot persistence (Quadlet)
The unit lives at `dankclaw/open-webui/quadlet/open-webui.container`. On the host it installs to `~/.config/containers/systemd/open-webui.container`. The generator turns it into `open-webui.service`. Because the unit sets `[Install] WantedBy=default.target` and user linger is on, it starts at boot. `systemctl --user is-enabled open-webui.service` reports `generated` (the quadlet boot-start state).
## Volumes
| Container path | Host path |
|----------------|-----------|
| `/app/backend/data` | `/home/dank/Containers/open-webui/data` |
The data dir holds the database, RAG indexes, and uploads. The deploy script creates it if missing and preserves it across reruns.
## Healthcheck
The quadlet carries a built-in healthcheck (30s interval, 3 retries, 300s start period). It checks two things from inside the container:
1. **App liveness** — `GET http://127.0.0.1:8080/health` returns 200 (`{"status":true}`).
2. **Network liveness** — a non-loopback interface exists in `/sys/class/net`. The dead-tap zombie state (History) leaves only `lo` behind, and this is the check that catches it.
`podman ps` shows the container as `unhealthy` if either fails. Probe manually at `http://dankclaw:3005/health`.
## Image generation (ComfyUI)
Image generation rides the local [[ComfyUI]] integration, not the chat
picker — ComfyUI's models never appear in the chat model picker (that is
chat LLMs only), so ComfyUI needs its own image-generation connection
configured in OpenWebUI. ComfyUI itself runs GPU-enabled on Dankclaw and
serves `:8188`.
Endpoint: `POST /api/images/generations` (image `0.11.x`: upstream moved the
route to `POST /api/v1/images/generations`; the `comfyui` engine is still
upstream) with `Authorization: Bearer <token>` and body
`{"model":"imagegen","prompt":"…","n":1,"size":"1024x1024"}`; the response
carries base64 image data or a URL.
Failure modes: connection refused ⇒ OpenWebUI or ComfyUI is down; timeouts
on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
## Retired instances
- The compose `open-webui` service (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind.
- The bare-container `open-webui` on Dankserver (port 3000) pointed at a dead backend (`:9090`) and was removed. Its data dir is preserved on Dankserver in case it is needed.
- The bare-container `searxng` on Dankserver (port 8081) was removed. SearXNG now runs only on Dankclaw behind Caddy.
## Gotchas
- The image tracks upstream `main`; pin a digest in the quadlet unit if reproducible rollback matters more than tracking. (The old "CPU-only host" framing is stale: this APU runs the gfx1151 ROCm build — see [[ComfyUI]].)
- The default admin account is created on first login; secure it immediately.
- If the backend is loading (3–8 minute model load), OpenWebUI shows connection errors until the model is ready.
- The on-host LLM services (frank-large `:7770`, frank-small `:7771`) are reached via `host.containers.internal`, NOT the host LAN IP: the container's netavark bridge cannot hairpin to its own host's non-published ports via `192.168.0.143` (connection refused), and the bridge gateway (`10.89.3.1`) lives in the rootless netns while llama-server runs in the host netns, so it refuses too. `host.containers.internal` (169.254.1.2) resolves to the host on the bridge and works — verified end-to-end for both endpoints. The Dankserver backend (frank-medium) is a different host, reached via its LAN IP `192.168.0.235:7770`.
## History
- **2026-08-22 — slirp4netns → pinned bridge.** The unit ran on `Network=slirp4netns`. The slirp4netns helper process died (no OOM, no kill entry, no crash log found), and the container kept running with only `lo` in its netns: published port 3005 accepted TCP and reset every request, so chat served empty responses for ~2 days. Nothing logged an error. Moving to a kernel bridge removes the per-container helper entirely, and the healthcheck's interface check makes the zombie state visible if it ever returns.
- **2026-09-21 — model picker rebuilt for the 3-model fleet.** The picker carried 39 stale rows (37 dead Ollama models + 2 `frank-default-*`) and was missing `frank-medium`. Rebuilt the `model` table to exactly `frank-large`/`frank-medium`/`frank-small` and set `model_ids` on each connection (llama.cpp's Ollama-shaped `/v1/models` is invisible to OpenWebUI's discovery). Pointed the two on-host endpoints at `host.containers.internal` — the bridge refuses the host LAN IP for non-published ports. All three verified end-to-end via `/openai/chat/completions`.
- **2026-09-24 — SSO via authentik.** OIDC authorization-code flow wired per the [SSO (authentik)](#sso-authentik) section: application/provider/entitlements created in authentik, client secret in the host env file, roles from the `roles` scope claim. E2E coverage in `tests/e2e/specs/18-openwebui-sso.spec.ts`.
- **2026-09-24 — flipped to SSO-only.** The OIDC round-trip verified in a real browser (login view renders no password form, only "Continue with authentik", and the flow lands on a signed-in session), so the local username/password path was retired: `ENABLE_LOGIN_FORM=false` + `ENABLE_PASSWORD_AUTH=false` in the quadlet and `ui.enable_login_form` set to `false` in the config DB (the DB wins over env, so both were changed together). `qa-e2e-admin` was demoted out of the `authentik Admins` group (it keeps `openwebui_users` / `homepage-admins` for app access).
## See Also
- [[Dankclaw Stack]]
- [[Hermes]] — the agent OpenWebUI can talk to
- [[SearXNG]] — the search engine on this host
- [LLM knowledge base](../LLM/index.md) — the vLLM and llama.cpp endpoints