mirror of
https://github.com/sunblaze-ucb/cybergym.git
synced 2026-10-12 08:42:24 +00:00
feat: add firewall through proxy
This commit is contained in:
3
src/cybergym/firewall/__init__.py
Normal file
3
src/cybergym/firewall/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from cybergym.firewall.proxy import DEFAULT_ALLOWLIST_PATH, ProxyManager, load_allowlist
|
||||||
|
|
||||||
|
__all__ = ["DEFAULT_ALLOWLIST_PATH", "ProxyManager", "load_allowlist"]
|
||||||
6
src/cybergym/firewall/__main__.py
Normal file
6
src/cybergym/firewall/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""CLI entry point: ``python -m cybergym.firewall``."""
|
||||||
|
|
||||||
|
from cybergym.firewall.proxy import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
23
src/cybergym/firewall/default_allowlist.txt
Normal file
23
src/cybergym/firewall/default_allowlist.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Default domain allowlist for the CyberGym proxy.
|
||||||
|
# Uses Squid dstdomain format: leading dot matches the domain and all subdomains.
|
||||||
|
# Lines starting with # are comments.
|
||||||
|
|
||||||
|
# apt install
|
||||||
|
archive.ubuntu.com
|
||||||
|
security.ubuntu.com
|
||||||
|
ppa.launchpadcontent.net
|
||||||
|
api.launchpad.net
|
||||||
|
launchpad.net
|
||||||
|
old-releases.ubuntu.com
|
||||||
|
|
||||||
|
# pip install
|
||||||
|
pypi.org
|
||||||
|
files.pythonhosted.org
|
||||||
|
pypi.python.org
|
||||||
|
bootstrap.pypa.io
|
||||||
|
|
||||||
|
# llm apis
|
||||||
|
api.openai.com
|
||||||
|
api.anthropic.com
|
||||||
|
generativelanguage.googleapis.com
|
||||||
|
api.together.xyz
|
||||||
450
src/cybergym/firewall/proxy.py
Normal file
450
src/cybergym/firewall/proxy.py
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
"""
|
||||||
|
Domain-allowlist proxy for agent containers.
|
||||||
|
|
||||||
|
Runs a Squid forward proxy that bridges between an **internal** Docker network
|
||||||
|
(no internet route) and the default bridge (internet). Agent containers sit
|
||||||
|
on the internal network and can *only* reach the proxy — even if a program
|
||||||
|
ignores HTTP_PROXY, direct connections fail because there is no route out.
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
------------
|
||||||
|
Agent ──(cybergym-internal, no internet)──▶ Squid ──(bridge)──▶ Internet
|
||||||
|
filtered by domain
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
from cybergym.firewall import ProxyManager, load_allowlist
|
||||||
|
|
||||||
|
proxy = ProxyManager(
|
||||||
|
allowed_domains=load_allowlist("allowlist.txt"),
|
||||||
|
)
|
||||||
|
proxy.start()
|
||||||
|
|
||||||
|
# The host is reachable at proxy.host_gateway (internal bridge gateway).
|
||||||
|
# This IP is auto-added to NO_PROXY so containers bypass the proxy for it.
|
||||||
|
server_url = f"http://{proxy.host_gateway}:13338"
|
||||||
|
|
||||||
|
# Create agent container on the internal network:
|
||||||
|
container = client.containers.run(
|
||||||
|
image=..., detach=True,
|
||||||
|
network=proxy.network_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge proxy env vars into docker exec:
|
||||||
|
envs.update(proxy.env_vars())
|
||||||
|
|
||||||
|
# Cleanup (optional — proxy is shared across runs):
|
||||||
|
proxy.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docker.errors import APIError, NotFound
|
||||||
|
|
||||||
|
import docker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROXY_CONTAINER_NAME = "cybergym-proxy"
|
||||||
|
PROXY_IMAGE = "ubuntu/squid:latest"
|
||||||
|
PROXY_PORT = 3128
|
||||||
|
INTERNAL_NETWORK = "cybergym-internal"
|
||||||
|
|
||||||
|
DEFAULT_ALLOWLIST_PATH = Path(__file__).with_name("default_allowlist.txt")
|
||||||
|
|
||||||
|
ALLOWLIST_CONTAINER_PATH = "/etc/squid/allowed_domains.txt"
|
||||||
|
|
||||||
|
SQUID_CONF_TEMPLATE = """\
|
||||||
|
# --- CyberGym domain-allowlist proxy ---
|
||||||
|
|
||||||
|
acl SSL_ports port 443
|
||||||
|
acl Safe_ports port 80 443 {extra_ports}
|
||||||
|
acl CONNECT method CONNECT
|
||||||
|
|
||||||
|
# Allowed destinations — loaded from external file
|
||||||
|
acl allowed_domains dstdomain "{allowlist_path}"
|
||||||
|
{ip_acls}
|
||||||
|
|
||||||
|
# Rules
|
||||||
|
http_access deny !Safe_ports
|
||||||
|
http_access deny CONNECT !SSL_ports
|
||||||
|
http_access allow CONNECT allowed_domains
|
||||||
|
http_access allow allowed_domains
|
||||||
|
{ip_rules}
|
||||||
|
http_access deny all
|
||||||
|
|
||||||
|
http_port {port}
|
||||||
|
|
||||||
|
# Disable disk cache
|
||||||
|
cache deny all
|
||||||
|
|
||||||
|
# Logging (Squid runs as user 'proxy' which cannot write to /dev/stdout)
|
||||||
|
access_log /var/log/squid/access.log
|
||||||
|
cache_log /var/log/squid/cache.log
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_allowlist(path: str | Path) -> list[str]:
|
||||||
|
"""Load domain allowlist from a text file.
|
||||||
|
|
||||||
|
The file should contain one domain per line in Squid ``dstdomain``
|
||||||
|
format — a leading dot means "match this domain and all subdomains"
|
||||||
|
(e.g. ``.pypi.org``). Empty lines and lines starting with ``#``
|
||||||
|
are ignored.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of domain strings, preserved exactly as written in the file.
|
||||||
|
"""
|
||||||
|
domains: list[str] = []
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
domains.append(line)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyManager:
|
||||||
|
"""Manages a Squid proxy container with network-level enforcement."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
allowlist_path: str | Path | None = None,
|
||||||
|
allowed_ips: list[str] | None = None,
|
||||||
|
no_proxy: list[str] | None = None,
|
||||||
|
proxy_image: str = PROXY_IMAGE,
|
||||||
|
proxy_port: int = PROXY_PORT,
|
||||||
|
container_name: str = PROXY_CONTAINER_NAME,
|
||||||
|
network_name: str = INTERNAL_NETWORK,
|
||||||
|
):
|
||||||
|
self.allowlist_path = Path(allowlist_path or DEFAULT_ALLOWLIST_PATH).resolve()
|
||||||
|
if not self.allowlist_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Allowlist file not found: {self.allowlist_path}")
|
||||||
|
self.allowed_ips = allowed_ips or []
|
||||||
|
self.no_proxy = no_proxy or []
|
||||||
|
self.proxy_image = proxy_image
|
||||||
|
self.proxy_port = proxy_port
|
||||||
|
self.container_name = container_name
|
||||||
|
self.network_name = network_name
|
||||||
|
self._client = docker.from_env()
|
||||||
|
|
||||||
|
# -- public API ----------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def proxy_url(self) -> str:
|
||||||
|
return f"http://{self.container_name}:{self.proxy_port}"
|
||||||
|
|
||||||
|
def env_vars(self) -> dict[str, str]:
|
||||||
|
"""Return env-var dict suitable for ``docker exec -e``."""
|
||||||
|
return {
|
||||||
|
"HTTP_PROXY": self.proxy_url,
|
||||||
|
"HTTPS_PROXY": self.proxy_url,
|
||||||
|
"NO_PROXY": ",".join(self.no_proxy),
|
||||||
|
"http_proxy": self.proxy_url,
|
||||||
|
"https_proxy": self.proxy_url,
|
||||||
|
"no_proxy": ",".join(self.no_proxy),
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host_gateway(self) -> str:
|
||||||
|
"""Return the host gateway IP on the internal network.
|
||||||
|
|
||||||
|
Containers on the internal network can reach the host at this IP
|
||||||
|
without going through the proxy. Available after ``start()``.
|
||||||
|
"""
|
||||||
|
net = self._client.networks.get(self.network_name)
|
||||||
|
configs = net.attrs.get("IPAM", {}).get("Config", [])
|
||||||
|
if configs and configs[0].get("Gateway"):
|
||||||
|
return configs[0]["Gateway"]
|
||||||
|
raise RuntimeError(f"No gateway found for network {self.network_name}")
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
"""Connect to an already-running proxy without starting anything.
|
||||||
|
|
||||||
|
Populates ``no_proxy`` with the host gateway and localhost so that
|
||||||
|
``env_vars()`` returns usable values. Raises if the network or
|
||||||
|
proxy container does not exist.
|
||||||
|
"""
|
||||||
|
# Verify infrastructure is up
|
||||||
|
self._client.networks.get(self.network_name)
|
||||||
|
c = self._client.containers.get(self.container_name)
|
||||||
|
if c.status != "running":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Proxy container {self.container_name} exists but is not running "
|
||||||
|
f"(status: {c.status})"
|
||||||
|
)
|
||||||
|
|
||||||
|
gw = self.host_gateway
|
||||||
|
if gw not in self.no_proxy:
|
||||||
|
self.no_proxy.append(gw)
|
||||||
|
for local in ["localhost", "127.0.0.1"]:
|
||||||
|
if local not in self.no_proxy:
|
||||||
|
self.no_proxy.append(local)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Connected to existing proxy: url=%s network=%s",
|
||||||
|
self.proxy_url,
|
||||||
|
self.network_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Ensure the internal network and proxy container are running."""
|
||||||
|
self._ensure_network()
|
||||||
|
# Auto-add host gateway so containers can reach the host directly
|
||||||
|
gw = self.host_gateway
|
||||||
|
if gw not in self.no_proxy:
|
||||||
|
self.no_proxy.append(gw)
|
||||||
|
if gw not in self.allowed_ips:
|
||||||
|
self.allowed_ips.append(gw)
|
||||||
|
|
||||||
|
for local in ["localhost", "127.0.0.1"]:
|
||||||
|
if local not in self.no_proxy:
|
||||||
|
self.no_proxy.append(local)
|
||||||
|
|
||||||
|
self._ensure_proxy()
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
"""Restart the proxy container with the current configuration.
|
||||||
|
|
||||||
|
Useful after modifying the allowlist file on the host. The new
|
||||||
|
container bind-mounts the (possibly updated) allowlist path.
|
||||||
|
The internal network is preserved so other containers stay connected.
|
||||||
|
"""
|
||||||
|
self.stop()
|
||||||
|
self._ensure_network()
|
||||||
|
gw = self.host_gateway
|
||||||
|
if gw not in self.allowed_ips:
|
||||||
|
self.allowed_ips.append(gw)
|
||||||
|
self._ensure_proxy()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop and remove the proxy container (network is left for reuse)."""
|
||||||
|
try:
|
||||||
|
c = self._client.containers.get(self.container_name)
|
||||||
|
c.remove(force=True)
|
||||||
|
logger.info("Removed proxy container %s", self.container_name)
|
||||||
|
except NotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop_all(self) -> None:
|
||||||
|
"""Stop proxy and remove the internal network (disconnects lingering containers)."""
|
||||||
|
self.stop()
|
||||||
|
try:
|
||||||
|
net = self._client.networks.get(self.network_name)
|
||||||
|
net.reload()
|
||||||
|
for c in net.containers:
|
||||||
|
logger.info("Disconnecting %s from %s", c.name, self.network_name)
|
||||||
|
net.disconnect(c, force=True)
|
||||||
|
net.remove()
|
||||||
|
logger.info("Removed network %s", self.network_name)
|
||||||
|
except NotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
"""Return a dict describing the current infrastructure state."""
|
||||||
|
info: dict = {"network": None, "proxy": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
net = self._client.networks.get(self.network_name)
|
||||||
|
net.reload()
|
||||||
|
info["network"] = {
|
||||||
|
"name": self.network_name,
|
||||||
|
"internal": net.attrs.get("Internal", False),
|
||||||
|
"containers": [c.name for c in net.containers],
|
||||||
|
}
|
||||||
|
except NotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = self._client.containers.get(self.container_name)
|
||||||
|
c.reload()
|
||||||
|
health = c.attrs.get("State", {}).get("Health", {}).get("Status", "unknown")
|
||||||
|
info["proxy"] = {
|
||||||
|
"name": self.container_name,
|
||||||
|
"status": c.status,
|
||||||
|
"health": health,
|
||||||
|
}
|
||||||
|
except NotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
# -- internals -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _ensure_network(self) -> None:
|
||||||
|
try:
|
||||||
|
self._client.networks.get(self.network_name)
|
||||||
|
except NotFound:
|
||||||
|
# internal=True → no default route to the internet
|
||||||
|
self._client.networks.create(
|
||||||
|
self.network_name, driver="bridge", internal=True
|
||||||
|
)
|
||||||
|
logger.info("Created internal network %s", self.network_name)
|
||||||
|
|
||||||
|
def _ensure_proxy(self) -> None:
|
||||||
|
# Already running?
|
||||||
|
try:
|
||||||
|
c = self._client.containers.get(self.container_name)
|
||||||
|
if c.status == "running":
|
||||||
|
logger.info("Proxy container already running")
|
||||||
|
return
|
||||||
|
c.remove(force=True)
|
||||||
|
except NotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create container (stopped), copy config files in, then start.
|
||||||
|
# This avoids bind-mounts so the container is self-contained.
|
||||||
|
try:
|
||||||
|
proxy = self._client.containers.create(
|
||||||
|
image=self.proxy_image,
|
||||||
|
name=self.container_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy squid.conf and allowlist into the container
|
||||||
|
self._put_file(
|
||||||
|
proxy,
|
||||||
|
"/etc/squid/squid.conf",
|
||||||
|
self._generate_squid_conf(),
|
||||||
|
)
|
||||||
|
self._put_file(
|
||||||
|
proxy,
|
||||||
|
ALLOWLIST_CONTAINER_PATH,
|
||||||
|
self.allowlist_path.read_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy.start()
|
||||||
|
|
||||||
|
# Connect proxy to the internal network so agents can reach it
|
||||||
|
net = self._client.networks.get(self.network_name)
|
||||||
|
net.connect(proxy)
|
||||||
|
self._wait_ready(proxy)
|
||||||
|
logger.info("Started proxy container %s", self.container_name)
|
||||||
|
except APIError as e:
|
||||||
|
if "Conflict" in str(e):
|
||||||
|
logger.info("Proxy container created by another thread")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _put_file(container, path: str, content: str) -> None:
|
||||||
|
"""Write *content* into *path* inside a (possibly stopped) container."""
|
||||||
|
data = content.encode()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||||
|
info = tarfile.TarInfo(name=path.rsplit("/", 1)[-1])
|
||||||
|
info.size = len(data)
|
||||||
|
tar.addfile(info, io.BytesIO(data))
|
||||||
|
buf.seek(0)
|
||||||
|
container.put_archive(str(Path(path).parent), buf)
|
||||||
|
|
||||||
|
def _wait_ready(self, proxy, timeout: int = 30) -> None:
|
||||||
|
"""Block until Squid is accepting connections."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
container = self._client.containers.get(proxy.id)
|
||||||
|
ret = container.exec_run(
|
||||||
|
["bash", "-c", f"echo > /dev/tcp/127.0.0.1/{self.proxy_port}"]
|
||||||
|
)
|
||||||
|
if ret.exit_code == 0:
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise TimeoutError(f"Squid not ready after {timeout}s")
|
||||||
|
|
||||||
|
def _generate_squid_conf(self) -> str:
|
||||||
|
ip_acls = ""
|
||||||
|
ip_rules = ""
|
||||||
|
if self.allowed_ips:
|
||||||
|
ip_acls = "\n".join(f"acl allowed_ips dst {ip}" for ip in self.allowed_ips)
|
||||||
|
ip_rules = "http_access allow allowed_ips"
|
||||||
|
|
||||||
|
# Allow any port for IP-based destinations
|
||||||
|
extra_ports = "1-65535" if self.allowed_ips else ""
|
||||||
|
|
||||||
|
return SQUID_CONF_TEMPLATE.format(
|
||||||
|
allowlist_path=ALLOWLIST_CONTAINER_PATH,
|
||||||
|
ip_acls=ip_acls,
|
||||||
|
ip_rules=ip_rules,
|
||||||
|
extra_ports=extra_ports,
|
||||||
|
port=self.proxy_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# CLI
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Manage the CyberGym proxy infrastructure.",
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="action", required=True)
|
||||||
|
|
||||||
|
p_start = sub.add_parser("start", help="Create network and start proxy")
|
||||||
|
p_start.add_argument("--ip", action="append", help="Extra allowed IP/CIDR")
|
||||||
|
p_start.add_argument(
|
||||||
|
"--allowlist",
|
||||||
|
type=Path,
|
||||||
|
help="Path to a domain allowlist file in Squid dstdomain format (default: built-in list)",
|
||||||
|
)
|
||||||
|
|
||||||
|
p_update = sub.add_parser(
|
||||||
|
"update", help="Restart proxy with current allowlist (network preserved)"
|
||||||
|
)
|
||||||
|
p_update.add_argument("--ip", action="append", help="Extra allowed IP/CIDR")
|
||||||
|
p_update.add_argument(
|
||||||
|
"--allowlist",
|
||||||
|
type=Path,
|
||||||
|
help="Path to a domain allowlist file in Squid dstdomain format (default: built-in list)",
|
||||||
|
)
|
||||||
|
|
||||||
|
sub.add_parser("stop", help="Stop proxy container")
|
||||||
|
sub.add_parser("stop-all", help="Stop proxy and remove network")
|
||||||
|
sub.add_parser("status", help="Show infrastructure state")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(format="%(asctime)s [%(name)s] %(message)s", level=logging.INFO)
|
||||||
|
|
||||||
|
allowlist_path: str | Path | None = None
|
||||||
|
ips: list[str] = []
|
||||||
|
if args.action in ("start", "update"):
|
||||||
|
if getattr(args, "allowlist", None):
|
||||||
|
allowlist_path = args.allowlist
|
||||||
|
if args.ip:
|
||||||
|
ips.extend(args.ip)
|
||||||
|
|
||||||
|
mgr = ProxyManager(allowlist_path=allowlist_path, allowed_ips=ips)
|
||||||
|
|
||||||
|
match args.action:
|
||||||
|
case "start":
|
||||||
|
mgr.start()
|
||||||
|
logger.info(
|
||||||
|
"Proxy ready url=%s network=%s host_gateway=%s",
|
||||||
|
mgr.proxy_url,
|
||||||
|
mgr.network_name,
|
||||||
|
mgr.host_gateway,
|
||||||
|
)
|
||||||
|
case "update":
|
||||||
|
mgr.update()
|
||||||
|
logger.info(
|
||||||
|
"Proxy updated url=%s allowlist=%s",
|
||||||
|
mgr.proxy_url,
|
||||||
|
mgr.allowlist_path,
|
||||||
|
)
|
||||||
|
case "stop":
|
||||||
|
mgr.stop()
|
||||||
|
case "stop-all":
|
||||||
|
mgr.stop_all()
|
||||||
|
case "status":
|
||||||
|
print(json.dumps(mgr.status(), indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user