From 41ba835a99f1fa7fc279738b0c96d5b98a8caa4f Mon Sep 17 00:00:00 2001 From: twothatit Date: Sun, 8 Feb 2026 17:24:05 +0100 Subject: [PATCH] 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 --- .claude/settings.local.json | 8 - .gitignore | 6 + README.md | 506 +++++++++++++-------------- app/database.py | 39 ++- app/main.py | 3 +- app/models.py | 33 ++ app/routers/auth.py | 120 ++++++- app/routers/deployments.py | 33 ++ app/routers/monitoring.py | 1 + app/routers/settings.py | 95 ++++- app/routers/users.py | 131 +++++++ app/services/docker_service.py | 32 +- app/services/netbird_service.py | 276 ++++++++------- app/utils/config.py | 2 + app/utils/security.py | 13 + app/utils/validators.py | 56 +++ netbird-msp-appliance.tar.gz | Bin 25486 -> 0 bytes requirements.txt | 2 + static/css/styles.css | 15 + static/index.html | 428 ++++++++++++++++------- static/js/app.js | 600 +++++++++++++++++++++++++++----- static/js/i18n.js | 100 ++++++ static/lang/de.json | 285 +++++++++++++++ static/lang/en.json | 285 +++++++++++++++ templates/Caddyfile.j2 | 36 ++ templates/dashboard.env.j2 | 13 + templates/docker-compose.yml.j2 | 40 ++- templates/management.json.j2 | 53 ++- 28 files changed, 2550 insertions(+), 661 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 app/routers/users.py delete mode 100644 netbird-msp-appliance.tar.gz create mode 100644 static/js/i18n.js create mode 100644 static/lang/de.json create mode 100644 static/lang/en.json create mode 100644 templates/Caddyfile.j2 create mode 100644 templates/dashboard.env.j2 diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 78e480e..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git status:*)", - "Bash(git add:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index 40f9887..ddcf765 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,9 @@ docker-compose.override.yml .pytest_cache/ .coverage htmlcov/ + +# Claude Code +.claude/ + +# Windows artifacts +nul diff --git a/README.md b/README.md index 4e4ead1..5223932 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # NetBird MSP Appliance -πŸš€ **Self-Hosted Multi-Tenant NetBird Management Platform** +**Self-Hosted Multi-Tenant NetBird Management Platform** -A complete management solution for running 100+ isolated NetBird instances for your MSP business. Manage all your customers' NetBird networks from a single, powerful web interface. +A management solution for running isolated NetBird instances for your MSP business. Manage all your customers' NetBird networks from a single web interface. ![License](https://img.shields.io/badge/license-MIT-blue.svg) ![Docker](https://img.shields.io/badge/docker-required-blue.svg) @@ -10,123 +10,173 @@ A complete management solution for running 100+ isolated NetBird instances for y --- -## πŸ“‹ Table of Contents +## Table of Contents - [Features](#features) - [Architecture](#architecture) - [System Requirements](#system-requirements) +- [Prerequisites](#prerequisites) - [Quick Start](#quick-start) - [Configuration](#configuration) - [Usage](#usage) - [API Documentation](#api-documentation) - [Troubleshooting](#troubleshooting) -- [Contributing](#contributing) +- [Updates](#updates) +- [Security Best Practices](#security-best-practices) - [License](#license) --- -## ✨ Features +## Features -- **🎯 Multi-Tenant Management**: Manage 100+ isolated NetBird instances from one dashboard -- **πŸ”’ Complete Isolation**: Each customer gets their own NetBird instance with separate databases -- **🌐 Nginx Proxy Manager Integration**: Automatic SSL certificate management and reverse proxy setup -- **🐳 Docker-Based**: Everything runs in containers for easy deployment and updates -- **πŸ“Š Web Dashboard**: Modern, responsive UI for managing customers and deployments -- **πŸš€ One-Click Deployment**: Deploy new customer instances in under 2 minutes -- **πŸ“ˆ Monitoring**: Real-time status monitoring and health checks -- **πŸ”„ Automated Updates**: Bulk update NetBird containers across all customers -- **πŸ’Ύ Backup & Restore**: Built-in backup functionality for all customer data -- **πŸ” Secure by Default**: Encrypted credentials, API tokens, and secrets management +### Core +- **Multi-Tenant Management** β€” Deploy and manage isolated NetBird instances per customer +- **Complete Isolation** β€” Each customer gets their own NetBird stack with separate data +- **One-Click Deployment** β€” Deploy new customer instances in under 2 minutes +- **Nginx Proxy Manager Integration** β€” Automatic SSL certificates and reverse proxy setup +- **Docker-Based** β€” Everything runs in containers for easy deployment + +### Dashboard +- **Modern Web UI** β€” Responsive Bootstrap 5 interface +- **Real-Time Monitoring** β€” Container status, health checks, resource usage +- **Container Logs** β€” View logs per container directly in the browser +- **Start / Stop / Restart** β€” Control customer instances from the dashboard +- **Customer Status Tracking** β€” Automatic status sync (active / inactive / error) + +### Multi-Language (i18n) +- **English and German** β€” Full UI translation +- **Global Default Language** β€” Set a system-wide default language in Settings > Branding +- **Per-User Language** β€” Each user can have their own preferred language +- **Language Priority** β€” User preference > System default > Browser language + +### Customization +- **Branding** β€” Configure platform name, subtitle, and logo via Settings +- **Login Page** β€” Branding is applied to the login page automatically +- **Configurable Docker Images** β€” Use custom or specific NetBird image versions + +### Security +- **JWT Authentication** β€” Token-based API authentication +- **Azure AD / OIDC** β€” Optional single sign-on via Microsoft Entra ID +- **Encrypted Credentials** β€” NPM passwords and relay secrets are Fernet-encrypted +- **User Management** β€” Create, edit, and delete admin users --- -## πŸ—οΈ Architecture +## Architecture ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ NetBird MSP Appliance β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Web GUI │───▢│ FastAPI │──▢│ SQLite DB β”‚ β”‚ -β”‚ β”‚ (Bootstrap) β”‚ β”‚ Backend β”‚ β”‚ β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β–Ό β–Ό β–Ό β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Docker β”‚ β”‚ NPM β”‚ β”‚ Firewall β”‚ β”‚ -β”‚ β”‚ Engine β”‚ β”‚ API β”‚ β”‚ Manager β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β–Ό β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Customer 1 β”‚ β”‚ Customer 100 β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Management β”‚ β”‚ β”‚ β”‚ Management β”‚ β”‚ -β”‚ β”‚ Signal β”‚ β”‚ ... β”‚ β”‚ Signal β”‚ β”‚ -β”‚ β”‚ Relay β”‚ β”‚ β”‚ β”‚ Relay β”‚ β”‚ -β”‚ β”‚ Dashboard β”‚ β”‚ β”‚ β”‚ Dashboard β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - kunde1.domain.de kunde100.domain.de - UDP 3478 UDP 3577 ++-------------------------------------------------------------+ +| NetBird MSP Appliance | +| | +| +--------------+ +--------------+ +---------------+ | +| | Web GUI |--->| FastAPI |-->| SQLite DB | | +| | (Bootstrap) | | Backend | | | | +| +--------------+ +--------------+ +---------------+ | +| | | +| +-------------------+-------------------+ | +| v v v | +| +-------------+ +-------------+ +---------------+ | +| | Docker | | NPM | | Template | | +| | Engine | | API | | Renderer | | +| +-------------+ +-------------+ +---------------+ | ++-------------------------------------------------------------+ + | + +-------------------+-------------------+ + v v ++------------------+ +------------------+ +| Customer 1 | | Customer N | +| +------------+ | | +------------+ | +| | Management | | | | Management | | +| | Signal | | ... | | Signal | | +| | Relay | | | | Relay | | +| | Dashboard | | | | Dashboard | | +| | Caddy | | | | Caddy | | +| +------------+ | | +------------+ | ++------------------+ +------------------+ + kunde1.domain.de kundeN.domain.de + UDP 3478 UDP 3478+N-1 ``` -### Components per Customer Instance: -- **Management Service**: API and network state management -- **Signal Service**: WebRTC signaling for peer connections -- **Relay Service**: STUN/TURN server for NAT traversal (requires public UDP port) -- **Dashboard**: Web UI for end-users +### Components per Customer Instance (5 containers): +- **Management** β€” API and network state management +- **Signal** β€” WebRTC signaling for peer connections +- **Relay** β€” STUN/TURN server for NAT traversal (requires public UDP port) +- **Dashboard** β€” Web UI for end-users +- **Caddy** β€” Reverse proxy / entry point for the customer stack All services are accessible via HTTPS through Nginx Proxy Manager, except the Relay STUN port which requires direct UDP access. --- -## πŸ’» System Requirements +## System Requirements -### For 100 Customers (10-20 devices per customer) +### Hardware Scaling -| Component | Minimum | Recommended | Notes | -|-----------|---------|-------------|-------| -| **CPU** | 8 cores | 16 cores | More cores = better concurrent deployment performance | -| **RAM** | 64 GB | 128 GB | ~600 MB per customer instance + OS overhead | -| **Storage** | 500 GB SSD | 1 TB NVMe SSD | Fast I/O critical for Docker performance | -| **Network** | 100 Mbps | 1 Gbps | Dedicated server recommended | -| **OS** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS | Other Debian-based distros work too | +Based on real-world measurements: **2 customers (11 containers) use ~220 MB RAM**. -### Resource Calculation Formula: -``` -Per Customer Instance: -- Management: ~100 MB RAM -- Signal: ~50 MB RAM -- Relay: ~150 MB RAM -- Dashboard: ~100 MB RAM -Total: ~400-600 MB RAM per customer +Per customer instance (5 containers): **~100 MB RAM** -For 100 customers: 40-60 GB RAM + 8 GB for OS + 8 GB for Appliance = ~64 GB minimum -``` +| Customers | Container RAM | Recommended Total | vCPU | Storage | +|-----------|--------------|-------------------|------|---------| +| 10 | ~1.0 GB | 2 GB | 2 | 20 GB | +| 25 | ~2.5 GB | 4 GB | 2 | 50 GB | +| 50 | ~5.0 GB | 8 GB | 4 | 100 GB | +| 100 | ~10.0 GB | 16 GB | 8 | 200 GB | +| 200 | ~20.0 GB | 32 GB | 16 | 500 GB | -### Port Requirements: -- **TCP 8000**: NetBird MSP Appliance Web UI -- **UDP 3478-3577**: STUN/TURN relay ports (one per customer) - - Customer 1: UDP 3478 - - Customer 2: UDP 3479 - - ... - - Customer 100: UDP 3577 +> **Note:** "Recommended Total" includes OS overhead and headroom. SSD/NVMe storage is recommended for Docker performance. -**⚠️ Important**: Your firewall must allow UDP ports 3478-3577 for full NetBird functionality! +### Port Requirements -### Prerequisites: -- **Docker Engine** 24.0+ with Docker Compose plugin -- **Nginx Proxy Manager** (running separately or on same host) -- **Domain with wildcard DNS** (e.g., `*.yourdomain.com` β†’ your server IP) -- **Root or sudo access** to the Linux VM +| Port | Protocol | Purpose | +|------|----------|---------| +| 8000 | TCP | NetBird MSP Appliance Web UI | +| 3478+ | UDP | STUN/TURN relay (one per customer) | + +Example: Customer 1 = UDP 3478, Customer 2 = UDP 3479, ..., Customer 100 = UDP 3577. + +**Your firewall must allow the UDP relay ports for NetBird to function!** --- -## πŸš€ Quick Start +## Prerequisites + +The following tools and services must be available **before** running the installer. + +### Required on the Host + +| Tool | Purpose | Check Command | +|------|---------|---------------| +| **Linux OS** | Ubuntu 22.04+, Debian 12+, or similar | `cat /etc/os-release` | +| **sudo / root** | Installation requires root privileges | `sudo -v` | +| **curl** | Used by the installer to install Docker | `curl --version` | +| **git** | Clone the repository | `git --version` | +| **openssl** | Generate encryption keys during install | `openssl version` | + +### Installed Automatically + +| Tool | Purpose | Notes | +|------|---------|-------| +| **Docker Engine** 24.0+ | Container runtime | Installed by `install.sh` if missing | +| **Docker Compose Plugin** | Multi-container orchestration | Installed with Docker | + +### External Services + +| Service | Purpose | Notes | +|---------|---------|-------| +| **Nginx Proxy Manager** | Reverse proxy + SSL certificates | Must be running and accessible from the host. Can be on the same server or a separate one. | +| **Wildcard DNS** | Route `*.yourdomain.com` to the server | Configure `*.yourdomain.com` as an A record pointing to your server IP | + +### Install Prerequisites (Ubuntu/Debian) + +```bash +sudo apt update +sudo apt install -y curl git openssl +``` + +--- + +## Quick Start ### 1. Clone the Repository @@ -143,23 +193,23 @@ sudo ./install.sh ``` The installer will **interactively ask you** for: -- βœ… Admin username and password -- βœ… Admin email address -- βœ… Base domain (e.g., `yourdomain.com`) -- βœ… Nginx Proxy Manager API URL and token -- βœ… Data directory location -- βœ… NetBird Docker images (optional customization) +- Admin username and password +- Admin email address +- Base domain (e.g., `yourdomain.com`) +- Nginx Proxy Manager API URL, email, and password +- Data directory location +- NetBird Docker images (optional customization) -**No manual .env file editing required!** Everything is configured through the installation wizard. +**No manual .env file editing required!** All configuration is stored in the database and editable via the Web UI. The installer will then: -- βœ… Check system requirements -- βœ… Install Docker if needed -- βœ… Create directories and Docker network -- βœ… Generate encryption keys -- βœ… Build and start all containers -- βœ… Configure firewall (optional) -- βœ… Initialize the database +- Check system requirements +- Install Docker if needed +- Create directories and Docker network +- Generate encryption keys +- Build and start all containers +- Seed configuration into the database +- Optionally configure the firewall (ufw) ### 3. Access the Web Interface @@ -170,85 +220,61 @@ http://your-server-ip:8000 Login with the credentials you provided during installation. -**All settings can be changed later via the Web UI!** - ### 4. Deploy Your First Customer 1. Click **"New Customer"** button -2. Fill in customer details: - - Name - - Subdomain (e.g., `customer1` β†’ `customer1.yourdomain.com`) - - Email - - Max Devices +2. Fill in customer details (name, subdomain, email, max devices) 3. Click **"Deploy"** 4. Wait ~60-90 seconds -5. Done! βœ… +5. Done! The system will automatically: - Assign a unique UDP port for the relay -- Generate all config files -- Start Docker containers +- Generate all config files from templates +- Start the 5 Docker containers - Create NPM proxy hosts with SSL -- Provide the setup URL +- Provide the setup URL for the customer --- -## βš™οΈ Configuration +## Configuration ### Environment Variables -Create a `.env` file (or use the one generated by installer): +The installer generates a minimal `.env` file with container-level variables only: ```bash -# Security -SECRET_KEY=your-secure-random-key-here -ADMIN_USERNAME=admin -ADMIN_PASSWORD=your-secure-password - -# Nginx Proxy Manager -NPM_API_URL=http://nginx-proxy-manager:81/api -NPM_API_TOKEN=your-npm-api-token - -# System +SECRET_KEY= +DATABASE_PATH=/app/data/netbird_msp.db DATA_DIR=/opt/netbird-instances DOCKER_NETWORK=npm-network -BASE_DOMAIN=yourdomain.com -ADMIN_EMAIL=admin@yourdomain.com - -# NetBird Images (optional - defaults to latest) -NETBIRD_MANAGEMENT_IMAGE=netbirdio/management:latest -NETBIRD_SIGNAL_IMAGE=netbirdio/signal:latest -NETBIRD_RELAY_IMAGE=netbirdio/relay:latest -NETBIRD_DASHBOARD_IMAGE=netbirdio/dashboard:latest - -# Database -DATABASE_PATH=/app/data/netbird_msp.db - -# Logging LOG_LEVEL=INFO +WEB_UI_PORT=8000 ``` -### System Configuration via Web UI +> **All application settings** (domain, NPM credentials, Docker images, branding, etc.) are stored in the SQLite database and editable via the Web UI under **Settings**. -All settings can be configured through the web interface under **Settings** β†’ **System Configuration**: +### Web UI Settings -- Base Domain -- Admin Email -- NPM Integration -- Docker Images -- Port Ranges -- Data Directories +Available under **Settings** in the web interface: + +| Tab | Settings | +|-----|----------| +| **System** | Base domain, admin email, NPM credentials, Docker images, port ranges, data directory | +| **Branding** | Platform name, subtitle, logo upload, default language | +| **Users** | Create/edit/delete admin users, per-user language preference | +| **Monitoring** | System resources, Docker stats | Changes are applied immediately without restart. --- -## πŸ“– Usage +## Usage ### Managing Customers #### Create a New Customer -1. Dashboard β†’ **New Customer** +1. Dashboard > **New Customer** 2. Fill in details 3. Click **Deploy** 4. Share the setup URL with your customer @@ -258,56 +284,42 @@ Changes are applied immediately without restart. - See deployment status, container health, logs - Copy setup URL and credentials -#### Start/Stop/Restart Containers -- Click the action buttons in the customer list -- Or use the detail view for more control +#### Start / Stop / Restart Containers +- Use the action buttons in the customer detail view +- Stopping all containers sets the customer status to "inactive" +- Starting containers sets the status back to "active" #### Delete a Customer -- Click **Delete** β†’ Confirm +- Click **Delete** > Confirm - All containers, data, and NPM entries are removed ### Monitoring The dashboard shows: -- **System Overview**: Total customers, active/inactive, errors -- **Resource Usage**: RAM, CPU, disk usage -- **Container Status**: Running/stopped/failed -- **Recent Activity**: Deployment logs and events +- **System Overview** β€” Total customers, active/inactive, errors +- **Resource Usage** β€” RAM, CPU per container +- **Container Health** β€” Running/stopped per container with color-coded status +- **Deployment Logs** β€” Action history per customer -### Bulk Operations +### Language Settings -Select multiple customers using checkboxes: -- **Bulk Update**: Update NetBird images across selected customers -- **Bulk Restart**: Restart all selected instances -- **Bulk Backup**: Create backups of selected customers - -### Backups - -#### Manual Backup -```bash -docker exec netbird-msp-appliance python -m app.backup --customer-id 1 -``` - -#### Automatic Backups -Configure in Settings β†’ Backup: -- Schedule: Daily/Weekly -- Retention: Number of backups to keep -- Destination: Local path or remote storage +- **Switch language** β€” Use the language switcher in the top navigation bar +- **Per-user default** β€” Set in Settings > Users during user creation +- **System default** β€” Set in Settings > Branding --- -## πŸ”Œ API Documentation +## API Documentation -The appliance provides a REST API for automation. +The appliance provides a REST API. ### Authentication ```bash -# Get API token (after login) curl -X POST http://localhost:8000/api/auth/token \ -d "username=admin&password=yourpassword" ``` -### API Endpoints +### Endpoints Full interactive documentation available at: ``` @@ -324,10 +336,14 @@ DELETE /api/customers/{id} # Delete customer POST /api/customers/{id}/start # Start containers POST /api/customers/{id}/stop # Stop containers +POST /api/customers/{id}/restart # Restart containers GET /api/customers/{id}/logs # Get container logs GET /api/customers/{id}/health # Health check -GET /api/status # System status +GET /api/settings/branding # Get branding (public, no auth) +PUT /api/settings # Update system settings +GET /api/users # List users +POST /api/users # Create user ``` ### Example: Create Customer via API @@ -345,134 +361,96 @@ curl -X POST http://localhost:8000/api/customers \ --- -## πŸ”§ Troubleshooting +## Troubleshooting -### Common Issues - -#### 1. Customer deployment fails +### Customer deployment fails **Symptom**: Status shows "error" after deployment **Solutions**: - Check Docker logs: `docker logs netbird-msp-appliance` -- Verify NPM is accessible: `curl http://npm-host:81/api` -- Check available UDP ports: `netstat -ulnp | grep 347` -- View detailed logs in the customer detail page +- Verify NPM is accessible from the appliance container +- Check available UDP ports: `ss -ulnp | grep 347` +- View detailed logs in the customer detail page (Logs tab) -#### 2. NetBird clients can't connect +### NetBird clients can't connect **Symptom**: Clients show "relay unavailable" **Solutions**: - **Most common**: UDP port not open in firewall ```bash - # Check if port is open - sudo ufw status - - # Open the relay port sudo ufw allow 3478/udp ``` - Verify relay container is running: `docker ps | grep relay` -- Test STUN server: Use online STUN tester with your port -#### 3. NPM integration not working -**Symptom**: SSL certificates not created +### NPM integration not working +**Symptom**: Proxy hosts or SSL certificates not created **Solutions**: -- Verify NPM API token is correct -- Check NPM is on same Docker network: `npm-network` -- Test NPM API manually: - ```bash - curl -X GET http://npm-host:81/api/nginx/proxy-hosts \ - -H "Authorization: Bearer YOUR_TOKEN" - ``` - -#### 4. Out of memory errors -**Symptom**: Containers crashing, system slow - -**Solutions**: -- Check RAM usage: `free -h` -- Reduce number of customers or upgrade RAM -- Stop inactive customer instances -- Configure swap space: - ```bash - sudo fallocate -l 16G /swapfile - sudo chmod 600 /swapfile - sudo mkswap /swapfile - sudo swapon /swapfile - ``` +- Verify NPM email and password are correct in Settings +- Check NPM is on same Docker network (`npm-network`) +- Check NPM logs for errors ### Debug Mode Enable debug logging: ```bash -# Edit .env +# In your .env file: LOG_LEVEL=DEBUG -# Restart -docker-compose restart +# Restart the appliance: +docker compose restart ``` -View detailed logs: +View logs: ```bash docker logs -f netbird-msp-appliance ``` -### Getting Help - -1. **Check the logs**: Most issues are explained in the logs -2. **GitHub Issues**: Search existing issues or create a new one -3. **NetBird Community**: For NetBird-specific questions -4. **Documentation**: Read the full docs in `/docs` folder - --- -## πŸ”„ Updates +## Updates ### Updating the Appliance ```bash -cd netbird-msp-appliance +cd /opt/netbird-msp git pull -docker-compose down -docker-compose up -d --build +docker compose down +docker compose up -d --build ``` +The database migrations run automatically on startup. + ### Updating NetBird Images -**Via Web UI**: -1. Settings β†’ System Configuration -2. Update image tags +Via the Web UI: +1. Settings > System Configuration +2. Change image tags (e.g., `netbirdio/management:0.35.0`) 3. Click "Save" -4. Use Bulk Update for customers - -**Via CLI**: -```bash -# Update all customer instances -docker exec netbird-msp-appliance python -m app.update --all -``` +4. Re-deploy individual customers to apply the new images --- -## πŸ›‘οΈ Security Best Practices +## Security Best Practices 1. **Change default credentials** immediately after installation -2. **Use strong passwords** (20+ characters, mixed case, numbers, symbols) -3. **Keep NPM API token secure** - never commit to git -4. **Enable firewall** and only open required ports -5. **Regular updates** - both the appliance and NetBird images -6. **Backup regularly** - automate daily backups -7. **Use HTTPS** - always access the web UI via HTTPS (configure reverse proxy) -8. **Monitor logs** - check for suspicious activity -9. **Limit access** - use VPN or IP whitelist for the management interface +2. **Use strong passwords** (12+ characters recommended) +3. **Keep NPM credentials secure** β€” they are stored encrypted in the database +4. **Enable firewall** and only open required ports (TCP 8000, UDP relay range) +5. **Use HTTPS** β€” put the MSP appliance behind a reverse proxy with SSL +6. **Regular updates** β€” both the appliance and NetBird images +7. **Backup your database** β€” `data/netbird_msp.db` contains all configuration +8. **Monitor logs** β€” check for suspicious activity +9. **Restrict access** β€” use VPN or IP whitelist for the management interface --- -## πŸ“Š Performance Tuning +## Performance Tuning -### For 100+ Customers: +### For 100+ Customers ```bash -# Increase Docker ulimits -# Add to /etc/docker/daemon.json +# Increase Docker ulimits β€” add to /etc/docker/daemon.json { "default-ulimits": { "nofile": { @@ -494,32 +472,14 @@ sudo sysctl -p --- -## 🀝 Contributing +## License -Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. +MIT License β€” see [LICENSE](LICENSE) file for details. --- -## πŸ“„ License +## Acknowledgments -MIT License - see [LICENSE](LICENSE) file for details. - ---- - -## πŸ™ Acknowledgments - -- **NetBird Team** - for the amazing open-source VPN solution -- **FastAPI** - for the high-performance Python framework -- **Nginx Proxy Manager** - for easy reverse proxy management - ---- - -## πŸ“ž Support - -- **Issues**: [GitHub Issues](https://github.com/yourusername/netbird-msp-appliance/issues) -- **Discussions**: [GitHub Discussions](https://github.com/yourusername/netbird-msp-appliance/discussions) -- **Email**: support@yourdomain.com - ---- - -**Made with ❀️ for MSPs and System Administrators** +- [NetBird](https://netbird.io/) β€” Open-source VPN solution +- [FastAPI](https://fastapi.tiangolo.com/) β€” High-performance Python framework +- [Nginx Proxy Manager](https://nginxproxymanager.com/) β€” Reverse proxy management diff --git a/app/database.py b/app/database.py index 0be9fc4..f38f007 100644 --- a/app/database.py +++ b/app/database.py @@ -39,7 +39,7 @@ def get_db() -> Generator[Session, None, None]: def init_db() -> None: - """Create all database tables.""" + """Create all database tables and run lightweight migrations.""" from app.models import ( # noqa: F401 Customer, Deployment, @@ -49,6 +49,43 @@ def init_db() -> None: ) Base.metadata.create_all(bind=engine) + _run_migrations() + + +def _run_migrations() -> None: + """Add columns that may be missing from older database versions.""" + import sqlite3 + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + def _has_column(table: str, column: str) -> bool: + cursor.execute(f"PRAGMA table_info({table})") + return any(row[1] == column for row in cursor.fetchall()) + + migrations = [ + ("deployments", "dashboard_port", "INTEGER"), + ("system_config", "dashboard_base_port", "INTEGER DEFAULT 9000"), + ("deployments", "netbird_admin_email", "TEXT"), + ("deployments", "netbird_admin_password", "TEXT"), + ("system_config", "branding_name", "TEXT DEFAULT 'NetBird MSP Appliance'"), + ("system_config", "branding_logo_path", "TEXT"), + ("users", "role", "TEXT DEFAULT 'admin'"), + ("users", "auth_provider", "TEXT DEFAULT 'local'"), + ("system_config", "azure_enabled", "BOOLEAN DEFAULT 0"), + ("system_config", "azure_tenant_id", "TEXT"), + ("system_config", "azure_client_id", "TEXT"), + ("system_config", "azure_client_secret_encrypted", "TEXT"), + ("system_config", "branding_subtitle", "TEXT DEFAULT 'Multi-Tenant Management Platform'"), + ("system_config", "default_language", "TEXT DEFAULT 'en'"), + ("users", "default_language", "TEXT"), + ] + for table, column, col_type in migrations: + if not _has_column(table, column): + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") + + conn.commit() + conn.close() if __name__ == "__main__": diff --git a/app/main.py b/app/main.py index d8afd33..be9cd11 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from app.database import init_db -from app.routers import auth, customers, deployments, monitoring, settings +from app.routers import auth, customers, deployments, monitoring, settings, users # --------------------------------------------------------------------------- # Logging @@ -50,6 +50,7 @@ app.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) app.include_router(customers.router, prefix="/api/customers", tags=["Customers"]) app.include_router(deployments.router, prefix="/api/customers", tags=["Deployments"]) app.include_router(monitoring.router, prefix="/api/monitoring", tags=["Monitoring"]) +app.include_router(users.router, prefix="/api/users", tags=["Users"]) # --------------------------------------------------------------------------- # Static files β€” serve the frontend SPA diff --git a/app/models.py b/app/models.py index 6eb7e0e..4e35eee 100644 --- a/app/models.py +++ b/app/models.py @@ -81,9 +81,12 @@ class Deployment(Base): ) 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) 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) deployment_status: Mapped[str] = mapped_column( String(20), default="pending", nullable=False ) @@ -106,9 +109,11 @@ class Deployment(Base): "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, "relay_secret": "***", # Never expose secrets "setup_url": self.setup_url, + "has_credentials": bool(self.netbird_admin_email and self.netbird_admin_password), "deployment_status": self.deployment_status, "deployed_at": self.deployed_at.isoformat() if self.deployed_at else None, "last_health_check": ( @@ -145,6 +150,19 @@ class SystemConfig(Base): 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") + 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -168,6 +186,15 @@ class SystemConfig(Base): "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", + "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), "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } @@ -220,6 +247,9 @@ class User(Base): 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) def to_dict(self) -> dict: @@ -229,5 +259,8 @@ class User(Base): "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, "created_at": self.created_at.isoformat() if self.created_at else None, } diff --git a/app/routers/auth.py b/app/routers/auth.py index 05b47a5..a08ea2a 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,15 +1,17 @@ -"""Authentication API endpoints β€” login, logout, current user, password change.""" +"""Authentication API endpoints β€” login, logout, current user, password change, Azure AD.""" import logging +import secrets from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import get_db from app.dependencies import create_access_token, get_current_user -from app.models import User -from app.utils.security import hash_password, verify_password +from app.models import SystemConfig, User +from app.utils.security import decrypt_value, hash_password, verify_password from app.utils.validators import ChangePasswordRequest, LoginRequest logger = logging.getLogger(__name__) @@ -95,3 +97,115 @@ async def change_password( db.commit() logger.info("Password changed for user %s.", current_user.username) return {"message": "Password changed successfully."} + + +class AzureCallbackRequest(BaseModel): + """Azure AD auth code callback payload.""" + code: str + redirect_uri: str + + +@router.get("/azure/config") +async def get_azure_config(db: Session = Depends(get_db)): + """Public endpoint β€” returns Azure AD config for the login page.""" + config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if not config or not config.azure_enabled: + return {"azure_enabled": False} + return { + "azure_enabled": True, + "azure_tenant_id": config.azure_tenant_id, + "azure_client_id": config.azure_client_id, + } + + +@router.post("/azure/callback") +async def azure_callback( + payload: AzureCallbackRequest, + db: Session = Depends(get_db), +): + """Exchange Azure AD authorization code for tokens and authenticate.""" + config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if not config or not config.azure_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Azure AD authentication is not enabled.", + ) + + if not config.azure_tenant_id or not config.azure_client_id or not config.azure_client_secret_encrypted: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Azure AD is not fully configured.", + ) + + try: + import msal + + client_secret = decrypt_value(config.azure_client_secret_encrypted) + authority = f"https://login.microsoftonline.com/{config.azure_tenant_id}" + + app = msal.ConfidentialClientApplication( + config.azure_client_id, + authority=authority, + client_credential=client_secret, + ) + + result = app.acquire_token_by_authorization_code( + payload.code, + scopes=["User.Read"], + redirect_uri=payload.redirect_uri, + ) + + if "error" in result: + logger.warning("Azure AD token exchange failed: %s", result.get("error_description", result["error"])) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=result.get("error_description", "Azure AD authentication failed."), + ) + + id_token_claims = result.get("id_token_claims", {}) + email = id_token_claims.get("preferred_username") or id_token_claims.get("email", "") + display_name = id_token_claims.get("name", email) + + if not email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Could not determine email from Azure AD token.", + ) + + # Find or create user + user = db.query(User).filter(User.username == email).first() + if not user: + user = User( + username=email, + password_hash=hash_password(secrets.token_urlsafe(32)), + email=email, + is_active=True, + role="admin", + auth_provider="azure", + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("Azure AD user '%s' auto-created.", email) + elif not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is disabled.", + ) + + token = create_access_token(user.username) + logger.info("Azure AD user '%s' logged in.", user.username) + return { + "access_token": token, + "token_type": "bearer", + "user": user.to_dict(), + } + + except HTTPException: + raise + except Exception as exc: + logger.exception("Azure AD authentication error") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Azure AD authentication error: {exc}", + ) diff --git a/app/routers/deployments.py b/app/routers/deployments.py index 1397910..1e5f8eb 100644 --- a/app/routers/deployments.py +++ b/app/routers/deployments.py @@ -9,6 +9,7 @@ from app.database import SessionLocal, get_db from app.dependencies import get_current_user from app.models import Customer, Deployment, User from app.services import docker_service, netbird_service +from app.utils.security import decrypt_value logger = logging.getLogger(__name__) router = APIRouter() @@ -174,6 +175,38 @@ async def check_customer_health( return netbird_service.get_customer_health(db, customer_id) +@router.get("/{customer_id}/credentials") +async def get_customer_credentials( + customer_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get the NetBird admin credentials for a customer's deployment. + + Args: + customer_id: Customer ID. + + Returns: + Dict with email and password. + """ + _require_customer(db, customer_id) + deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() + if not deployment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No deployment found for this customer.", + ) + if not deployment.netbird_admin_email or not deployment.netbird_admin_password: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No credentials available. Admin must complete setup manually.", + ) + return { + "email": decrypt_value(deployment.netbird_admin_email), + "password": decrypt_value(deployment.netbird_admin_password), + } + + def _require_customer(db: Session, customer_id: int) -> Customer: """Helper to fetch a customer or raise 404. diff --git a/app/routers/monitoring.py b/app/routers/monitoring.py index 1fa31ee..a35e8bd 100644 --- a/app/routers/monitoring.py +++ b/app/routers/monitoring.py @@ -71,6 +71,7 @@ async def all_customers_status( entry["deployment_status"] = c.deployment.deployment_status entry["containers"] = containers entry["relay_udp_port"] = c.deployment.relay_udp_port + entry["dashboard_port"] = c.deployment.dashboard_port entry["setup_url"] = c.deployment.setup_url else: entry["deployment_status"] = None diff --git a/app/routers/settings.py b/app/routers/settings.py index 99073cc..a6990b7 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -5,9 +5,11 @@ There is no .env file. Every setting lives in the ``system_config`` table """ import logging +import os +import shutil from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status from sqlalchemy.orm import Session from app.database import get_db @@ -21,6 +23,10 @@ from app.utils.validators import SystemConfigUpdate logger = logging.getLogger(__name__) router = APIRouter() +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "uploads") +MAX_LOGO_SIZE = 512 * 1024 # 500 KB +ALLOWED_LOGO_TYPES = {"image/png", "image/jpeg", "image/svg+xml"} + @router.get("/system") async def get_settings( @@ -75,6 +81,11 @@ async def update_settings( raw_password = update_data.pop("npm_api_password") row.npm_api_password_encrypted = encrypt_value(raw_password) + # Handle Azure client secret encryption + if "azure_client_secret" in update_data: + raw_secret = update_data.pop("azure_client_secret") + row.azure_client_secret_encrypted = encrypt_value(raw_secret) + for field, value in update_data.items(): if hasattr(row, field): setattr(row, field, value) @@ -116,3 +127,85 @@ async def test_npm( config.npm_api_url, config.npm_api_email, config.npm_api_password ) return result + + +@router.get("/branding") +async def get_branding(db: Session = Depends(get_db)): + """Public endpoint β€” returns branding info for the login page (no auth required).""" + row = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if not row: + return { + "branding_name": "NetBird MSP Appliance", + "branding_subtitle": "Multi-Tenant Management Platform", + "branding_logo_path": None, + "default_language": "en", + } + return { + "branding_name": row.branding_name or "NetBird MSP Appliance", + "branding_subtitle": row.branding_subtitle or "Multi-Tenant Management Platform", + "branding_logo_path": row.branding_logo_path, + "default_language": row.default_language or "en", + } + + +@router.post("/branding/logo") +async def upload_logo( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Upload a branding logo image (PNG, JPG, SVG, max 500KB).""" + if file.content_type not in ALLOWED_LOGO_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File type '{file.content_type}' not allowed. Use PNG, JPG, or SVG.", + ) + + content = await file.read() + if len(content) > MAX_LOGO_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large ({len(content)} bytes). Maximum is {MAX_LOGO_SIZE} bytes.", + ) + + os.makedirs(UPLOAD_DIR, exist_ok=True) + + ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/svg+xml": ".svg"} + ext = ext_map.get(file.content_type, ".png") + filename = f"logo{ext}" + filepath = os.path.join(UPLOAD_DIR, filename) + + with open(filepath, "wb") as f: + f.write(content) + + logo_url = f"/static/uploads/{filename}" + + row = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if row: + row.branding_logo_path = logo_url + row.updated_at = datetime.utcnow() + db.commit() + + logger.info("Logo uploaded by %s: %s", current_user.username, logo_url) + return {"branding_logo_path": logo_url} + + +@router.delete("/branding/logo") +async def delete_logo( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Remove the branding logo and reset to default icon.""" + row = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if row and row.branding_logo_path: + old_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + row.branding_logo_path.lstrip("/"), + ) + if os.path.isfile(old_path): + os.remove(old_path) + row.branding_logo_path = None + row.updated_at = datetime.utcnow() + db.commit() + + return {"branding_logo_path": None} diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..f10b980 --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,131 @@ +"""User management API β€” CRUD operations for local users.""" + +import logging +import secrets + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import get_current_user +from app.models import User +from app.utils.security import hash_password +from app.utils.validators import UserCreate, UserUpdate + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("") +async def list_users( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """List all users.""" + users = db.query(User).order_by(User.id).all() + return [u.to_dict() for u in users] + + +@router.post("") +async def create_user( + payload: UserCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Create a new local user.""" + existing = db.query(User).filter(User.username == payload.username).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Username '{payload.username}' already exists.", + ) + + user = User( + username=payload.username, + password_hash=hash_password(payload.password), + email=payload.email, + is_active=True, + role="admin", + auth_provider="local", + default_language=payload.default_language, + ) + db.add(user) + db.commit() + db.refresh(user) + + logger.info("User '%s' created by '%s'.", user.username, current_user.username) + return user.to_dict() + + +@router.put("/{user_id}") +async def update_user( + user_id: int, + payload: UserUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Update an existing user (email, is_active, role).""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + update_data = payload.model_dump(exclude_none=True) + for field, value in update_data.items(): + if hasattr(user, field): + setattr(user, field, value) + + db.commit() + db.refresh(user) + + logger.info("User '%s' updated by '%s'.", user.username, current_user.username) + return user.to_dict() + + +@router.delete("/{user_id}") +async def delete_user( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Delete a user (cannot delete yourself).""" + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot delete your own account.", + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + username = user.username + db.delete(user) + db.commit() + + logger.info("User '%s' deleted by '%s'.", username, current_user.username) + return {"message": f"User '{username}' deleted."} + + +@router.post("/{user_id}/reset-password") +async def reset_password( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Generate a new random password for a user.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + if user.auth_provider != "local": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot reset password for Azure AD users.", + ) + + new_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(new_password) + db.commit() + + logger.info("Password reset for user '%s' by '%s'.", user.username, current_user.username) + return {"message": "Password reset successfully.", "new_password": new_password} diff --git a/app/services/docker_service.py b/app/services/docker_service.py index 341c988..1690575 100644 --- a/app/services/docker_service.py +++ b/app/services/docker_service.py @@ -26,12 +26,20 @@ def _get_client() -> docker.DockerClient: return docker.from_env() -def compose_up(instance_dir: str, project_name: str) -> bool: +def compose_up( + instance_dir: str, + project_name: str, + services: Optional[list[str]] = None, + timeout: int = 300, +) -> bool: """Run ``docker compose up -d`` for a customer instance. Args: instance_dir: Absolute path to the customer's instance directory. project_name: Docker Compose project name (e.g. ``netbird-kunde5``). + services: Optional list of service names to start. + If None, all services are started. + timeout: Subprocess timeout in seconds (default 300). Returns: True on success. @@ -47,16 +55,22 @@ def compose_up(instance_dir: str, project_name: str) -> bool: "docker", "compose", "-f", compose_file, "-p", project_name, - "up", "-d", "--remove-orphans", + "up", "-d", ] + if not services: + cmd.append("--remove-orphans") + if services: + cmd.extend(services) + logger.info("Running: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if result.returncode != 0: logger.error("docker compose up failed: %s", result.stderr) raise RuntimeError(f"docker compose up failed: {result.stderr}") - logger.info("docker compose up succeeded for %s", project_name) + svc_info = f" (services: {', '.join(services)})" if services else "" + logger.info("docker compose up succeeded for %s%s", project_name, svc_info) return True @@ -169,9 +183,13 @@ def get_container_status(container_prefix: str) -> list[dict[str, Any]]: try: containers = client.containers.list(all=True, filters={"name": container_prefix}) for c in containers: - health = "N/A" - if c.attrs.get("State", {}).get("Health"): - health = c.attrs["State"]["Health"].get("Status", "N/A") + # Derive health from container status. + # Docker HEALTHCHECK is unreliable (e.g. netbirdio/management + # defines a wget-based check but wget is not installed). + if c.status == "running": + health = "healthy" + else: + health = "unhealthy" results.append({ "name": c.name, "status": c.status, diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index bc5a5c1..3b2bd17 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -6,26 +6,33 @@ Coordinates the full customer deployment lifecycle: 3. Generate configs from Jinja2 templates 4. Create instance directory and write files 5. Start Docker containers -6. Wait for health checks -7. Create NPM proxy hosts -8. Update database +6. Create NPM proxy hosts (production only) +7. Update database + +Uses NetBird's embedded IdP (built-in since v0.62) β€” no external +identity provider (Zitadel, Keycloak, etc.) required. Includes comprehensive rollback on failure. """ +import json import logging import os +import secrets import shutil +import time +import urllib.request +import urllib.error from datetime import datetime from typing import Any from jinja2 import Environment, FileSystemLoader from sqlalchemy.orm import Session -from app.models import Customer, Deployment, DeploymentLog, SystemConfig +from app.models import Customer, Deployment, DeploymentLog from app.services import docker_service, npm_service, port_manager from app.utils.config import get_system_config -from app.utils.security import encrypt_value, generate_relay_secret +from app.utils.security import encrypt_value, generate_datastore_encryption_key, generate_relay_secret logger = logging.getLogger(__name__) @@ -41,19 +48,16 @@ def _get_jinja_env() -> Environment: ) +def _is_local_domain(base_domain: str) -> bool: + """Check if the base domain is a local/test domain.""" + local_suffixes = (".local", ".test", ".localhost", ".internal", ".example") + return base_domain == "localhost" or any(base_domain.endswith(s) for s in local_suffixes) + + def _log_action( db: Session, customer_id: int, action: str, status: str, message: str, details: str = "" ) -> None: - """Write a deployment log entry. - - Args: - db: Active session. - customer_id: The customer this log belongs to. - action: Action name (e.g. ``deploy``, ``stop``). - status: ``success``, ``error``, or ``info``. - message: Human-readable message. - details: Additional details (optional). - """ + """Write a deployment log entry.""" log = DeploymentLog( customer_id=customer_id, action=action, @@ -65,15 +69,20 @@ def _log_action( db.commit() +def _render_template(jinja_env: Environment, template_name: str, output_path: str, **vars) -> None: + """Render a Jinja2 template and write the output to a file.""" + template = jinja_env.get_template(template_name) + content = template.render(**vars) + with open(output_path, "w") as f: + f.write(content) + + async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: """Execute the full deployment workflow for a customer. - Args: - db: Active session. - customer_id: Customer to deploy. - - Returns: - Dict with ``success``, ``setup_url``, or ``error``. + Uses NetBird's embedded IdP β€” no external identity provider needed. + After deployment, the admin opens the dashboard URL and completes + the initial setup wizard (/setup) to create the first user. """ customer = db.query(Customer).filter(Customer.id == customer_id).first() if not customer: @@ -83,7 +92,6 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: if not config: return {"success": False, "error": "System not configured. Please set up system settings first."} - # Update status to deploying customer.status = "deploying" db.commit() @@ -92,103 +100,161 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: allocated_port = None instance_dir = None container_prefix = f"netbird-kunde{customer_id}" + local_mode = _is_local_domain(config.base_domain) try: # Step 1: Allocate relay UDP port allocated_port = port_manager.allocate_port(db, config.relay_base_port) _log_action(db, customer_id, "deploy", "info", f"Allocated UDP port {allocated_port}.") - # Step 2: Generate relay secret + # Step 2: Generate secrets relay_secret = generate_relay_secret() + datastore_key = generate_datastore_encryption_key() - # Step 3: Create instance directory + # Step 3: Compute dashboard port and URLs + dashboard_port = config.dashboard_base_port + customer_id + netbird_domain = f"{customer.subdomain}.{config.base_domain}" + + if local_mode: + external_url = f"http://localhost:{dashboard_port}" + netbird_protocol = "http" + netbird_port = str(dashboard_port) + else: + external_url = f"https://{netbird_domain}" + netbird_protocol = "https" + netbird_port = "443" + + # Step 4: Create instance directory instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}") os.makedirs(instance_dir, exist_ok=True) os.makedirs(os.path.join(instance_dir, "data", "management"), exist_ok=True) os.makedirs(os.path.join(instance_dir, "data", "signal"), exist_ok=True) _log_action(db, customer_id, "deploy", "info", f"Created directory {instance_dir}.") - # Step 4: Render templates + # Step 5: Render all config files jinja_env = _get_jinja_env() template_vars = { "customer_id": customer_id, "subdomain": customer.subdomain, "base_domain": config.base_domain, + "netbird_domain": netbird_domain, "instance_dir": instance_dir, "relay_udp_port": allocated_port, "relay_secret": relay_secret, + "dashboard_port": dashboard_port, + "external_url": external_url, + "netbird_protocol": netbird_protocol, + "netbird_port": netbird_port, "netbird_management_image": config.netbird_management_image, "netbird_signal_image": config.netbird_signal_image, "netbird_relay_image": config.netbird_relay_image, "netbird_dashboard_image": config.netbird_dashboard_image, "docker_network": config.docker_network, + "datastore_encryption_key": datastore_key, } - # docker-compose.yml - dc_template = jinja_env.get_template("docker-compose.yml.j2") - dc_content = dc_template.render(**template_vars) - with open(os.path.join(instance_dir, "docker-compose.yml"), "w") as f: - f.write(dc_content) - - # management.json - mgmt_template = jinja_env.get_template("management.json.j2") - mgmt_content = mgmt_template.render(**template_vars) - with open(os.path.join(instance_dir, "management.json"), "w") as f: - f.write(mgmt_content) - - # relay.env - relay_template = jinja_env.get_template("relay.env.j2") - relay_content = relay_template.render(**template_vars) - with open(os.path.join(instance_dir, "relay.env"), "w") as f: - f.write(relay_content) + _render_template(jinja_env, "docker-compose.yml.j2", + os.path.join(instance_dir, "docker-compose.yml"), **template_vars) + _render_template(jinja_env, "management.json.j2", + os.path.join(instance_dir, "management.json"), **template_vars) + _render_template(jinja_env, "relay.env.j2", + os.path.join(instance_dir, "relay.env"), **template_vars) + _render_template(jinja_env, "Caddyfile.j2", + os.path.join(instance_dir, "Caddyfile"), **template_vars) + _render_template(jinja_env, "dashboard.env.j2", + os.path.join(instance_dir, "dashboard.env"), **template_vars) _log_action(db, customer_id, "deploy", "info", "Configuration files generated.") - # Step 5: Start Docker containers - docker_service.compose_up(instance_dir, container_prefix) + # Step 6: Start all Docker containers + docker_service.compose_up(instance_dir, container_prefix, timeout=120) _log_action(db, customer_id, "deploy", "info", "Docker containers started.") - # Step 6: Wait for containers to be healthy - healthy = docker_service.wait_for_healthy(container_prefix, timeout=60) + # Step 7: Wait for containers to be healthy + healthy = docker_service.wait_for_healthy(container_prefix, timeout=90) if not healthy: _log_action( - db, customer_id, "deploy", "error", - "Containers did not become healthy within 60 seconds." + db, customer_id, "deploy", "info", + "Not all containers healthy within 90s β€” may still be starting." ) - # Don't fail completely β€” containers might still come up - # Step 7: Create NPM proxy host - domain = f"{customer.subdomain}.{config.base_domain}" - dashboard_container = f"netbird-kunde{customer_id}-dashboard" - npm_result = await npm_service.create_proxy_host( - api_url=config.npm_api_url, - npm_email=config.npm_api_email, - npm_password=config.npm_api_password, - domain=domain, - forward_host=dashboard_container, - forward_port=80, - admin_email=config.admin_email, - subdomain=customer.subdomain, - customer_id=customer_id, - ) + # Step 8: Auto-create admin user via NetBird setup API + admin_email = customer.email + admin_password = secrets.token_urlsafe(16) + management_container = f"netbird-kunde{customer_id}-management" + setup_api_url = f"http://{management_container}:80/api/setup" + setup_payload = json.dumps({ + "name": customer.name, + "email": admin_email, + "password": admin_password, + }).encode("utf-8") - npm_proxy_id = npm_result.get("proxy_id") - if npm_result.get("error"): - _log_action( - db, customer_id, "deploy", "error", - f"NPM proxy creation failed: {npm_result['error']}", + setup_ok = False + for attempt in range(10): + try: + req = urllib.request.Request( + setup_api_url, + data=setup_payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status in (200, 201): + setup_ok = True + _log_action(db, customer_id, "deploy", "info", + f"Admin user created: {admin_email}") + break + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + if e.code == 409 or "already" in body.lower(): + _log_action(db, customer_id, "deploy", "info", + "Instance already set up β€” skipping admin creation.") + setup_ok = True + break + logger.info("Setup attempt %d failed (HTTP %d): %s", attempt + 1, e.code, body) + except Exception as e: + logger.info("Setup attempt %d failed: %s", attempt + 1, e) + time.sleep(5) + + if not setup_ok: + _log_action(db, customer_id, "deploy", "info", + "Auto-setup failed β€” admin must complete setup manually.") + + # Step 9: Create NPM proxy host (production only) + npm_proxy_id = None + if not local_mode: + caddy_container = f"netbird-kunde{customer_id}-caddy" + npm_result = await npm_service.create_proxy_host( + api_url=config.npm_api_url, + npm_email=config.npm_api_email, + npm_password=config.npm_api_password, + domain=netbird_domain, + forward_host=caddy_container, + forward_port=80, + admin_email=config.admin_email, + subdomain=customer.subdomain, + customer_id=customer_id, ) - # Continue β€” deployment works without NPM, admin can fix later + npm_proxy_id = npm_result.get("proxy_id") + if npm_result.get("error"): + _log_action( + db, customer_id, "deploy", "error", + f"NPM proxy creation failed: {npm_result['error']}", + ) + + # Step 9: Create deployment record + setup_url = external_url - # Step 8: Create deployment record - setup_url = f"https://{domain}" deployment = Deployment( customer_id=customer_id, container_prefix=container_prefix, relay_udp_port=allocated_port, + dashboard_port=dashboard_port, npm_proxy_id=npm_proxy_id, relay_secret=encrypt_value(relay_secret), setup_url=setup_url, + netbird_admin_email=encrypt_value(admin_email) if setup_ok else None, + netbird_admin_password=encrypt_value(admin_password) if setup_ok else None, deployment_status="running", deployed_at=datetime.utcnow(), ) @@ -197,7 +263,8 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: customer.status = "active" db.commit() - _log_action(db, customer_id, "deploy", "success", f"Deployment complete. URL: {setup_url}") + _log_action(db, customer_id, "deploy", "success", + f"Deployment complete. Open {setup_url} to complete initial setup.") return {"success": True, "setup_url": setup_url} @@ -234,15 +301,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]: async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]: - """Remove all resources for a customer deployment. - - Args: - db: Active session. - customer_id: Customer to undeploy. - - Returns: - Dict with ``success`` bool. - """ + """Remove all resources for a customer deployment.""" customer = db.query(Customer).filter(Customer.id == customer_id).first() if not customer: return {"success": False, "error": "Customer not found."} @@ -288,15 +347,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]: def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: - """Stop containers for a customer. - - Args: - db: Active session. - customer_id: Customer whose containers to stop. - - Returns: - Dict with ``success`` bool. - """ + """Stop containers for a customer.""" deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() config = get_system_config(db) if not deployment or not config: @@ -306,6 +357,9 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: ok = docker_service.compose_stop(instance_dir, deployment.container_prefix) if ok: deployment.deployment_status = "stopped" + customer = db.query(Customer).filter(Customer.id == customer_id).first() + if customer: + customer.status = "inactive" db.commit() _log_action(db, customer_id, "stop", "success", "Containers stopped.") else: @@ -314,15 +368,7 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]: def start_customer(db: Session, customer_id: int) -> dict[str, Any]: - """Start containers for a customer. - - Args: - db: Active session. - customer_id: Customer whose containers to start. - - Returns: - Dict with ``success`` bool. - """ + """Start containers for a customer.""" deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() config = get_system_config(db) if not deployment or not config: @@ -332,6 +378,9 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]: ok = docker_service.compose_start(instance_dir, deployment.container_prefix) if ok: deployment.deployment_status = "running" + customer = db.query(Customer).filter(Customer.id == customer_id).first() + if customer: + customer.status = "active" db.commit() _log_action(db, customer_id, "start", "success", "Containers started.") else: @@ -340,15 +389,7 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]: def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: - """Restart containers for a customer. - - Args: - db: Active session. - customer_id: Customer whose containers to restart. - - Returns: - Dict with ``success`` bool. - """ + """Restart containers for a customer.""" deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() config = get_system_config(db) if not deployment or not config: @@ -358,6 +399,9 @@ def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: ok = docker_service.compose_restart(instance_dir, deployment.container_prefix) if ok: deployment.deployment_status = "running" + customer = db.query(Customer).filter(Customer.id == customer_id).first() + if customer: + customer.status = "active" db.commit() _log_action(db, customer_id, "restart", "success", "Containers restarted.") else: @@ -366,15 +410,7 @@ def restart_customer(db: Session, customer_id: int) -> dict[str, Any]: def get_customer_health(db: Session, customer_id: int) -> dict[str, Any]: - """Check health of a customer's deployment. - - Args: - db: Active session. - customer_id: Customer ID. - - Returns: - Dict with container statuses and overall health. - """ + """Check health of a customer's deployment.""" deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first() if not deployment: return {"healthy": False, "error": "No deployment found.", "containers": []} @@ -382,12 +418,16 @@ def get_customer_health(db: Session, customer_id: int) -> dict[str, Any]: containers = docker_service.get_container_status(deployment.container_prefix) all_running = all(c["status"] == "running" for c in containers) if containers else False - # Update last health check time deployment.last_health_check = datetime.utcnow() + customer = db.query(Customer).filter(Customer.id == customer_id).first() if all_running: deployment.deployment_status = "running" + if customer: + customer.status = "active" elif containers: deployment.deployment_status = "failed" + if customer: + customer.status = "error" db.commit() return { diff --git a/app/utils/config.py b/app/utils/config.py index 03f321a..69716fa 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -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, ) diff --git a/app/utils/security.py b/app/utils/security.py index bd67cc3..acadd46 100644 --- a/app/utils/security.py +++ b/app/utils/security.py @@ -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() diff --git a/app/utils/validators.py b/app/utils/validators.py index f88e7ff..1f28ec3 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -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 # --------------------------------------------------------------------------- diff --git a/netbird-msp-appliance.tar.gz b/netbird-msp-appliance.tar.gz deleted file mode 100644 index 234010354e9211f25297fae37823ae948acb5eec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25486 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 V&((AFTs?2}{C~s - +
@@ -44,20 +50,31 @@