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 2340103..0000000 Binary files a/netbird-msp-appliance.tar.gz and /dev/null differ diff --git a/requirements.txt b/requirements.txt index 77fd10b..ce1ca73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,9 +13,11 @@ python-multipart==0.0.6 httpx==0.26.0 jinja2==3.1.2 docker==7.0.0 +requests<2.32.0 urllib3<2 psutil==5.9.7 pyyaml==6.0.1 +msal==1.28.0 pytest==7.4.3 pytest-asyncio==0.23.2 pytest-httpx==0.28.0 diff --git a/static/css/styles.css b/static/css/styles.css index 3df5641..a78ddfb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,5 +1,11 @@ /* NetBird MSP Appliance - Custom Styles */ +/* i18n FOUC prevention */ +body.i18n-loading #login-page, +body.i18n-loading #app-page { + visibility: hidden; +} + /* Login */ .login-container { min-height: 100vh; @@ -163,6 +169,15 @@ letter-spacing: 0.5px; } +.navbar-brand img { + object-fit: contain; +} + +/* Login logo */ +#login-logo img { + object-fit: contain; +} + /* Card improvements */ .card { border-radius: 10px; diff --git a/static/index.html b/static/index.html index 8a297a5..21c68cc 100644 --- a/static/index.html +++ b/static/index.html @@ -8,32 +8,38 @@ - +
@@ -44,20 +50,31 @@