#!/bin/sh # ===================================================================== # Network namespace guard for owuitools-api and owuitools-searxng. # # WHY THIS EXISTS # # Putting the API on an `internal: true` network is not, on its own, # enough. `internal` removes the default route, so the container cannot # reach the wider LAN - but the bridge gateway address (172.22.0.1) is the # HOST, and it sits on the container's own subnet. It is therefore # on-link and reachable with no routing at all. Measured on this host # before this file existed: a TCP connect from the API container to # 172.22.0.1:22 succeeded, which is the host's sshd. Every other service # the host binds to 0.0.0.0 - nginx, portainer, grafana - was equally # reachable. # # That is precisely the thing this suite is supposed to make impossible, # so the containers that handle user-controlled input run inside this # namespace instead, behind a default-deny filter. They keep no network # interface of their own; see `network_mode: container:owuitools-netguard` # in docker-compose.yml. # # The result is a hard allowlist: squid, valkey, and the gateway's inbound # request. The host is not on it. # ===================================================================== set -eu # What this applied, kept with every other component's log. `exec` so the # redirection covers the verification output at the end too, which is the # part worth having: it is the difference between "the filter ran" and # "the filter is in force". LOG_DIR="${OWUITOOLS_LOG_DIR:-/logs}" if [ -d "$LOG_DIR" ] && [ -w "$LOG_DIR" ]; then exec > >(tee -a "$LOG_DIR/netguard.log") 2>&1 fi # These come from docker-compose.yml, which is where the addresses are # pinned in the first place. They used to be written out again here, and # two copies of an address is one copy that gets changed: a compose edit # that moved a container would leave this file quietly filtering for the # old address, which fails CLOSED but looks like the service is down. # # The defaults are the pinned values, so the script still runs standalone. GW_IP="${OWUITOOLS_GW_IP:-172.22.0.10}" # the only permitted inbound source EGRESS_IP="${OWUITOOLS_EGRESS_IP:-172.22.0.12}" # squid, the only route to the internet VALKEY_IP="${OWUITOOLS_VALKEY_IP:-172.22.0.14}" # cache API_PORT="${OWUITOOLS_API_PORT:-8000}" echo "Configuring owuitools netguard..." # Fail closed before opening anything. iptables -w 10 -P INPUT DROP iptables -w 10 -P OUTPUT DROP iptables -w 10 -P FORWARD DROP # Filter table only. Docker's embedded DNS resolver installs NAT rules in # this namespace; flushing nat would break name resolution for every # service lookup the containers make. iptables -w 10 -F INPUT iptables -w 10 -F OUTPUT iptables -w 10 -F FORWARD # Loopback: the embedded DNS resolver at 127.0.0.11, the API's own health # check, and api -> searxng, which is now localhost because both share # this namespace. iptables -w 10 -A INPUT -i lo -j ACCEPT iptables -w 10 -A OUTPUT -o lo -j ACCEPT iptables -w 10 -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables -w 10 -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT # Inbound: only the gateway, only the API port. iptables -w 10 -A INPUT -s "$GW_IP/32" -p tcp --dport "$API_PORT" \ -m conntrack --ctstate NEW -j ACCEPT # Outbound: squid for anything leaving this host, valkey for the cache. iptables -w 10 -A OUTPUT -d "$EGRESS_IP/32" -p tcp --dport 3128 \ -m conntrack --ctstate NEW -j ACCEPT iptables -w 10 -A OUTPUT -d "$VALKEY_IP/32" -p tcp --dport 6379 \ -m conntrack --ctstate NEW -j ACCEPT # Verify the rules that matter actually took, and that the policies are # still DROP. A firewall that silently failed to apply is worse than none, # because everything downstream carries on as though it were enforced. iptables -w 10 -C INPUT -s "$GW_IP/32" -p tcp --dport "$API_PORT" \ -m conntrack --ctstate NEW -j ACCEPT iptables -w 10 -C OUTPUT -d "$EGRESS_IP/32" -p tcp --dport 3128 \ -m conntrack --ctstate NEW -j ACCEPT test "$(iptables -S INPUT | head -n 1)" = "-P INPUT DROP" test "$(iptables -S OUTPUT | head -n 1)" = "-P OUTPUT DROP" test "$(iptables -S FORWARD | head -n 1)" = "-P FORWARD DROP" touch /tmp/firewall-ready echo echo "owuitools netguard ready." echo " inbound $GW_IP -> tcp/$API_PORT" echo " outbound $EGRESS_IP:3128 (squid), $VALKEY_IP:6379 (valkey)" echo " the host (172.22.0.1) is NOT reachable from this namespace" echo trap 'exit 0' TERM INT while :; do sleep 3600 & wait $! done