mirror of
https://github.com/oqyude/nixos.git
synced 2026-10-07 20:47:14 +03:00
Compare commits
1
Commits
5e9641602a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58333d0257 |
@@ -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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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" = {
|
"zeroq.su" = {
|
||||||
forceSSL = true;
|
forceSSL = true;
|
||||||
enableACME = true;
|
enableACME = true;
|
||||||
|
|||||||
@@ -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";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user