Initial Commit
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user