#!/usr/bin/env bash # # deerflow-firewall.sh — egress firewall for the hardened DeerFlow container. # # Restricts outbound traffic from the DeerFlow Docker bridge so the agent # cannot reach LAN devices, only the Internet plus a small allowlist of # Wireguard hosts (Searx + local model servers). # # Threat model: a prompt-injected agent inside the container should be # unable to scan or attack other devices on the home LAN. Inbound LAN # traffic to the container's published ports is unaffected because the # rules are stateful (ESTABLISHED,RELATED returns first). # # Idempotent: running `up` repeatedly will only ever leave one copy of # each rule in DOCKER-USER. Running `down` removes them all. # # Usage: # deerflow-firewall.sh up # install rules # deerflow-firewall.sh down # remove rules # deerflow-firewall.sh status # show DOCKER-USER chain # # Requires: iptables, root privileges, the `DOCKER-USER` chain (created # by the Docker daemon at startup). set -euo pipefail readonly BRIDGE="br-deerflow" readonly CHAIN="DOCKER-USER" # IPs that the container IS allowed to reach inside RFC1918: readonly -a ALLOW_HOSTS=( "10.67.67.1" # Searx (Wireguard) "10.67.67.2" # XTTS / Whisper / Ollama-local (Wireguard) ) # Subnets that the container is NOT allowed to reach: readonly -a BLOCK_NETS=( "192.168.3.0/24" # home LAN "10.0.0.0/8" # rest of /8 except whitelisted /32s above "172.16.0.0/12" # other Docker bridges + RFC1918 leftovers ) if [[ $EUID -ne 0 ]]; then echo "deerflow-firewall: must run as root" >&2 exit 1 fi require_chain() { if ! iptables -w -nL "$CHAIN" >/dev/null 2>&1; then echo "deerflow-firewall: chain $CHAIN does not exist — is the Docker daemon running?" >&2 exit 2 fi } # Run iptables only if the same rule is not already present. add_rule() { local args=("$@") if ! iptables -w -C "$CHAIN" "${args[@]}" 2>/dev/null; then iptables -w -I "$CHAIN" "${args[@]}" fi } # Run iptables -D until the rule is gone (in case it was added multiple times). del_rule() { local args=("$@") while iptables -w -C "$CHAIN" "${args[@]}" 2>/dev/null; do iptables -w -D "$CHAIN" "${args[@]}" done } cmd_up() { require_chain # NOTE: -I prepends, so build the chain bottom-up. # Final order from top of DOCKER-USER: # 1. ESTABLISHED,RELATED -> RETURN (let inbound responses out) # 2. -d 10.67.67.1 -> RETURN (Searx) # 3. -d 10.67.67.2 -> RETURN (XTTS/Whisper/Ollama) # 4. -d 192.168.3.0/24 -> REJECT (home LAN) # 5. -d 10.0.0.0/8 -> REJECT # 6. -d 172.16.0.0/12 -> REJECT # Insert rules in reverse so the final order matches the spec. for net in "${BLOCK_NETS[@]}"; do add_rule -i "$BRIDGE" -d "$net" -j REJECT --reject-with icmp-net-prohibited done for host in "${ALLOW_HOSTS[@]}"; do add_rule -i "$BRIDGE" -d "$host" -j RETURN done add_rule -i "$BRIDGE" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN if ! ip link show "$BRIDGE" >/dev/null 2>&1; then echo "deerflow-firewall: WARNING: bridge $BRIDGE does not exist yet." echo "deerflow-firewall: rules are installed and will activate as soon as you run" echo " docker compose -f deer-flow/docker/docker-compose.yaml \\" echo " -f docker/docker-compose.override.yaml up -d" fi echo "deerflow-firewall: up" } cmd_down() { require_chain for net in "${BLOCK_NETS[@]}"; do del_rule -i "$BRIDGE" -d "$net" -j REJECT --reject-with icmp-net-prohibited done for host in "${ALLOW_HOSTS[@]}"; do del_rule -i "$BRIDGE" -d "$host" -j RETURN done del_rule -i "$BRIDGE" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN echo "deerflow-firewall: down" } cmd_status() { require_chain echo "DOCKER-USER chain (rules matching $BRIDGE):" # -nvL prints the input interface column so we can grep for our bridge. iptables -w -nvL "$CHAIN" --line-numbers | awk -v b="$BRIDGE" 'NR<=2 || $0 ~ b' if ip link show "$BRIDGE" >/dev/null 2>&1; then echo echo "Bridge $BRIDGE: present" else echo echo "Bridge $BRIDGE: NOT present (DeerFlow container not started)" fi } case "${1:-}" in up) cmd_up ;; down) cmd_down ;; status) cmd_status ;; *) echo "Usage: $0 {up|down|status}" >&2 exit 64 ;; esac