First Build alpha 0.1

This commit is contained in:
2026-02-07 12:18:20 +01:00
parent 29e83436b2
commit 42a3cc9d9f
36 changed files with 4982 additions and 51 deletions

0
app/utils/__init__.py Normal file
View File

74
app/utils/config.py Normal file
View File

@@ -0,0 +1,74 @@
"""Configuration management — loads all settings from the database (system_config table).
There is NO .env file for application config. The install.sh script collects values
interactively and seeds them into the database. The Web UI settings page allows
editing every value at runtime.
"""
import os
from dataclasses import dataclass
from typing import Optional
from sqlalchemy.orm import Session
from app.utils.security import decrypt_value
@dataclass
class AppConfig:
"""In-memory snapshot of system configuration."""
base_domain: str
admin_email: str
npm_api_url: str
npm_api_token: str # decrypted
netbird_management_image: str
netbird_signal_image: str
netbird_relay_image: str
netbird_dashboard_image: str
data_dir: str
docker_network: str
relay_base_port: int
# Environment-level settings (not stored in DB)
SECRET_KEY: str = os.environ.get("SECRET_KEY", "change-me-in-production")
DATABASE_PATH: str = os.environ.get("DATABASE_PATH", "/app/data/netbird_msp.db")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 480 # 8 hours
def get_system_config(db: Session) -> Optional[AppConfig]:
"""Load the singleton SystemConfig row and return an AppConfig dataclass.
Args:
db: Active SQLAlchemy session.
Returns:
AppConfig instance or None if the system_config row does not exist yet.
"""
from app.models import SystemConfig
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if row is None:
return None
try:
npm_token = decrypt_value(row.npm_api_token_encrypted)
except Exception:
npm_token = ""
return AppConfig(
base_domain=row.base_domain,
admin_email=row.admin_email,
npm_api_url=row.npm_api_url,
npm_api_token=npm_token,
netbird_management_image=row.netbird_management_image,
netbird_signal_image=row.netbird_signal_image,
netbird_relay_image=row.netbird_relay_image,
netbird_dashboard_image=row.netbird_dashboard_image,
data_dir=row.data_dir,
docker_network=row.docker_network,
relay_base_port=row.relay_base_port,
)

91
app/utils/security.py Normal file
View File

@@ -0,0 +1,91 @@
"""Security utilities — password hashing (bcrypt) and token encryption (Fernet)."""
import os
import secrets
from cryptography.fernet import Fernet
from passlib.context import CryptContext
# ---------------------------------------------------------------------------
# Password hashing (bcrypt)
# ---------------------------------------------------------------------------
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""Hash a plaintext password with bcrypt.
Args:
plain: The plaintext password.
Returns:
Bcrypt hash string.
"""
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Verify a plaintext password against a bcrypt hash.
Args:
plain: The plaintext password to check.
hashed: The stored bcrypt hash.
Returns:
True if the password matches.
"""
return pwd_context.verify(plain, hashed)
# ---------------------------------------------------------------------------
# Fernet encryption for secrets (NPM token, relay secrets, etc.)
# ---------------------------------------------------------------------------
def _get_fernet() -> Fernet:
"""Derive a Fernet key from the application SECRET_KEY.
The SECRET_KEY from the environment is used as the basis. We pad/truncate
it to produce a valid 32-byte URL-safe-base64 key that Fernet requires.
"""
import base64
import hashlib
secret = os.environ.get("SECRET_KEY", "change-me-in-production")
# Derive a stable 32-byte key via SHA-256
key_bytes = hashlib.sha256(secret.encode()).digest()
fernet_key = base64.urlsafe_b64encode(key_bytes)
return Fernet(fernet_key)
def encrypt_value(plaintext: str) -> str:
"""Encrypt a string value with Fernet.
Args:
plaintext: Value to encrypt.
Returns:
Encrypted string (base64-encoded Fernet token).
"""
f = _get_fernet()
return f.encrypt(plaintext.encode()).decode()
def decrypt_value(ciphertext: str) -> str:
"""Decrypt a Fernet-encrypted string.
Args:
ciphertext: Encrypted value.
Returns:
Original plaintext string.
"""
f = _get_fernet()
return f.decrypt(ciphertext.encode()).decode()
def generate_relay_secret() -> str:
"""Generate a cryptographically secure relay secret.
Returns:
A 32-character hex string.
"""
return secrets.token_hex(16)

