Files
NetBirdMSP-Appliance/app/models.py
T
twothatITandClaude Sonnet 5 189c49d505 feat(monitoring): add automatic restart of unreachable customer containers
Some customers only regain access to their NetBird web UI after a manual
container restart, and until now that required someone to notice and do it
by hand. The scheduler now optionally probes each customer's actual
dashboard URL over HTTP(S) every poll interval (not just Docker container
state, since a container can be "running" while the web UI behind it still
doesn't respond) and restarts that customer's containers if it doesn't
answer, gated by a per-deployment cooldown to avoid restart loops on a
persistently broken customer.

New system setting "Automatic Restart of Unreachable Containers" (default
off) with a configurable cooldown. Every automatic restart is logged in the
affected customer's own log, same as a manual restart.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-23 13:20:40 +02:00

415 lines
20 KiB
Python

"""SQLAlchemy ORM models for NetBird MSP Appliance."""
import json
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Customer(Base):
"""Customer model representing an MSP client."""
__tablename__ = "customers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
subdomain: Mapped[str] = mapped_column(String(63), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False)
max_devices: Mapped[int] = mapped_column(Integer, default=20)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20),
default="active",
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
__table_args__ = (
CheckConstraint(
"status IN ('active', 'inactive', 'deploying', 'error')",
name="ck_customer_status",
),
)
deployment: Mapped[Optional["Deployment"]] = relationship(
"Deployment", back_populates="customer", uselist=False, cascade="all, delete-orphan"
)
logs: Mapped[list["DeploymentLog"]] = relationship(
"DeploymentLog", back_populates="customer", cascade="all, delete-orphan"
)
def to_dict(self) -> dict:
"""Serialize customer to dictionary."""
return {
"id": self.id,
"name": self.name,
"company": self.company,
"subdomain": self.subdomain,
"email": self.email,
"max_devices": self.max_devices,
"notes": self.notes,
"status": self.status,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class Deployment(Base):
"""Deployment model tracking a customer's NetBird instance."""
__tablename__ = "deployments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
Integer, ForeignKey("customers.id", ondelete="CASCADE"), unique=True, nullable=False
)
container_prefix: Mapped[str] = mapped_column(String(100), nullable=False)
relay_udp_port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
dashboard_port: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
npm_proxy_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
npm_stream_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
relay_secret: Mapped[str] = mapped_column(Text, nullable=False)
setup_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
netbird_admin_email: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
netbird_admin_password: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
netbird_api_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
netbird_api_token_renewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
deployment_status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False
)
deployed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_health_check: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
last_auto_restart_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
__table_args__ = (
CheckConstraint(
"deployment_status IN ('pending', 'running', 'stopped', 'failed')",
name="ck_deployment_status",
),
)
customer: Mapped["Customer"] = relationship("Customer", back_populates="deployment")
def to_dict(self) -> dict:
"""Serialize deployment to dictionary."""
return {
"id": self.id,
"customer_id": self.customer_id,
"container_prefix": self.container_prefix,
"relay_udp_port": self.relay_udp_port,
"dashboard_port": self.dashboard_port,
"npm_proxy_id": self.npm_proxy_id,
"npm_stream_id": self.npm_stream_id,
"relay_secret": "***", # Never expose secrets
"setup_url": self.setup_url,
"has_credentials": bool(self.netbird_admin_email and self.netbird_admin_password),
"has_netbird_api_token": bool(self.netbird_api_token_encrypted),
"netbird_api_token_renewed_at": (
self.netbird_api_token_renewed_at.isoformat() if self.netbird_api_token_renewed_at else None
),
"deployment_status": self.deployment_status,
"deployed_at": self.deployed_at.isoformat() if self.deployed_at else None,
"last_health_check": (
self.last_health_check.isoformat() if self.last_health_check else None
),
"last_auto_restart_at": (
self.last_auto_restart_at.isoformat() if self.last_auto_restart_at else None
),
}
class SystemConfig(Base):
"""Singleton system configuration — always id=1."""
__tablename__ = "system_config"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, default=1
)
base_domain: Mapped[str] = mapped_column(String(255), nullable=False)
admin_email: Mapped[str] = mapped_column(String(255), nullable=False)
npm_api_url: Mapped[str] = mapped_column(String(500), nullable=False)
npm_api_email_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
npm_api_password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
netbird_management_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/management:latest"
)
netbird_signal_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/signal:latest"
)
netbird_relay_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/relay:latest"
)
netbird_dashboard_image: Mapped[str] = mapped_column(
String(255), default="netbirdio/dashboard:latest"
)
data_dir: Mapped[str] = mapped_column(String(500), default="/opt/netbird-instances")
docker_network: Mapped[str] = mapped_column(String(100), default="npm-network")
relay_base_port: Mapped[int] = mapped_column(Integer, default=3478)
dashboard_base_port: Mapped[int] = mapped_column(Integer, default=9000)
branding_name: Mapped[Optional[str]] = mapped_column(
String(255), default="NetBird MSP Appliance"
)
branding_subtitle: Mapped[Optional[str]] = mapped_column(
String(255), default="Multi-Tenant Management Platform"
)
branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en")
ssl_mode: Mapped[str] = mapped_column(String(20), default="letsencrypt")
wildcard_cert_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
azure_client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
azure_client_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
azure_allowed_group_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
comment="If set, only Azure AD users in this group (object ID) are allowed to log in."
)
# Windows DNS integration
dns_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
dns_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
dns_username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
dns_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
dns_zone: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
dns_record_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
# LDAP / Active Directory authentication
ldap_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
ldap_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
ldap_port: Mapped[int] = mapped_column(Integer, default=389)
ldap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
ldap_bind_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ldap_bind_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ldap_base_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ldap_user_filter: Mapped[Optional[str]] = mapped_column(
String(255), default="(sAMAccountName={username})"
)
ldap_group_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Update management
git_repo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
git_branch: Mapped[Optional[str]] = mapped_column(String(100), default="main")
git_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Automatic NetBird image update check/apply
auto_update_check_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
auto_update_check_time: Mapped[Optional[str]] = mapped_column(String(5), default="03:00")
auto_update_apply_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
auto_update_last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Automatic restart of customer containers when found unreachable/not running
auto_restart_unreachable_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
auto_restart_cooldown_minutes: Mapped[int] = mapped_column(Integer, default=30)
# Master default for the NetBird *client* (peer) automatic-updates feature
# (Settings > Clients > Automatic Updates inside each customer's own
# NetBird dashboard) — pushed to customers via the NetBird Management API.
netbird_client_auto_update_version: Mapped[str] = mapped_column(String(50), default="disabled")
netbird_client_auto_update_always: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
__table_args__ = (
CheckConstraint("id = 1", name="ck_system_config_singleton"),
)
def to_dict(self) -> dict:
"""Serialize config to dictionary (credentials masked)."""
return {
"base_domain": self.base_domain,
"admin_email": self.admin_email,
"npm_api_url": self.npm_api_url,
"npm_credentials_set": bool(self.npm_api_email_encrypted and self.npm_api_password_encrypted),
"netbird_management_image": self.netbird_management_image,
"netbird_signal_image": self.netbird_signal_image,
"netbird_relay_image": self.netbird_relay_image,
"netbird_dashboard_image": self.netbird_dashboard_image,
"data_dir": self.data_dir,
"docker_network": self.docker_network,
"relay_base_port": self.relay_base_port,
"dashboard_base_port": self.dashboard_base_port,
"branding_name": self.branding_name or "NetBird MSP Appliance",
"branding_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform",
"branding_logo_path": self.branding_logo_path,
"default_language": self.default_language or "en",
"ssl_mode": self.ssl_mode or "letsencrypt",
"wildcard_cert_id": self.wildcard_cert_id,
"mfa_enabled": bool(self.mfa_enabled),
"azure_enabled": bool(self.azure_enabled),
"azure_tenant_id": self.azure_tenant_id or "",
"azure_client_id": self.azure_client_id or "",
"azure_client_secret_set": bool(self.azure_client_secret_encrypted),
"azure_allowed_group_id": self.azure_allowed_group_id or "",
"dns_enabled": bool(self.dns_enabled),
"dns_server": self.dns_server or "",
"dns_username": self.dns_username or "",
"dns_password_set": bool(self.dns_password_encrypted),
"dns_zone": self.dns_zone or "",
"dns_record_ip": self.dns_record_ip or "",
"ldap_enabled": bool(self.ldap_enabled),
"ldap_server": self.ldap_server or "",
"ldap_port": self.ldap_port or 389,
"ldap_use_ssl": bool(self.ldap_use_ssl),
"ldap_bind_dn": self.ldap_bind_dn or "",
"ldap_bind_password_set": bool(self.ldap_bind_password_encrypted),
"ldap_base_dn": self.ldap_base_dn or "",
"ldap_user_filter": self.ldap_user_filter or "(sAMAccountName={username})",
"ldap_group_dn": self.ldap_group_dn or "",
"git_repo_url": self.git_repo_url or "",
"git_branch": self.git_branch or "main",
"git_token_set": bool(self.git_token_encrypted),
"auto_update_check_enabled": bool(self.auto_update_check_enabled),
"auto_update_check_time": self.auto_update_check_time or "03:00",
"auto_update_apply_enabled": bool(self.auto_update_apply_enabled),
"auto_update_last_run_at": (
self.auto_update_last_run_at.isoformat() if self.auto_update_last_run_at else None
),
"auto_restart_unreachable_enabled": bool(self.auto_restart_unreachable_enabled),
"auto_restart_cooldown_minutes": self.auto_restart_cooldown_minutes or 30,
"netbird_client_auto_update_version": self.netbird_client_auto_update_version or "disabled",
"netbird_client_auto_update_always": bool(self.netbird_client_auto_update_always),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class DeploymentLog(Base):
"""Log entries for deployment actions."""
__tablename__ = "deployment_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False
)
action: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
details: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
__table_args__ = (
CheckConstraint(
"status IN ('success', 'error', 'info')",
name="ck_log_status",
),
)
customer: Mapped["Customer"] = relationship("Customer", back_populates="logs")
def to_dict(self) -> dict:
"""Serialize log entry to dictionary."""
return {
"id": self.id,
"customer_id": self.customer_id,
"action": self.action,
"status": self.status,
"message": self.message,
"details": self.details,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class UpdateRunLog(Base):
"""Audit trail for NetBird Docker image update runs (pull + container recreate).
Written for both scheduled (automatic) and manual (button-triggered)
runs, so the actual outcome — which image pulls failed, which customer
container recreates failed — is visible in the UI instead of only in
container logs.
"""
__tablename__ = "update_run_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_type: Mapped[str] = mapped_column(String(20), nullable=False) # "scheduled" | "manual"
trigger: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # username, or "scheduler"
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(
String(20), default="running"
) # running | no_update | success | partial | failed
any_update_available: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
pull_attempted: Mapped[bool] = mapped_column(Boolean, default=False)
pull_success: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
pull_details: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON: {image: {success, error}}
apply_attempted: Mapped[bool] = mapped_column(Boolean, default=False)
customers_updated: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
customers_total: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
customer_details: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
def to_dict(self) -> dict:
"""Serialize to dict, parsing the embedded JSON blobs."""
return {
"id": self.id,
"run_type": self.run_type,
"trigger": self.trigger,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
"status": self.status,
"any_update_available": self.any_update_available,
"pull_attempted": self.pull_attempted,
"pull_success": self.pull_success,
"pull_details": json.loads(self.pull_details) if self.pull_details else None,
"apply_attempted": self.apply_attempted,
"customers_updated": self.customers_updated,
"customers_total": self.customers_total,
"customer_details": json.loads(self.customer_details) if self.customer_details else None,
"error": self.error,
}
class User(Base):
"""Admin user model."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
role: Mapped[str] = mapped_column(String(20), default="admin")
auth_provider: Mapped[str] = mapped_column(String(20), default="local")
default_language: Mapped[Optional[str]] = mapped_column(String(10), nullable=True, default=None)
totp_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
def to_dict(self) -> dict:
"""Serialize user to dictionary (no password, no TOTP secret)."""
return {
"id": self.id,
"username": self.username,
"email": self.email,
"is_active": self.is_active,
"role": self.role or "admin",
"auth_provider": self.auth_provider or "local",
"default_language": self.default_language,
"totp_enabled": bool(self.totp_enabled),
"created_at": self.created_at.isoformat() if self.created_at else None,
}