Compare commits

..
3 Commits
Author SHA1 Message Date
oqyude 75433e2af7 vhost connecting 2026-10-07 17:53:06 +03:00
oqyude 7fd1736f1d vtimeline arch 2026-10-07 17:52:54 +03:00
oqyude 58333d0257 open-webui via podman added 2026-10-07 13:49:40 +03:00
7 changed files with 290 additions and 29 deletions
+183
View File
@@ -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/<host>/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";
};
};
}
+10
View File
@@ -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
+2
View File
@@ -20,11 +20,13 @@
192.168.1.20 kuma.zeroq.su 192.168.1.20 kuma.zeroq.su
192.168.1.20 navidrome.zeroq.su 192.168.1.20 navidrome.zeroq.su
192.168.1.20 nextcloud.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 office.zeroq.su
192.168.1.20 pdf.zeroq.su 192.168.1.20 pdf.zeroq.su
192.168.1.20 syncthing.zeroq.su 192.168.1.20 syncthing.zeroq.su
192.168.1.20 talk.zeroq.su 192.168.1.20 talk.zeroq.su
192.168.1.20 turn.zeroq.su 192.168.1.20 turn.zeroq.su
192.168.1.20 vtimeline.zeroq.su
fallthrough fallthrough
} }
cache 300 cache 300
+1 -1
View File
@@ -6,6 +6,7 @@
{ {
imports = [ imports = [
../containers/3x-ui.nix ../containers/3x-ui.nix
../containers/open-webui.nix
../containers/tape-rotation.nix ../containers/tape-rotation.nix
../pkgs/beets.nix ../pkgs/beets.nix
./acme.nix ./acme.nix
@@ -38,7 +39,6 @@
# ./n8n.nix # ./n8n.nix
# ./netdata.nix # ./netdata.nix
# ./nfs.nix # ./nfs.nix
# ./open-webui.nix
# ./rsync.nix # ./rsync.nix
# ./step-ca.nix # ./step-ca.nix
# ./stirling-pdf.nix # ./stirling-pdf.nix
+69
View File
@@ -65,6 +65,12 @@ let
domain = "tape-rotation.zeroq.su"; domain = "tape-rotation.zeroq.su";
port = 5174; 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"; domain = "navidrome.zeroq.su";
port = 4533; port = 4533;
@@ -117,6 +123,21 @@ in
forceSSL = true; forceSSL = true;
enableACME = true; enableACME = true;
}; };
# vtimeline.zeroq.su — static site behind HTTP basic auth.
# Files live under /home/oqyude/External/Git/VeeamTimelineView/public_html,
# which is bind-mounted to /var/lib/vtimeline (see systemd.mounts below)
# because /home/oqyude is mode 700 and the nginx user (uid 60) cannot
# traverse it. Credentials are pulled from sops; see the sops.secrets
# block at the bottom of this file.
"vtimeline.zeroq.su" = {
forceSSL = true;
enableACME = true;
root = "/var/lib/vtimeline";
extraConfig = ''
auth_basic "vtimeline";
auth_basic_user_file ${config.sops.secrets.vtimeline-htpasswd.path};
'';
};
"pdf.private" = { "pdf.private" = {
forceSSL = false; forceSSL = false;
enableACME = false; enableACME = false;
@@ -162,6 +183,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" = { "zeroq.su" = {
forceSSL = true; forceSSL = true;
enableACME = true; enableACME = true;
@@ -226,4 +266,33 @@ in
80 80
443 443
]; ];
# Bind-mount the vtimeline source tree into /var/lib so the nginx user
# (uid 60) doesn't have to traverse /home/oqyude (mode 700). The mount is
# lazy (x-systemd.automount) and nofail, so a missing /home/oqyude/External
# only shows up as a per-request 500/403, never as a hard boot failure.
systemd.mounts = [
(xlib.helpers.mkSystemdBind {
what = "/home/oqyude/External/Git/VeeamTimelineView/public_html";
where = "/var/lib/vtimeline";
})
];
systemd.tmpfiles.rules = [
(xlib.helpers.mkTmpfile "d" "/var/lib/vtimeline" "0755" "nginx" "nginx")
];
# htpasswd file for vtimeline.zeroq.su basic auth.
# Source layout (per modules/server/secrets/vtimeline-htpasswd.yaml):
# passwords: |
# <user>:<bcrypt-or-apr1-hash>
# sops-nix extracts the `passwords` key as the only decrypted content.
# The resulting file is consumed by nginx via auth_basic_user_file.
sops.secrets.vtimeline-htpasswd = {
format = "yaml";
key = "passwords";
sopsFile = ./secrets/vtimeline-htpasswd.yaml;
owner = "nginx";
group = "nginx";
mode = "0640";
};
} }
-28
View File
@@ -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";
};
};
};
}
@@ -0,0 +1,25 @@
#ENC[AES256_GCM,data:UW49BNUTjSgrBCXW4f5/7lJPUqXZp1U1iFHEvR4QOGm9KWPwAqYTg+i4I2dWeMTP4PqkFA8ry+TtFUHV+UTyEJxMbTZPSaqXJmQAh7k07Trv17snVEtvotzTHn5yjZwAVvPi,iv:/H/FXmF0n86xlE4wA/oBioEYJkc14+mLzSL61qJk1z8=,tag:kbCFXytipQ+FTP1OiHfO8w==,type:comment]
#ENC[AES256_GCM,data:OWEZavcPrsSst1YUaspDqXeYx1HTLsw2zT9j2ao8mmxrgjgVJ2teclWQT6R8D9OxMwVh/Cbd25XtwwsgWpwMzQChSAD4oFPofvujpFHhndwuuyA12kA/rGRp6oLF5ZVavFR/4oTjm6xDE6AlwgKsT64=,iv:15ZKD0tvJFbRLaX/KclDaUc7TstivGItyTxmN81HVCQ=,tag:9871kdKv+GhH1/Gyy/oYPg==,type:comment]
#
#ENC[AES256_GCM,data:6P4BRz2PQ76929PaDFp9KHXKgVx9C6FncoQBx7ia/GfeR86O/ZCtf9k=,iv:SNSpMmo4LqxHl3WE0VeW9gyJLfTwhrlLgbpHNgM/zuI=,tag:M2b8hGlAUOro8Pk79YV3mA==,type:comment]
#ENC[AES256_GCM,data:1uTKcKavRm0dgeTKk4ReXSzxd6hUfQ2NxvKbtME1kJRWeyUuS/H2DYRXYWLun6O/xXA=,iv:1Fo5dTDuJXRkfTjPvCNRLzPgVcusZXB/S5TVimqPQb4=,tag:jfdXqAfsE2DCyfRdJFS70w==,type:comment]
#ENC[AES256_GCM,data:OIyr3AEEKrU73nHK3X5vJ6+YkBCvDVBnXYiEkI/yutRMASnP6ZBc1DmLxV+bWKS7d1aJxA2k3S+/IKN9UhwSa6AfR8iuvLSFnkMZlgOyulTb+I1Qdg==,iv:ZjLfBkAjJT99k3c1qtRglQuwoA8eVGtoHjJJNtHsqpQ=,tag:RYdq29YgqoFzzEwU0UQ5Vw==,type:comment]
#
#ENC[AES256_GCM,data:WGLSnMuM1Kj6V/bUSZKQVdu3M3SmR7ADrQK0WuGufRmg1Z2q/I8eeP3mKFkk9EobYV7fHab34B7CCDMtQemcvV92lF4+e59ggfxP53nrVsLh8ZWIxJycneY=,iv:UUQZkq+WP8muG0XUN1TJ4fz6Hen54JO+4of/YiFamEE=,tag:Zac0l31mULvA/2p2GJBfGg==,type:comment]
#ENC[AES256_GCM,data:sXr+q5pUauY1atCv5H0X1C53b8pnO0yKwb6EbizJkTqmoajaSu/rp4vtfZ1eWOffyNwrUZoGsB4kauT75H/kpEJ3V+g0Hizu7JozxLji9hUdugfbQKFFqbD/cm+ctj70GpY=,iv:YclhEQ+sX6ae+y8KVgut53oQlVCKAm9T8J3xMM9r174=,tag:/tZucqVbVLPeCi4re3x4ZQ==,type:comment]
passwords: ENC[AES256_GCM,data:s49DRPQO5DcFeZQGwBQ01Eh7mgSVCrPl2u3On3msqPmm/VRBst4RigDFS6xKzPPSHbkinLMGxkOhXPuegGPzRgs=,iv:XBuodKnec/qNsPXXDKCsVgTl39EgZZvWsyRPZ6lN9m0=,tag:nO9MWneybijH9ZIeXGPQVw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDWXliaFM4RkFmZE1yM0N1
blJnTmp3Q2p2ZHM3THlyUGpQckNEcC9EZ2c0CkFxU0pUTmVHNDZXYS9STDVTamIr
M3A4VlAzdzFEYTUydGF2T01DNFkxT0EKLS0tIDlSS2s1YjF4TmkveHd4LzBRbTI4
anhpeUZ1VUFXYWVObTU2YVpCaTFXN00KwMHeXtaKxMpdLPRANabj+Vpxx5WLsyPW
T9npuQcI52YaXuNpUy+MtWNASwSXvmA7nl4KJNLCWAhgGKrd48Hwxw==
-----END AGE ENCRYPTED FILE-----
recipient: age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
lastmodified: "2026-10-07T14:31:12Z"
mac: ENC[AES256_GCM,data:nu44CjCVES+B+UI+6xwT3fCEN958FuKH7eHUTr+XEoHEGfh2Nx+5G5VciJD1TcsQ9yb0C1uWEGkCH8wrjcUEEDNe9MPVGqsREV7fpmO9Cr0wrW5Ji8gnbTGTjI7GswoYG3jUtMzdktBJnX8g6vit2VE7s9l+mE2S3YH3RXyHBDE=,iv:iff4ebSxNFumw1b82FEd0IdWBHGMOowG3LTQui7wTyg=,tag:TGhNeml/F9vtMrJm7d6MJA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3