Initial Commit
This commit is contained in:
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user