initial commit

This commit is contained in:
Marco Realacci 2025-01-28 15:12:38 +01:00
parent a23a5ac947
commit 8265a12b72
2 changed files with 358 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.idea
.venv

356
main.py Normal file
View file

@ -0,0 +1,356 @@
import os
import json
import logging
import signal
import subprocess
import ipaddress
from docker import from_env
from docker.errors import DockerException
def parse_daemon_json():
"""Parse Docker's daemon.json file to extract IPv4 and IPv6 subnets."""
config_path = "/etc/docker/daemon.json"
subnets = []
if os.path.exists(config_path):
with open(config_path, "r") as f:
data = json.load(f)
pools = data.get("default-address-pools", [])
for pool in pools:
subnets.append(pool["base"])
return subnets
def run_iptables_command(command):
"""Run an iptables or ip6tables command."""
logging.info(f"Running command: {command}")
try:
subprocess.run(command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
logging.error(f"Error running command: {command}\n{e.stderr.decode()}")
def add_rule_if_not_exists(insert_command):
"""Check if the rule exists using iptables -C, and add it if not."""
check_command = insert_command.replace(" -I ", " -C ", 1).replace(" -A ", " -C ", 1)
try:
subprocess.run(check_command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
logging.debug(f"Rule already exists: {check_command}")
except subprocess.CalledProcessError:
run_iptables_command(insert_command)
def setup_default_rule(subnets, ip_version="ipv4"):
"""Ensure stateful default rules exist in DOCKER-USER chain."""
# Remove default RETURN statement
run_iptables_command("iptables -D DOCKER-USER -j RETURN")
for subnet in subnets:
# IPv4 Rules
if ip_version == "ipv4" and ":" not in subnet:
# Allow established connections
cmd = (f"iptables -C DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT || "
f"iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT")
run_iptables_command(cmd)
# Block new incoming TCP
cmd = (f"iptables -C DOCKER-USER ! -s {subnet} -d {subnet} -p tcp -m conntrack --ctstate NEW "
f"-j REJECT --reject-with icmp-port-unreachable || "
f"iptables -A DOCKER-USER ! -s {subnet} -d {subnet} -p tcp -m conntrack --ctstate NEW "
f"-j REJECT --reject-with icmp-port-unreachable")
run_iptables_command(cmd)
# Block all UDP
cmd = (f"iptables -C DOCKER-USER ! -s {subnet} -d {subnet} -p udp -j DROP || "
f"iptables -A DOCKER-USER ! -s {subnet} -d {subnet} -p udp -j DROP")
run_iptables_command(cmd)
# IPv6 Rules
elif ip_version == "ipv6" and ":" in subnet:
# Allow established connections
cmd = (f"ip6tables -C DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT || "
f"ip6tables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT")
run_iptables_command(cmd)
# Block new incoming TCP
cmd = (f"ip6tables -C DOCKER-USER ! -s {subnet} -d {subnet} -p tcp -m conntrack --ctstate NEW "
f"-j REJECT --reject-with icmp6-port-unreachable || "
f"ip6tables -A DOCKER-USER ! -s {subnet} -d {subnet} -p tcp -m conntrack --ctstate NEW "
f"-j REJECT --reject-with icmp6-port-unreachable")
run_iptables_command(cmd)
# Block all UDP
cmd = (f"ip6tables -C DOCKER-USER ! -s {subnet} -d {subnet} -p udp -j DROP || "
f"ip6tables -A DOCKER-USER ! -s {subnet} -d {subnet} -p udp -j DROP")
run_iptables_command(cmd)
def manage_firewall_rules(container, subnets, ip_version="ipv4"):
"""Add or remove rules based on container labels."""
container_id = container.id[:12]
labels = container.labels
allow_icc = labels.get("magicfw.firewall.allow_icc", "false").lower() == "true"
allow_external = labels.get("magicfw.firewall.allow_external", "false").lower() == "true"
if allow_icc:
logging.info(f"Allowing ICC traffic for container {container_id}")
if allow_external:
logging.info(f"Allowing external traffic for container {container_id}")
ips = []
network_settings = container.attrs.get("NetworkSettings", {}).get("Networks", {})
for network_name, network in network_settings.items():
ip_address = network.get("IPAddress") if ip_version == "ipv4" else network.get("GlobalIPv6Address")
if ip_address:
ips.append(ip_address)
logging.debug(f"Container {container_id} has IPs: {ips}")
published_ports = container.attrs.get('NetworkSettings', {}).get('Ports', {})
for ip in ips:
logging.debug(f"Managing firewall rules for container {container_id} with IP {ip}")
if allow_icc:
for subnet in subnets:
logging.debug(f"Adding rule to allow ICC traffic from {ip} to {subnet}")
if ip_version == "ipv6" and ":" in subnet:
insert_cmd = f"ip6tables -I DOCKER-USER 2 -s {ip} -d {subnet} -j ACCEPT -m comment --comment \"{container_id}:allow_icc\""
add_rule_if_not_exists(insert_cmd)
insert_cmd = f"ip6tables -I DOCKER-USER 2 -s {subnet} -d {ip} -j ACCEPT -m comment --comment \"{container_id}:allow_icc\""
add_rule_if_not_exists(insert_cmd)
elif ip_version == "ipv4" and ":" not in subnet:
insert_cmd = f"iptables -I DOCKER-USER 2 -s {ip} -d {subnet} -j ACCEPT -m comment --comment \"{container_id}:allow_icc\""
add_rule_if_not_exists(insert_cmd)
insert_cmd = f"iptables -I DOCKER-USER 2 -s {subnet} -d {ip} -j ACCEPT -m comment --comment \"{container_id}:allow_icc\""
add_rule_if_not_exists(insert_cmd)
if allow_external:
if ip_version == "ipv6" and ":" in ip:
insert_cmd = f"ip6tables -I DOCKER-USER 2 -d {ip} -j ACCEPT -m comment --comment \"{container_id}:allow_external\""
add_rule_if_not_exists(insert_cmd)
elif ip_version == "ipv4" and ":" not in ip:
insert_cmd = f"iptables -I DOCKER-USER 2 -d {ip} -j ACCEPT -m comment --comment \"{container_id}:allow_external\""
add_rule_if_not_exists(insert_cmd)
# Handle exposed ports
if published_ports:
logging.info(f"Processing published ports for {container_id}: {published_ports}")
table = "ip6tables" if ip_version == "ipv6" else "iptables"
# Delete existing rules for this container's IP and ports
cmd = (
f"{table} -S DOCKER-USER | "
f"grep -E ' --comment \"{container_id}:(published|exposed):' | " # Clean old+new comments
f"grep ' -d {ip}/' | "
"sed 's/^-A/-D/' | xargs -r -L1 echo"
)
try:
result = subprocess.run(cmd, shell=True, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
for delete_cmd in result.stdout.decode().splitlines():
if delete_cmd:
run_iptables_command(f"{table} {delete_cmd}")
except subprocess.CalledProcessError:
pass # No rules to delete
# Add new rules for each published port
for port_proto, bindings in published_ports.items():
if not bindings: # Skip ports that are exposed but not published
continue
proto = port_proto.split('/')[1]
port = port_proto.split('/')[0]
insert_cmd = (
f"{table} -I DOCKER-USER 2 "
f"-d {ip} -p {proto} --dport {port} -j ACCEPT "
f"-m comment --comment \"{container_id}:published:{port_proto}\""
)
add_rule_if_not_exists(insert_cmd)
def clean_docker_nat_rules(docker_subnets, ip_version="ipv4"):
"""Remove Docker's MASQUERADE rules targeting container subnets."""
table = "ip6tables" if ip_version == "ipv6" else "iptables"
chain = "POSTROUTING"
version = 4 if ip_version == "ipv4" else 6
try:
docker_networks = []
for subnet in docker_subnets:
try:
network = ipaddress.ip_network(subnet, strict=False)
if network.version == version:
docker_networks.append(network)
except ValueError:
continue
result = subprocess.run(
f"{table} -t nat -S {chain}",
shell=True,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
rules = result.stdout.decode().splitlines()
for rule in rules:
if " -j MASQUERADE" not in rule:
continue
parts = rule.split()
src_net = None
dst_net = None
try:
if "-s" in parts:
s_idx = parts.index("-s") + 1
src_net = ipaddress.ip_network(parts[s_idx].split("/")[0], strict=False)
if "-d" in parts:
d_idx = parts.index("-d") + 1
dst_net = ipaddress.ip_network(parts[d_idx].split("/")[0], strict=False)
except (ValueError, IndexError):
continue
match = False
for docker_net in docker_networks:
if src_net and src_net.subnet_of(docker_net):
match = True
break
if dst_net and dst_net.subnet_of(docker_net):
match = True
break
if match:
del_rule = rule.replace("-A", "-D")
run_iptables_command(f"{table} -t nat {del_rule}")
except subprocess.CalledProcessError as e:
logging.error(f"Error cleaning NAT rules: {e.stderr.decode()}")
def clean_up_rules(docker_client, ip_version="ipv4", specific_container_id=None):
"""Remove stale rules from iptables including published port rules."""
table = "ip6tables" if ip_version == "ipv6" else "iptables"
result = subprocess.run(f"{table} -S DOCKER-USER", shell=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
rules = result.stdout.decode().splitlines()
for rule in rules:
if "--comment" in rule and any(x in rule for x in
[":allow_icc", ":allow_external", ":exposed:", ":published:"]):
parts = rule.split("--comment \"")
if len(parts) < 2:
continue
comment_part = parts[1]
rule_container_id = comment_part.split(":")[0]
if specific_container_id and rule_container_id != specific_container_id[:12]:
continue
try: # Check if container still exists
docker_client.containers.get(rule_container_id)
except DockerException:
logging.info(f"Removing stale rule for {rule_container_id}")
delete_rule = rule.replace("-A", "-D")
run_iptables_command(f"{table} {delete_rule}")
def handle_event(event, docker_client, subnets, enable_ipv4, enable_ipv6):
"""Handle Docker events and update firewall rules accordingly."""
try:
if event['Type'] == 'container':
container_id = event.get('Actor', {}).get('ID')
action = event.get('Action')
try:
container = docker_client.containers.get(container_id) if container_id else None
except DockerException:
container = None
if action in ['start', 'restart']:
logging.info(f"Container {container_id[:12]} {action}, updating rules")
if enable_ipv4 and container:
manage_firewall_rules(container, subnets, "ipv4")
if enable_ipv6 and container:
manage_firewall_rules(container, subnets, "ipv6")
elif action in ['die', 'destroy']:
logging.info(f"Container {container_id[:12]} {action}, cleaning rules")
if enable_ipv4:
clean_up_rules(docker_client, "ipv4", container_id)
if enable_ipv6:
clean_up_rules(docker_client, "ipv6", container_id)
elif event['Type'] == 'network':
container_id = event.get('Actor', {}).get('Attributes', {}).get('container')
action = event.get('Action')
if action in ['connect', 'disconnect'] and container_id:
logging.info(f"Network {action} for container {container_id[:12]}")
try:
container = docker_client.containers.get(container_id)
if enable_ipv4:
manage_firewall_rules(container, subnets, "ipv4")
if enable_ipv6:
manage_firewall_rules(container, subnets, "ipv6")
except DockerException:
pass
if action in ['create', 'destroy']:
logging.info(f"Network {action}, cleaning NAT rules")
if enable_ipv4:
clean_docker_nat_rules(subnets, "ipv4")
if enable_ipv6:
clean_docker_nat_rules(subnets, "ipv6")
except Exception as e:
logging.error(f"Error handling event: {str(e)}")
def main_loop():
"""Main event loop that runs indefinitely."""
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s - %(levelname)s - %(message)s")
enable_ipv4 = os.getenv("ENABLE_IPV4", "true").lower() == "true"
enable_ipv6 = os.getenv("ENABLE_IPV6", "true").lower() == "true"
disable_nat = os.getenv("DISABLE_NAT", "true").lower() == "true"
subnets = parse_daemon_json()
docker_client = from_env()
if disable_nat:
if enable_ipv4:
clean_docker_nat_rules(subnets, "ipv4")
if enable_ipv6:
clean_docker_nat_rules(subnets, "ipv6")
if enable_ipv4:
setup_default_rule(subnets, "ipv4")
for container in docker_client.containers.list():
manage_firewall_rules(container, subnets, "ipv4")
clean_up_rules(docker_client, "ipv4")
if enable_ipv6:
# setup_default_rule(subnets, "ipv6")
for container in docker_client.containers.list():
manage_firewall_rules(container, subnets, "ipv6")
clean_up_rules(docker_client, "ipv6")
event_filter = {"type": ["container", "network"]}
event_generator = docker_client.events(decode=True, filters=event_filter)
def shutdown(signum, frame):
logging.info("Shutting down...")
exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
logging.info("Listening for Docker events...")
while True:
try:
for event in event_generator:
handle_event(event, docker_client, subnets, enable_ipv4, enable_ipv6)
except DockerException as e:
logging.error(f"Docker connection error: {str(e)}. Reconnecting...")
docker_client = from_env()
event_generator = docker_client.events(decode=True, filters=event_filter)
if __name__ == "__main__":
main_loop()