First Build alpha 0.1
This commit is contained in:
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
74
app/utils/config.py
Normal file
74
app/utils/config.py
Normal 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
91
app/utils/security.py
Normal 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
165
app/utils/validators.py
Normal 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
|
||||
Reference in New Issue
Block a user