diff --git a/Dockerfile b/Dockerfile index bd585e1..dc08588 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,8 @@ RUN apk add --no-cache \ bind-tools \ curl \ bash \ - iproute2 + iproute2 \ + ipset COPY network-filter.sh /network-filter.sh diff --git a/README.md b/README.md index 563ccee..637b824 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ A lightweight Docker container that provides network filtering capabilities usin The network-filter uses a combination of: - **iptables**: Drops all outbound traffic by default, then allows only specific IP addresses resolved from allowed domains -- **dnsmasq**: Acts as a local DNS server that only resolves allowed domains -- **Dynamic IP resolution**: Periodically refreshes IP addresses for allowed domains to handle DNS changes +- **dnsmasq**: Acts as a local DNS server that only resolves allowed domains, using ipset groups The filter operates at the network level, meaning any container that shares its network namespace will inherit these restrictions. @@ -47,7 +46,6 @@ services: |---------------------|-------------|---------| | `ALLOWED_DOMAINS` | Comma-separated list of allowed domains with optional port specifications | (none - required) | | `DNS_SERVERS` | Comma-separated list of upstream DNS servers | `8.8.8.8,8.8.4.4` | -| `REFRESH_INTERVAL` | How often to refresh domain IP addresses (seconds) | `300` | | `RUN_SELFTEST` | Run connectivity tests on startup | `false` | ### Domain and port specification @@ -115,7 +113,6 @@ docker run --rm --network "container:net-filter" alpine ping -c 3 google.com ## Limitations - **IPv4 only**: Currently only supports IPv4 addresses. IPv6 traffic is blocked and AAAA DNS records are filtered out -- Requires periodic refresh to handle DNS changes - All containers sharing the network namespace share the same restrictions ## Q&A diff --git a/network-filter.sh b/network-filter.sh index 320d327..ec3213a 100755 --- a/network-filter.sh +++ b/network-filter.sh @@ -1,85 +1,124 @@ #!/bin/bash set -e -set -x + +# Global arrays for domain/port mapping +declare -A DOMAIN_PORTSET # domain -> port set name mapping +declare -A PORTSET_PORTS # port set name -> actual ports mapping +declare -A PORTSET_DOMAINS # port set name -> domains mapping +declare -a ALL_PORTSETS # unique port set names # --- Configuration --- setup_env() { DNS_SERVERS="${DNS_SERVERS:-8.8.8.8,8.8.4.4}" - REFRESH_INTERVAL="${REFRESH_INTERVAL:-300}" RUN_SELFTEST="${RUN_SELFTEST:-false}" + IPSET_TIMEOUT="${IPSET_TIMEOUT:-600}" # 10 minutes default echo "--- Configuration ---" echo "DNS Servers: $DNS_SERVERS" echo "Allowed Domains: $ALLOWED_DOMAINS" - echo "Refresh Interval: $REFRESH_INTERVAL seconds" + echo "IPSet Timeout: $IPSET_TIMEOUT seconds" echo "Run Selftest on start: $RUN_SELFTEST" } +# --- Domain Parsing --- +parse_domains() { + if [[ -z "$ALLOWED_DOMAINS" ]]; then + return + fi + + IFS=',' read -ra DOMAINS <<< "$ALLOWED_DOMAINS" + for domain_spec in "${DOMAINS[@]}"; do + domain_spec=$(echo "$domain_spec" | xargs) + local domain=$(echo "$domain_spec" | cut -d':' -f1) + local ports_part=$(echo "$domain_spec" | cut -d':' -f2-) + + local ports=() + if [[ "$domain_spec" == *":"* ]]; then + IFS=':' read -ra PORT_LIST <<< "$ports_part" + for port in "${PORT_LIST[@]}"; do + if [[ "$port" =~ ^[0-9]+$ ]] && [[ "$port" -ge 1 ]] && [[ "$port" -le 65535 ]]; then + ports+=("$port") + fi + done + else + ports=(80 443) # Default ports + fi + + # Sort ports for consistent naming + IFS=$'\n' sorted_ports=($(sort -n <<<"${ports[*]}")); unset IFS + + # Create port set name (e.g., "80_443" or "22_80") + local portset_name=$(IFS='_'; echo "${sorted_ports[*]}") + + DOMAIN_PORTSET[$domain]="$portset_name" + PORTSET_PORTS[$portset_name]="${sorted_ports[*]}" + + # Track domains using this port set + if [[ -z "${PORTSET_DOMAINS[$portset_name]}" ]]; then + PORTSET_DOMAINS[$portset_name]="$domain" + ALL_PORTSETS+=("$portset_name") + else + PORTSET_DOMAINS[$portset_name]="${PORTSET_DOMAINS[$portset_name]} $domain" + fi + done +} + +# --- ipset Management --- +setup_ipsets() { + # Clean up any existing ipsets + for portset in "${ALL_PORTSETS[@]}"; do + ipset destroy "allowed_${portset}" 2>/dev/null || true + done + + # Create ipset for each port set + for portset in "${ALL_PORTSETS[@]}"; do + ipset create "allowed_${portset}" hash:ip timeout "${IPSET_TIMEOUT}" + echo "Created ipset: allowed_${portset} for ports ${PORTSET_PORTS[$portset]}" + done +} + # --- iptables --- setup_iptables() { + # Clear existing rules iptables -t filter -F OUTPUT iptables -t nat -F iptables -P OUTPUT DROP + # Allow local traffic iptables -A OUTPUT -o lo -j ACCEPT iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT iptables -A OUTPUT -d 127.0.0.0/8 -j ACCEPT iptables -A OUTPUT -d 10.0.0.0/8 -j ACCEPT iptables -A OUTPUT -d 172.16.0.0/12 -j ACCEPT iptables -A OUTPUT -d 192.168.0.0/16 -j ACCEPT + + # Allow DNS iptables -A OUTPUT -p udp --dport 53 -j ACCEPT iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT + # Allow configured DNS servers IFS=',' read -ra DNS_LIST <<< "$DNS_SERVERS" for dns_server in "${DNS_LIST[@]}"; do dns_server=$(echo "$dns_server" | xargs) iptables -A OUTPUT -d "$dns_server" -p udp --dport 53 -j ACCEPT iptables -A OUTPUT -d "$dns_server" -p tcp --dport 53 -j ACCEPT done -} -add_domain_rule() { - local domain_spec=$1 - local domain=$(echo "$domain_spec" | cut -d':' -f1) - local ports_part=$(echo "$domain_spec" | cut -d':' -f2-) - - local ports_to_allow=() - if [[ "$domain_spec" == *":"* ]]; then - IFS=':' read -ra PORT_LIST <<< "$ports_part" - for port in "${PORT_LIST[@]}"; do - if [[ "$port" =~ ^[0-9]+$ ]] && [[ "$port" -ge 1 ]] && [[ "$port" -le 65535 ]]; then - ports_to_allow+=("$port") - fi + # Add ipset-based rules for each port set + for portset in "${ALL_PORTSETS[@]}"; do + IFS=' ' read -ra ports <<< "${PORTSET_PORTS[$portset]}" + for port in "${ports[@]}"; do + iptables -A OUTPUT -p tcp --dport "$port" -m set --match-set "allowed_${portset}" dst -j ACCEPT + echo "Added iptables rule for port $port using ipset allowed_${portset}" done - else - ports_to_allow=(80 443) - fi - - local ipv4_addresses=$(nslookup "$domain" 127.0.0.1 2>/dev/null | awk '/^Address:/ && !/127.0.0.1/ { print $2 }' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$') - - for ip in $ipv4_addresses; do - if [[ -n "$ip" ]]; then - for port in "${ports_to_allow[@]}"; do - iptables -A OUTPUT -d "$ip" -p tcp --dport "$port" -j ACCEPT - done - fi done } -apply_domain_rules() { - if [[ -n "$ALLOWED_DOMAINS" ]]; then - IFS=',' read -ra DOMAINS <<< "$ALLOWED_DOMAINS" - for domain in "${DOMAINS[@]}"; do - domain=$(echo "$domain" | xargs) - add_domain_rule "$domain" - done - fi -} - # --- DNS --- setup_dnsmasq() { PRIMARY_DNS=$(echo "$DNS_SERVERS" | cut -d',' -f1 | xargs) + # Start building dnsmasq config cat > /etc/dnsmasq.conf << EOF listen-address=0.0.0.0 port=53 @@ -89,16 +128,16 @@ no-resolv no-poll log-queries filter-AAAA -min-cache-ttl=$((REFRESH_INTERVAL * 2)) -max-cache-ttl=$((REFRESH_INTERVAL * 2)) -$(if [[ -n "$ALLOWED_DOMAINS" ]]; then - IFS=',' read -ra DOMAINS <<< "$ALLOWED_DOMAINS" - for domain in "${DOMAINS[@]}"; do - domain_name=$(echo "$domain" | cut -d':' -f1 | xargs) - echo "server=/$domain_name/$PRIMARY_DNS" - done -fi) EOF + + # Add server and ipset entries for all domains + for domain in "${!DOMAIN_PORTSET[@]}"; do + echo "server=/${domain}/${PRIMARY_DNS}" >> /etc/dnsmasq.conf + echo "ipset=/${domain}/allowed_${DOMAIN_PORTSET[$domain]}" >> /etc/dnsmasq.conf + done + + echo "--- Generated dnsmasq configuration ---" + cat /etc/dnsmasq.conf } override_dns() { @@ -110,78 +149,125 @@ override_dns() { # --- Self Test --- run_tests() { echo "--- Running Self Test ---" + echo "--- Checking ipsets ---" + for portset in "${ALL_PORTSETS[@]}"; do + echo "IPSet allowed_${portset} (ports: ${PORTSET_PORTS[$portset]}):" + ipset list "allowed_${portset}" | grep -E "^(Name:|Type:|Timeout:|Number of entries:)" + done + echo "--- Testing DNS functionality ---" ss -ln | grep :53 || echo "No process listening on port 53" PRIMARY_DNS=$(echo "$DNS_SERVERS" | cut -d',' -f1 | xargs) - echo "Testing allowed domain (github.com) with dig:" - timeout 10 dig @127.0.0.1 github.com +short || echo "Failed to resolve github.com" - echo "Testing blocked domain (monadical.com) with dig:" - timeout 10 dig @127.0.0.1 monadical.com +short || echo "Successfully blocked monadical.com" + # Test allowed domain + if [[ -n "${!DOMAIN_PORTSET[@]}" ]]; then + test_domain=$(echo "${!DOMAIN_PORTSET[@]}" | cut -d' ' -f1) + portset="${DOMAIN_PORTSET[$test_domain]}" + echo "Testing allowed domain ($test_domain) with dig:" + timeout 10 dig @127.0.0.1 "$test_domain" +short || echo "Failed to resolve $test_domain" - echo "Testing direct upstream DNS ($PRIMARY_DNS):" - timeout 10 dig @"$PRIMARY_DNS" github.com +short || echo "Cannot reach upstream DNS" + # Check if IPs were added to ipsets + sleep 2 # Give dnsmasq time to populate ipsets + echo "Checking ipset allowed_${portset} for $test_domain entries:" + ipset list "allowed_${portset}" | grep -E "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" || echo "No entries found" + fi + + echo "Testing blocked domain (example.org) with dig:" + timeout 10 dig @127.0.0.1 example.org +short || echo "Successfully blocked example.org" echo "--- Self Test Complete ---" } -selftest() { - setup_env - setup_iptables - setup_dnsmasq - override_dns - - dnsmasq --test - - dnsmasq --no-daemon --log-facility=- & - DNSMASQ_PID=$! - sleep 3 - apply_domain_rules - - - run_tests - - kill $DNSMASQ_PID +# --- Monitoring --- +monitor_ipsets() { + while true; do + sleep 60 + echo "--- IPSet Status ---" + for portset in "${ALL_PORTSETS[@]}"; do + count=$(ipset list "allowed_${portset}" | grep -c "^[0-9]" || echo "0") + echo "allowed_${portset} (ports ${PORTSET_PORTS[$portset]}): $count entries" + done + done } # --- Main --- start() { setup_env + parse_domains + + echo "Debug: ALL_PORTSETS array has ${#ALL_PORTSETS[@]} elements" + echo "Debug: ALL_PORTSETS contents: ${ALL_PORTSETS[@]}" + + if [[ ${#ALL_PORTSETS[@]} -eq 0 ]]; then + echo "No allowed domains configured. Exiting." + exit 1 + fi + + setup_ipsets setup_iptables setup_dnsmasq override_dns + # Test dnsmasq config + dnsmasq --test + + # Start dnsmasq dnsmasq --no-daemon --log-facility=- & DNSMASQ_PID=$! sleep 3 - apply_domain_rules - if [[ "$RUN_SELFTEST" == "true" ]]; then run_tests fi echo "Network filter setup complete. Monitoring..." + # Start ipset monitor in background + monitor_ipsets & + MONITOR_PID=$! + + # Main loop - just monitor dnsmasq health while true; do - sleep "$REFRESH_INTERVAL" + sleep 60 + + # Check dnsmasq health if ! kill -0 $DNSMASQ_PID 2>/dev/null; then - dnsmasq --no-daemon & + echo "dnsmasq died, restarting..." + dnsmasq --no-daemon --log-facility=- & DNSMASQ_PID=$! fi + + # Check resolv.conf if [[ "$(cat /etc/resolv.conf | grep -c '127.0.0.1')" -eq 0 ]]; then + echo "resolv.conf was modified, fixing..." override_dns fi - - # Clear dnsmasq cache before refreshing rules - kill -HUP $DNSMASQ_PID 2>/dev/null || true - - setup_iptables - apply_domain_rules done } +# --- Cleanup --- +cleanup() { + echo "Cleaning up..." + + # Kill processes + [[ -n "$DNSMASQ_PID" ]] && kill $DNSMASQ_PID 2>/dev/null || true + [[ -n "$MONITOR_PID" ]] && kill $MONITOR_PID 2>/dev/null || true + + # Clean up ipsets + for portset in "${ALL_PORTSETS[@]}"; do + ipset destroy "allowed_${portset}" 2>/dev/null || true + done + + # Reset iptables + iptables -P OUTPUT ACCEPT + iptables -F OUTPUT + + exit 0 +} + +trap cleanup EXIT INT TERM + # --- Command Dispatcher --- CMD="${1:-start}" case "$CMD" in @@ -189,10 +275,12 @@ case "$CMD" in start ;; selftest) - selftest + RUN_SELFTEST=true + start ;; *) echo "Unknown command: $CMD" + echo "Usage: $0 [start|selftest]" exit 1 ;; esac