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]>
415 lines
20 KiB
Python
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,
|
|
}
|