feat: add network filtering with domain restrictions (#22)

* fix: remove config override logging to prevent API key exposure

* feat: add network filtering with domain restrictions

- Add --domains flag to restrict container network access to specific domains/ports
- Integrate monadicalsas/network-filter container for network isolation
- Support domain patterns like 'example.com:443', '*.api.com'
- Add defaults.domains configuration option
- Automatically handle network-filter container lifecycle
- Prevent conflicts between --domains and --network options

* docs: add --domains option to README usage examples

* docs: remove wildcard domain example from --domains help

Wildcard domains are not currently supported by network-filter
This commit is contained in:
2025-07-30 18:33:17 -06:00
committed by GitHub
parent afae8a13e1
commit 2eb15a31f8
5 changed files with 215 additions and 43 deletions

View File

@@ -98,6 +98,9 @@ cubbix /path/to/project
# Connect to external Docker networks # Connect to external Docker networks
cubbix --network teamnet --network dbnet cubbix --network teamnet --network dbnet
# Restrict network access to specific domains
cubbix --domains github.com --domains "api.example.com:443"
# Connect to MCP servers for extended capabilities # Connect to MCP servers for extended capabilities
cubbix --mcp github --mcp jira cubbix --mcp github --mcp jira

View File

@@ -179,6 +179,11 @@ def create_session(
"-c", "-c",
help="Override configuration values (KEY=VALUE) for this session only", help="Override configuration values (KEY=VALUE) for this session only",
), ),
domains: List[str] = typer.Option(
[],
"--domains",
help="Restrict network access to specified domains/ports (e.g., 'example.com:443', 'api.github.com')",
),
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"), verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"),
) -> None: ) -> None:
"""Create a new Cubbi session """Create a new Cubbi session
@@ -213,7 +218,6 @@ def create_session(
else: else:
typed_value = value typed_value = value
config_overrides[key] = typed_value config_overrides[key] = typed_value
console.print(f"[blue]Config override: {key} = {typed_value}[/blue]")
else: else:
console.print( console.print(
f"[yellow]Warning: Ignoring invalid config format: {config_item}. Use KEY=VALUE.[/yellow]" f"[yellow]Warning: Ignoring invalid config format: {config_item}. Use KEY=VALUE.[/yellow]"
@@ -303,6 +307,18 @@ def create_session(
# Combine default networks with user-specified networks, removing duplicates # Combine default networks with user-specified networks, removing duplicates
all_networks = list(set(default_networks + network)) all_networks = list(set(default_networks + network))
# Get default domains from user config
default_domains = temp_user_config.get("defaults.domains", [])
# Combine default domains with user-specified domains
all_domains = default_domains + list(domains)
# Check for conflict between network and domains
if all_domains and all_networks:
console.print(
"[yellow]Warning: --domains cannot be used with --network. Network restrictions will take precedence.[/yellow]"
)
# Get default MCPs from user config if none specified # Get default MCPs from user config if none specified
all_mcps = mcp if isinstance(mcp, list) else [] all_mcps = mcp if isinstance(mcp, list) else []
if not all_mcps: if not all_mcps:
@@ -315,6 +331,9 @@ def create_session(
if all_networks: if all_networks:
console.print(f"Networks: {', '.join(all_networks)}") console.print(f"Networks: {', '.join(all_networks)}")
if all_domains:
console.print(f"Domain restrictions: {', '.join(all_domains)}")
# Show volumes that will be mounted # Show volumes that will be mounted
if volume_mounts: if volume_mounts:
console.print("Volumes:") console.print("Volumes:")
@@ -361,6 +380,7 @@ def create_session(
ssh=ssh, ssh=ssh,
model=final_model, model=final_model,
provider=final_provider, provider=final_provider,
domains=all_domains,
) )
if session: if session:

View File

@@ -64,6 +64,7 @@ class ConfigManager:
}, },
defaults={ defaults={
"image": "goose", "image": "goose",
"domains": [],
}, },
) )

View File

