Add i18n, branding, user management, health checks, and cleanup for deployment

- Multi-language support (EN/DE) with i18n engine and language files
- Configurable branding (name, subtitle, logo) in Settings
- Global default language and per-user language preference
- User management router with CRUD endpoints
- Customer status sync on start/stop/restart
- Health check fixes: derive status from container state, remove broken wget healthcheck
- Caddy reverse proxy and dashboard env templates for customer stacks
- Updated README with real hardware specs, prerequisites, and new features
- Removed .claude settings (JWT tokens) and build artifacts from tracking
- Updated .gitignore for .claude/ and Windows artifacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 17:24:05 +01:00
parent c4d68db2f4
commit 41ba835a99
28 changed files with 2550 additions and 661 deletions

View File

@@ -30,6 +30,7 @@ class AppConfig:
data_dir: str
docker_network: str
relay_base_port: int
dashboard_base_port: int
# Environment-level settings (not stored in DB)
@@ -77,4 +78,5 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
data_dir=row.data_dir,
docker_network=row.docker_network,
relay_base_port=row.relay_base_port,
dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000,
)

View File

@@ -89,3 +89,16 @@ def generate_relay_secret() -> str:
A 32-character hex string.
"""
return secrets.token_hex(16)
def generate_datastore_encryption_key() -> str:
"""Generate a base64-encoded 32-byte key for NetBird DataStoreEncryptionKey.
NetBird management (Go) expects standard base64 decoding to exactly 32 bytes.
Returns:
A standard base64-encoded string representing 32 random bytes.
"""
import base64
return base64.b64encode(secrets.token_bytes(32)).decode()

View File

@@ -109,6 +109,14 @@ class SystemConfigUpdate(BaseModel):
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)
dashboard_base_port: Optional[int] = Field(None, ge=1024, le=65535)
branding_name: Optional[str] = Field(None, max_length=255)
branding_subtitle: Optional[str] = Field(None, max_length=255)
default_language: Optional[str] = Field(None, max_length=10)
azure_enabled: Optional[bool] = None
azure_tenant_id: Optional[str] = Field(None, max_length=255)
azure_client_id: Optional[str] = Field(None, max_length=255)
azure_client_secret: Optional[str] = None # encrypted before storage
@field_validator("base_domain")
@classmethod
@@ -143,6 +151,54 @@ class SystemConfigUpdate(BaseModel):
return v.lower().strip()
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
class UserCreate(BaseModel):
"""Payload to create a new local user."""
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=8, max_length=128)
email: Optional[str] = Field(None, max_length=255)
default_language: Optional[str] = Field(None, max_length=10)
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
if not re.match(r"^[a-zA-Z0-9_.-]+$", v):
raise ValueError("Username may only contain letters, digits, dots, hyphens, and underscores.")
return v.strip()
@field_validator("email")
@classmethod
def validate_user_email(cls, v: Optional[str]) -> Optional[str]:
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()
class UserUpdate(BaseModel):
"""Payload to update an existing user."""
email: Optional[str] = Field(None, max_length=255)
is_active: Optional[bool] = None
role: Optional[str] = Field(None, max_length=20)
default_language: Optional[str] = Field(None, max_length=10)
@field_validator("email")
@classmethod
def validate_user_email(cls, v: Optional[str]) -> Optional[str]:
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
# ---------------------------------------------------------------------------