From 58333d02572a78d9a34408b8abddcd06f073be33 Mon Sep 17 00:00:00 2001 From: oqyude Date: Wed, 7 Oct 2026 12:49:48 +0300 Subject: [PATCH] open-webui via podman added --- modules/containers/open-webui.nix | 183 ++++++++++++++++++++++ modules/containers/secrets/open-webui.env | 10 ++ modules/server/coredns.nix | 1 + modules/server/default.nix | 2 +- modules/server/nginx.nix | 25 +++ modules/server/open-webui.nix | 28 ---- 6 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 modules/containers/open-webui.nix create mode 100644 modules/containers/secrets/open-webui.env delete mode 100644 modules/server/open-webui.nix diff --git a/modules/containers/open-webui.nix b/modules/containers/open-webui.nix new file mode 100644 index 0000000..0a1c86a --- /dev/null +++ b/modules/containers/open-webui.nix @@ -0,0 +1,183 @@ +{ + config, + lib, + pkgs, + xlib, + ... +}: +# Open WebUI — self-hosted AI chat UI, deployed here as a UI-client for +# external LLM APIs (OpenAI-compatible: OpenAI, OpenRouter, vLLM, LM Studio, +# GroqCloud, Mistral, etc.). Runs locally without bundled Ollama. +# +# Architecture mirrors modules/containers/{3x-ui,tape-rotation}.nix: +# - one container, one systemd unit + a root.target +# - data on /mnt/services/nodes//open-webui/data → /app/backend/data +# (see AGENTS.md §Подтверждённые инварианты #2 — guard chain is satisfied +# because mkServiceStorage already bind-mounts /mnt/services on boot) +# - host port bound to 127.0.0.1 only — the only ingress is the nginx +# vhost open.zeroq.su (no firewall exception, no public exposure). +# Same pattern as 3x-ui.nix:30-31 binding the panel to 127.0.0.1:2049. +# +# Secrets come from a single sops-encrypted dotenv file +# (format = "dotenv", key = "" → whole file). The owner creates the +# encrypted file with `sops modules/containers/secrets/open-webui.env` +# after filling the .example template next to it. +# +# Hard requirement (env.py:762 — SystemExit at startup): +# WEBUI_SECRET_KEY must be set when WEBUI_AUTH=true. +# Generate with: head -c 24 /dev/urandom | base64 +# +# Reverse-proxy requirements (docs.openwebui.com/reference/https): +# - WEBUI_URL = public HTTPS URL (OAuth callbacks, internal links) +# - CORS_ALLOW_ORIGIN = same public URL (else WebSocket fails silently) +# - proxy_buffering off (else SSE streaming breaks markdown) +# - proxy_read_timeout ≥ 300s (LLM responses can run minutes) +# - WebSocket pass-through (Upgrade / Connection headers) +# All of the above are wired into modules/server/nginx.nix:open.zeroq.su. +let + panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/open-webui"; +in +{ + virtualisation = { + podman = { + enable = true; + autoPrune = { + enable = true; + flags = [ "--all" ]; + }; + dockerCompat = true; + }; + oci-containers = { + backend = "podman"; + containers."open-webui" = { + image = "ghcr.io/open-webui/open-webui:main"; + environment = { + TZ = "Europe/Moscow"; + # Container-internal port (also the upstream default). + PORT = "8080"; + # Required when behind a public HTTPS URL — OAuth callbacks, + # share links and internal redirects resolve against this. + WEBUI_URL = "https://open.zeroq.su"; + # Must exactly match WEBUI_URL or WebSocket connections fail + # silently (per upstream HTTPS docs). nginx (127.0.0.1) is the + # only allowed origin, so a single explicit URL is enough. + CORS_ALLOW_ORIGIN = "https://open.zeroq.su"; + # Honour X-Forwarded-* headers from the reverse proxy. + FORWARDED_ALLOW_IPS = "127.0.0.1"; + # Closed self-hosted: admin creates accounts manually after the + # first boot via WEBUI_ADMIN_* from the sops env file. + WEBUI_AUTH = "True"; + ENABLE_SIGNUP = "False"; + ENABLE_LOGIN_FORM = "True"; + ENABLE_VERSION_UPDATE_CHECK = "False"; + # Out of the box Open WebUI phones home to Scarf. The opt-outs + # below preserve the previous behaviour from the stub at + # modules/server/open-webui.nix (still in tree, commented out in + # modules/server/default.nix:41) until that file is removed. + ANONYMIZED_TELEMETRY = "False"; + DO_NOT_TRACK = "True"; + SCARF_NO_ANALYTICS = "True"; + # No bundled providers. Owners wire OPENAI_API_KEY / + # OPENAI_API_BASE_URL / etc. either via the sops env file + # (see sops.secrets."open-webui-env" below) or interactively in + # Admin → Settings → Connections once WEBUI_AUTH=true. Empty + # base URL is intentional: an empty OPENAI_API_BASE_URL + # disables the default /ollama proxy and prevents the container + # from probing localhost:11434 on boot. + OLLAMA_BASE_URL = ""; + OPENAI_API_BASE_URL = ""; + }; + # Mount the decrypted dotenv only when the sops file exists. Until + # the owner creates ./secrets/open-webui.env, the inline environment + # is the only source — and the container will refuse to start with + # WEBUI_SECRET_KEY="" (env.py:762 — SystemExit). The error message is + # the clear signal that the secret needs to be created. + environmentFiles = lib.optional (builtins.pathExists ./secrets/open-webui.env) + "/run/secrets/open-webui-env"; + volumes = [ + "${panel}/data:/app/backend/data:rw" + ]; + log-driver = "journald"; + # 127.0.0.1 only — the container is not exposed externally. + ports = [ "127.0.0.1:8080:8080/tcp" ]; + }; + }; + }; + + # Enable container name DNS for all Podman networks (mirrors 3x-ui.nix:120-128). + networking.firewall.interfaces = + let + matchAll = if !config.networking.nftables.enable then "podman+" else "podman*"; + in + { + "${matchAll}".allowedUDPPorts = [ 53 ]; + }; + + systemd = { + services = { + "podman-open-webui" = { + serviceConfig.Restart = lib.mkOverride 90 "always"; + partOf = [ "podman-compose-open-webui-root.target" ]; + wantedBy = [ "podman-compose-open-webui-root.target" ]; + }; + "podman-update-open-webui" = { + path = [ pkgs.podman ]; + serviceConfig = { + Type = "oneshot"; + TimeoutSec = 300; + }; + script = '' + podman pull ghcr.io/open-webui/open-webui:main + systemctl restart podman-open-webui.service + ''; + }; + }; + # Starts/stops together with the open-webui container. + targets."podman-compose-open-webui-root" = { + unitConfig.Description = "Root target for open-webui."; + wantedBy = [ "multi-user.target" ]; + }; + # Enable automatic image updates: + # systemd.timers."podman-update-open-webui" = { + # wantedBy = [ "timers.target" ]; + # timerConfig = { + # OnCalendar = "weekly"; + # Persistent = true; + # }; + # }; + tmpfiles.rules = [ + (xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root") + (xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root") + (xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755" + "root" + "root" + ) + (xlib.helpers.mkTmpfile "d" panel "0755" "root" "root") + (xlib.helpers.mkTmpfile "d" "${panel}/data" "0755" "root" "root") + # Relabel panel dir for SELinux so containers can access it. + (xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root") + ]; + }; + + # sops secret is declared only when the encrypted file actually exists, + # so the flake still evaluates (and rebuilds apply) on a host that hasn't + # created the secret yet. Once ./secrets/open-webui.env is created and + # encrypted with `sops modules/containers/secrets/open-webui.env`, this + # condition becomes true and the secret is wired in. + # + # Hard requirement (env.py:762 — SystemExit at startup): + # WEBUI_SECRET_KEY must be present in the env file when WEBUI_AUTH=true. + sops.secrets = lib.optionalAttrs (builtins.pathExists ./secrets/open-webui.env) { + "open-webui-env" = { + # key = "" → decrypt the whole file, not a single key. + # format = "dotenv" → the file IS one .env ready for environmentFiles: + # every non-comment KEY=VALUE line lands in the container environment. + # After this module is wired the file is mounted at + # /run/secrets/open-webui-env (sops-nix default for this attr name). + key = ""; + format = "dotenv"; + sopsFile = ./secrets/open-webui.env; + mode = "0400"; + }; + }; +} \ No newline at end of file diff --git a/modules/containers/secrets/open-webui.env b/modules/containers/secrets/open-webui.env new file mode 100644 index 0000000..bf5ea30 --- /dev/null +++ b/modules/containers/secrets/open-webui.env @@ -0,0 +1,10 @@ +WEBUI_SECRET_KEY=ENC[AES256_GCM,data:l6USiQmMkMz/zniIebT35HfXxZI8qrhe6Cdl8hpT98c=,iv:Po9bova4dfiykl+ckH4v6DqzSJOgULx7ro3kXMFRvFI=,tag:7jurD14N7QDRHX5ruFDEeQ==,type:str] +WEBUI_ADMIN_EMAIL=ENC[AES256_GCM,data:EZgNXSpbpROz3TZRLaSQTQ==,iv:i98kChemam9nB3iCMwCTRYB69b2eUBy6QCoeZ3AjAP0=,tag:INnB1Zp9VA6M//yyAhRh3A==,type:str] +WEBUI_ADMIN_NAME=ENC[AES256_GCM,data:8l8dJ85p,iv:LltveatNlX4FEGmxhtYLYmviIvLK0xSMuVsk9DRRglw=,tag:OrTlnOLlQe03hoYTkaPotg==,type:str] +WEBUI_ADMIN_PASSWORD=ENC[AES256_GCM,data:PKfZQHiAa96vcGUCGigjXQ==,iv:WLb1mgCV3IJHnHcBvf4yAPiautgXqCC2L2bzt6i0t7U=,tag:nw78tvyUOYmGMunBwvIr+A==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4d3pNVlZEQS85d2R3WUZX\nKy9iOFZ4MjU2UkQwVHdobTlBY3l0MldONWlvCnU5dllobmtLQXlMM28xN0FSTmxD\nNnhmZVRwdnpaZ3NkZDVCWERBckZiQjgKLS0tIFBPeXZMNXRjZ3pBQUlndXB5MTBB\nMVdhSGJvZkE2VzZiZ2VxL0RKTDJ2aDQKsxlibeAoO74411VemXT+8UBG0JdemgHD\nVONIEp/VsbEJDWgDfSGhLaH4KN2hTsCtyhdkCU0FohgWB+xWyJz6MA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm +sops_lastmodified=2026-10-07T09:32:44Z +sops_mac=ENC[AES256_GCM,data:SSHgEEc3u2Zf13q5W4LD7bkrVlQTzLIYiZWXhBiDS6CjC4fUZcJq99OTSTNixzqpxSdnjeRtmzA6d6vGNfxvEOmsE1f4hBNm9ps0RHU4yLfr5vQG9Ff973uDwDU+JMqP3aMU+xpUuPkhW0zRpeST+w0thuPtjzhR2z/A2yPvYzU=,iv:5/cfD51eK0R9cGsr4wZu6CnwEdMjP0CYj3CM7+X4XQg=,tag:1FRcAULHG+XzmRiTMK8aWQ==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.13.3 diff --git a/modules/server/coredns.nix b/modules/server/coredns.nix index b1cd65d..da0a0a0 100644 --- a/modules/server/coredns.nix +++ b/modules/server/coredns.nix @@ -20,6 +20,7 @@ 192.168.1.20 kuma.zeroq.su 192.168.1.20 navidrome.zeroq.su 192.168.1.20 nextcloud.zeroq.su + 192.168.1.20 open.zeroq.su 192.168.1.20 office.zeroq.su 192.168.1.20 pdf.zeroq.su 192.168.1.20 syncthing.zeroq.su diff --git a/modules/server/default.nix b/modules/server/default.nix index 6ab5b0b..67a65e7 100644 --- a/modules/server/default.nix +++ b/modules/server/default.nix @@ -6,6 +6,7 @@ { imports = [ ../containers/3x-ui.nix + ../containers/open-webui.nix ../containers/tape-rotation.nix ../pkgs/beets.nix ./acme.nix @@ -38,7 +39,6 @@ # ./n8n.nix # ./netdata.nix # ./nfs.nix - # ./open-webui.nix # ./rsync.nix # ./step-ca.nix # ./stirling-pdf.nix diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index 59c1377..6b8268d 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -65,6 +65,12 @@ let domain = "tape-rotation.zeroq.su"; port = 5174; } + # NOTE: open.zeroq.su is intentionally NOT in this `sites` list — + # mkProxy hard-codes ${server} = 192.168.1.20, but the Open WebUI + # container binds to 127.0.0.1:8080 only (loopback, see + # modules/containers/open-webui.nix). The vhost is added directly + # to `virtualHosts` below, alongside x.zeroq.su (3x-ui panel, + # same loopback-only pattern). { domain = "navidrome.zeroq.su"; port = 4533; @@ -162,6 +168,25 @@ in }; }; }; + # Open WebUI — same loopback-only pattern as x.zeroq.su above. + # The container listens on 127.0.0.1:8080 (modules/containers/open-webui.nix), + # so we proxy_pass to 127.0.0.1, not the LAN IP. The two extra + # directives are required by the upstream HTTPS docs: + # proxy_buffering off for SSE streaming (markdown in chat breaks + # under the default `proxy_buffering on` from recommendedProxySettings), + # and a 300 s read timeout for long LLM completions. + "open.zeroq.su" = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://127.0.0.1:8080"; + proxyWebsockets = true; + }; + extraConfig = '' + proxy_buffering off; + proxy_read_timeout 300s; + ''; + }; "zeroq.su" = { forceSSL = true; enableACME = true; diff --git a/modules/server/open-webui.nix b/modules/server/open-webui.nix deleted file mode 100644 index 1d8c188..0000000 --- a/modules/server/open-webui.nix +++ /dev/null @@ -1,28 +0,0 @@ -{ - config, - inputs, - lib, - pkgs, - ... -}: -{ - services = { - open-webui = { - enable = false; - host = "0.0.0.0"; - port = 11112; - openFirewall = true; - environment = { - ANONYMIZED_TELEMETRY = "False"; - DO_NOT_TRACK = "True"; - SCARF_NO_ANALYTICS = "True"; - OPENAI_API_BASE_URL = "http://192.168.1.100:1234/v1"; - #OLLAMA_API_BASE_URL = "http://127.0.0.1:1234"; - WEBUI_AUTH = "True"; - ENABLE_SIGNUP = "False"; - ENABLE_SIGNUP_PASSWORD_CONFIRMATION = "True"; - ENABLE_VERSION_UPDATE_CHECK = "False"; - }; - }; - }; -}