From 809c34d6f5691e5a588fb674796d858aea0561d0 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 30 Jul 2025 12:21:00 -0600 Subject: [PATCH] feat: first version --- .pre-commit-config.yaml | 8 ++ Dockerfile | 15 ++++ LICENSE | 9 ++ README.md | 125 ++++++++++++++++++++++++++ docker-compose.yml | 17 ++++ network-filter.sh | 192 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 366 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100755 network-filter.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bbad051 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: debug-statements + - id: trailing-whitespace + - id: detect-private-key + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd585e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:latest + +RUN apk add --no-cache \ + iptables \ + dnsmasq \ + bind-tools \ + curl \ + bash \ + iproute2 + +COPY network-filter.sh /network-filter.sh + +EXPOSE 53/udp 53/tcp + +CMD ["/network-filter.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b5ab5c --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 Monadical SAS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c991b3 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# network-filter + +A lightweight Docker container that provides network filtering capabilities using iptables and dnsmasq. It restricts outbound network access to an allowlist of domains, making it useful for creating secure network environments where containers should only communicate with specific external services. + +## How it works + +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 + +The filter operates at the network level, meaning any container that shares its network namespace will inherit these restrictions. + +## Usage + +### Basic usage + +```bash +docker run --cap-add NET_ADMIN \ + -e ALLOWED_DOMAINS="github.com,api.github.com" \ + network-filter +``` + +### Docker Compose + +To restrict a container's network access, use Docker's `network_mode` to share the network-filter's network namespace. + +This ensures the container can only access domains specified in `ALLOWED_DOMAINS`. + +```yaml +services: + network-filter: + build: . + cap_add: + - NET_ADMIN + environment: + - ALLOWED_DOMAINS=github.com,api.github.com + + my-app: + image: my-app:latest + network_mode: "service:network-filter" +``` + +## Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `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 + +You can specify which ports are allowed for each domain: + +- `domain.com` - allows ports 80 and 443 (default) +- `domain.com:443` - allows only port 443 +- `domain.com:22:80:443` - allows ports 22, 80, and 443 + +Examples: +```bash +# Default ports (80, 443) +ALLOWED_DOMAINS=github.com,api.github.com + +# HTTPS only for github.com +ALLOWED_DOMAINS=github.com:443,api.github.com + +# Multiple ports including SSH +ALLOWED_DOMAINS=github.com:22:443,api.github.com +``` + +## Network rules + +The filter allows: +- All loopback traffic +- Established connections +- Local network ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) +- DNS queries to configured DNS servers +- TCP connections to resolved IPs of allowed domains on specified ports + +All other outbound traffic is dropped. + +## Testing + +Enable self-test on startup to verify the configuration: + +```bash +docker run --cap-add NET_ADMIN \ + -e ALLOWED_DOMAINS="github.com" \ + -e RUN_SELFTEST=true \ + network-filter +``` + +Or run the self-test manually: +```bash +docker exec /network-filter.sh selftest +``` + +### Example: Testing with ping + +Test that allowed domains work while others are blocked: + +```bash +# Start the network filter container +docker run -d --name net-filter --cap-add NET_ADMIN -e ALLOWED_DOMAINS="github.com" network-filter + +# This will work - github.com is in the allowed list +docker run --rm --network "container:net-filter" alpine ping -c 3 github.com + +# This will fail - google.com is not in the allowed list +docker run --rm --network "container:net-filter" alpine ping -c 3 google.com +``` + +## Limitations + +- Only supports IPv4 addresses +- Requires periodic refresh to handle DNS changes +- All containers sharing the network namespace share the same restrictions + +## Q&A + +### Why is NET_ADMIN capability required? + +The `NET_ADMIN` capability is required to configure iptables rules within the container. This capability only affects the container's network namespace and does not grant any privileges to modify the host's network configuration. The container's iptables rules are isolated and cannot impact the host system's networking. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f2850f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + network-filter: + build: . + cap_add: + - NET_ADMIN + environment: + - ALLOWED_DOMAINS=github.com,api.github.com,httpbin.org,raw.githubusercontent.com + # - DNS_SERVERS=8.8.8.8,8.8.4.4 + # - REFRESH_INTERVAL=600 # Optional: Refresh interval in seconds (default: 300) + # - RUN_SELFTEST=true # Optional: Run a self-test on startup (default: false) + command: ["/network-filter.sh"] + networks: + - public + +networks: + public: + driver: bridge diff --git a/network-filter.sh b/network-filter.sh new file mode 100755 index 0000000..f142f27 --- /dev/null +++ b/network-filter.sh @@ -0,0 +1,192 @@ +#!/bin/bash +set -e + +# --- 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}" + + echo "--- Configuration ---" + echo "DNS Servers: $DNS_SERVERS" + echo "Allowed Domains: $ALLOWED_DOMAINS" + echo "Refresh Interval: $REFRESH_INTERVAL seconds" + echo "Run Selftest on start: $RUN_SELFTEST" +} + +# --- iptables --- +setup_iptables() { + iptables -t filter -F OUTPUT + iptables -t nat -F + iptables -P OUTPUT DROP + + 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 + iptables -A OUTPUT -p udp --dport 53 -j ACCEPT + iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT + + 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 + done + else + ports_to_allow=(80 443) + fi + + PRIMARY_DNS=$(echo "$DNS_SERVERS" | cut -d',' -f1 | xargs) + local ipv4_addresses=$(nslookup "$domain" "$PRIMARY_DNS" 2>/dev/null | awk '/^Address:/ && !/'$PRIMARY_DNS'/ { 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) + + cat > /etc/dnsmasq.conf << EOF +listen-address=0.0.0.0 +port=53 +bind-interfaces +no-hosts +no-resolv +no-poll +log-queries +$(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 +} + +override_dns() { + pkill -f "127.0.0.11" 2>/dev/null || true + echo "nameserver 127.0.0.1" > /etc/resolv.conf + echo "options timeout:1 attempts:1" >> /etc/resolv.conf +} + +# --- Self Test --- +run_tests() { + echo "--- Running Self Test ---" + 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" + + echo "Testing direct upstream DNS ($PRIMARY_DNS):" + timeout 10 dig @"$PRIMARY_DNS" github.com +short || echo "Cannot reach upstream DNS" + + echo "--- Self Test Complete ---" +} + +selftest() { + setup_env + setup_iptables + apply_domain_rules + setup_dnsmasq + + dnsmasq --test + + dnsmasq --no-daemon --log-facility=- & + DNSMASQ_PID=$! + sleep 3 + + override_dns + + run_tests + + kill $DNSMASQ_PID +} + +# --- Main --- +start() { + setup_env + setup_iptables + apply_domain_rules + setup_dnsmasq + + dnsmasq --no-daemon --log-facility=- & + DNSMASQ_PID=$! + sleep 3 + + override_dns + + if [[ "$RUN_SELFTEST" == "true" ]]; then + run_tests + fi + + echo "Network filter setup complete. Monitoring..." + + while true; do + sleep "$REFRESH_INTERVAL" + if ! kill -0 $DNSMASQ_PID 2>/dev/null; then + dnsmasq --no-daemon & + DNSMASQ_PID=$! + fi + if [[ "$(cat /etc/resolv.conf | grep -c '127.0.0.1')" -eq 0 ]]; then + override_dns + fi + + setup_iptables + apply_domain_rules + done +} + +# --- Command Dispatcher --- +CMD="${1:-start}" +case "$CMD" in + start) + start + ;; + selftest) + selftest + ;; + *) + echo "Unknown command: $CMD" + exit 1 + ;; +esac