@@ -12,7 +12,7 @@ from docker.errors import DockerException, ImageNotFound
from .config import ConfigManager from .config import ConfigManager
from .mcp import MCPManager from .mcp import MCPManager
from .models import Session, SessionStatus from .models import Image, Session, SessionStatus
from .session import SessionManager from .session import SessionManager
from .user_config import UserConfigManager from .user_config import UserConfigManager
@@ -162,6 +162,7 @@ class ContainerManager:
model: Optional[str] = None, model: Optional[str] = None,
provider: Optional[str] = None, provider: Optional[str] = None,
ssh: bool = False, ssh: bool = False,
domains: Optional[List[str]] = None,
) -> Optional[Session]: ) -> Optional[Session]:
"""Create a new Cubbi session """Create a new Cubbi session
@@ -182,13 +183,26 @@ class ContainerManager:
model: Optional model to use model: Optional model to use
provider: Optional provider to use provider: Optional provider to use
ssh: Whether to start the SSH server in the container (default: False) ssh: Whether to start the SSH server in the container (default: False)
domains: Optional list of domains to restrict network access to (uses network-filter)
""" """
try: try:
# Validate image exists # Try to get image from config first
image = self.config_manager.get_image(image_name) image = self.config_manager.get_image(image_name)
if not image: if not image:
print(f"Image '{image_name}' not found") # If not found in config, treat it as a Docker image name
return None print(
f"Image '{image_name}' not found in Cubbi config, using as Docker image..."
)
image = Image(
name=image_name,
description=f"Docker image: {image_name}",
version="latest",
maintainer="unknown",
image=image_name,
ports=[],
volumes=[],
persistent_configs=[],
)
# Generate session ID and name # Generate session ID and name
session_id = self._generate_session_id() session_id = self._generate_session_id()
@@ -517,17 +531,99 @@ class ContainerManager:
"defaults.provider", "" "defaults.provider", ""
) )
# Handle network-filter if domains are specified
network_filter_container = None
network_mode = None
if domains:
# Check for conflicts
if networks:
print(
"[yellow]Warning: Cannot use --domains with --network. Using domain restrictions only.[/yellow]"
)
networks = []
network_list = [default_network]
# Create network-filter container
network_filter_name = f"cubbi-network-filter-{session_id}"
# Pull network-filter image if needed
network_filter_image = "monadicalsas/network-filter:latest"
try:
self.client.images.get(network_filter_image)
except ImageNotFound:
print(f"Pulling network-filter image {network_filter_image}...")
self.client.images.pull(network_filter_image)
# Create and start network-filter container
print("Creating network-filter container for domain restrictions...")
try:
# First check if a network-filter container already exists with this name
try:
existing = self.client.containers.get(network_filter_name)
print(
f"Removing existing network-filter container {network_filter_name}"
)
existing.stop()
existing.remove()
except DockerException:
pass # Container doesn't exist, which is fine
network_filter_container = self.client.containers.run(
image=network_filter_image,
name=network_filter_name,
hostname=network_filter_name,
detach=True,
environment={"ALLOWED_DOMAINS": ",".join(domains)},
labels={
"cubbi.network-filter": "true",
"cubbi.session.id": session_id,
"cubbi.session.name": session_name,
},
cap_add=["NET_ADMIN"], # Required for iptables
remove=False, # Don't auto-remove on stop
)
# Wait for container to be running
import time
for i in range(10): # Wait up to 10 seconds
network_filter_container.reload()
if network_filter_container.status == "running":
break
time.sleep(1)
else:
raise Exception(
f"Network-filter container failed to start. Status: {network_filter_container.status}"
)
# Use container ID instead of name for network_mode
network_mode = f"container:{network_filter_container.id}"
print(
f"Network restrictions enabled for domains: {', '.join(domains)}"
)
print(f"Using network mode: {network_mode}")
except Exception as e:
print(f"[red]Error creating network-filter container: {e}[/red]")
raise
# Warn about MCP limitations when using network-filter
if mcp_names:
print(
"[yellow]Warning: MCP servers may not be accessible when using domain restrictions.[/yellow]"
)
# Create container # Create container
container = self.client.containers.create( container_params = {
image=image.image, "image": image.image,
name=session_name, "name": session_name,
hostname=session_name, "detach": True,
detach=True, "tty": True,
tty=True, "stdin_open": True,
stdin_open=True, "environment": env_vars,
environment=env_vars, "volumes": session_volumes,
volumes=session_volumes, "labels": {
labels={
"cubbi.session": "true", "cubbi.session": "true",
"cubbi.session.id": session_id, "cubbi.session.id": session_id,
"cubbi.session.name": session_name, "cubbi.session.name": session_name,
@@ -536,17 +632,29 @@ class ContainerManager:
"cubbi.project_name": project_name or "", "cubbi.project_name": project_name or "",
"cubbi.mcps": ",".join(mcp_names) if mcp_names else "", "cubbi.mcps": ",".join(mcp_names) if mcp_names else "",
}, },
network=network_list[0], # Connect to the first network initially "command": container_command, # Set the command
command=container_command, # Set the command "entrypoint": entrypoint, # Set the entrypoint (might be None)
entrypoint=entrypoint, # Set the entrypoint (might be None) "ports": {f"{port}/tcp": None for port in image.ports},
ports={f"{port}/tcp": None for port in image.ports}, }
)
# Use network_mode if domains are specified, otherwise use regular network
if network_mode:
container_params["network_mode"] = network_mode
# Cannot set hostname when using network_mode
else:
container_params["hostname"] = session_name
container_params["network"] = network_list[
0
] # Connect to the first network initially
container = self.client.containers.create(**container_params)
# Start container # Start container
container.start() container.start()
# Connect to additional networks (after the first one in network_list) # Connect to additional networks (after the first one in network_list)
if len(network_list) > 1: # Note: Cannot connect to networks when using network_mode
if len(network_list) > 1 and not network_mode:
for network_name in network_list[1:]: for network_name in network_list[1:]:
try: try:
# Get or create the network # Get or create the network
@@ -567,32 +675,35 @@ class ContainerManager:
container.reload() container.reload()
# Connect directly to each MCP's dedicated network # Connect directly to each MCP's dedicated network
for mcp_name in mcp_names: # Note: Cannot connect to networks when using network_mode
try: if not network_mode:
# Get the dedicated network for this MCP for mcp_name in mcp_names:
dedicated_network_name = f"cubbi-mcp-{mcp_name}-network"
try: try:
network = self.client.networks.get(dedicated_network_name) # Get the dedicated network for this MCP
dedicated_network_name = f"cubbi-mcp-{mcp_name}-network"
# Connect the session container to the MCP's dedicated network try:
network.connect(container, aliases=[session_name]) network = self.client.networks.get(dedicated_network_name)
print(
f"Connected session to MCP '{mcp_name}' via dedicated network: {dedicated_network_name}"
)
except DockerException:
# print(
# f"Error connecting to MCP dedicated network '{dedicated_network_name}': {e}"
# )
# commented out, may be accessible through another attached network, it's
# not mandatory here.
pass
except Exception as e: # Connect the session container to the MCP's dedicated network
print(f"Error connecting session to MCP '{mcp_name}': {e}") network.connect(container, aliases=[session_name])
print(
f"Connected session to MCP '{mcp_name}' via dedicated network: {dedicated_network_name}"
)
except DockerException:
# print(
# f"Error connecting to MCP dedicated network '{dedicated_network_name}': {e}"
# )
# commented out, may be accessible through another attached network, it's
# not mandatory here.
pass
except Exception as e:
print(f"Error connecting session to MCP '{mcp_name}': {e}")
# Connect to additional user-specified networks # Connect to additional user-specified networks
if networks: # Note: Cannot connect to networks when using network_mode
if networks and not network_mode:
for network_name in networks: for network_name in networks:
# Check if already connected to this network # Check if already connected to this network
# NetworkSettings.Networks contains a dict where keys are network names # NetworkSettings.Networks contains a dict where keys are network names
@@ -651,6 +762,15 @@ class ContainerManager:
except DockerException as e: except DockerException as e:
print(f"Error creating session: {e}") print(f"Error creating session: {e}")
# Clean up network-filter container if it was created
if network_filter_container:
try:
network_filter_container.stop()
network_filter_container.remove()
except Exception:
pass
return None return None
def close_session(self, session_id: str) -> bool: def close_session(self, session_id: str) -> bool:
@@ -749,9 +869,24 @@ class ContainerManager:
return False return False
try: try:
# First, close the main session container
container = self.client.containers.get(session.container_id) container = self.client.containers.get(session.container_id)
container.stop() container.stop()
container.remove() container.remove()
# Check for and close any associated network-filter container
network_filter_name = f"cubbi-network-filter-{session.id}"
try:
network_filter_container = self.client.containers.get(
network_filter_name
)
logger.info(f"Stopping network-filter container {network_filter_name}")
network_filter_container.stop()
network_filter_container.remove()
except DockerException:
# Network-filter container might not exist, which is fine
pass
self.session_manager.remove_session(session.id) self.session_manager.remove_session(session.id)
return True return True
except DockerException as e: except DockerException as e:
@@ -785,6 +920,19 @@ class ContainerManager:
# Stop and remove container # Stop and remove container
container.stop() container.stop()
container.remove() container.remove()
# Check for and close any associated network-filter container
network_filter_name = f"cubbi-network-filter-{session.id}"
try:
network_filter_container = self.client.containers.get(
network_filter_name
)
network_filter_container.stop()
network_filter_container.remove()
except DockerException:
# Network-filter container might not exist, which is fine
pass
# Remove from session storage # Remove from session storage
self.session_manager.remove_session(session.id) self.session_manager.remove_session(session.id)

View File

@@ -111,5 +111,5 @@ class Config(BaseModel):
images: Dict[str, Image] = Field(default_factory=dict) images: Dict[str, Image] = Field(default_factory=dict)
defaults: Dict[str, object] = Field( defaults: Dict[str, object] = Field(
default_factory=dict default_factory=dict
) # Can store strings, booleans, or other values ) # Can store strings, booleans, lists, or other values
mcps: List[Dict[str, Any]] = Field(default_factory=list) mcps: List[Dict[str, Any]] = Field(default_factory=list)