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()