Switched from Dockmon to Beszel
This commit is contained in:
175
dockmon/backend/models/docker_models.py
Normal file
175
dockmon/backend/models/docker_models.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Docker Models for DockMon
|
||||
Pydantic models for Docker hosts, containers, and configurations
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator, model_validator
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DockerHostConfig(BaseModel):
|
||||
"""Configuration for a Docker host"""
|
||||
name: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9][a-zA-Z0-9 ._-]*$')
|
||||
url: str = Field(..., min_length=1, max_length=500)
|
||||
tls_cert: Optional[str] = Field(None, max_length=10000)
|
||||
tls_key: Optional[str] = Field(None, max_length=10000)
|
||||
tls_ca: Optional[str] = Field(None, max_length=10000)
|
||||
|
||||
@validator('name')
|
||||
def validate_name(cls, v):
|
||||
"""Validate host name for security"""
|
||||
if not v or not v.strip():
|
||||
raise ValueError('Host name cannot be empty')
|
||||
# Prevent XSS and injection
|
||||
sanitized = re.sub(r'[<>"\']', '', v.strip())
|
||||
if len(sanitized) != len(v.strip()):
|
||||
raise ValueError('Host name contains invalid characters')
|
||||
return sanitized
|
||||
|
||||
@validator('url')
|
||||
def validate_url(cls, v):
|
||||
"""Validate Docker URL for security - prevent SSRF attacks"""
|
||||
if not v or not v.strip():
|
||||
raise ValueError('URL cannot be empty')
|
||||
|
||||
v = v.strip()
|
||||
|
||||
# Only allow specific protocols
|
||||
allowed_protocols = ['tcp://', 'unix://', 'http://', 'https://']
|
||||
if not any(v.startswith(proto) for proto in allowed_protocols):
|
||||
raise ValueError('URL must use tcp://, unix://, http:// or https:// protocol')
|
||||
|
||||
# Block ONLY the most dangerous SSRF targets (cloud metadata & loopback)
|
||||
# Allow private networks (10.*, 172.16-31.*, 192.168.*) for legitimate Docker hosts
|
||||
extremely_dangerous_patterns = [
|
||||
r'169\.254\.169\.254', # AWS/GCP metadata (specific)
|
||||
r'169\.254\.', # Link-local range (broader)
|
||||
r'metadata\.google\.internal', # GCP metadata
|
||||
r'metadata\.goog', # GCP metadata alternative
|
||||
r'100\.100\.100\.200', # Alibaba Cloud metadata
|
||||
r'fd00:ec2::254', # AWS IPv6 metadata
|
||||
r'0\.0\.0\.0', # All interfaces binding
|
||||
r'::1', # IPv6 localhost
|
||||
r'localhost(?!\:|$)', # Localhost variations but allow localhost:port
|
||||
r'127\.0\.0\.(?!1$)', # 127.x.x.x but allow 127.0.0.1
|
||||
]
|
||||
|
||||
# Check for extremely dangerous metadata service targets
|
||||
for pattern in extremely_dangerous_patterns:
|
||||
if re.search(pattern, v, re.IGNORECASE):
|
||||
# Special handling for localhost - allow localhost:port but block bare localhost
|
||||
if 'localhost' in pattern.lower() and ':' in v:
|
||||
continue # Allow localhost:2376 etc
|
||||
raise ValueError('URL targets cloud metadata service or dangerous internal endpoint')
|
||||
|
||||
# Additional validation: warn about but allow private networks
|
||||
private_network_patterns = [
|
||||
r'10\.', # 10.0.0.0/8
|
||||
r'172\.(1[6-9]|2[0-9]|3[01])\.', # 172.16.0.0/12
|
||||
r'192\.168\.', # 192.168.0.0/16
|
||||
]
|
||||
|
||||
# Log private network usage for monitoring (but don't block)
|
||||
for pattern in private_network_patterns:
|
||||
if re.search(pattern, v, re.IGNORECASE):
|
||||
logger.info(f"Docker host configured on private network: {v[:50]}...")
|
||||
break
|
||||
|
||||
return v
|
||||
|
||||
@validator('tls_cert', 'tls_key', 'tls_ca')
|
||||
def validate_certificate(cls, v):
|
||||
"""Validate TLS certificate data"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
v = v.strip()
|
||||
if not v:
|
||||
return None
|
||||
|
||||
# Basic PEM format validation with helpful error messages
|
||||
if '-----BEGIN' not in v and '-----END' not in v:
|
||||
raise ValueError('Certificate is incomplete. PEM certificates must start with "-----BEGIN" and end with "-----END". Please copy the entire certificate including both lines.')
|
||||
elif '-----BEGIN' not in v:
|
||||
raise ValueError('Certificate is missing the "-----BEGIN" header line. Make sure you copied the complete certificate starting from the "-----BEGIN" line.')
|
||||
elif '-----END' not in v:
|
||||
raise ValueError('Certificate is missing the "-----END" footer line. Make sure you copied the complete certificate including the "-----END" line.')
|
||||
|
||||
# Block potential code injection
|
||||
dangerous_patterns = ['<script', 'javascript:', 'data:', 'vbscript:', '<?php', '<%', '{{', '{%']
|
||||
v_lower = v.lower()
|
||||
if any(pattern in v_lower for pattern in dangerous_patterns):
|
||||
raise ValueError('Certificate contains potentially dangerous content')
|
||||
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_tls_complete(self):
|
||||
"""Ensure TLS configuration is complete when using TCP with certificates"""
|
||||
# Only validate for TCP connections
|
||||
if not self.url or not self.url.startswith('tcp://'):
|
||||
return self
|
||||
|
||||
# If any TLS field is provided, all three must be provided
|
||||
tls_fields_provided = [
|
||||
('Client Certificate', self.tls_cert),
|
||||
('Client Private Key', self.tls_key),
|
||||
('CA Certificate', self.tls_ca)
|
||||
]
|
||||
|
||||
provided_fields = [(name, val) for name, val in tls_fields_provided if val and val.strip()]
|
||||
|
||||
if provided_fields and len(provided_fields) < 3:
|
||||
# Some but not all fields provided
|
||||
missing = [name for name, val in tls_fields_provided if not val or not val.strip()]
|
||||
missing_str = ', '.join(missing)
|
||||
raise ValueError(
|
||||
f'Incomplete TLS configuration. For secure TCP connections, you must provide all three certificates. '
|
||||
f'Missing: {missing_str}. Either provide all three certificates or remove all of them.'
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class DockerHost(BaseModel):
|
||||
"""Docker host with connection status"""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str
|
||||
url: str
|
||||
status: str = "offline"
|
||||
security_status: Optional[str] = None # "secure", "insecure", "unknown"
|
||||
last_checked: datetime = Field(default_factory=datetime.now)
|
||||
container_count: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class Container(BaseModel):
|
||||
"""Container information"""
|
||||
id: str
|
||||
short_id: str
|
||||
name: str
|
||||
state: str
|
||||
status: str
|
||||
host_id: str
|
||||
host_name: str
|
||||
image: str
|
||||
created: str
|
||||
auto_restart: bool = False
|
||||
restart_attempts: int = 0
|
||||
# Stats from Go stats service
|
||||
cpu_percent: Optional[float] = None
|
||||
memory_usage: Optional[int] = None
|
||||
memory_limit: Optional[int] = None
|
||||
memory_percent: Optional[float] = None
|
||||
network_rx: Optional[int] = None
|
||||
network_tx: Optional[int] = None
|
||||
disk_read: Optional[int] = None
|
||||
disk_write: Optional[int] = None
|
||||
Reference in New Issue
Block a user