mirror of
https://github.com/Monadical-SAS/cubbi.git
synced 2025-12-20 12:19:07 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
22
cubbi/cli.py
22
cubbi/cli.py
@@ -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:
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class ConfigManager:
|
|||||||
},
|
},
|
||||||
defaults={
|
defaults={
|
||||||
"image": "goose",
|
"image": "goose",
|
||||||
|
"domains": [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user