165
app/utils/validators.py Normal file
View File

@@ -0,0 +1,165 @@
"""Input validation with Pydantic models for all API endpoints."""
import re
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
"""Login credentials."""
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=1)
class ChangePasswordRequest(BaseModel):
"""Password change payload."""
current_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=12, max_length=128)
# ---------------------------------------------------------------------------
# Customer
# ---------------------------------------------------------------------------
class CustomerCreate(BaseModel):
"""Payload to create a new customer."""
name: str = Field(..., min_length=1, max_length=255)
company: Optional[str] = Field(None, max_length=255)
subdomain: str = Field(..., min_length=1, max_length=63)
email: str = Field(..., max_length=255)
max_devices: int = Field(default=20, ge=1, le=10000)
notes: Optional[str] = None
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v: str) -> str:
"""Subdomain must be lowercase alphanumeric + hyphens, no leading/trailing hyphen."""
v = v.lower().strip()
if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", v):
raise ValueError(
"Subdomain must be lowercase, alphanumeric with hyphens, "
"2-63 chars, no leading/trailing hyphen."
)
return v
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
"""Basic email format check."""
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email address.")
return v.lower().strip()
class CustomerUpdate(BaseModel):
"""Payload to update an existing customer."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
company: Optional[str] = Field(None, max_length=255)
email: Optional[str] = Field(None, max_length=255)
max_devices: Optional[int] = Field(None, ge=1, le=10000)
notes: Optional[str] = None
status: Optional[str] = None
@field_validator("email")
@classmethod
def validate_email(cls, v: Optional[str]) -> Optional[str]:
"""Basic email format check."""
if v is None:
return v
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email address.")
return v.lower().strip()
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""Status must be one of the allowed values."""
if v is None:
return v
allowed = {"active", "inactive", "deploying", "error"}
if v not in allowed:
raise ValueError(f"Status must be one of: {', '.join(sorted(allowed))}")
return v
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
class SystemConfigUpdate(BaseModel):
"""Payload to update system configuration."""
base_domain: Optional[str] = Field(None, min_length=1, max_length=255)
admin_email: Optional[str] = Field(None, max_length=255)
npm_api_url: Optional[str] = Field(None, max_length=500)
npm_api_token: Optional[str] = None # plaintext, will be encrypted before storage
netbird_management_image: Optional[str] = Field(None, max_length=255)
netbird_signal_image: Optional[str] = Field(None, max_length=255)
netbird_relay_image: Optional[str] = Field(None, max_length=255)
netbird_dashboard_image: Optional[str] = Field(None, max_length=255)
data_dir: Optional[str] = Field(None, max_length=500)
docker_network: Optional[str] = Field(None, max_length=100)
relay_base_port: Optional[int] = Field(None, ge=1024, le=65535)
@field_validator("base_domain")
@classmethod
def validate_domain(cls, v: Optional[str]) -> Optional[str]:
"""Validate domain format."""
if v is None:
return v
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
if not re.match(pattern, v):
raise ValueError("Invalid domain format.")
return v.lower().strip()
@field_validator("npm_api_url")
@classmethod
def validate_npm_url(cls, v: Optional[str]) -> Optional[str]:
"""NPM URL must start with http(s)://."""
if v is None:
return v
if not re.match(r"^https?://", v):
raise ValueError("NPM API URL must start with http:// or https://")
return v.rstrip("/")
@field_validator("admin_email")
@classmethod
def validate_email(cls, v: Optional[str]) -> Optional[str]:
"""Validate admin email."""
if v is None:
return v
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email address.")
return v.lower().strip()
# ---------------------------------------------------------------------------
# Query params
# ---------------------------------------------------------------------------
class CustomerListParams(BaseModel):
"""Query parameters for listing customers."""
page: int = Field(default=1, ge=1)
per_page: int = Field(default=25, ge=1, le=100)
search: Optional[str] = None
status: Optional[str] = None
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""Filter status validation."""
if v is None or v == "":
return None
allowed = {"active", "inactive", "deploying", "error"}
if v not in allowed:
raise ValueError(f"Status must be one of: {', '.join(sorted(allowed))}")
return v