Files
ServerKit/backend/app/models/server.py
T
Juan Denis bd8d2e09f6 Add security, logging, DB hardening & SCSS refactor
Broad refactor and hardening across backend and frontend:

- Standardize API error responses by removing legacy `success` flags and returning concise error payloads.
- Replace inline admin decorators with centralized middleware import (admin_required) in DB and Docker APIs.
- DatabaseService: add safer MySQL execution helpers and secure user-creation flow (hex-encoding, env-pwd usage) and improve pg drop to validate identifiers.
- Models: add eager-loading (subquery) for relationships and add indexes to frequently queried columns to reduce N+1 and improve performance.
- DockerService: add structured logging and error messages for better observability when Docker commands fail.
- Config: tighten ProductionConfig cookie settings (secure, httponly, samesite).
- Frontend: migrate many .less styles to .scss, add new security UI components and API client modules, update package metadata accordingly.
- Misc: add APP_IMPROVEMENTS.md to .gitignore.

These changes improve security, reliability, observability, and frontend styling consistency.
2026-03-23 22:00:36 -04:00

570 lines
23 KiB
Python

import uuid
import secrets
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
class ServerGroup(db.Model):
"""Group servers for organization"""
__tablename__ = 'server_groups'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(7), default='#6366f1') # Hex color for UI
icon = db.Column(db.String(50), default='server') # Icon name
parent_id = db.Column(db.String(36), db.ForeignKey('server_groups.id'), nullable=True)
# Fleet Management
auto_upgrade = db.Column(db.Boolean, default=False)
upgrade_channel = db.Column(db.String(20), default='stable') # stable, beta
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
# Use 'subquery' to eagerly load servers in a single query, avoiding N+1
servers = db.relationship('Server', back_populates='group', lazy='subquery')
children = db.relationship('ServerGroup', backref=db.backref('parent', remote_side=[id]))
def to_dict(self, include_servers=False):
result = {
'id': self.id,
'name': self.name,
'description': self.description,
'color': self.color,
'icon': self.icon,
'parent_id': self.parent_id,
'auto_upgrade': self.auto_upgrade,
'upgrade_channel': self.upgrade_channel,
'server_count': len(self.servers),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
if include_servers:
result['servers'] = [s.to_dict() for s in self.servers]
return result
def __repr__(self):
return f'<ServerGroup {self.name}>'
class Server(db.Model):
"""Represents a remote server managed by ServerKit"""
__tablename__ = 'servers'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# Basic Info
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
hostname = db.Column(db.String(255)) # Reported by agent
ip_address = db.Column(db.String(45)) # IPv4 or IPv6
# Organization
group_id = db.Column(db.String(36), db.ForeignKey('server_groups.id'), nullable=True, index=True)
tags = db.Column(db.JSON, default=list) # ["production", "us-east", "docker"]
# Status
status = db.Column(db.String(20), default='pending', index=True)
# pending, connecting, online, offline, error, maintenance
last_seen = db.Column(db.DateTime)
last_error = db.Column(db.Text)
# Agent Info
agent_version = db.Column(db.String(20))
agent_id = db.Column(db.String(36), unique=True, index=True) # Agent's UUID
auto_upgrade = db.Column(db.Boolean, default=False)
upgrade_channel = db.Column(db.String(20), default='stable') # stable, beta
# System Info (reported by agent)
os_type = db.Column(db.String(20)) # linux, windows, darwin
os_version = db.Column(db.String(100))
platform = db.Column(db.String(100))
architecture = db.Column(db.String(20)) # amd64, arm64
cpu_cores = db.Column(db.Integer)
cpu_model = db.Column(db.String(200))
total_memory = db.Column(db.BigInteger) # bytes
total_disk = db.Column(db.BigInteger) # bytes
docker_version = db.Column(db.String(50))
# Security
api_key_hash = db.Column(db.String(256)) # bcrypt hash
api_key_prefix = db.Column(db.String(12)) # For identification: "sk_abc123"
api_secret_encrypted = db.Column(db.Text) # Fernet-encrypted api_secret for signature verification
permissions = db.Column(db.JSON, default=list) # List of permission scopes
allowed_ips = db.Column(db.JSON, default=list) # IP whitelist (empty = all)
# API Key Rotation
api_key_pending_hash = db.Column(db.String(256)) # Hash of pending new API key
api_key_pending_prefix = db.Column(db.String(12)) # Prefix of pending new API key
api_secret_pending_encrypted = db.Column(db.Text) # Encrypted pending new API secret
api_key_rotation_expires = db.Column(db.DateTime) # When pending key rotation expires
api_key_rotation_id = db.Column(db.String(36)) # Unique ID for current rotation
api_key_last_rotated = db.Column(db.DateTime) # Last successful rotation timestamp
# Registration
registration_token_hash = db.Column(db.String(256))
registration_token_expires = db.Column(db.DateTime)
registered_at = db.Column(db.DateTime)
registered_by = db.Column(db.Integer, db.ForeignKey('users.id'))
# Timestamps
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
group = db.relationship('ServerGroup', back_populates='servers')
metrics = db.relationship('ServerMetrics', back_populates='server', lazy='dynamic', cascade='all, delete-orphan')
commands = db.relationship('ServerCommand', back_populates='server', lazy='dynamic', cascade='all, delete-orphan')
sessions = db.relationship('AgentSession', back_populates='server', lazy='dynamic', cascade='all, delete-orphan')
@staticmethod
def generate_registration_token():
"""Generate a secure registration token"""
return f"sk_reg_{secrets.token_urlsafe(32)}"
@staticmethod
def generate_api_credentials():
"""Generate API key and secret"""
api_key = f"sk_{secrets.token_urlsafe(16)}"
api_secret = secrets.token_urlsafe(32)
return api_key, api_secret
def set_registration_token(self, token):
"""Hash and store registration token"""
self.registration_token_hash = generate_password_hash(token)
def verify_registration_token(self, token):
"""Verify a registration token"""
if not self.registration_token_hash:
return False
if self.registration_token_expires and datetime.utcnow() > self.registration_token_expires:
return False
return check_password_hash(self.registration_token_hash, token)
def set_api_key(self, api_key):
"""Hash and store API key"""
self.api_key_hash = generate_password_hash(api_key)
self.api_key_prefix = api_key[:12] if len(api_key) >= 12 else api_key
def verify_api_key(self, api_key):
"""Verify an API key"""
if not self.api_key_hash:
return False
return check_password_hash(self.api_key_hash, api_key)
def set_api_secret_encrypted(self, api_secret):
"""Encrypt and store the API secret for signature verification"""
try:
from app.utils.crypto import encrypt_secret
self.api_secret_encrypted = encrypt_secret(api_secret)
except Exception as e:
print(f"Error encrypting API secret: {e}")
# Don't fail - system can work without signature verification
def get_api_secret(self):
"""Decrypt and return the API secret"""
if not self.api_secret_encrypted:
return None
try:
from app.utils.crypto import decrypt_secret
return decrypt_secret(self.api_secret_encrypted)
except Exception as e:
print(f"Error decrypting API secret: {e}")
return None
def start_key_rotation(self):
"""
Start API key rotation process.
Returns tuple: (new_api_key, new_api_secret, rotation_id)
"""
from datetime import timedelta
new_api_key, new_api_secret = self.generate_api_credentials()
rotation_id = str(uuid.uuid4())
# Store pending credentials
self.api_key_pending_hash = generate_password_hash(new_api_key)
self.api_key_pending_prefix = new_api_key[:12] if len(new_api_key) >= 12 else new_api_key
try:
from app.utils.crypto import encrypt_secret
self.api_secret_pending_encrypted = encrypt_secret(new_api_secret)
except Exception as e:
print(f"Error encrypting pending API secret: {e}")
self.api_key_rotation_id = rotation_id
self.api_key_rotation_expires = datetime.utcnow() + timedelta(minutes=5)
return new_api_key, new_api_secret, rotation_id
def complete_key_rotation(self, rotation_id):
"""
Complete API key rotation by activating pending credentials.
Returns: True if successful, False otherwise
"""
if self.api_key_rotation_id != rotation_id:
return False
if self.api_key_rotation_expires and datetime.utcnow() > self.api_key_rotation_expires:
self.cancel_key_rotation()
return False
# Move pending to active
self.api_key_hash = self.api_key_pending_hash
self.api_key_prefix = self.api_key_pending_prefix
self.api_secret_encrypted = self.api_secret_pending_encrypted
# Clear pending and record rotation
self.api_key_pending_hash = None
self.api_key_pending_prefix = None
self.api_secret_pending_encrypted = None
self.api_key_rotation_id = None
self.api_key_rotation_expires = None
self.api_key_last_rotated = datetime.utcnow()
return True
def cancel_key_rotation(self):
"""Cancel a pending key rotation"""
self.api_key_pending_hash = None
self.api_key_pending_prefix = None
self.api_secret_pending_encrypted = None
self.api_key_rotation_id = None
self.api_key_rotation_expires = None
def verify_pending_api_key(self, api_key):
"""Verify against pending API key during rotation"""
if not self.api_key_pending_hash:
return False
if self.api_key_rotation_expires and datetime.utcnow() > self.api_key_rotation_expires:
return False
return check_password_hash(self.api_key_pending_hash, api_key)
def has_permission(self, scope):
"""Check if server/agent has a specific permission scope"""
if not self.permissions:
return False
if '*' in self.permissions:
return True
# Check exact match or wildcard
scope_parts = scope.split(':')
for perm in self.permissions:
if perm == scope:
return True
# Check wildcard patterns like 'docker:*'
perm_parts = perm.split(':')
if len(perm_parts) <= len(scope_parts):
match = True
for i, part in enumerate(perm_parts):
if part == '*':
break
if i >= len(scope_parts) or part != scope_parts[i]:
match = False
break
if match:
return True
return False
def to_dict(self, include_metrics=False):
result = {
'id': self.id,
'name': self.name,
'description': self.description,
'hostname': self.hostname,
'ip_address': self.ip_address,
'group_id': self.group_id,
'group_name': self.group.name if self.group else None,
'tags': self.tags or [],
'status': self.status,
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
'last_error': self.last_error,
'agent_version': self.agent_version,
'agent_id': self.agent_id,
'os_type': self.os_type,
'os_version': self.os_version,
'platform': self.platform,
'architecture': self.architecture,
'cpu_cores': self.cpu_cores,
'cpu_model': self.cpu_model,
'total_memory': self.total_memory,
'total_disk': self.total_disk,
'docker_version': self.docker_version,
'permissions': self.permissions or [],
'allowed_ips': self.allowed_ips or [],
'api_key_last_rotated': self.api_key_last_rotated.isoformat() if self.api_key_last_rotated else None,
'registered_at': self.registered_at.isoformat() if self.registered_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
if include_metrics:
# Get latest metrics
latest = self.metrics.order_by(ServerMetrics.timestamp.desc()).first()
if latest:
result['latest_metrics'] = latest.to_dict()
return result
def __repr__(self):
return f'<Server {self.name}>'
class ServerMetrics(db.Model):
"""Historical metrics from servers"""
__tablename__ = 'server_metrics'
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=False, index=True)
timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True)
# System Metrics
cpu_percent = db.Column(db.Float)
memory_percent = db.Column(db.Float)
memory_used = db.Column(db.BigInteger)
disk_percent = db.Column(db.Float)
disk_used = db.Column(db.BigInteger)
network_rx = db.Column(db.BigInteger) # Bytes received (total)
network_tx = db.Column(db.BigInteger) # Bytes transmitted (total)
network_rx_rate = db.Column(db.Float) # Bytes/sec
network_tx_rate = db.Column(db.Float) # Bytes/sec
# Docker Metrics
container_count = db.Column(db.Integer)
container_running = db.Column(db.Integer)
# Additional data as JSON
extra = db.Column(db.JSON)
server = db.relationship('Server', back_populates='metrics')
__table_args__ = (
db.Index('ix_server_metrics_server_time', 'server_id', 'timestamp'),
)
def to_dict(self):
return {
'id': self.id,
'server_id': self.server_id,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'cpu_percent': self.cpu_percent,
'memory_percent': self.memory_percent,
'memory_used': self.memory_used,
'disk_percent': self.disk_percent,
'disk_used': self.disk_used,
'network_rx': self.network_rx,
'network_tx': self.network_tx,
'network_rx_rate': self.network_rx_rate,
'network_tx_rate': self.network_tx_rate,
'container_count': self.container_count,
'container_running': self.container_running,
'extra': self.extra,
}
class ServerCommand(db.Model):
"""Audit log of commands executed on servers"""
__tablename__ = 'server_commands'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Command details
command_type = db.Column(db.String(50)) # docker:container:start, system:exec, etc.
command_data = db.Column(db.JSON) # The actual command/parameters
# Execution
status = db.Column(db.String(20), default='pending') # pending, running, completed, failed, timeout
started_at = db.Column(db.DateTime)
completed_at = db.Column(db.DateTime)
# Result
result = db.Column(db.JSON)
error = db.Column(db.Text)
exit_code = db.Column(db.Integer)
# Retry / offline queue
retry_count = db.Column(db.Integer, default=0)
max_retries = db.Column(db.Integer, default=3)
next_retry_at = db.Column(db.DateTime)
backoff_seconds = db.Column(db.Integer, default=30) # initial backoff, doubles each retry
queued = db.Column(db.Boolean, default=False) # True when agent was offline at send time
created_at = db.Column(db.DateTime, default=datetime.utcnow)
server = db.relationship('Server', back_populates='commands')
def to_dict(self):
return {
'id': self.id,
'server_id': self.server_id,
'user_id': self.user_id,
'command_type': self.command_type,
'command_data': self.command_data,
'status': self.status,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'result': self.result,
'error': self.error,
'exit_code': self.exit_code,
'retry_count': self.retry_count,
'max_retries': self.max_retries,
'queued': self.queued,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class AgentSession(db.Model):
"""Active agent WebSocket sessions"""
__tablename__ = 'agent_sessions'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=False, index=True)
session_token = db.Column(db.String(256)) # Current session token
socket_id = db.Column(db.String(100)) # SocketIO session ID
connected_at = db.Column(db.DateTime, default=datetime.utcnow)
last_heartbeat = db.Column(db.DateTime, default=datetime.utcnow)
ip_address = db.Column(db.String(45))
user_agent = db.Column(db.String(255)) # Agent version info
# Latency tracking
heartbeat_latency_ms = db.Column(db.Float) # Latest heartbeat round-trip latency
avg_latency_ms = db.Column(db.Float) # Running average latency
latency_samples = db.Column(db.Integer, default=0) # Number of samples in average
is_active = db.Column(db.Boolean, default=True, index=True)
disconnected_at = db.Column(db.DateTime)
disconnect_reason = db.Column(db.String(100))
server = db.relationship('Server', back_populates='sessions')
def update_latency(self, latency_ms):
"""Update latency with exponential moving average"""
self.heartbeat_latency_ms = latency_ms
if self.avg_latency_ms is None or self.latency_samples == 0:
self.avg_latency_ms = latency_ms
self.latency_samples = 1
else:
# EMA with alpha = 0.2 for smoothing
alpha = 0.2
self.avg_latency_ms = alpha * latency_ms + (1 - alpha) * self.avg_latency_ms
self.latency_samples += 1
def to_dict(self):
return {
'id': self.id,
'server_id': self.server_id,
'connected_at': self.connected_at.isoformat() if self.connected_at else None,
'last_heartbeat': self.last_heartbeat.isoformat() if self.last_heartbeat else None,
'ip_address': self.ip_address,
'heartbeat_latency_ms': self.heartbeat_latency_ms,
'avg_latency_ms': self.avg_latency_ms,
'is_active': self.is_active,
'disconnected_at': self.disconnected_at.isoformat() if self.disconnected_at else None,
'disconnect_reason': self.disconnect_reason,
}
class AgentRollout(db.Model):
"""Tracks staged rollout progress"""
__tablename__ = 'agent_rollouts'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
version_id = db.Column(db.String(36), db.ForeignKey('agent_versions.id'), nullable=False)
group_id = db.Column(db.String(36), db.ForeignKey('server_groups.id'), nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Configuration
batch_size = db.Column(db.Integer, default=5)
delay_minutes = db.Column(db.Integer, default=10)
strategy = db.Column(db.String(20), default='staged') # staged, all, canary
# Progress
status = db.Column(db.String(20), default='pending') # pending, running, paused, completed, failed, cancelled
total_servers = db.Column(db.Integer, default=0)
processed_servers = db.Column(db.Integer, default=0)
failed_servers = db.Column(db.Integer, default=0)
current_wave = db.Column(db.Integer, default=0)
# Results per server
server_results = db.Column(db.JSON, default=list) # [{server_id, status, error, wave}]
error = db.Column(db.Text)
started_at = db.Column(db.DateTime)
completed_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
version = db.relationship('AgentVersion')
def to_dict(self):
return {
'id': self.id,
'version_id': self.version_id,
'version': self.version.version if self.version else None,
'group_id': self.group_id,
'user_id': self.user_id,
'batch_size': self.batch_size,
'delay_minutes': self.delay_minutes,
'strategy': self.strategy,
'status': self.status,
'total_servers': self.total_servers,
'processed_servers': self.processed_servers,
'failed_servers': self.failed_servers,
'current_wave': self.current_wave,
'server_results': self.server_results or [],
'error': self.error,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class AgentVersion(db.Model):
"""Available agent versions and compatibility matrix"""
__tablename__ = 'agent_versions'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
version = db.Column(db.String(20), nullable=False, unique=True)
channel = db.Column(db.String(20), default='stable') # stable, beta
# Compatibility
min_panel_version = db.Column(db.String(20))
max_panel_version = db.Column(db.String(20))
# Metadata
release_notes = db.Column(db.Text)
is_active = db.Column(db.Boolean, default=True)
published_at = db.Column(db.DateTime, default=datetime.utcnow)
# Assets (mapped by platform: linux-amd64, windows-amd64, etc.)
assets = db.Column(db.JSON) # {"linux-amd64": "url", "checksums": "url"}
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'version': self.version,
'channel': self.channel,
'min_panel_version': self.min_panel_version,
'max_panel_version': self.max_panel_version,
'release_notes': self.release_notes,
'is_active': self.is_active,
'published_at': self.published_at.isoformat() if self.published_at else None,
'assets': self.assets or {},
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<AgentVersion {self.version}>'