@@ -434,6 +434,97 @@ async def _create_container(
434434 stack .__exit__ (* sys .exc_info ())
435435 raise
436436
437+ async def _install_firewall_stub (self , container : docker .models .containers .Container ) -> None :
438+ """
439+ On every container installation, we add an initially empty chain (named CTR-<cid>).
440+ Then, we add a jump that only sends packets on the forward chain from this specific container to that currently empty chain.
441+ This allows us to implement container specific internet blocking rules (if desired) without impacting parallel/future containers.
442+ Both are tagged with --comment "alcatraz_block" so we can find/remove them later.
443+ Important note: This does NOT change the internet perms of the container, it only allows us to create unique ones later.
444+ """
445+ # Dont forget to update _remove_firewall_stub when modifying this!!
446+ container .reload ()
447+ cid = container .id [:12 ]
448+ sandbox_key = container .attrs ["NetworkSettings" ].get ("SandboxKey" , "" )
449+ veth = Path (sandbox_key ).name
450+ ctr_ip = next (iter (container .attrs ["NetworkSettings" ]["Networks" ].values ()))["IPAddress" ] # e.g. 172.18.0.2
451+ net_info = next (iter (container .attrs ["NetworkSettings" ]["Networks" ].values ()))
452+ bridge_id = net_info ["NetworkID" ][:12 ]
453+ bridge_if = f"br-{ bridge_id } "
454+
455+ # Make a unique chain for the container -- atm silences all errors (such that if chain already exists it gets cleared), but TODO: separate errors
456+ await async_subprocess_run (
457+ ["bash" , "-c" , f"iptables -N CTR-{ cid } 2>/dev/null || true" ]
458+ )
459+ await async_subprocess_run (["sudo" , "iptables" , "-F" , f"CTR-{ cid } " ])
460+
461+ # Insert jump from forward to this new container specific chain
462+ await async_subprocess_run (
463+ [
464+ "bash" ,
465+ "-c" ,
466+ (
467+ f"iptables -C DOCKER-USER -i { bridge_if } -s { ctr_ip } -j CTR-{ cid } "
468+ f"-m comment --comment alcatraz_block 2>/dev/null || "
469+ f"iptables -I DOCKER-USER 1 -i { bridge_if } -s { ctr_ip } -j CTR-{ cid } "
470+ f"-m comment --comment alcatraz_block"
471+ ),
472+ ]
473+ )
474+ logger .info ("Added jump for veth %s --> CTR-%s" , veth , cid )
475+ # remove container specific jumps for host cleanliness
476+ self ._exit_stack .callback (
477+ lambda cid = cid , veth = veth , bridge_if = bridge_if , ctr_ip = ctr_ip : asyncio .create_task (self ._remove_firewall_stub (cid , veth , bridge_if , ctr_ip ))
478+ )
479+
480+ async def _remove_firewall_stub (self , cid : str , veth : str , bridge_if : str , ctr_ip : str ) -> None :
481+ """
482+ Reverse the work of _install_firewall_stub **and** _ensure_input_block.
483+ If either of those change without mirrored updates here, proper cleanup can't happen.
484+ This function deletes the FORWARD jump and deletes the container specific chain (both done by _install_firewall_stub)
485+ It also removes the INPUT rules done by _ensure_input_block.
486+ """
487+ # deletes the FORWARD jump
488+
489+ await async_subprocess_run (
490+ [
491+ "bash" ,
492+ "-c" ,
493+ (
494+ f"iptables -D DOCKER-USER -i { bridge_if } -s { ctr_ip } -j CTR-{ cid } "
495+ f"-m comment --comment alcatraz_block 2>/dev/null || true"
496+ ),
497+ ]
498+ )
499+
500+ # clears and deletes the container specific chain
501+ await async_subprocess_run (["bash" , "-c" , f"iptables -F CTR-{ cid } 2>/dev/null || true" ])
502+ await async_subprocess_run (["bash" , "-c" , f"iptables -X CTR-{ cid } 2>/dev/null || true" ])
503+
504+ # deletes the container specific INPUT rules (order independent but exact rule dependent -- must be exactly what is added in _ensure_input_block)
505+ await async_subprocess_run (
506+ [
507+ "bash" ,
508+ "-c" ,
509+ (
510+ f"iptables -D INPUT -i { veth } -m conntrack --ctstate RELATED,ESTABLISHED "
511+ f"-j ACCEPT -m comment --comment alcatraz_block 2>/dev/null || true"
512+ ),
513+ ]
514+ )
515+ await async_subprocess_run (
516+ [
517+ "bash" ,
518+ "-c" ,
519+ (
520+ f"iptables -D INPUT -i { veth } -j REJECT "
521+ f"-m comment --comment alcatraz_block 2>/dev/null || true"
522+ ),
523+ ]
524+ )
525+
526+ logger .info ("Removed firewall stub and INPUT rules for veth %s → CTR-%s" , veth , cid )
527+
437528 async def _pull_image (self , i : str ) -> None :
438529 # Ideally, we would only retry if not docker.errors.NotFound, to crash quickly when the image is misspelled.
439530 # However, since this code is also used by all workers in the cluster, we can overload ACR. This often
@@ -596,6 +687,9 @@ async def _start(self) -> None:
596687 shm_size = self .shm_size if i == 0 else None ,
597688 mem_limit = self .mem_limit if i == 0 else None ,
598689 )
690+
691+ await self ._install_firewall_stub (ctr ) # this doesn't yet restrict container access, just allows future places to add container specific restrictions
692+
599693 self .containers .append (ctr )
600694
601695 if self .docker_compose_yaml :
@@ -980,6 +1074,105 @@ async def get_container_net_config(self) -> ContainerNetConfig:
9801074 "vm_private_ip" : vm_private_ip ,
9811075 }
9821076
1077+ async def add_container_network_block_via_ip_tables (self , cid_prefix : str ) -> None :
1078+ """
1079+ Instead of the universal blocks added by add_weak_network_block_via_ip_tables, this function distinguishes between containers.
1080+ Specifically, we add on startup a jump chain from DOCKER-USER to a unqiue container ID chain.
1081+ Then, we add the same rules added to DOCKER-USER in the below add_weak_network_block_via_ip_tables, but to this specific chain.
1082+ This way internet blocking rules apply only to this container, and not parallel/future containers.
1083+ The same logic applies for INPUT-chain rules (instead of jumping to the container specific chain we add container specific rules directly to the INPUT-chain)
1084+ """
1085+ # Find the specific container to apply the rules to its chain, and then apply them
1086+ try :
1087+ container = next (c for c in self .containers if c .id .startswith (cid_prefix ))
1088+ except StopIteration :
1089+ raise RuntimeError (f"No container with id prefix { cid_prefix } in this cluster" )
1090+
1091+ await self ._populate_ctr_chain (container )
1092+
1093+ await self ._ensure_input_block (container )
1094+
1095+ try :
1096+ await self ._check_shell_command (
1097+ "python -c \" import urllib.request; urllib.request.urlopen('http://example.com', timeout=5)\" "
1098+ )
1099+ except Exception :
1100+ pass
1101+ else :
1102+ assert False , "Setting up network block with IP tables failed"
1103+
1104+ async def _populate_ctr_chain (self , container : docker .models .containers .Container ) -> None :
1105+ """
1106+ Add the three rules to the container specific chain.
1107+ """
1108+ # Note: even if changed, _remove_firewall_stub does not necessarily need to be updated.
1109+ # The function _remove_firewall_stub currently just clears and deletes this chain entirely, so modifying what happens inside of the chain is okay.
1110+ # If something major is changed, like adding a jump to a new chain, make sure things are properly cleaned up by _remove_firewall_stub.
1111+ await asyncio .to_thread (container .reload )
1112+
1113+ cid = container .id [:12 ]
1114+ attrs = container .attrs
1115+
1116+ ctr_ip = next (iter (container .attrs ["NetworkSettings" ]["Networks" ].values ()))["IPAddress" ] # e.g. 172.18.0.2
1117+ net_config = await self .get_container_net_config ()
1118+ subnet = net_config ["subnet" ]
1119+
1120+ # Allow already established connections into the container
1121+ await async_subprocess_run (
1122+ ["bash" , "-c" ,
1123+ f"iptables -C CTR-{ cid } -m conntrack --ctstate RELATED,ESTABLISHED "
1124+ f"-j ACCEPT -m comment --comment alcatraz_block 2>/dev/null || "
1125+ f"iptables -A CTR-{ cid } -m conntrack --ctstate RELATED,ESTABLISHED "
1126+ f"-j ACCEPT -m comment --comment alcatraz_block" ]
1127+ )
1128+
1129+ # Allow container to communicate within the Docker network (ctr -> ctr2)
1130+ await async_subprocess_run (
1131+ ["bash" , "-c" ,
1132+ f"iptables -C CTR-{ cid } -s { subnet } -d { subnet } -j ACCEPT "
1133+ f"-m comment --comment alcatraz_block 2>/dev/null || "
1134+ f"iptables -A CTR-{ cid } -s { subnet } -d { subnet } -j ACCEPT "
1135+ f"-m comment --comment alcatraz_block" ]
1136+ )
1137+
1138+ # Reject all other outgoing connections from the container to the world
1139+ await async_subprocess_run (
1140+ ["bash" , "-c" ,
1141+ f"iptables -C CTR-{ cid } -s { ctr_ip } -j REJECT "
1142+ f"-m comment --comment alcatraz_block 2>/dev/null || "
1143+ f"iptables -A CTR-{ cid } -s { ctr_ip } -j REJECT "
1144+ f"-m comment --comment alcatraz_block" ]
1145+ )
1146+
1147+ async def _ensure_input_block (self , container : docker .models .containers .Container ) -> None :
1148+ """
1149+ This adds two rules. One allows preexisting connections (ctr --> host okay if host initiated them). This is container specific -- we match by the veth as usual.
1150+ The second rule is also a container specific rule (matching the veth) which prevents the container from accessing the host.
1151+ """
1152+ # Important note: dont forget to update _remove_firewall_stub when modifying this!!
1153+ # These rules need to be cleaned up on container shutdown, which is handled by _remove_firewall_stub
1154+ # If the added rules are modified, the cleanup needs to be edited too.
1155+ cid = container .id [:12 ]
1156+ veth = Path (container .attrs ["NetworkSettings" ]["SandboxKey" ]).name
1157+
1158+ # Allow preexisting connections (so ctr -> host comms are allowed if the host initiated them). This is also separated by container.
1159+ await async_subprocess_run (
1160+ ["bash" , "-c" ,
1161+ f"iptables -C INPUT -i { veth } -m conntrack --ctstate RELATED,ESTABLISHED "
1162+ f"-m comment --comment alcatraz_block -j ACCEPT 2>/dev/null || "
1163+ f"iptables -I INPUT 1 -i { veth } -m conntrack --ctstate RELATED,ESTABLISHED "
1164+ f"-m comment --comment alcatraz_block -j ACCEPT" ]
1165+ )
1166+
1167+ # Prevent container from accessing host. (INPUT = host input)
1168+ await async_subprocess_run (
1169+ ["bash" , "-c" ,
1170+ f"iptables -C INPUT -i { veth } -j REJECT "
1171+ f"-m comment --comment alcatraz_block 2>/dev/null || "
1172+ f"iptables -I INPUT 2 -i { veth } -j REJECT "
1173+ f"-m comment --comment alcatraz_block" ]
1174+ )
1175+
9831176 async def add_weak_network_block_via_ip_tables (self ) -> None :
9841177 """
9851178 Blocking internet access with IP tables isn't super secure tbh. Model can escape container to change the VM level IP table.
0 commit comments