# ===================================================================== # 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