Switched from Dockmon to Beszel

This commit is contained in:
2025-10-31 17:13:00 +01:00
parent cc6454cef9
commit f4a4142799
75 changed files with 24313 additions and 122 deletions

View File

@@ -0,0 +1 @@
# Data models module for DockMon

View File

@@ -0,0 +1,18 @@
"""
Authentication Models for DockMon
Pydantic models for authentication requests and responses
"""
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
"""Login request model with validation"""
username: str = Field(..., min_length=1, max_length=50)
password: str = Field(..., min_length=1, max_length=100)
class ChangePasswordRequest(BaseModel):
"""Change password request model"""
current_password: str = Field(..., min_length=1, max_length=100)
new_password: str = Field(..., min_length=8, max_length=100) # Minimum 8 characters for security

View 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

View File

@@ -0,0 +1,275 @@
"""
Request Models for DockMon API Endpoints
Pydantic models for API request validation
"""
import re
import time
from datetime import datetime
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, validator
class ContainerHostPair(BaseModel):
"""Container and host pair for alert rules"""
host_id: str = Field(..., max_length=50)
container_name: str = Field(..., min_length=1, max_length=200)
class AutoRestartRequest(BaseModel):
"""Request model for toggling auto-restart"""
host_id: str
container_name: str
enabled: bool
class AlertRuleCreate(BaseModel):
"""Request model for creating alert rules"""
name: str = Field(..., min_length=1, max_length=100)
containers: Optional[List[ContainerHostPair]] = Field(None, max_items=100)
trigger_events: Optional[List[str]] = Field(None, max_items=20) # Docker events
trigger_states: Optional[List[str]] = Field(None, max_items=10) # Docker states
notification_channels: List[int] = Field(..., min_items=1, max_items=20)
cooldown_minutes: int = Field(15, ge=1, le=1440) # 1 min to 24 hours
enabled: bool = True
@validator('name')
def validate_name(cls, v):
"""Validate rule name for security"""
if not v or not v.strip():
raise ValueError('Rule name cannot be empty')
# Sanitize and prevent XSS
sanitized = re.sub(r'[<>"\']', '', v.strip())
if len(sanitized) != len(v.strip()):
raise ValueError('Rule name contains invalid characters')
return sanitized
@validator('trigger_events')
def validate_trigger_events(cls, v):
"""Validate Docker events"""
if not v:
return v # Events are optional
valid_events = {
# Critical events
'oom', 'die-nonzero', 'health_status:unhealthy',
# Warning events
'kill', 'die-zero', 'restart-loop', 'stuck-removing',
# Info events
'start', 'stop', 'create', 'destroy', 'pause', 'unpause',
'health_status:healthy'
}
invalid_events = [event for event in v if event not in valid_events]
if invalid_events:
raise ValueError(f'Invalid trigger events: {invalid_events}')
return v
@validator('trigger_states')
def validate_trigger_states(cls, v):
"""Validate container states"""
if not v:
return v # States are optional now
valid_states = {'created', 'restarting', 'running', 'removing', 'paused', 'exited', 'dead'}
invalid_states = [state for state in v if state not in valid_states]
if invalid_states:
raise ValueError(f'Invalid trigger states: {invalid_states}')
return v
def __init__(self, **data):
"""Custom validation to ensure at least one trigger is specified"""
super().__init__(**data)
events = self.trigger_events or []
states = self.trigger_states or []
if not events and not states:
raise ValueError('At least one trigger event or state is required')
@validator('notification_channels')
def validate_notification_channels(cls, v):
"""Validate notification channel IDs"""
if not v:
raise ValueError('At least one notification channel is required')
# Validate all are positive integers
invalid_ids = [id for id in v if id <= 0]
if invalid_ids:
raise ValueError(f'Invalid notification channel IDs: {invalid_ids}')
return v
class AlertRuleUpdate(BaseModel):
"""Request model for updating alert rules"""
name: Optional[str] = None
containers: Optional[List[ContainerHostPair]] = None
trigger_events: Optional[List[str]] = None # Docker events
trigger_states: Optional[List[str]] = None # Docker states
notification_channels: Optional[List[int]] = None
cooldown_minutes: Optional[int] = None
enabled: Optional[bool] = None
class NotificationChannelCreate(BaseModel):
"""Request model for creating notification channels"""
name: str = Field(..., min_length=1, max_length=100)
type: str = Field(..., min_length=1, max_length=20)
config: Dict[str, Any] = Field(..., min_items=1, max_items=10)
enabled: bool = True
@validator('name')
def validate_name(cls, v):
"""Validate channel name for security"""
if not v or not v.strip():
raise ValueError('Channel name cannot be empty')
# Sanitize and prevent XSS
sanitized = re.sub(r'[<>"\']', '', v.strip())
if len(sanitized) != len(v.strip()):
raise ValueError('Channel name contains invalid characters')
return sanitized
@validator('type')
def validate_type(cls, v):
"""Validate notification type"""
if not v or not v.strip():
raise ValueError('Channel type cannot be empty')
valid_types = {'telegram', 'discord', 'pushover', 'slack', 'gotify', 'smtp'}
v = v.strip().lower()
if v not in valid_types:
raise ValueError(f'Invalid channel type. Must be one of: {valid_types}')
return v
@validator('config')
def validate_config(cls, v, values):
"""Validate channel configuration based on type"""
if not v:
raise ValueError('Configuration cannot be empty')
channel_type = values.get('type', '').lower()
# Validate configuration based on channel type
if channel_type == 'telegram':
required_keys = {'bot_token', 'chat_id'}
if not all(key in v for key in required_keys):
raise ValueError(f'Telegram config must contain: {required_keys}')
# Validate bot token format
bot_token = v.get('bot_token', '')
if not re.match(r'^\d+:[A-Za-z0-9_-]+$', bot_token):
raise ValueError('Invalid Telegram bot token format')
elif channel_type == 'discord':
required_keys = {'webhook_url'}
if not all(key in v for key in required_keys):
raise ValueError(f'Discord config must contain: {required_keys}')
# Validate Discord webhook URL
webhook_url = v.get('webhook_url', '')
if not webhook_url.startswith('https://discord.com/api/webhooks/'):
raise ValueError('Invalid Discord webhook URL')
elif channel_type == 'slack':
required_keys = {'webhook_url'}
if not all(key in v for key in required_keys):
raise ValueError(f'Slack config must contain: {required_keys}')
# Validate Slack webhook URL
webhook_url = v.get('webhook_url', '')
if not (webhook_url.startswith('https://hooks.slack.com/services/') or
webhook_url.startswith('https://hooks.slack.com/workflows/')):
raise ValueError('Invalid Slack webhook URL')
elif channel_type == 'pushover':
required_keys = {'app_token', 'user_key'}
if not all(key in v for key in required_keys):
raise ValueError(f'Pushover config must contain: {required_keys}')
# Validate token formats
app_token = v.get('app_token', '')
user_key = v.get('user_key', '')
if not re.match(r'^[a-z0-9]{30}$', app_token, re.IGNORECASE):
raise ValueError('Invalid Pushover app token format')
if not re.match(r'^[a-z0-9]{30}$', user_key, re.IGNORECASE):
raise ValueError('Invalid Pushover user key format')
elif channel_type == 'gotify':
required_keys = {'server_url', 'app_token'}
if not all(key in v for key in required_keys):
raise ValueError(f'Gotify config must contain: {required_keys}')
# Validate server URL
server_url = v.get('server_url', '')
if not (server_url.startswith('http://') or server_url.startswith('https://')):
raise ValueError('Gotify server URL must start with http:// or https://')
elif channel_type == 'smtp':
required_keys = {'smtp_host', 'from_email', 'to_email'}
if not all(key in v for key in required_keys):
raise ValueError(f'SMTP config must contain: {required_keys}')
# Default port to 587 if not provided or empty
if 'smtp_port' not in v or v['smtp_port'] == '' or v['smtp_port'] is None:
v['smtp_port'] = 587
# Validate email format
import re
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
from_email = v.get('from_email', '')
to_email = v.get('to_email', '')
if not re.match(email_pattern, from_email):
raise ValueError('Invalid from_email format')
if not re.match(email_pattern, to_email):
raise ValueError('Invalid to_email format')
# Validate port
try:
port = int(v.get('smtp_port', 587))
if port < 1 or port > 65535:
raise ValueError('SMTP port must be between 1 and 65535')
v['smtp_port'] = port # Ensure it's stored as int
except (ValueError, TypeError):
raise ValueError('SMTP port must be a valid number')
# Validate all string values in config for security
for key, value in v.items():
if isinstance(value, str):
# Prevent code injection in configuration values
dangerous_patterns = ['<script', 'javascript:', 'data:', 'vbscript:', '<?php', '<%']
value_lower = value.lower()
if any(pattern in value_lower for pattern in dangerous_patterns):
raise ValueError(f'Configuration value for {key} contains potentially dangerous content')
# Limit string length to prevent DoS
if len(value) > 1000:
raise ValueError(f'Configuration value for {key} is too long (max 1000 characters)')
return v
class NotificationChannelUpdate(BaseModel):
"""Request model for updating notification channels"""
name: Optional[str] = None
config: Optional[Dict[str, Any]] = None
enabled: Optional[bool] = None
class EventLogFilter(BaseModel):
"""Request model for filtering events"""
category: Optional[str] = None
event_type: Optional[str] = None
severity: Optional[str] = None
host_id: Optional[str] = None
container_id: Optional[str] = None
container_name: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
correlation_id: Optional[str] = None
search: Optional[str] = None
limit: int = 100
offset: int = 0

