feat: first version

This commit is contained in:
2025-07-30 12:21:00 -06:00
commit 809c34d6f5
6 changed files with 366 additions and 0 deletions

8
.pre-commit-config.yaml Normal file
View File

@@ -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

15
Dockerfile Normal file
View File

@@ -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"]

9
LICENSE Normal file
View File

@@ -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.

125
README.md Normal file
View File

@@ -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 <container-id> /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.

17
docker-compose.yml Normal file
View File

@@ -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

192
network-filter.sh Executable file
View File

@@ -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