Files
owui-tools/docker-compose.yml
2026-09-15 15:24:03 -04:00

635 lines
24 KiB
YAML

# =====================================================================
# owuitools - OpenAPI tool suite for the qwen3.8 assistant in Open WebUI
#
# docker compose up -d --build (from the repository root)
#
# WHY TEN CONTAINERS
#
# Anyone who can talk to this assistant can reach anything the model can
# call. The realistic attack is not someone breaking out of a container -
# it is someone talking the model into fetching an address on your LAN
# and reading the answer back out. A URL allowlist in Python is one typo
# away from failing open, so the boundary is the network instead.
#
# Six containers are the tool server:
#
# netguard owns a network namespace with a default-deny filter. api and
# searxng run inside it and have no interface of their own. See
# netguard/firewall.sh for why an `internal: true` network alone was
# NOT enough - the host is on-link at the bridge gateway address, and
# was reachable until this existed.
#
# gw is the only container on Open WebUI's network, and it is a plain
# reverse proxy holding no application code.
#
# egress (squid) is the only container that touches the internet, and
# it re-resolves every hostname itself before applying its
# private-range denies - so DNS rebinding dies there too.
#
# valkey is the cache, and octave is the code sandbox, which has
# `network_mode: none` and is reached by a file drop.
#
# Four more are the VM sub-agent, and they repeat the same shape one
# level down:
#
# vmguard owns a namespace whose allowlist is one address. vmagent runs
# inside it, holds no secret, and can reach nothing else.
#
# vmgw is that one address: an nginx exposing exactly two Ollama paths.
# Ollama has no authentication and serves DELETE /api/delete, so
# anything that can reach it can delete your model.
#
# Addresses are static because the firewall rules name them, and a rule
# pointing at a stale address is a rule that silently stops matching.
# =====================================================================
name: owuitools
x-hardened: &hardened
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# No docker socket. Bind mounts are read-only config, with two
# deliberate exceptions: ./logs, which every service writes its own log
# file into, and vmagent's /run/owuivm/agent socket directory - the one
# host mount in the suite, and the whole reason the privileged side is
# reachable only through a fixed verb list.
services:
# -------------------------------------------------------------------
# Ingress. Bridges openwebui_default -> the guarded namespace.
# -------------------------------------------------------------------
gw:
<<: *hardened
image: nginx:stable-alpine
container_name: owuitools-gw
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
- /var/cache/nginx:size=16m
- /var/run:size=1m
cap_add:
- CHOWN
- SETGID
- SETUID
volumes:
- ./configs/services/gateway.nginx.conf:/etc/nginx/nginx.conf:ro
# Every component writes its own file into ./logs, so there is one
# place to look rather than nine compose invocations. stdout is
# unchanged, so `docker compose logs` still works. See logs/README.md.
- ./logs:/logs
networks:
core:
ipv4_address: 172.22.0.10
openwebui:
depends_on:
api:
condition: service_healthy
mem_limit: 64m
pids_limit: 64
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
wud.watch: "true"
wud.tag.include: '^\d+\.\d+\.\d+-alpine$$'
# -------------------------------------------------------------------
# The guarded namespace. Holds no application logic - it exists to own
# the netns and the iptables rules that api and searxng live behind.
# -------------------------------------------------------------------
netguard:
<<: *hardened
image: nicolaka/netshoot:v0.16
container_name: owuitools-netguard
cap_add:
- NET_ADMIN
volumes:
- ./netguard/firewall.sh:/firewall.sh:ro
- ./logs:/logs
# ---------------------------------------------------------------
# RECREATING THIS CONTAINER STRANDS THE ONES THAT SHARE ITS NETNS.
#
# api and searxng join this container's network namespace. Docker
# does not re-attach them when it is replaced, so after a
# `docker compose up -d netguard` they are still running, still
# healthy by their own healthcheck - which is a loopback call - and
# have no route to anything at all. Measured 2026-09-15: squid and
# valkey both came back "Network is unreachable" and the gateway got
# connection refused, with every container reporting Up.
#
# So: touching this service means recreating its dependents too.
#
# docker compose up -d --force-recreate netguard api searxng
#
# The same applies to vmguard and vmagent below.
# ---------------------------------------------------------------
environment:
# The filter's allowlist, from the same file that pins the addresses.
OWUITOOLS_GW_IP: 172.22.0.10
OWUITOOLS_EGRESS_IP: 172.22.0.12
OWUITOOLS_VALKEY_IP: 172.22.0.14
OWUITOOLS_API_PORT: "8000"
entrypoint: ["/bin/sh", "-e", "/firewall.sh"]
networks:
core:
ipv4_address: 172.22.0.11
mem_limit: 64m
pids_limit: 32
healthcheck:
# Nothing may start in this namespace until the filter is confirmed
# applied; firewall.sh only creates this after verifying its rules.
test: ["CMD", "test", "-f", "/tmp/firewall-ready"]
interval: 5s
timeout: 3s
retries: 10
start_period: 3s
labels:
wud.watch: "false"
# -------------------------------------------------------------------
# The tool server. No network interface of its own.
# -------------------------------------------------------------------
api:
<<: *hardened
build:
context: ./app
image: local/owuitools-api:latest
container_name: owuitools-api
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- octave_jobs:/jobs
# The VM sandbox's dispatch, same file-drop protocol as octave.
# Mounting it is also what makes the vm_agent tool appear at all:
# main.py registers that router only when this directory exists, so
# a host without the VM stack does not describe a tool it cannot run.
- vm_jobs:/vmjobs
# The tool switch. Read at startup: a tool set to false here is
# dropped from the OpenAPI spec AND its endpoint stops existing, so
# the model is never told about it and you stop paying its schema
# tokens. Mounted rather than baked so a change is a restart, not a
# rebuild - but the spec is cached for the process lifetime, so it
# IS a restart plus a re-save of the Open WebUI connection.
- ./configs/tools.toml:/configs/tools.toml:ro
# Every component writes its own file into ./logs, so there is one
# place to look rather than nine compose invocations. stdout is
# unchanged, so `docker compose logs` still works. See logs/README.md.
- ./logs:/logs
network_mode: "container:owuitools-netguard"
environment:
OWUITOOLS_OCTAVE_JOBS_DIR: /jobs
OWUITOOLS_VM_JOBS_DIR: /vmjobs
OWUITOOLS_TOOLS_CONFIG: /configs/tools.toml
OWUITOOLS_VM_WAIT_SECONDS: ${OWUITOOLS_VM_WAIT_SECONDS:-7000}
OWUITOOLS_AUTH_TOKEN: ${OWUITOOLS_AUTH_TOKEN:?generate one - see .env.example}
OWUITOOLS_EGRESS_PROXY: http://172.22.0.12:3128
# searxng shares this namespace, so it is reachable on loopback.
OWUITOOLS_SEARXNG_URL: http://127.0.0.1:8080
OWUITOOLS_VALKEY_URL: redis://172.22.0.14:6379/0
OWUITOOLS_CONTACT: ${OWUITOOLS_CONTACT:-owuitools@localhost}
OWUITOOLS_PUBLIC_URL: ${OWUITOOLS_PUBLIC_URL:-}
OWUITOOLS_MAX_RESPONSE_CHARS: ${OWUITOOLS_MAX_RESPONSE_CHARS:-12000}
OWUITOOLS_PROBE_RATE_PER_HOUR: ${OWUITOOLS_PROBE_RATE_PER_HOUR:-30}
OWUITOOLS_LOG_LEVEL: ${OWUITOOLS_LOG_LEVEL:-INFO}
# All optional. Each lifts a rate limit that is otherwise tight
# enough to matter for a shared assistant; none are required.
OWUITOOLS_GITHUB_TOKEN: ${OWUITOOLS_GITHUB_TOKEN:-}
OWUITOOLS_STACKEXCHANGE_KEY: ${OWUITOOLS_STACKEXCHANGE_KEY:-}
OWUITOOLS_NVD_KEY: ${OWUITOOLS_NVD_KEY:-}
depends_on:
netguard:
condition: service_healthy
octave:
condition: service_started
valkey:
condition: service_healthy
egress:
condition: service_started
searxng:
condition: service_started
mem_limit: 1g
cpus: 2.0
pids_limit: 256
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
labels:
wud.watch: "false"
# -------------------------------------------------------------------
# Search backend. Also inside the guarded namespace: it does not accept
# arbitrary URLs, but it has no business reaching the host either, and
# sharing the namespace costs nothing.
# -------------------------------------------------------------------
searxng:
<<: *hardened
image: searxng/searxng:latest
container_name: owuitools-searxng
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
network_mode: "container:owuitools-netguard"
volumes:
- ./configs/services/searxng.settings.yml:/etc/searxng/settings.yml:ro
- ./logs:/logs
# THE ONE COMPONENT THAT CANNOT WRITE ITS OWN LOG FILE.
#
# SearXNG logs through Python's logging to stdout and exposes no file
# option, and the image dropped uwsgi (which did have one). So its
# output is teed on the way past instead.
#
# The cost is that PID 1 becomes the shell, which does not forward
# SIGTERM through a pipeline - so stopping this container waits out
# the timeout and then kills it. That is acceptable here and nowhere
# else in this file: SearXNG holds no state, serves no writes, and a
# hard kill loses nothing.
entrypoint: ["/bin/sh", "-c",
"/usr/local/searxng/entrypoint.sh 2>&1 | tee -a /logs/searxng.log"]
environment:
SEARXNG_BASE_URL: http://127.0.0.1:8080/
# NOTE: this image used to run under uwsgi and no longer does, so
# the UWSGI_* settings below are inert on current tags. They are
# kept because they cost nothing and still apply if you pin an
# older image.
# Engine fan-out goes through the same chokepoint as everything else.
HTTP_PROXY: http://172.22.0.12:3128
HTTPS_PROXY: http://172.22.0.12:3128
NO_PROXY: localhost,127.0.0.1
SEARXNG_LIMITER: "false"
UWSGI_WORKERS: "4"
UWSGI_THREADS: "4"
depends_on:
netguard:
condition: service_healthy
egress:
condition: service_started
mem_limit: 512m
pids_limit: 128
labels:
wud.watch: "false"
# -------------------------------------------------------------------
# The only container with internet access. See configs/services/egress.squid.conf.
# -------------------------------------------------------------------
egress:
<<: *hardened
image: ubuntu/squid:latest
container_name: owuitools-egress
read_only: true
# Squid normally starts as root and setuids to proxy - after which it
# can no longer reopen /dev/stdout, because the container's stdout pipe
# belongs to root, and it dies with "Cannot open '/dev/stdout' for
# writing". Being proxy from the outset keeps the access log on
# `docker logs`, which is where the audit trail of what the assistant
# fetched needs to be.
user: "13:13"
tmpfs:
- /var/spool/squid:size=32m
- /var/log/squid:size=16m
- /var/run:size=4m
- /tmp:size=8m
volumes:
- ./configs/services/egress.squid.conf:/etc/squid/squid.conf:ro
- ./logs:/logs
networks:
core:
ipv4_address: 172.22.0.12
wan:
mem_limit: 256m
pids_limit: 128
labels:
wud.watch: "false"
# -------------------------------------------------------------------
# Response cache. Load-bearing, not an optimisation: Stack Exchange
# allows 300 calls/day/IP without a key and GitHub 60/hour, and those
# budgets are shared by every user of the assistant at once.
# -------------------------------------------------------------------
valkey:
<<: *hardened
image: valkey/valkey:9-alpine
container_name: owuitools-valkey
read_only: true
# The image's entrypoint chowns /data when it starts as root, which
# cap_drop: ALL makes impossible. Starting as valkey's own uid skips
# that branch; the tmpfs is created owned by it instead.
user: "999:1000"
tmpfs:
- /data:size=128m,uid=999,gid=1000,mode=0700
volumes:
- ./logs:/logs
# `umask 022` so the log lands world-readable: valkey writes it 0600
# as its own uid, which the operator cannot read. `exec` keeps valkey
# as PID 1, so signals and a clean shutdown are unaffected - unlike
# the pipeline searxng needs.
entrypoint: ["/bin/sh", "-c", "umask 022; exec \"$$@\"", "--"]
command: >
valkey-server
--logfile /logs/valkey.log
--save ""
--appendonly no
--maxmemory 192mb
--maxmemory-policy allkeys-lru
networks:
core:
ipv4_address: 172.22.0.14
mem_limit: 256m
pids_limit: 64
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 15s
timeout: 3s
retries: 5
start_period: 5s
labels:
wud.watch: "true"
wud.tag.include: '^\d+-alpine$$'
# -------------------------------------------------------------------
# Code execution sandbox. The only container that runs code written by
# whoever is talking to the model, and therefore the most locked down:
#
# network_mode "none" - no network stack at all, not even loopback
# to the rest of the suite. It cannot be
# reached over HTTP and cannot call out, so an
# SSRF or exfiltration bug has nothing to use.
# read_only + tmpfs - nothing it writes survives the run.
# user 10001 - same uid as the api, purely so the job tmpfs
# can be mode 0700 rather than world-writable.
#
# Work reaches it as files on octave_jobs, a 64 MB tmpfs shared with the
# api. See octave/run.sh for the protocol and for the one limitation
# this design keeps: a hostile script can delete another in-flight job.
# -------------------------------------------------------------------
octave:
<<: *hardened
build:
context: ./octave
image: local/owuitools-octave:latest
container_name: owuitools-octave
read_only: true
network_mode: "none"
user: "10001:10001"
tmpfs:
- /tmp:size=256m,mode=1777
volumes:
- octave_jobs:/jobs
- ./logs:/logs
environment:
HOME: /tmp/octhome
# Without a UTF-8 locale gnuplot writes "iconv failed to convert degree
# sign" to stderr on every plot that has a degree in a label, which the
# model then reads alongside its real errors.
LANG: C.UTF-8
LC_ALL: C.UTF-8
OWUITOOLS_OCTAVE_TIMEOUT: ${OWUITOOLS_OCTAVE_TIMEOUT:-25}
OWUITOOLS_OCTAVE_MAX_TIMEOUT: ${OWUITOOLS_OCTAVE_MAX_TIMEOUT:-60}
mem_limit: 1g
cpus: 1.5
pids_limit: 128
healthcheck:
test: ["CMD-SHELL", "test -n \"$$(find /jobs/.heartbeat -newermt '-90 seconds' 2>/dev/null)\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
labels:
wud.watch: "false"
# -------------------------------------------------------------------
# The VM sub-agent. Three containers, and the split is the security
# argument rather than tidiness:
#
# vmgw holds BOTH secrets - an Open WebUI admin key and the only
# permitted Ollama path - and exposes three URLs. It is the
# one that can reach Open WebUI and Ollama.
# vmguard owns a network namespace with a default-deny filter whose
# entire allowlist is vmgw.
# vmagent runs the agent loop inside that namespace, holding no
# credential and able to reach exactly one address.
#
# Ollama has NO authentication and serves DELETE /api/delete. That is
# why it is behind vmgw's `location =` rather than on any container's
# allowlist: a URL-validation bug anywhere else must not be one request
# away from deleting the model the whole assistant runs on.
# -------------------------------------------------------------------
vmgw:
<<: *hardened
image: nginx:stable-alpine
container_name: owuitools-vmgw
read_only: true
cap_drop: [ALL]
cap_add: [CHOWN, SETGID, SETUID]
volumes:
- ./configs/services/vmgw.nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
- ./logs:/logs
environment:
# No credential of any kind. Live progress used to need an Open
# WebUI admin key here; it is now collected by a filter running
# inside Open WebUI, so there is nothing to hold.
OWUITOOLS_OLLAMA_HOST: ${OWUITOOLS_OLLAMA_HOST:-172.17.0.1:11434}
NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/conf.d
tmpfs:
- /var/cache/nginx:size=16m
- /var/run:size=1m
- /tmp:size=16m
- /etc/nginx/conf.d:size=1m
networks:
vmcore:
ipv4_address: 172.22.2.10
# `vmcore` is internal:true, so on its own this container has no
# default route and cannot reach Ollama on the docker bridge at
# all - the healthcheck passes and every proxied request times out.
# This membership exists purely for that route. It is no longer
# used to reach Open WebUI: that location is gone, and nginx
# proxies only what its config names.
openwebui: {}
mem_limit: 64m
pids_limit: 64
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/health >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
labels:
wud.watch: "false"
vmguard:
<<: *hardened
image: nicolaka/netshoot:v0.16
container_name: owuitools-vmguard
cap_drop: [ALL]
cap_add: [NET_ADMIN]
volumes:
- ./vmguard/firewall.sh:/firewall.sh:ro
- ./logs:/logs
# vmagent shares this namespace. Recreating this service without
# recreating vmagent leaves the agent with no route to vmgw, and so
# to Ollama - see the note on netguard above.
#
# docker compose up -d --force-recreate vmguard vmagent
environment:
# The one address the agent container may reach. Must equal
# OWUITOOLS_VMGW_URL on the vmagent service below.
OWUITOOLS_VMGW_IP: 172.22.2.10
OWUITOOLS_VMGW_PORT: "8000"
entrypoint: ["/bin/sh", "/firewall.sh"]
networks:
vmcore:
ipv4_address: 172.22.2.11
mem_limit: 64m
pids_limit: 32
healthcheck:
test: ["CMD-SHELL", "test -f /tmp/firewall-ready"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
labels:
wud.watch: "false"
vmagent:
<<: *hardened
build:
context: .
dockerfile: vmagent/Dockerfile
image: local/owuitools-vmagent:latest
container_name: owuitools-vmagent
read_only: true
user: "10001:10001"
network_mode: "container:owuitools-vmguard"
volumes:
- vm_jobs:/vmjobs
# The ONLY host bind mount in this suite, and the reason for it is
# the guarantee itself: everything privileged - /dev/kvm, the
# frozen base images, the ZFS root, the Gitea token, the ability to
# unfreeze a disk - is on the far side of this socket, reachable
# only through vmd's fixed verb list. The DIRECTORY is mounted, not
# the socket: Docker creates a directory in place of a bind source
# that does not exist yet, and vmd could then never bind at all.
- /run/owuivm/agent:/run/owuivm/agent
# The agent's prompts and its tool definitions. Mounted rather than
# baked into the image, so rewording a tool description or a prompt
# fragment is a container restart instead of a rebuild. Read-only,
# and it is the whole of what this container is configured by -
# note there is still no secret anywhere in here, which is the
# property the two boundaries above exist to protect.
- ./configs/agent:/configs/agent:ro
# Shared with the api container. The sub-agent's research verbs are
# calls BACK into the tool server, so a tool switched off there must
# not be offered here - it would 404 and read as a broken tool.
- ./configs/tools.toml:/configs/tools.toml:ro
# The machine registry. The agent reads the shell, project path and
# path example for ITS machine out of this - the substitutions that
# turn one set of tool descriptions into two.
- ./configs/vms.toml:/configs/vms.toml:ro
- ./logs:/logs
tmpfs:
- /tmp:size=64m
- /home/agent:size=16m
# The model settings and session bounds live with the agent's prompts
# and tool definitions, in configs/agent/params.env, rather than being
# spread between here and the stack's .env. env_file is the right
# mechanism: this container reads them from its environment, and
# unlike ${...} substitution it needs nothing in the compose file to
# be kept in step with the config.
env_file:
- ./configs/agent/params.env
environment:
HOME: /home/agent
OWUITOOLS_VM_JOBS_DIR: /vmjobs
OWUITOOLS_AGENT_CONFIG_DIR: /configs/agent
OWUITOOLS_TOOLS_CONFIG: /configs/tools.toml
OWUITOOLS_VMGW_URL: http://172.22.2.10:8000
OWUITOOLS_LOG_LEVEL: ${OWUITOOLS_LOG_LEVEL:-INFO}
depends_on:
vmguard:
condition: service_healthy
vmgw:
condition: service_healthy
mem_limit: 512m
pids_limit: 128
healthcheck:
test: ["CMD-SHELL", "test -n \"$$(find /vmjobs/.heartbeat -newermt '-120 seconds' 2>/dev/null)\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
labels:
wud.watch: "false"
networks:
# No route off its own subnet. Necessary but, on its own, not sufficient
# - the host sits on this subnet at .1. netguard/firewall.sh closes that.
core:
name: owuitools_core
internal: true
ipam:
config:
- subnet: 172.22.0.0/24
wan:
name: owuitools_wan
ipam:
config:
- subnet: 172.22.1.0/24
# The VM agent's own namespace. Separate from `core` on purpose: the
# agent's allowlist is vmgw alone, while the api's is squid and valkey,
# and collapsing them would give one the other's reach.
vmcore:
name: owuitools_vmcore
internal: true
ipam:
config:
- subnet: 172.22.2.0/24
# Pre-existing, owned by the openwebui stack. Joining is additive and
# does not restart or reconfigure open-webui.
#
# `openwebui_default` is what Docker Compose names the default network
# of a stack in a directory called `openwebui`. Yours will differ if
# the directory or the project name does - check with
# `docker network ls` and set OWUITOOLS_OWUI_NETWORK in
# configs/owuitools.env. If Open WebUI is not on this host at all,
# publish the gateway's port instead and point the tool connection at
# the host address.
openwebui:
name: ${OWUITOOLS_OWUI_NETWORK:-openwebui_default}
external: true
volumes:
# Deliberately tmpfs, not a disk volume: job files are transient, they
# contain code somebody typed into a chat window, and none of it should
# outlive a reboot or land on the operator's disk. 64 MB is a cap as much as an
# allocation - it bounds what a runaway script can write.
octave_jobs:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=64m,uid=10001,gid=10001,mode=0700
# Same reasoning as octave_jobs, same shape. A job is a sentence of
# instructions and a result is a page of prose; neither should outlive
# a reboot or land on the operator's disk.
vm_jobs:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=16m,uid=10001,gid=10001,mode=0700