View File

@@ -0,0 +1,85 @@
"""
Settings and Configuration Models for DockMon
Pydantic models for global settings, alerts, and notifications
"""
import uuid
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, validator
from typing import Optional, List
class GlobalSettings(BaseModel):
"""Global monitoring settings"""
max_retries: int = Field(3, ge=0, le=10) # 0-10 retries
retry_delay: int = Field(30, ge=5, le=300) # 5 seconds to 5 minutes
default_auto_restart: bool = False
polling_interval: int = Field(2, ge=1, le=300) # 1 second to 5 minutes
connection_timeout: int = Field(10, ge=1, le=60) # 1-60 seconds
alert_template: Optional[str] = Field(None, max_length=2000) # Global notification template
blackout_windows: Optional[List[dict]] = None # Blackout windows configuration
timezone_offset: int = Field(0, ge=-720, le=720) # Timezone offset in minutes from UTC (-12h to +12h)
show_host_stats: bool = Field(True) # Show host statistics graphs on dashboard
show_container_stats: bool = Field(True) # Show container statistics on dashboard
@validator('max_retries')
def validate_max_retries(cls, v):
"""Validate retry count to prevent resource exhaustion"""
if v < 0:
raise ValueError('Max retries cannot be negative')
if v > 10:
raise ValueError('Max retries cannot exceed 10 to prevent resource exhaustion')
return v
@validator('retry_delay')
def validate_retry_delay(cls, v):
"""Validate retry delay to prevent system overload"""
if v < 5:
raise ValueError('Retry delay must be at least 5 seconds')
if v > 300:
raise ValueError('Retry delay cannot exceed 300 seconds')
return v
@validator('polling_interval')
def validate_polling_interval(cls, v):
"""Validate polling interval to prevent system overload"""
if v < 1:
raise ValueError('Polling interval must be at least 1 second')
if v > 300:
raise ValueError('Polling interval cannot exceed 300 seconds')
return v
@validator('connection_timeout')
def validate_connection_timeout(cls, v):
"""Validate connection timeout"""
if v < 1:
raise ValueError('Connection timeout must be at least 1 second')
if v > 60:
raise ValueError('Connection timeout cannot exceed 60 seconds')
return v
class AlertRule(BaseModel):
"""Alert rule configuration"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
name: str
trigger_events: Optional[List[str]] = None
trigger_states: Optional[List[str]] = None
notification_channels: List[int]
cooldown_minutes: int = 15
enabled: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
last_triggered: Optional[datetime] = None
class NotificationSettings(BaseModel):
"""Notification channel settings"""
telegram_token: Optional[str] = None
telegram_chat_id: Optional[str] = None
discord_webhook: Optional[str] = None
pushover_app_token: Optional[str] = None
pushover_user_key: Optional[str] = None