Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
# =====================================================================
# owuitools egress proxy - THE authoritative SSRF boundary.
#
# owuitools-api sits on an `internal: true` network with no default
# route. Every byte it sends to the internet goes through here, so this
# file - not the application's URL validation - is what actually stops a
# guest from talking the assistant into fetching http://192.168.1.10:9443
# or http://172.19.0.2:8080/api/.
#
# Squid resolves destination hostnames ITSELF and applies the ACLs to the
# address it resolved. That is the point: it means a DNS rebinding attack,
# where a name passes the app's check and then resolves to a private
# address a moment later, still dies here.
# =====================================================================
http_port 3128
# Only our own core network may use the proxy at all.
acl core_net src 172.22.0.0/24
# --- Destination denial -------------------------------------------------
# Everything RFC1918/loopback/link-local/CGNAT/documentation/reserved.
# 100.64.0.0/10 (CGNAT) is in here deliberately: this host may sit behind
# a carrier-grade NAT, and that range can reach other subscribers.
acl to_private dst 0.0.0.0/8
acl to_private dst 10.0.0.0/8
acl to_private dst 100.64.0.0/10
acl to_private dst 127.0.0.0/8
acl to_private dst 169.254.0.0/16
acl to_private dst 172.16.0.0/12
acl to_private dst 192.0.0.0/24
acl to_private dst 192.0.2.0/24
acl to_private dst 192.168.0.0/16
acl to_private dst 198.18.0.0/15
acl to_private dst 198.51.100.0/24
acl to_private dst 203.0.113.0/24
acl to_private dst 224.0.0.0/4
acl to_private dst 240.0.0.0/4
acl to_private6 dst ::/128
acl to_private6 dst ::1/128
acl to_private6 dst fe80::/10
acl to_private6 dst fc00::/7
# --- Port restriction ---------------------------------------------------
acl Safe_ports port 80
acl Safe_ports port 443
acl SSL_ports port 443
acl CONNECT method CONNECT
# --- Policy (order matters; first match wins) ---------------------------
http_access deny to_private
http_access deny to_private6
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access allow core_net
http_access deny all
# --- Behaviour ----------------------------------------------------------
# Stateless: memory cache only, no cache_dir, so the container needs no
# writable spool and can run with a read-only rootfs.
cache deny all
cache_mem 16 MB
maximum_object_size_in_memory 512 KB
# Don't advertise the proxy or the client to upstream sites.
forwarded_for delete
via off
httpd_suppress_version_string on
request_header_access X-Forwarded-For deny all
# Fail fast rather than hanging a tool call.
connect_timeout 10 seconds
read_timeout 45 seconds
request_timeout 45 seconds
dns_v4_first on
pid_filename none
# Audit trail: which tool reached for what. stdout so `docker logs` has it.
logfile_rotate 0
# squid creates its log files 0640 proxy:proxy, which the operator cannot
# read - and a log nobody can open is not an audit trail. This makes them
# world-readable. They contain URLs the assistant fetched, nothing secret.
umask 022
# THE AUDIT TRAIL: one line per outbound request, allowed or denied.
# Written to both, so `docker compose logs egress` keeps working and
# ./logs has it alongside every other component.
access_log stdio:/dev/stdout squid
access_log stdio:/logs/egress.access.log squid
cache_log stdio:/logs/egress.cache.log
+102
View File
@@ -0,0 +1,102 @@
# =====================================================================
# owuitools ingress gateway.
#
# Deliberately dumb: this is the ONLY container that joins
# openwebui_default, and it holds no application logic. Its whole job is
# to let open-webui reach owuitools-api, which lives on an internal-only
# network and therefore cannot be reached - or reach out - any other way.
#
# Keeping the app off openwebui_default is the point. If the app sat
# there it would have a default route, and a bug in URL validation would
# put the host's other stacks one HTTP request away.
# =====================================================================
worker_processes 1;
# Both destinations on purpose: stderr keeps `docker compose logs`
# working, the file puts it with every other component in ./logs.
error_log /dev/stderr warn;
error_log /logs/gateway.error.log warn;
pid /tmp/nginx.pid;
events { worker_connections 512; }
http {
access_log /dev/stdout combined;
access_log /logs/gateway.access.log combined;
# Read-only rootfs: everything nginx wants to write goes to tmpfs.
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
sendfile on;
server_tokens off;
# Tool responses are capped server-side but a datasheet fetch can
# still be a few hundred KB before trimming.
client_max_body_size 1m;
proxy_buffering off;
upstream owuitools_api {
server owuitools-netguard:8000;
keepalive 8;
}
server {
listen 8000;
server_name _;
# A tool call that needs a slow origin plus a PDF parse can take a
# while: at 90 s a 995-page manual was cut off mid-extraction and the
# model received nginx's HTML 504 page as its tool result. The app
# bounds its own slow paths (PDF extraction at 120 s) so that it, not
# this gateway, is what answers.
#
# CORRECTION, measured on 0.11.3. This comment used to say Open WebUI
# sets no tool-server timeout of its own "because AIOHTTP_CLIENT_TIMEOUT
# is unset here". That is wrong, and wrong in the dangerous direction:
# env.py falls AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER back to
# AIOHTTP_CLIENT_TIMEOUT, which defaults to 300 when unset - not to
# "no limit". So there are TWO clocks on an ordinary tool call, and
# Open WebUI's is the shorter one.
proxy_connect_timeout 10s;
proxy_send_timeout 240s;
proxy_read_timeout 240s;
# The one exception. A VM session is a slow model with thinking on
# driving a real machine, so it is minutes to hours rather than
# seconds. This needs all three clocks raised together - this one,
# OWUITOOLS_VM_WAIT_SECONDS in the app, and
# AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER on the open-webui container -
# and the app's is deliberately the smallest so that IT answers.
#
# Only this location: every other tool keeps the 240 s ceiling,
# because a tool that hangs should fail rather than tie up a worker
# for two hours.
location = /tools/vm_agent {
proxy_pass http://owuitools_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass_request_headers on;
proxy_read_timeout 7200s;
proxy_send_timeout 7200s;
}
location / {
proxy_pass http://owuitools_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# open-webui forwards these when ENABLE_FORWARD_USER_INFO_HEADERS
# is on; the app keys its per-user rate limits off them, so they
# must survive the hop.
proxy_pass_request_headers on;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
# =====================================================================
# owuitools' own SearXNG.
#
# Separate from /configs/searxng on purpose. That instance is tuned for
# the voice assistant, where a slow answer is a bad answer, so it runs
# outgoing.request_timeout at 2.0s and drops any engine that dawdles.
# Research is the opposite trade: waiting 5s for a good result beats
# answering fast from one engine.
# =====================================================================
use_default_settings: true
general:
instance_name: "owuitools"
# No public UI, no metrics endpoint to leak engine health.
enable_metrics: false
server:
secret_key: "CHANGE_ME - generate with: openssl rand -hex 32"
limiter: false # only owuitools-api can reach this; no bot problem
image_proxy: false
method: "GET"
search:
safe_search: 0
autocomplete: ""
default_lang: "en"
# json is what the tool server consumes. html stays enabled so a human
# can port-forward and eyeball why a query returned junk.
formats:
- html
- json
outgoing:
request_timeout: 5.0
max_request_timeout: 8.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
engines:
# The `general` set is chosen by measurement, not reputation. Every engine
# below was queried from this host's own egress IP on 2026-09-10; the
# result counts are in the comments. Re-measure before changing it - which
# engines will serve this IP is a property of the IP, and it drifts.
#
# The failure this guards against is real and was observed: brave, startpage
# and google cse were the entire `general` set, and all three suspended
# themselves within one working session (rate limit, CAPTCHA, API quota),
# taking web_search - the most-used tool in the suite - to zero results
# while every other tool stayed healthy.
# Working. DuckDuckGo previously served this IP a CAPTCHA and was disabled
# for it; re-measured 2026-09-10 and it now returns full results. The old
# note was stale, which is the argument for re-measuring rather than
# trusting a comment.
- name: duckduckgo # 10 results
disabled: false
- name: bing # 10 results
disabled: false
- name: google # 9 results - the scraper, not the quota-limited CSE
disabled: false
- name: yahoo # 7 results
disabled: false
# Kept for diversity and because suspensions expire, but not relied on:
# each of these returned nothing from this IP on 2026-09-10.
- name: mojeek # 0
disabled: false
- name: brave # suspended: too many requests
disabled: false
- name: startpage # suspended: CAPTCHA
disabled: false
- name: wikipedia
disabled: false
# Measured and rejected: qwant (CAPTCHA), seznam (timeout), marginalia /
# stract / right dao / presearch (no results). wiby (12) and mwmbl (4)
# respond but index the small/old web, which is wrong for a suite answering
# current IT and engineering questions.
# `it` category is the high-value one for this assistant: stackoverflow,
# askubuntu, superuser, mdn, mankier, and the distro/docker package
# indexes. Measured 126 results vs 20 for `general` on a technical query.
- name: stackoverflow
disabled: false
- name: askubuntu
disabled: false
- name: superuser
disabled: false
- name: mdn
disabled: false
# Package-registry and niche-language engines sit in the `it` category and
# match on any keyword, so a query like "qemu bridge networking" led with a
# Docker Hub result for "Strimzi Kafka Bridge". lookup_package covers
# registry questions properly; here they are just noise crowding out the
# Stack Overflow and man-page hits that actually answer the question.
- name: docker hub
disabled: true
- name: gentoo
disabled: true
- name: hoogle
disabled: true
# Noise for this use case - all of it costs latency and returns nothing
# a technical answer can cite.
- name: bing images
disabled: true
- name: google images
disabled: true
- name: youtube
disabled: true
# The `science` category was running on SearXNG's defaults, which are
# arXiv-heavy: a biomedical query came back with computer-science preprints
# in five of twenty-three result slots. These two ship disabled upstream and
# are the ones that carry real bibliographic metadata - journal, authors,
# DOI - which web_search now renders as a CITE AS line. Without them the
# model gets a title and a URL and fills the venue in from the domain, which
# is how an npj Flexible Electronics paper got cited as Nature Electronics.
- name: openalex
disabled: false
- name: crossref
disabled: false
# Upstream default is 30s, well past this instance's max_request_timeout,
# so a slow Crossref would simply never return.
timeout: 8.0
- name: pubmed
disabled: false
ui:
static_use_hash: true
+76
View File
@@ -0,0 +1,76 @@
# Its own file in ./logs, alongside every other component.
error_log /logs/vmgw.error.log warn;
# =====================================================================
# owuitools-vmgw - the agent's entire outbound surface.
#
# WHY THIS EXISTS AT ALL
#
# Ollama has NO authentication and serves DELETE /api/delete, POST
# /api/pull and POST /api/create. Anything that can reach
# 172.17.0.1:11434 can delete qwen3.8 and take the whole assistant down.
# So the agent does not reach it: owuitools-vmguard's filter permits this
# container and nothing else, and this container exposes one exact Ollama
# path plus a read-only one.
#
# This is also the concrete reason the agent is not simply added to the
# api container's netguard allowlist: that would put "delete the model"
# one SSRF away from fetch_page.
#
# WHAT USED TO BE HERE AND IS NOT ANY MORE
#
# A third location proxied live status and screenshots to Open WebUI's
# /api/v1/chats/{id}/messages/{id}/event, with an Open WebUI ADMIN key
# injected here as a header. It had to be an admin key: anyone may invoke
# vm_agent and the chat belongs to whoever is talking, so that endpoint
# refuses anything less. Keeping it out of the agent container was not
# the same as it not existing.
#
# It is gone. The agent writes progress to the job volume it already
# shares with the api container, and the `vm_agent_progress` filter -
# running INSIDE Open WebUI, where the event emitter is already bound to
# the requesting user's session - reads it and emits the events itself.
# No Open WebUI credential exists anywhere in this stack now.
# =====================================================================
server {
listen 8000;
server_name _;
# A thinking model answering with 160k of context is slow, and the
# agent's whole loop is these calls. The gateway must not be the
# thing that gives up first.
proxy_connect_timeout 10s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
proxy_buffering off;
client_max_body_size 64m;
# --- the model -----------------------------------------------------
# `location =` for an exact match. /api/chat and nothing else: not
# /api/delete, not /api/pull, not /api/create, and no prefix that
# could be walked out of.
location = /ollama/api/chat {
proxy_pass http://${OWUITOOLS_OLLAMA_HOST}/api/chat;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
# A read-only endpoint so the agent can confirm the model is loaded
# and see its context length without guessing.
location = /ollama/api/ps {
proxy_pass http://${OWUITOOLS_OLLAMA_HOST}/api/ps;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location = /health {
access_log /logs/vmgw.access.log combined;
return 200 "ok\n";
}
location / {
return 404 "owuitools-vmgw serves two paths and this is not one of them\n";
}
}