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

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

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

View File

@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git add:*)"
]
}
}

6
.gitignore vendored
View File

@@ -61,3 +61,9 @@ docker-compose.override.yml
.pytest_cache/
.coverage
htmlcov/
# Claude Code
.claude/
# Windows artifacts
nul

506
README.md
View File

@@ -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=<auto-generated>
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

View File

@@ -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__":

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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}",
)

View File

@@ -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.

View File

@@ -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

View File

@@ -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}

131
app/routers/users.py Normal file
View File

@@ -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}

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -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

View File

@@ -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;

View File

@@ -8,32 +8,38 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/css/styles.css" rel="stylesheet">
</head>
<body>
<body class="i18n-loading">
<!-- Login Page -->
<div id="login-page" class="d-none">
<div class="login-container">
<div class="card login-card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-hdd-network fs-1 text-primary"></i>
<h3 class="mt-2">NetBird MSP Appliance</h3>
<p class="text-muted">Multi-Tenant Management Platform</p>
<div id="login-logo"><i class="bi bi-hdd-network fs-1 text-primary"></i></div>
<h3 class="mt-2" id="login-title">NetBird MSP Appliance</h3>
<p class="text-muted" id="login-subtitle" data-i18n="login.subtitle">Multi-Tenant Management Platform</p>
</div>
<div id="login-error" class="alert alert-danger d-none"></div>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Username</label>
<label class="form-label" data-i18n="login.username">Username</label>
<input type="text" class="form-control" id="login-username" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<label class="form-label" data-i18n="login.password">Password</label>
<input type="password" class="form-control" id="login-password" required>
</div>
<button type="submit" class="btn btn-primary w-100" id="login-btn">
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
Sign In
<span data-i18n="login.signIn">Sign In</span>
</button>
</form>
<div id="azure-login-divider" class="d-none">
<hr class="my-3">
<button type="button" class="btn btn-outline-dark w-100" id="azure-login-btn" onclick="loginWithAzure()">
<i class="bi bi-microsoft me-2"></i><span data-i18n="login.signInWithMicrosoft">Sign in with Microsoft</span>
</button>
</div>
</div>
</div>
</div>
@@ -44,20 +50,31 @@
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="#"><i class="bi bi-hdd-network me-2"></i>NetBird MSP</a>
<a class="navbar-brand d-flex align-items-center" href="#" onclick="showPage('dashboard'); return false;">
<span id="nav-logo"><i class="bi bi-hdd-network me-2"></i></span>
<span id="nav-brand-name">NetBird MSP</span>
</a>
<div class="d-flex align-items-center">
<!-- Language Switcher -->
<div class="dropdown me-2">
<button class="btn btn-outline-light btn-sm dropdown-toggle" id="language-switcher-btn" data-bs-toggle="dropdown" aria-expanded="false">EN</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-lang="en" onclick="switchLanguage('en'); return false;">English</a></li>
<li><a class="dropdown-item" href="#" data-lang="de" onclick="switchLanguage('de'); return false;">Deutsch</a></li>
</ul>
</div>
<button class="btn btn-success btn-sm me-3" onclick="showNewCustomerModal()">
<i class="bi bi-plus-lg me-1"></i>New Customer
<i class="bi bi-plus-lg me-1"></i><span data-i18n="nav.newCustomer">New Customer</span>
</button>
<div class="dropdown">
<button class="btn btn-outline-light btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i><span id="nav-username">Admin</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="showPage('settings')"><i class="bi bi-gear me-2"></i>Settings</a></li>
<li><a class="dropdown-item" href="#" onclick="showPage('monitoring')"><i class="bi bi-activity me-2"></i>Monitoring</a></li>
<li><a class="dropdown-item" href="#" onclick="showPage('settings')"><i class="bi bi-gear me-2"></i><span data-i18n="nav.settings">Settings</span></a></li>
<li><a class="dropdown-item" href="#" onclick="showPage('monitoring')"><i class="bi bi-activity me-2"></i><span data-i18n="nav.monitoring">Monitoring</span></a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="logout()"><i class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
<li><a class="dropdown-item text-danger" href="#" onclick="logout()"><i class="bi bi-box-arrow-right me-2"></i><span data-i18n="nav.logout">Logout</span></a></li>
</ul>
</div>
</div>
@@ -74,7 +91,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Total Customers</div>
<div class="text-muted small" data-i18n="dashboard.totalCustomers">Total Customers</div>
<div class="fs-3 fw-bold" id="stat-total">0</div>
</div>
<div class="stat-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-people"></i></div>
@@ -87,7 +104,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Active</div>
<div class="text-muted small" data-i18n="dashboard.active">Active</div>
<div class="fs-3 fw-bold text-success" id="stat-active">0</div>
</div>
<div class="stat-icon bg-success bg-opacity-10 text-success"><i class="bi bi-check-circle"></i></div>
@@ -100,7 +117,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Inactive</div>
<div class="text-muted small" data-i18n="dashboard.inactive">Inactive</div>
<div class="fs-3 fw-bold text-warning" id="stat-inactive">0</div>
</div>
<div class="stat-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-pause-circle"></i></div>
@@ -113,7 +130,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Errors</div>
<div class="text-muted small" data-i18n="dashboard.errors">Errors</div>
<div class="fs-3 fw-bold text-danger" id="stat-error">0</div>
</div>
<div class="stat-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-exclamation-triangle"></i></div>
@@ -128,19 +145,19 @@
<div class="card-body">
<div class="row g-2">
<div class="col-md-6">
<input type="text" class="form-control" id="search-input" placeholder="Search by name, subdomain, email...">
<input type="text" class="form-control" id="search-input" data-i18n-placeholder="dashboard.searchPlaceholder" placeholder="Search by name, subdomain, email...">
</div>
<div class="col-md-3">
<select class="form-select" id="status-filter">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="deploying">Deploying</option>
<option value="error">Error</option>
<option value="" data-i18n="dashboard.allStatuses">All Statuses</option>
<option value="active" data-i18n="dashboard.statusActive">Active</option>
<option value="inactive" data-i18n="dashboard.statusInactive">Inactive</option>
<option value="deploying" data-i18n="dashboard.statusDeploying">Deploying</option>
<option value="error" data-i18n="dashboard.statusError">Error</option>
</select>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-outline-secondary" onclick="loadCustomers()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
<button class="btn btn-outline-secondary" onclick="loadCustomers()"><i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="dashboard.refresh">Refresh</span></button>
</div>
</div>
</div>
@@ -152,24 +169,24 @@
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Company</th>
<th>Subdomain</th>
<th>Status</th>
<th>Devices</th>
<th>Created</th>
<th>Actions</th>
<th data-i18n="dashboard.thId">ID</th>
<th data-i18n="dashboard.thName">Name</th>
<th data-i18n="dashboard.thSubdomain">Subdomain</th>
<th data-i18n="dashboard.thStatus">Status</th>
<th data-i18n="dashboard.thDashboard">Dashboard</th>
<th data-i18n="dashboard.thDevices">Devices</th>
<th data-i18n="dashboard.thCreated">Created</th>
<th data-i18n="dashboard.thActions">Actions</th>
</tr>
</thead>
<tbody id="customers-table-body">
<tr><td colspan="8" class="text-center text-muted py-4">Loading...</td></tr>
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted small" id="pagination-info">Showing 0 of 0</div>
<div class="text-muted small" id="pagination-info" data-i18n="dashboard.showingEmpty">Showing 0 of 0</div>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination-controls"></ul>
</nav>
@@ -183,45 +200,45 @@
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i>Back</button>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
<span class="fs-4 fw-bold" id="detail-customer-name">Customer</span>
<span class="badge ms-2" id="detail-customer-status">active</span>
</div>
<div>
<button class="btn btn-outline-primary btn-sm me-1" onclick="editCurrentCustomer()"><i class="bi bi-pencil me-1"></i>Edit</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i>Delete</button>
<button class="btn btn-outline-primary btn-sm me-1" onclick="editCurrentCustomer()"><i class="bi bi-pencil me-1"></i><span data-i18n="customer.edit">Edit</span></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i><span data-i18n="customer.delete">Delete</span></button>
</div>
</div>
<ul class="nav nav-tabs mb-3" id="detail-tabs">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-info">Info</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deployment">Deployment</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-logs">Logs</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-health">Health</a></li>
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-info" data-i18n="customer.tabInfo">Info</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deployment" data-i18n="customer.tabDeployment">Deployment</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-logs" data-i18n="customer.tabLogs">Logs</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-health" data-i18n="customer.tabHealth">Health</a></li>
</ul>
<div class="tab-content">
<!-- Tab: Info -->
<div class="tab-pane fade show active" id="tab-info">
<div class="card shadow-sm">
<div class="card-body" id="detail-info-content">Loading...</div>
<div class="card-body" id="detail-info-content" data-i18n="common.loading">Loading...</div>
</div>
</div>
<!-- Tab: Deployment -->
<div class="tab-pane fade" id="tab-deployment">
<div class="card shadow-sm">
<div class="card-body" id="detail-deployment-content">Loading...</div>
<div class="card-body" id="detail-deployment-content" data-i18n="common.loading">Loading...</div>
</div>
</div>
<!-- Tab: Logs -->
<div class="tab-pane fade" id="tab-logs">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Container Logs</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
<span data-i18n="customer.containerLogs">Container Logs</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> <span data-i18n="dashboard.refresh">Refresh</span></button>
</div>
<div class="card-body">
<div id="detail-logs-content" class="log-viewer">No logs loaded.</div>
<div id="detail-logs-content" class="log-viewer" data-i18n="customer.noLogsLoaded">No logs loaded.</div>
</div>
</div>
</div>
@@ -229,10 +246,10 @@
<div class="tab-pane fade" id="tab-health">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Health Check</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> Check</button>
<span data-i18n="customer.healthCheck">Health Check</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> <span data-i18n="customer.check">Check</span></button>
</div>
<div class="card-body" id="detail-health-content">Click "Check" to run a health check.</div>
<div class="card-body" id="detail-health-content" data-i18n="customer.clickCheck">Click "Check" to run a health check.</div>
</div>
</div>
</div>
@@ -242,14 +259,22 @@
<!-- Page: Settings -->
<div id="page-settings" class="page-content d-none">
<div class="container-fluid p-4">
<h4 class="mb-4"><i class="bi bi-gear me-2"></i>System Settings</h4>
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
<i class="bi bi-gear me-2"></i><span data-i18n="settings.title">System Settings</span>
</h4>
</div>
<div id="settings-alert" class="d-none"></div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#settings-system">System Configuration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-npm">NPM Integration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-images">Docker Images</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security">Security</a></li>
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#settings-system" data-i18n="settings.tabSystem">System Configuration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-npm" data-i18n="settings.tabNpm">NPM Integration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-images" data-i18n="settings.tabImages">Docker Images</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-branding" data-i18n="settings.tabBranding">Branding</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-users" onclick="loadUsers()" data-i18n="settings.tabUsers">Users</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-azure" data-i18n="settings.tabAzure">Azure AD</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security" data-i18n="settings.tabSecurity">Security</a></li>
</ul>
<div class="tab-content">
@@ -260,30 +285,35 @@
<form id="settings-system-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Base Domain</label>
<input type="text" class="form-control" id="cfg-base-domain" placeholder="yourdomain.com">
<div class="form-text">Customers get subdomains: kunde.yourdomain.com</div>
<label class="form-label" data-i18n="settings.baseDomain">Base Domain</label>
<input type="text" class="form-control" id="cfg-base-domain" data-i18n-placeholder="settings.baseDomainPlaceholder" placeholder="yourdomain.com">
<div class="form-text" data-i18n="settings.baseDomainHint">Customers get subdomains: customer.yourdomain.com</div>
</div>
<div class="col-md-6">
<label class="form-label">Admin Email</label>
<input type="email" class="form-control" id="cfg-admin-email" placeholder="admin@yourdomain.com">
<label class="form-label" data-i18n="settings.adminEmail">Admin Email</label>
<input type="email" class="form-control" id="cfg-admin-email" data-i18n-placeholder="settings.adminEmailPlaceholder" placeholder="admin@yourdomain.com">
</div>
<div class="col-md-6">
<label class="form-label">Data Directory</label>
<input type="text" class="form-control" id="cfg-data-dir" placeholder="/opt/netbird-instances">
<label class="form-label" data-i18n="settings.dataDir">Data Directory</label>
<input type="text" class="form-control" id="cfg-data-dir" data-i18n-placeholder="settings.dataDirPlaceholder" placeholder="/opt/netbird-instances">
</div>
<div class="col-md-6">
<label class="form-label">Docker Network</label>
<input type="text" class="form-control" id="cfg-docker-network" placeholder="npm-network">
<label class="form-label" data-i18n="settings.dockerNetwork">Docker Network</label>
<input type="text" class="form-control" id="cfg-docker-network" data-i18n-placeholder="settings.dockerNetworkPlaceholder" placeholder="npm-network">
</div>
<div class="col-md-6">
<label class="form-label">Relay Base Port</label>
<label class="form-label" data-i18n="settings.relayBasePort">Relay Base Port</label>
<input type="number" class="form-control" id="cfg-relay-base-port" min="1024" max="65535">
<div class="form-text">First UDP port for relay. Range: base to base+99</div>
<div class="form-text" data-i18n="settings.relayBasePortHint">First UDP port for relay. Range: base to base+99</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.dashboardBasePort">Dashboard Base Port</label>
<input type="number" class="form-control" id="cfg-dashboard-base-port" min="1024" max="65535">
<div class="form-text" data-i18n="settings.dashboardBasePortHint">Base port for customer dashboards. Customer N gets base+N</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save System Settings</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveSystemSettings">Save System Settings</span></button>
</div>
</form>
</div>
@@ -295,31 +325,31 @@
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-npm-form">
<p class="text-muted mb-3">NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.</p>
<p class="text-muted mb-3" data-i18n="settings.npmDescription">NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.</p>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">NPM API URL</label>
<input type="text" class="form-control" id="cfg-npm-api-url" placeholder="http://nginx-proxy-manager:81/api">
<div class="form-text">http:// or https:// - must include /api at the end</div>
<label class="form-label" data-i18n="settings.npmApiUrl">NPM API URL</label>
<input type="text" class="form-control" id="cfg-npm-api-url" data-i18n-placeholder="settings.npmApiUrlPlaceholder" placeholder="http://nginx-proxy-manager:81/api">
<div class="form-text" data-i18n="settings.npmApiUrlHint">http:// or https:// - must include /api at the end</div>
</div>
<div class="col-md-8">
<label class="form-label">NPM Login Email</label>
<input type="text" class="form-control" id="cfg-npm-api-email" placeholder="Leave empty to keep current">
<label class="form-label" data-i18n="settings.npmLoginEmail">NPM Login Email</label>
<input type="text" class="form-control" id="cfg-npm-api-email" data-i18n-placeholder="settings.npmLoginEmailPlaceholder" placeholder="Leave empty to keep current">
<div class="form-text" id="npm-credentials-status"></div>
</div>
<div class="col-md-8">
<label class="form-label">NPM Login Password</label>
<label class="form-label" data-i18n="settings.npmLoginPassword">NPM Login Password</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-npm-api-password" placeholder="Leave empty to keep current">
<input type="password" class="form-control" id="cfg-npm-api-password" data-i18n-placeholder="settings.npmLoginPasswordPlaceholder" placeholder="Leave empty to keep current">
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-password')"><i class="bi bi-eye"></i></button>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i>Save NPM Settings</button>
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveNpmSettings">Save NPM Settings</span></button>
<button type="button" class="btn btn-outline-info" id="test-npm-btn" onclick="testNpmConnection()">
<span class="spinner-border spinner-border-sm d-none me-1" id="npm-test-spinner"></span>
<i class="bi bi-plug me-1"></i>Test Connection
<i class="bi bi-plug me-1"></i><span data-i18n="settings.testConnection">Test Connection</span>
</button>
</div>
</form>
@@ -335,24 +365,142 @@
<form id="settings-images-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Management Image</label>
<input type="text" class="form-control" id="cfg-mgmt-image" placeholder="netbirdio/management:latest">
<label class="form-label" data-i18n="settings.managementImage">Management Image</label>
<input type="text" class="form-control" id="cfg-mgmt-image" data-i18n-placeholder="settings.managementImagePlaceholder" placeholder="netbirdio/management:latest">
</div>
<div class="col-md-6">
<label class="form-label">Signal Image</label>
<input type="text" class="form-control" id="cfg-signal-image" placeholder="netbirdio/signal:latest">
<label class="form-label" data-i18n="settings.signalImage">Signal Image</label>
<input type="text" class="form-control" id="cfg-signal-image" data-i18n-placeholder="settings.signalImagePlaceholder" placeholder="netbirdio/signal:latest">
</div>
<div class="col-md-6">
<label class="form-label">Relay Image</label>
<input type="text" class="form-control" id="cfg-relay-image" placeholder="netbirdio/relay:latest">
<label class="form-label" data-i18n="settings.relayImage">Relay Image</label>
<input type="text" class="form-control" id="cfg-relay-image" data-i18n-placeholder="settings.relayImagePlaceholder" placeholder="netbirdio/relay:latest">
</div>
<div class="col-md-6">
<label class="form-label">Dashboard Image</label>
<input type="text" class="form-control" id="cfg-dashboard-image" placeholder="netbirdio/dashboard:latest">
<label class="form-label" data-i18n="settings.dashboardImage">Dashboard Image</label>
<input type="text" class="form-control" id="cfg-dashboard-image" data-i18n-placeholder="settings.dashboardImagePlaceholder" placeholder="netbirdio/dashboard:latest">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Image Settings</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveImageSettings">Save Image Settings</span></button>
</div>
</form>
</div>
</div>
</div>
<!-- Branding -->
<div class="tab-pane fade" id="settings-branding">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.brandingTitle">Branding Settings</h5>
<form id="settings-branding-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" data-i18n="settings.companyName">Company / Application Name</label>
<input type="text" class="form-control" id="cfg-branding-name" data-i18n-placeholder="settings.companyNamePlaceholder" placeholder="NetBird MSP Appliance" maxlength="255">
<div class="form-text" data-i18n="settings.companyNameHint">Displayed on login page and navbar</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.brandingSubtitle">Subtitle</label>
<input type="text" class="form-control" id="cfg-branding-subtitle" data-i18n-placeholder="settings.brandingSubtitlePlaceholder" placeholder="Multi-Tenant Management Platform" maxlength="255">
<div class="form-text" data-i18n="settings.brandingSubtitleHint">Shown below the title on the login page</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.defaultLanguage">Default Language</label>
<select class="form-select" id="cfg-default-language">
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
<div class="form-text" data-i18n="settings.defaultLanguageHint">Default language for users without a preference</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.logoPreview">Logo Preview</label>
<div class="border rounded p-3 text-center" id="branding-logo-preview" style="min-height:80px;">
<i class="bi bi-hdd-network fs-1 text-primary"></i>
<div class="text-muted small mt-1" data-i18n="settings.defaultIcon">Default icon (no logo uploaded)</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.uploadLogo">Upload Logo (PNG, JPG, SVG, max 500KB)</label>
<div class="input-group">
<input type="file" class="form-control" id="branding-logo-file" accept=".png,.jpg,.jpeg,.svg">
<button type="button" class="btn btn-outline-primary" onclick="uploadLogo()"><i class="bi bi-upload me-1"></i><span data-i18n="settings.uploadBtn">Upload</span></button>
</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger" onclick="deleteLogo()"><i class="bi bi-trash me-1"></i><span data-i18n="settings.removeLogo">Remove Logo</span></button>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveBranding">Save Branding</span></button>
</div>
</form>
</div>
</div>
</div>
<!-- Users -->
<div class="tab-pane fade" id="settings-users">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span data-i18n="settings.userManagement">User Management</span>
<button class="btn btn-sm btn-success" onclick="showNewUserModal()"><i class="bi bi-plus-lg me-1"></i><span data-i18n="settings.newUser">New User</span></button>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th data-i18n="settings.thId">ID</th>
<th data-i18n="settings.thUsername">Username</th>
<th data-i18n="settings.thEmail">Email</th>
<th data-i18n="settings.thRole">Role</th>
<th data-i18n="settings.thAuth">Auth</th>
<th data-i18n="settings.thLanguage">Language</th>
<th data-i18n="settings.thStatus">Status</th>
<th data-i18n="settings.thActions">Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Azure AD -->
<div class="tab-pane fade" id="settings-azure">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.azureTitle">Azure AD / Entra ID Integration</h5>
<form id="settings-azure-form">
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cfg-azure-enabled">
<label class="form-check-label" for="cfg-azure-enabled" data-i18n="settings.enableAzureSso">Enable Azure AD SSO</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.tenantId">Tenant ID</label>
<input type="text" class="form-control" id="cfg-azure-tenant" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.clientId">Client ID (Application ID)</label>
<input type="text" class="form-control" id="cfg-azure-client-id" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.clientSecret">Client Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-azure-client-secret" data-i18n-placeholder="settings.clientSecretPlaceholder" placeholder="Leave empty to keep current">
<button class="btn btn-outline-secondary" type="button" data-toggle-pw onclick="togglePasswordVisibility('cfg-azure-client-secret')"><i class="bi bi-eye"></i></button>
</div>
<div class="form-text" id="azure-secret-status"></div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveAzureSettings">Save Azure AD Settings</span></button>
</div>
</form>
</div>
@@ -363,25 +511,25 @@
<div class="tab-pane fade" id="settings-security">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3">Change Admin Password</h5>
<h5 class="mb-3" data-i18n="settings.securityTitle">Change Admin Password</h5>
<form id="change-password-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Current Password</label>
<label class="form-label" data-i18n="settings.currentPassword">Current Password</label>
<input type="password" class="form-control" id="pw-current" required>
</div>
<div class="col-md-6"></div>
<div class="col-md-6">
<label class="form-label">New Password (min 12 chars)</label>
<label class="form-label" data-i18n="settings.newPassword">New Password (min 12 chars)</label>
<input type="password" class="form-control" id="pw-new" required minlength="12">
</div>
<div class="col-md-6">
<label class="form-label">Confirm New Password</label>
<label class="form-label" data-i18n="settings.confirmPassword">Confirm New Password</label>
<input type="password" class="form-control" id="pw-confirm" required minlength="12">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i>Change Password</button>
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i><span data-i18n="settings.changePassword">Change Password</span></button>
</div>
</form>
<div id="password-result" class="mt-3 d-none"></div>
@@ -396,34 +544,38 @@
<div id="page-monitoring" class="page-content d-none">
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-activity me-2"></i>System Monitoring</h4>
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
<h4 class="mb-0">
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
<i class="bi bi-activity me-2"></i><span data-i18n="monitoring.title">System Monitoring</span>
</h4>
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="monitoring.refresh">Refresh</span></button>
</div>
<!-- Host Resources -->
<div class="card shadow-sm mb-4">
<div class="card-header">Host Resources</div>
<div class="card-body" id="monitoring-resources">Loading...</div>
<div class="card-header" data-i18n="monitoring.hostResources">Host Resources</div>
<div class="card-body" id="monitoring-resources" data-i18n="common.loading">Loading...</div>
</div>
<!-- Customer Statuses -->
<div class="card shadow-sm">
<div class="card-header">All Customer Deployments</div>
<div class="card-header" data-i18n="monitoring.allCustomerDeployments">All Customer Deployments</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Subdomain</th>
<th>Status</th>
<th>Deployment</th>
<th>Relay Port</th>
<th>Containers</th>
<th data-i18n="monitoring.thId">ID</th>
<th data-i18n="monitoring.thName">Name</th>
<th data-i18n="monitoring.thSubdomain">Subdomain</th>
<th data-i18n="monitoring.thStatus">Status</th>
<th data-i18n="monitoring.thDeployment">Deployment</th>
<th data-i18n="monitoring.thDashboard">Dashboard</th>
<th data-i18n="monitoring.thRelayPort">Relay Port</th>
<th data-i18n="monitoring.thContainers">Containers</th>
</tr>
</thead>
<tbody id="monitoring-customers-body">
<tr><td colspan="7" class="text-center text-muted py-4">Loading...</td></tr>
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody>
</table>
</div>
@@ -437,7 +589,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="customer-modal-title">New Customer</h5>
<h5 class="modal-title" id="customer-modal-title" data-i18n="customerModal.newCustomer">New Customer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@@ -445,40 +597,40 @@
<form id="customer-form">
<input type="hidden" id="customer-edit-id">
<div class="mb-3">
<label class="form-label">Name *</label>
<label class="form-label" data-i18n="customerModal.nameLabel">Name *</label>
<input type="text" class="form-control" id="cust-name" required>
</div>
<div class="mb-3">
<label class="form-label">Company</label>
<label class="form-label" data-i18n="customerModal.companyLabel">Company</label>
<input type="text" class="form-control" id="cust-company">
</div>
<div class="mb-3">
<label class="form-label">Subdomain *</label>
<label class="form-label" data-i18n="customerModal.subdomainLabel">Subdomain *</label>
<div class="input-group">
<input type="text" class="form-control" id="cust-subdomain" required pattern="[a-z0-9][-a-z0-9]*[a-z0-9]">
<span class="input-group-text" id="cust-subdomain-suffix">.domain.com</span>
</div>
<div class="form-text">Lowercase, alphanumeric + hyphens</div>
<div class="form-text" data-i18n="customerModal.subdomainHint">Lowercase, alphanumeric + hyphens</div>
</div>
<div class="mb-3">
<label class="form-label">Email *</label>
<label class="form-label" data-i18n="customerModal.emailLabel">Email *</label>
<input type="email" class="form-control" id="cust-email" required>
</div>
<div class="mb-3">
<label class="form-label">Max Devices</label>
<label class="form-label" data-i18n="customerModal.maxDevicesLabel">Max Devices</label>
<input type="number" class="form-control" id="cust-max-devices" value="20" min="1">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<label class="form-label" data-i18n="customerModal.notesLabel">Notes</label>
<textarea class="form-control" id="cust-notes" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="customer-save-btn" onclick="saveCustomer()">
<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span>
Save & Deploy
<span data-i18n="customerModal.saveAndDeploy">Save & Deploy</span>
</button>
</div>
</div>
@@ -490,26 +642,68 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">Confirm Deletion</h5>
<h5 class="modal-title" data-i18n="deleteModal.title">Confirm Deletion</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete customer <strong id="delete-customer-name"></strong>?</p>
<p class="text-danger">This will remove all containers, NPM entries, and data. This action cannot be undone.</p>
<p><span data-i18n="deleteModal.confirmText">Are you sure you want to delete customer</span> <strong id="delete-customer-name"></strong>?</p>
<p class="text-danger" data-i18n="deleteModal.warning">This will remove all containers, NPM entries, and data. This action cannot be undone.</p>
<input type="hidden" id="delete-customer-id">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">
<span class="spinner-border spinner-border-sm d-none me-1" id="delete-spinner"></span>
Delete
<span data-i18n="common.delete">Delete</span>
</button>
</div>
</div>
</div>
</div>
<!-- Modal: New User -->
<div class="modal fade" id="user-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" data-i18n="userModal.title">New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="user-modal-error" class="alert alert-danger d-none"></div>
<form id="user-form">
<div class="mb-3">
<label class="form-label" data-i18n="userModal.usernameLabel">Username *</label>
<input type="text" class="form-control" id="new-user-username" required minlength="3">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="userModal.passwordLabel">Password * (min 8 chars)</label>
<input type="password" class="form-control" id="new-user-password" required minlength="8">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="userModal.emailLabel">Email</label>
<input type="email" class="form-control" id="new-user-email">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="userModal.languageLabel">Default Language</label>
<select class="form-select" id="new-user-language">
<option value="" data-i18n="settings.systemDefault">System Default</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveNewUser()" data-i18n="userModal.createUser">Create User</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -12,6 +12,8 @@ let currentPage = 'dashboard';
let currentCustomerId = null;
let currentCustomerData = null;
let customersPage = 1;
let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null };
let azureConfig = { azure_enabled: false };
// ---------------------------------------------------------------------------
// API helper
@@ -32,21 +34,32 @@ async function api(method, path, body = null) {
resp = await fetch(`/api${path}`, opts);
} catch (networkErr) {
console.error(`API network error: ${method} ${path}`, networkErr);
throw new Error('Network error — server not reachable.');
throw new Error(t('errors.networkError'));
}
if (resp.status === 401) {
logout();
throw new Error('Session expired.');
throw new Error(t('errors.sessionExpired'));
}
let data;
try {
data = await resp.json();
} catch (jsonErr) {
console.error(`API JSON parse error: ${method} ${path} (status ${resp.status})`, jsonErr);
throw new Error(`Server error (HTTP ${resp.status}).`);
throw new Error(t('errors.serverError', { status: resp.status }));
}
if (!resp.ok) {
const msg = data.detail || data.message || 'Request failed.';
let msg = t('errors.requestFailed');
if (Array.isArray(data.detail)) {
msg = data.detail.map(e => {
const field = e.loc ? e.loc[e.loc.length - 1] : '';
const text = (e.msg || '').replace(/^Value error, ?/, '');
return field ? `${field}: ${text}` : text;
}).join('\n');
} else if (typeof data.detail === 'string') {
msg = data.detail;
} else if (data.message) {
msg = data.message;
}
console.error(`API error: ${method} ${path} (status ${resp.status})`, msg);
throw new Error(msg);
}
@@ -56,20 +69,27 @@ async function api(method, path, body = null) {
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function initApp() {
async function initApp() {
await initI18n();
await loadBranding();
await loadAzureLoginConfig();
if (authToken) {
api('GET', '/auth/me')
.then(user => {
currentUser = user;
document.getElementById('nav-username').textContent = user.username;
showAppPage();
loadDashboard();
})
.catch(() => {
authToken = null;
localStorage.removeItem('authToken');
showLoginPage();
});
try {
const user = await api('GET', '/auth/me');
currentUser = user;
document.getElementById('nav-username').textContent = user.username;
// Apply user's language preference if set
if (user.default_language && !localStorage.getItem('language')) {
await setLanguage(user.default_language);
}
showAppPage();
loadDashboard();
} catch {
authToken = null;
localStorage.removeItem('authToken');
showLoginPage();
}
} else {
showLoginPage();
}
@@ -85,6 +105,112 @@ function showAppPage() {
document.getElementById('app-page').classList.remove('d-none');
}
async function loadBranding() {
try {
const resp = await fetch('/api/settings/branding');
if (resp.ok) {
brandingData = await resp.json();
// Set system default language from server config
if (brandingData.default_language) {
setSystemDefault(brandingData.default_language);
}
applyBranding();
}
} catch {
// Use defaults
}
}
function applyBranding() {
const name = brandingData.branding_name || 'NetBird MSP Appliance';
const subtitle = brandingData.branding_subtitle || t('login.subtitle');
const logoPath = brandingData.branding_logo_path;
// Login page
document.getElementById('login-title').textContent = name;
const subtitleEl = document.getElementById('login-subtitle');
if (subtitleEl) subtitleEl.textContent = subtitle;
document.title = name;
if (logoPath) {
document.getElementById('login-logo').innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;" class="mb-1">`;
} else {
document.getElementById('login-logo').innerHTML = '<i class="bi bi-hdd-network fs-1 text-primary"></i>';
}
// Navbar — use short form for the nav bar
const shortName = name.length > 30 ? name.substring(0, 30) + '\u2026' : name;
document.getElementById('nav-brand-name').textContent = shortName;
if (logoPath) {
document.getElementById('nav-logo').innerHTML = `<img src="${logoPath}" alt="Logo" style="height:28px;max-width:120px;" class="me-2">`;
} else {
document.getElementById('nav-logo').innerHTML = '<i class="bi bi-hdd-network me-2"></i>';
}
}
async function loadAzureLoginConfig() {
try {
const resp = await fetch('/api/auth/azure/config');
if (resp.ok) {
azureConfig = await resp.json();
if (azureConfig.azure_enabled) {
document.getElementById('azure-login-divider').classList.remove('d-none');
} else {
document.getElementById('azure-login-divider').classList.add('d-none');
}
}
} catch {
// Azure not configured
}
}
function loginWithAzure() {
if (!azureConfig.azure_enabled || !azureConfig.azure_tenant_id || !azureConfig.azure_client_id) {
alert(t('errors.azureNotConfigured'));
return;
}
const redirectUri = window.location.origin + '/';
const authUrl = `https://login.microsoftonline.com/${azureConfig.azure_tenant_id}/oauth2/v2.0/authorize`
+ `?client_id=${azureConfig.azure_client_id}`
+ `&response_type=code`
+ `&redirect_uri=${encodeURIComponent(redirectUri)}`
+ `&scope=${encodeURIComponent('openid profile email User.Read')}`
+ `&response_mode=query`;
window.location.href = authUrl;
}
async function handleAzureCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) return false;
// Clear URL params
window.history.replaceState({}, document.title, '/');
try {
const data = await api('POST', '/auth/azure/callback', {
code: code,
redirect_uri: window.location.origin + '/',
});
authToken = data.access_token;
localStorage.setItem('authToken', authToken);
currentUser = data.user;
document.getElementById('nav-username').textContent = currentUser.username;
// Apply user's language preference
if (currentUser.default_language) {
await setLanguage(currentUser.default_language);
}
showAppPage();
loadDashboard();
return true;
} catch (err) {
const errorEl = document.getElementById('login-error');
errorEl.textContent = t('errors.azureLoginFailed', { error: err.message });
errorEl.classList.remove('d-none');
showLoginPage();
return true;
}
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
@@ -101,6 +227,10 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
localStorage.setItem('authToken', authToken);
currentUser = data.user;
document.getElementById('nav-username').textContent = currentUser.username;
// Apply user's language preference
if (currentUser.default_language) {
await setLanguage(currentUser.default_language);
}
showAppPage();
loadDashboard();
} catch (err) {
@@ -128,6 +258,23 @@ function logout() {
showLoginPage();
}
// ---------------------------------------------------------------------------
// Language switching (saves preference to server for logged-in users)
// ---------------------------------------------------------------------------
async function switchLanguage(lang) {
await setLanguage(lang);
applyBranding();
// Save preference to server if user is logged in
if (currentUser && currentUser.id) {
try {
await api('PUT', `/users/${currentUser.id}`, { default_language: lang });
currentUser.default_language = lang;
} catch {
// Silently fail — localStorage already saved
}
}
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
@@ -178,39 +325,44 @@ async function loadCustomers() {
function renderCustomersTable(data) {
const tbody = document.getElementById('customers-table-body');
if (!data.items || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No customers found. Click "New Customer" to create one.</td></tr>';
document.getElementById('pagination-info').textContent = 'Showing 0 of 0';
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`;
document.getElementById('pagination-info').textContent = t('dashboard.showingEmpty');
document.getElementById('pagination-controls').innerHTML = '';
return;
}
tbody.innerHTML = data.items.map(c => `
<tr>
tbody.innerHTML = data.items.map(c => {
const dPort = c.deployment && c.deployment.dashboard_port;
const dashUrl = c.deployment && c.deployment.setup_url;
const dashLink = dPort
? `<a href="${esc(dashUrl || 'http://localhost:' + dPort)}" target="_blank" class="text-decoration-none" title="${t('customer.openDashboard')}">:${dPort} <i class="bi bi-box-arrow-up-right"></i></a>`
: '-';
return `<tr>
<td>${c.id}</td>
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
<td>${esc(c.company || '-')}</td>
<td><code>${esc(c.subdomain)}</code></td>
<td>${statusBadge(c.status)}</td>
<td>${dashLink}</td>
<td>${c.max_devices}</td>
<td>${formatDate(c.created_at)}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" title="View" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
<button class="btn btn-outline-primary" title="${t('common.view')}" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
${c.deployment && c.deployment.deployment_status === 'running'
? `<button class="btn btn-outline-warning" title="Stop" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
: `<button class="btn btn-outline-success" title="Start" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
? `<button class="btn btn-outline-warning" title="${t('common.stop')}" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
: `<button class="btn btn-outline-success" title="${t('common.start')}" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
}
<button class="btn btn-outline-info" title="Restart" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
<button class="btn btn-outline-danger" title="Delete" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
<button class="btn btn-outline-info" title="${t('common.restart')}" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('');
</tr>`;
}).join('');
// Pagination
const start = (data.page - 1) * data.per_page + 1;
const end = Math.min(data.page * data.per_page, data.total);
document.getElementById('pagination-info').textContent = `Showing ${start}-${end} of ${data.total}`;
document.getElementById('pagination-info').textContent = t('dashboard.showing', { start, end, total: data.total });
let paginationHtml = '';
for (let i = 1; i <= data.pages; i++) {
@@ -232,12 +384,13 @@ document.getElementById('status-filter').addEventListener('change', () => { cust
// Customer CRUD
// ---------------------------------------------------------------------------
function showNewCustomerModal() {
document.getElementById('customer-modal-title').textContent = 'New Customer';
document.getElementById('customer-modal-title').textContent = t('customerModal.newCustomer');
document.getElementById('customer-edit-id').value = '';
document.getElementById('customer-form').reset();
document.getElementById('cust-max-devices').value = '20';
document.getElementById('customer-modal-error').classList.add('d-none');
document.getElementById('customer-save-btn').innerHTML = '<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span> Save &amp; Deploy';
const saveBtnSpan = document.getElementById('customer-save-btn').querySelector('span[data-i18n]');
if (saveBtnSpan) saveBtnSpan.textContent = t('customerModal.saveAndDeploy');
// Update subdomain suffix
api('GET', '/settings/system').then(cfg => {
@@ -254,7 +407,7 @@ function showNewCustomerModal() {
function editCurrentCustomer() {
if (!currentCustomerData) return;
const c = currentCustomerData;
document.getElementById('customer-modal-title').textContent = 'Edit Customer';
document.getElementById('customer-modal-title').textContent = t('customerModal.editCustomer');
document.getElementById('customer-edit-id').value = c.id;
document.getElementById('cust-name').value = c.name;
document.getElementById('cust-company').value = c.company || '';
@@ -264,7 +417,8 @@ function editCurrentCustomer() {
document.getElementById('cust-max-devices').value = c.max_devices;
document.getElementById('cust-notes').value = c.notes || '';
document.getElementById('customer-modal-error').classList.add('d-none');
document.getElementById('customer-save-btn').innerHTML = '<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span> Save Changes';
const saveBtnSpan = document.getElementById('customer-save-btn').querySelector('span[data-i18n]');
if (saveBtnSpan) saveBtnSpan.textContent = t('customerModal.saveChanges');
const modalEl = document.getElementById('customer-modal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
@@ -309,7 +463,7 @@ async function saveCustomer() {
}
} catch (err) {
console.error('saveCustomer error:', err);
errorEl.textContent = err.message || 'An unknown error occurred.';
errorEl.textContent = err.message || t('errors.unknownError');
errorEl.classList.remove('d-none');
errorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} finally {
@@ -338,7 +492,7 @@ async function confirmDeleteCustomer() {
bootstrap.Modal.getInstance(document.getElementById('delete-modal')).hide();
showPage('dashboard');
} catch (err) {
alert('Delete failed: ' + err.message);
alert(t('errors.deleteFailed', { error: err.message }));
} finally {
spinner.classList.add('d-none');
}
@@ -353,7 +507,7 @@ async function customerAction(id, action) {
if (currentPage === 'dashboard') loadCustomers();
if (currentCustomerId == id) viewCustomer(id);
} catch (err) {
alert(`${action} failed: ${err.message}`);
alert(t('errors.actionFailed', { action, error: err.message }));
}
}
@@ -374,15 +528,15 @@ async function viewCustomer(id) {
// Info tab
document.getElementById('detail-info-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Name:</strong> ${esc(data.name)}</div>
<div class="col-md-6"><strong>Company:</strong> ${esc(data.company || '-')}</div>
<div class="col-md-6"><strong>Subdomain:</strong> <code>${esc(data.subdomain)}</code></div>
<div class="col-md-6"><strong>Email:</strong> ${esc(data.email)}</div>
<div class="col-md-6"><strong>Max Devices:</strong> ${data.max_devices}</div>
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(data.status)}</div>
<div class="col-md-6"><strong>Created:</strong> ${formatDate(data.created_at)}</div>
<div class="col-md-6"><strong>Updated:</strong> ${formatDate(data.updated_at)}</div>
${data.notes ? `<div class="col-12"><strong>Notes:</strong> ${esc(data.notes)}</div>` : ''}
<div class="col-md-6"><strong>${t('customer.name')}</strong> ${esc(data.name)}</div>
<div class="col-md-6"><strong>${t('customer.company')}</strong> ${esc(data.company || '-')}</div>
<div class="col-md-6"><strong>${t('customer.subdomain')}</strong> <code>${esc(data.subdomain)}</code></div>
<div class="col-md-6"><strong>${t('customer.email')}</strong> ${esc(data.email)}</div>
<div class="col-md-6"><strong>${t('customer.maxDevices')}</strong> ${data.max_devices}</div>
<div class="col-md-6"><strong>${t('customer.status')}</strong> ${statusBadge(data.status)}</div>
<div class="col-md-6"><strong>${t('customer.created')}</strong> ${formatDate(data.created_at)}</div>
<div class="col-md-6"><strong>${t('customer.updated')}</strong> ${formatDate(data.updated_at)}</div>
${data.notes ? `<div class="col-12"><strong>${t('customer.notes')}</strong> ${esc(data.notes)}</div>` : ''}
</div>
`;
@@ -391,29 +545,62 @@ async function viewCustomer(id) {
const d = data.deployment;
document.getElementById('detail-deployment-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(d.deployment_status)}</div>
<div class="col-md-6"><strong>Relay UDP Port:</strong> ${d.relay_udp_port}</div>
<div class="col-md-6"><strong>Container Prefix:</strong> <code>${esc(d.container_prefix)}</code></div>
<div class="col-md-6"><strong>Deployed:</strong> ${formatDate(d.deployed_at)}</div>
<div class="col-md-6"><strong>${t('customer.deploymentStatus')}</strong> ${statusBadge(d.deployment_status)}</div>
<div class="col-md-6"><strong>${t('customer.relayUdpPort')}</strong> ${d.relay_udp_port}</div>
<div class="col-md-6"><strong>${t('customer.dashboardPort')}</strong> ${d.dashboard_port || '-'}${d.dashboard_port ? ` <a href="${esc(d.setup_url || 'http://localhost:' + d.dashboard_port)}" target="_blank" class="ms-2"><i class="bi bi-box-arrow-up-right"></i> ${t('customer.open')}</a>` : ''}</div>
<div class="col-md-6"><strong>${t('customer.containerPrefix')}</strong> <code>${esc(d.container_prefix)}</code></div>
<div class="col-md-6"><strong>${t('customer.deployed')}</strong> ${formatDate(d.deployed_at)}</div>
<div class="col-12">
<strong>Setup URL:</strong>
<strong>${t('customer.setupUrl')}</strong>
<div class="input-group mt-1">
<input type="text" class="form-control" value="${esc(d.setup_url || '')}" readonly id="setup-url-input">
<button class="btn btn-outline-secondary" onclick="copySetupUrl()"><i class="bi bi-clipboard"></i> Copy</button>
<button class="btn btn-outline-secondary" onclick="copySetupUrl()"><i class="bi bi-clipboard"></i> ${t('customer.copy')}</button>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-key me-1"></i>${t('customer.netbirdLogin')}</strong>
${d.has_credentials ? '' : `<span class="badge bg-secondary">${t('customer.notAvailable')}</span>`}
</div>
<div class="card-body" id="credentials-container">
${d.has_credentials ? `
<div id="credentials-placeholder">
<button class="btn btn-outline-primary btn-sm" onclick="loadCredentials(${id})">
<i class="bi bi-shield-lock me-1"></i>${t('customer.showCredentials')}
</button>
</div>
<div id="credentials-content" style="display:none">
<div class="mb-2">
<label class="form-label mb-1"><small>${t('customer.credEmail')}</small></label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="cred-email" readonly>
<button class="btn btn-outline-secondary" onclick="copyCredential('cred-email')" title="${t('customer.copy')}"><i class="bi bi-clipboard"></i></button>
</div>
</div>
<div>
<label class="form-label mb-1"><small>${t('customer.credPassword')}</small></label>
<div class="input-group input-group-sm">
<input type="password" class="form-control" id="cred-password" readonly>
<button class="btn btn-outline-secondary" data-toggle-pw onclick="togglePasswordVisibility('cred-password')" title="${t('customer.showHide')}"><i class="bi bi-eye"></i></button>
<button class="btn btn-outline-secondary" onclick="copyCredential('cred-password')" title="${t('customer.copy')}"><i class="bi bi-clipboard"></i></button>
</div>
</div>
</div>
` : `<p class="text-muted mb-0">${t('customer.credentialsNotAvailable')}</p>`}
</div>
</div>
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>Start</button>
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>Stop</button>
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>Restart</button>
<button class="btn btn-outline-primary btn-sm" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Re-Deploy</button>
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
<button class="btn btn-outline-primary btn-sm" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
</div>
`;
} else {
document.getElementById('detail-deployment-content').innerHTML = `
<p class="text-muted">No deployment found.</p>
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Deploy Now</button>
<p class="text-muted">${t('customer.noDeployment')}</p>
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.deployNow')}</button>
`;
}
@@ -434,7 +621,7 @@ async function loadCustomerLogs() {
const data = await api('GET', `/customers/${currentCustomerId}/logs`);
const content = document.getElementById('detail-logs-content');
if (!data.logs || Object.keys(data.logs).length === 0) {
content.innerHTML = '<p class="text-muted">No container logs available.</p>';
content.innerHTML = `<p class="text-muted">${t('customer.noContainerLogs')}</p>`;
return;
}
let html = '';
@@ -452,16 +639,18 @@ async function loadCustomerHealth() {
try {
const data = await api('GET', `/customers/${currentCustomerId}/health`);
const content = document.getElementById('detail-health-content');
let html = `<div class="mb-3"><strong>Overall:</strong> ${data.healthy ? '<span class="text-success">Healthy</span>' : '<span class="text-danger">Unhealthy</span>'}</div>`;
let html = `<div class="mb-3"><strong>${t('customer.overall')}</strong> ${data.healthy ? `<span class="text-success">${t('customer.healthy')}</span>` : `<span class="text-danger">${t('customer.unhealthy')}</span>`}</div>`;
if (data.containers && data.containers.length > 0) {
html += '<table class="table table-sm"><thead><tr><th>Container</th><th>Status</th><th>Health</th><th>Image</th></tr></thead><tbody>';
html += `<table class="table table-sm"><thead><tr><th>${t('customer.thContainer')}</th><th>${t('customer.thContainerStatus')}</th><th>${t('customer.thHealth')}</th><th>${t('customer.thImage')}</th></tr></thead><tbody>`;
data.containers.forEach(c => {
const statusClass = c.status === 'running' ? 'text-success' : 'text-danger';
html += `<tr><td>${esc(c.name)}</td><td class="${statusClass}">${c.status}</td><td>${c.health}</td><td><code>${esc(c.image)}</code></td></tr>`;
const healthClass = c.health === 'healthy' ? 'text-success' : 'text-danger';
const healthLabel = c.health === 'healthy' ? t('customer.healthy') : t('customer.unhealthy');
html += `<tr><td>${esc(c.name)}</td><td class="${statusClass}">${c.status}</td><td class="${healthClass}">${healthLabel}</td><td><code>${esc(c.image)}</code></td></tr>`;
});
html += '</tbody></table>';
}
html += `<div class="text-muted small">Last check: ${formatDate(data.last_check)}</div>`;
html += `<div class="text-muted small">${t('customer.lastCheck', { time: formatDate(data.last_check) })}</div>`;
content.innerHTML = html;
} catch (err) {
document.getElementById('detail-health-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
@@ -471,7 +660,29 @@ async function loadCustomerHealth() {
function copySetupUrl() {
const input = document.getElementById('setup-url-input');
navigator.clipboard.writeText(input.value).then(() => {
showToast('Setup URL copied to clipboard.');
showToast(t('messages.setupUrlCopied'));
});
}
async function loadCredentials(customerId) {
try {
const data = await api('GET', `/customers/${customerId}/credentials`);
document.getElementById('cred-email').value = data.email;
document.getElementById('cred-password').value = data.password;
document.getElementById('credentials-placeholder').style.display = 'none';
document.getElementById('credentials-content').style.display = 'block';
} catch (err) {
showToast(t('errors.failedToLoadCredentials', { error: err.message }), 'danger');
}
}
function copyCredential(fieldId) {
const input = document.getElementById(fieldId);
const origType = input.type;
input.type = 'text';
navigator.clipboard.writeText(input.value).then(() => {
input.type = origType;
showToast(t('messages.copiedToClipboard'));
});
}
@@ -486,14 +697,36 @@ async function loadSettings() {
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
document.getElementById('cfg-docker-network').value = cfg.docker_network || '';
document.getElementById('cfg-relay-base-port').value = cfg.relay_base_port || 3478;
document.getElementById('cfg-dashboard-base-port').value = cfg.dashboard_base_port || 9000;
document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || '';
document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? 'Credentials are set (leave empty to keep current)' : 'No NPM credentials configured';
document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? t('settings.credentialsSet') : t('settings.noCredentials');
document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || '';
document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || '';
document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || '';
document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || '';
// Branding tab
document.getElementById('cfg-branding-name').value = cfg.branding_name || '';
document.getElementById('cfg-branding-subtitle').value = cfg.branding_subtitle || '';
document.getElementById('cfg-default-language').value = cfg.default_language || 'en';
updateLogoPreview(cfg.branding_logo_path);
// Azure AD tab
document.getElementById('cfg-azure-enabled').checked = cfg.azure_enabled || false;
document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || '';
document.getElementById('cfg-azure-client-id').value = cfg.azure_client_id || '';
document.getElementById('azure-secret-status').textContent = cfg.azure_client_secret_set ? t('settings.secretSet') : t('settings.noSecret');
} catch (err) {
showSettingsAlert('danger', 'Failed to load settings: ' + err.message);
showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message }));
}
}
function updateLogoPreview(logoPath) {
const preview = document.getElementById('branding-logo-preview');
if (logoPath) {
preview.innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;"><div class="text-muted small mt-1">${logoPath}</div>`;
} else {
preview.innerHTML = `<i class="bi bi-hdd-network fs-1 text-primary"></i><div class="text-muted small mt-1">${t('settings.defaultIcon')}</div>`;
}
}
@@ -507,10 +740,11 @@ document.getElementById('settings-system-form').addEventListener('submit', async
data_dir: document.getElementById('cfg-data-dir').value,
docker_network: document.getElementById('cfg-docker-network').value,
relay_base_port: parseInt(document.getElementById('cfg-relay-base-port').value),
dashboard_base_port: parseInt(document.getElementById('cfg-dashboard-base-port').value),
});
showSettingsAlert('success', 'System settings saved.');
showSettingsAlert('success', t('messages.systemSettingsSaved'));
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
@@ -524,12 +758,12 @@ document.getElementById('settings-npm-form').addEventListener('submit', async (e
if (password) payload.npm_api_password = password;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', 'NPM settings saved.');
showSettingsAlert('success', t('messages.npmSettingsSaved'));
document.getElementById('cfg-npm-api-email').value = '';
document.getElementById('cfg-npm-api-password').value = '';
loadSettings();
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
@@ -543,9 +777,9 @@ document.getElementById('settings-images-form').addEventListener('submit', async
netbird_relay_image: document.getElementById('cfg-relay-image').value,
netbird_dashboard_image: document.getElementById('cfg-dashboard-image').value,
});
showSettingsAlert('success', 'Image settings saved.');
showSettingsAlert('success', t('messages.imageSettingsSaved'));
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
@@ -579,7 +813,7 @@ document.getElementById('change-password-form').addEventListener('submit', async
if (newPw !== confirmPw) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = 'Passwords do not match.';
resultEl.textContent = t('errors.passwordsDoNotMatch');
resultEl.classList.remove('d-none');
return;
}
@@ -590,7 +824,7 @@ document.getElementById('change-password-form').addEventListener('submit', async
new_password: newPw,
});
resultEl.className = 'mt-3 alert alert-success';
resultEl.textContent = 'Password changed successfully.';
resultEl.textContent = t('messages.passwordChanged');
resultEl.classList.remove('d-none');
document.getElementById('change-password-form').reset();
} catch (err) {
@@ -608,9 +842,193 @@ function showSettingsAlert(type, msg) {
setTimeout(() => el.classList.add('d-none'), 5000);
}
// Branding form
document.getElementById('settings-branding-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PUT', '/settings/system', {
branding_name: document.getElementById('cfg-branding-name').value || 'NetBird MSP Appliance',
branding_subtitle: document.getElementById('cfg-branding-subtitle').value || 'Multi-Tenant Management Platform',
default_language: document.getElementById('cfg-default-language').value || 'en',
});
showSettingsAlert('success', t('messages.brandingNameSaved'));
await loadBranding();
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
async function uploadLogo() {
const fileInput = document.getElementById('branding-logo-file');
if (!fileInput.files.length) {
showSettingsAlert('danger', t('errors.selectFileFirst'));
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const resp = await fetch('/api/settings/branding/logo', {
method: 'POST',
headers: { 'Authorization': `Bearer ${authToken}` },
body: formData,
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.detail || t('errors.uploadFailed'));
}
updateLogoPreview(data.branding_logo_path);
showSettingsAlert('success', t('messages.logoUploaded'));
fileInput.value = '';
await loadBranding();
} catch (err) {
showSettingsAlert('danger', t('errors.logoUploadFailed', { error: err.message }));
}
}
async function deleteLogo() {
try {
await api('DELETE', '/settings/branding/logo');
updateLogoPreview(null);
showSettingsAlert('success', t('messages.logoRemoved'));
await loadBranding();
} catch (err) {
showSettingsAlert('danger', t('errors.failedToRemoveLogo', { error: err.message }));
}
}
// ---------------------------------------------------------------------------
// User Management
// ---------------------------------------------------------------------------
async function loadUsers() {
try {
const users = await api('GET', '/users');
const tbody = document.getElementById('users-table-body');
if (!users || users.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('settings.noUsersFound') || t('common.loading')}</td></tr>`;
return;
}
tbody.innerHTML = users.map(u => {
const langDisplay = u.default_language ? u.default_language.toUpperCase() : `<span class="text-muted">${t('settings.systemDefault')}</span>`;
return `<tr>
<td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.email || '-')}</td>
<td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td>
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
<td>${langDisplay}</td>
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
<td>
<div class="btn-group btn-group-sm">
${u.is_active
? `<button class="btn btn-outline-warning" title="${t('common.disable')}" onclick="toggleUserActive(${u.id}, false)"><i class="bi bi-pause-circle"></i></button>`
: `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></i></button>`
}
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('users-table-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`;
}
}
function showNewUserModal() {
document.getElementById('user-form').reset();
document.getElementById('user-modal-error').classList.add('d-none');
new bootstrap.Modal(document.getElementById('user-modal')).show();
}
async function saveNewUser() {
const errorEl = document.getElementById('user-modal-error');
errorEl.classList.add('d-none');
const langValue = document.getElementById('new-user-language').value;
const payload = {
username: document.getElementById('new-user-username').value,
password: document.getElementById('new-user-password').value,
email: document.getElementById('new-user-email').value || null,
default_language: langValue || null,
};
try {
await api('POST', '/users', payload);
bootstrap.Modal.getInstance(document.getElementById('user-modal')).hide();
showSettingsAlert('success', t('messages.userCreated', { username: payload.username }));
loadUsers();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
async function deleteUser(id, username) {
if (!confirm(t('messages.confirmDeleteUser', { username }))) return;
try {
await api('DELETE', `/users/${id}`);
showSettingsAlert('success', t('messages.userDeleted', { username }));
loadUsers();
} catch (err) {
showSettingsAlert('danger', t('errors.deleteFailed', { error: err.message }));
}
}
async function toggleUserActive(id, active) {
try {
await api('PUT', `/users/${id}`, { is_active: active });
loadUsers();
} catch (err) {
showSettingsAlert('danger', t('errors.updateFailed', { error: err.message }));
}
}
async function resetUserPassword(id, username) {
if (!confirm(t('messages.confirmResetPassword', { username }))) return;
try {
const data = await api('POST', `/users/${id}/reset-password`);
alert(t('messages.newPasswordAlert', { username, password: data.new_password }));
showSettingsAlert('success', t('messages.passwordResetFor', { username }));
} catch (err) {
showSettingsAlert('danger', t('errors.passwordResetFailed', { error: err.message }));
}
}
// ---------------------------------------------------------------------------
// Azure AD Settings
// ---------------------------------------------------------------------------
document.getElementById('settings-azure-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
azure_enabled: document.getElementById('cfg-azure-enabled').checked,
azure_tenant_id: document.getElementById('cfg-azure-tenant').value || null,
azure_client_id: document.getElementById('cfg-azure-client-id').value || null,
};
const secret = document.getElementById('cfg-azure-client-secret').value;
if (secret) payload.azure_client_secret = secret;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', t('messages.azureSettingsSaved'));
document.getElementById('cfg-azure-client-secret').value = '';
loadSettings();
await loadAzureLoginConfig();
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
input.type = input.type === 'password' ? 'text' : 'password';
if (!input) return;
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
const btn = input.parentElement.querySelector('[data-toggle-pw]');
if (btn) {
const icon = btn.querySelector('i');
if (icon) icon.className = isHidden ? 'bi bi-eye-slash' : 'bi bi-eye';
}
}
// ---------------------------------------------------------------------------
@@ -626,26 +1044,26 @@ async function loadResources() {
document.getElementById('monitoring-resources').innerHTML = `
<div class="row g-3">
<div class="col-md-3">
<div class="text-muted small">Hostname</div>
<div class="text-muted small">${t('monitoring.hostname')}</div>
<div class="fw-bold">${esc(data.hostname)}</div>
<div class="text-muted small">${esc(data.os)}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">CPU (${data.cpu.count} cores)</div>
<div class="text-muted small">${t('monitoring.cpu', { count: data.cpu.count })}</div>
<div class="progress mt-1" style="height: 20px;">
<div class="progress-bar ${data.cpu.percent > 80 ? 'bg-danger' : data.cpu.percent > 50 ? 'bg-warning' : 'bg-success'}"
style="width: ${data.cpu.percent}%">${data.cpu.percent}%</div>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Memory (${data.memory.used_gb}/${data.memory.total_gb} GB)</div>
<div class="text-muted small">${t('monitoring.memory', { used: data.memory.used_gb, total: data.memory.total_gb })}</div>
<div class="progress mt-1" style="height: 20px;">
<div class="progress-bar ${data.memory.percent > 80 ? 'bg-danger' : data.memory.percent > 50 ? 'bg-warning' : 'bg-success'}"
style="width: ${data.memory.percent}%">${data.memory.percent}%</div>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Disk (${data.disk.used_gb}/${data.disk.total_gb} GB)</div>
<div class="text-muted small">${t('monitoring.disk', { used: data.disk.used_gb, total: data.disk.total_gb })}</div>
<div class="progress mt-1" style="height: 20px;">
<div class="progress-bar ${data.disk.percent > 80 ? 'bg-danger' : data.disk.percent > 50 ? 'bg-warning' : 'bg-success'}"
style="width: ${data.disk.percent}%">${data.disk.percent}%</div>
@@ -663,23 +1081,28 @@ async function loadAllCustomerStatuses() {
const data = await api('GET', '/monitoring/customers');
const tbody = document.getElementById('monitoring-customers-body');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No customers.</td></tr>';
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('monitoring.noCustomers')}</td></tr>`;
return;
}
tbody.innerHTML = data.map(c => {
const containerInfo = c.containers.map(ct => `${ct.name}: ${ct.status}`).join(', ') || '-';
const dashPort = c.dashboard_port;
const dashLink = dashPort
? `<a href="${esc(c.setup_url || 'http://localhost:' + dashPort)}" target="_blank">:${dashPort}</a>`
: '-';
return `<tr>
<td>${c.id}</td>
<td>${esc(c.name)}</td>
<td><code>${esc(c.subdomain)}</code></td>
<td>${statusBadge(c.status)}</td>
<td>${c.deployment_status ? statusBadge(c.deployment_status) : '-'}</td>
<td>${dashLink}</td>
<td>${c.relay_udp_port || '-'}</td>
<td class="small">${esc(containerInfo)}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="7" class="text-danger">${err.message}</td></tr>`;
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`;
}
}
@@ -699,8 +1122,9 @@ function statusBadge(status) {
function formatDate(isoStr) {
if (!isoStr) return '-';
const locale = getCurrentLanguage() === 'de' ? 'de-DE' : 'en-US';
const d = new Date(isoStr);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
function esc(str) {
@@ -730,4 +1154,14 @@ function showToast(message) {
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', initApp);
document.addEventListener('DOMContentLoaded', async () => {
// Check for Azure AD callback first
const params = new URLSearchParams(window.location.search);
if (params.has('code')) {
await initI18n();
await loadBranding();
const handled = await handleAzureCallback();
if (handled) return;
}
initApp();
});

100
static/js/i18n.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* i18n - Internationalization for NetBird MSP Appliance
* Supports: English (en), German (de)
*/
let currentLanguage = null;
let systemDefaultLanguage = 'en';
const translations = {};
const SUPPORTED_LANGS = ['en', 'de'];
function setSystemDefault(lang) {
if (SUPPORTED_LANGS.includes(lang)) {
systemDefaultLanguage = lang;
}
}
function detectLanguage() {
const stored = localStorage.getItem('language');
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
// Fall back to system default (from server settings)
if (systemDefaultLanguage && SUPPORTED_LANGS.includes(systemDefaultLanguage)) return systemDefaultLanguage;
const browser = (navigator.language || '').toLowerCase();
if (browser.startsWith('de')) return 'de';
return 'en';
}
async function loadLanguage(lang) {
if (translations[lang]) return;
try {
const resp = await fetch(`/static/lang/${lang}.json`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
translations[lang] = await resp.json();
} catch (err) {
console.error(`i18n: failed to load ${lang}`, err);
if (lang !== 'en') await loadLanguage('en');
}
}
function t(key, params) {
const lang = currentLanguage || 'en';
const dict = translations[lang] || translations['en'] || {};
let value = key.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, dict);
if (value === null && lang !== 'en') {
const en = translations['en'] || {};
value = key.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, en);
}
if (value === null) return key;
if (params && typeof value === 'string') {
value = value.replace(/\{(\w+)\}/g, (m, p) => params[p] !== undefined ? params[p] : m);
}
return value;
}
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.getAttribute('data-i18n-title'));
});
document.querySelectorAll('[data-i18n-html]').forEach(el => {
el.innerHTML = t(el.getAttribute('data-i18n-html'));
});
}
function updateLanguageSwitcher() {
const btn = document.getElementById('language-switcher-btn');
if (btn) btn.textContent = (currentLanguage || 'en').toUpperCase();
document.querySelectorAll('[data-lang]').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-lang') === currentLanguage);
});
}
async function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) lang = 'en';
if (!translations[lang]) await loadLanguage(lang);
currentLanguage = lang;
localStorage.setItem('language', lang);
document.documentElement.lang = lang;
updateLanguageSwitcher();
applyTranslations();
}
function getCurrentLanguage() {
return currentLanguage || 'en';
}
async function initI18n() {
const lang = detectLanguage();
await loadLanguage('en');
if (lang !== 'en') await loadLanguage(lang);
currentLanguage = lang;
document.documentElement.lang = lang;
updateLanguageSwitcher();
applyTranslations();
document.body.classList.remove('i18n-loading');
}

285
static/lang/de.json Normal file
View File

@@ -0,0 +1,285 @@
{
"login": {
"subtitle": "Multi-Tenant Management Plattform",
"username": "Benutzername",
"password": "Passwort",
"signIn": "Anmelden",
"signInWithMicrosoft": "Mit Microsoft anmelden"
},
"nav": {
"newCustomer": "Neuer Kunde",
"settings": "Einstellungen",
"monitoring": "Monitoring",
"logout": "Abmelden"
},
"dashboard": {
"totalCustomers": "Kunden gesamt",
"active": "Aktiv",
"inactive": "Inaktiv",
"errors": "Fehler",
"searchPlaceholder": "Suche nach Name, Subdomain, E-Mail...",
"allStatuses": "Alle Status",
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"statusDeploying": "Wird bereitgestellt",
"statusError": "Fehler",
"refresh": "Aktualisieren",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDashboard": "Dashboard",
"thDevices": "Geraete",
"thCreated": "Erstellt",
"thActions": "Aktionen",
"noCustomers": "Keine Kunden gefunden. Klicken Sie auf \"Neuer Kunde\" um einen anzulegen.",
"showing": "Zeige {start}-{end} von {total}",
"showingEmpty": "Zeige 0 von 0"
},
"customer": {
"back": "Zurueck",
"customer": "Kunde",
"edit": "Bearbeiten",
"delete": "Loeschen",
"tabInfo": "Info",
"tabDeployment": "Deployment",
"tabLogs": "Logs",
"tabHealth": "Zustand",
"name": "Name:",
"company": "Firma:",
"subdomain": "Subdomain:",
"email": "E-Mail:",
"maxDevices": "Max. Geraete:",
"status": "Status:",
"created": "Erstellt:",
"updated": "Aktualisiert:",
"notes": "Notizen:",
"deploymentStatus": "Status:",
"relayUdpPort": "Relay UDP Port:",
"dashboardPort": "Dashboard Port:",
"containerPrefix": "Container-Praefix:",
"deployed": "Bereitgestellt:",
"setupUrl": "Setup URL:",
"copy": "Kopieren",
"open": "Oeffnen",
"netbirdLogin": "NetBird Login",
"notAvailable": "Nicht verfuegbar",
"showCredentials": "Zugangsdaten anzeigen",
"credEmail": "E-Mail",
"credPassword": "Passwort",
"showHide": "Anzeigen/Verbergen",
"credentialsNotAvailable": "Zugangsdaten nicht verfuegbar. Der Admin muss das Setup manuell ueber die Setup URL abschliessen.",
"start": "Starten",
"stop": "Stoppen",
"restart": "Neustarten",
"reDeploy": "Neu bereitstellen",
"noDeployment": "Kein Deployment gefunden.",
"deployNow": "Jetzt bereitstellen",
"containerLogs": "Container Logs",
"noContainerLogs": "Keine Container-Logs verfuegbar.",
"noLogsLoaded": "Keine Logs geladen.",
"healthCheck": "Zustandspruefung",
"check": "Pruefen",
"clickCheck": "Klicken Sie auf \"Pruefen\" um eine Zustandspruefung durchzufuehren.",
"healthy": "Gesund",
"unhealthy": "Fehlerhaft",
"overall": "Gesamt:",
"thContainer": "Container",
"thContainerStatus": "Status",
"thHealth": "Zustand",
"thImage": "Image",
"lastCheck": "Letzte Pruefung: {time}",
"openDashboard": "Dashboard oeffnen"
},
"customerModal": {
"newCustomer": "Neuer Kunde",
"editCustomer": "Kunde bearbeiten",
"nameLabel": "Name *",
"companyLabel": "Firma",
"subdomainLabel": "Subdomain *",
"subdomainHint": "Kleinbuchstaben, alphanumerisch + Bindestriche",
"emailLabel": "E-Mail *",
"maxDevicesLabel": "Max. Geraete",
"notesLabel": "Notizen",
"cancel": "Abbrechen",
"saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Aenderungen speichern"
},
"deleteModal": {
"title": "Loeschung bestaetigen",
"confirmText": "Sind Sie sicher, dass Sie den Kunden loeschen moechten",
"warning": "Alle Container, NPM-Eintraege und Daten werden entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden.",
"cancel": "Abbrechen",
"delete": "Loeschen"
},
"settings": {
"title": "Systemeinstellungen",
"tabSystem": "Systemkonfiguration",
"tabNpm": "NPM Integration",
"tabImages": "Docker Images",
"tabBranding": "Branding",
"tabUsers": "Benutzer",
"tabAzure": "Azure AD",
"tabSecurity": "Sicherheit",
"baseDomain": "Basis-Domain",
"baseDomainPlaceholder": "ihredomain.com",
"baseDomainHint": "Kunden erhalten Subdomains: kunde.ihredomain.com",
"adminEmail": "Admin E-Mail",
"adminEmailPlaceholder": "admin@ihredomain.com",
"dataDir": "Datenverzeichnis",
"dataDirPlaceholder": "/opt/netbird-instances",
"dockerNetwork": "Docker Netzwerk",
"dockerNetworkPlaceholder": "npm-network",
"relayBasePort": "Relay Basis-Port",
"relayBasePortHint": "Erster UDP-Port fuer Relay. Bereich: Basis bis Basis+99",
"dashboardBasePort": "Dashboard Basis-Port",
"dashboardBasePortHint": "Basis-Port fuer Kunden-Dashboards. Kunde N erhaelt Basis+N",
"saveSystemSettings": "Systemeinstellungen speichern",
"npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Anmeldedaten (E-Mail + Passwort) ein. Das System meldet sich automatisch an und bezieht Tokens fuer API-Aufrufe.",
"npmApiUrl": "NPM API URL",
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
"npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten",
"npmLoginEmail": "NPM Login E-Mail",
"npmLoginEmailPlaceholder": "Leer lassen um aktuelle beizubehalten",
"npmLoginPassword": "NPM Login Passwort",
"npmLoginPasswordPlaceholder": "Leer lassen um aktuelles beizubehalten",
"credentialsSet": "Zugangsdaten sind gesetzt (leer lassen um aktuelle beizubehalten)",
"noCredentials": "Keine NPM-Zugangsdaten konfiguriert",
"saveNpmSettings": "NPM Einstellungen speichern",
"testConnection": "Verbindung testen",
"managementImage": "Management Image",
"managementImagePlaceholder": "netbirdio/management:latest",
"signalImage": "Signal Image",
"signalImagePlaceholder": "netbirdio/signal:latest",
"relayImage": "Relay Image",
"relayImagePlaceholder": "netbirdio/relay:latest",
"dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Image Einstellungen speichern",
"brandingTitle": "Branding Einstellungen",
"companyName": "Firmen- / Anwendungsname",
"companyNamePlaceholder": "NetBird MSP Appliance",
"companyNameHint": "Wird auf der Login-Seite und in der Navbar angezeigt",
"logoPreview": "Logo-Vorschau",
"defaultIcon": "Standard-Icon (kein Logo hochgeladen)",
"uploadLogo": "Logo hochladen (PNG, JPG, SVG, max 500KB)",
"uploadBtn": "Hochladen",
"removeLogo": "Logo entfernen",
"brandingSubtitle": "Untertitel",
"brandingSubtitlePlaceholder": "Multi-Tenant Management Plattform",
"brandingSubtitleHint": "Wird unter dem Titel auf der Login-Seite angezeigt",
"defaultLanguage": "Standardsprache",
"defaultLanguageHint": "Standardsprache fuer Benutzer ohne eigene Einstellung",
"systemDefault": "Systemstandard",
"saveBranding": "Branding speichern",
"userManagement": "Benutzerverwaltung",
"newUser": "Neuer Benutzer",
"thId": "ID",
"thUsername": "Benutzername",
"thEmail": "E-Mail",
"thRole": "Rolle",
"thAuth": "Auth",
"thLanguage": "Sprache",
"thStatus": "Status",
"thActions": "Aktionen",
"azureTitle": "Azure AD / Entra ID Integration",
"enableAzureSso": "Azure AD SSO aktivieren",
"tenantId": "Tenant ID",
"clientId": "Client ID (Anwendungs-ID)",
"clientSecret": "Client Secret",
"clientSecretPlaceholder": "Leer lassen um aktuelles beizubehalten",
"secretSet": "Secret ist gesetzt (leer lassen um aktuelles beizubehalten)",
"noSecret": "Kein Client Secret konfiguriert",
"saveAzureSettings": "Azure AD Einstellungen speichern",
"securityTitle": "Admin-Passwort aendern",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort (min. 12 Zeichen)",
"confirmPassword": "Neues Passwort bestaetigen",
"changePassword": "Passwort aendern"
},
"monitoring": {
"title": "System Monitoring",
"refresh": "Aktualisieren",
"hostResources": "Host-Ressourcen",
"hostname": "Hostname",
"cpu": "CPU ({count} Kerne)",
"memory": "Speicher ({used}/{total} GB)",
"disk": "Festplatte ({used}/{total} GB)",
"allCustomerDeployments": "Alle Kunden-Deployments",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDeployment": "Deployment",
"thDashboard": "Dashboard",
"thRelayPort": "Relay Port",
"thContainers": "Container",
"noCustomers": "Keine Kunden."
},
"userModal": {
"title": "Neuer Benutzer",
"usernameLabel": "Benutzername *",
"passwordLabel": "Passwort * (min. 8 Zeichen)",
"emailLabel": "E-Mail",
"languageLabel": "Standardsprache",
"cancel": "Abbrechen",
"createUser": "Benutzer erstellen"
},
"common": {
"loading": "Laden...",
"back": "Zurueck",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Loeschen",
"edit": "Bearbeiten",
"view": "Anzeigen",
"start": "Starten",
"stop": "Stoppen",
"restart": "Neustarten",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"resetPassword": "Passwort zuruecksetzen",
"open": "Oeffnen",
"active": "Aktiv",
"disabled": "Deaktiviert"
},
"errors": {
"networkError": "Netzwerkfehler \u2014 Server nicht erreichbar.",
"sessionExpired": "Sitzung abgelaufen.",
"requestFailed": "Anfrage fehlgeschlagen.",
"serverError": "Serverfehler (HTTP {status}).",
"unknownError": "Ein unbekannter Fehler ist aufgetreten.",
"uploadFailed": "Upload fehlgeschlagen.",
"deleteFailed": "Loeschen fehlgeschlagen: {error}",
"failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}",
"failed": "Fehlgeschlagen: {error}",
"logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}",
"failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}",
"updateFailed": "Aktualisierung fehlgeschlagen: {error}",
"passwordResetFailed": "Passwort-Zuruecksetzung fehlgeschlagen: {error}",
"selectFileFirst": "Bitte waehlen Sie zuerst eine Datei aus.",
"passwordsDoNotMatch": "Passwoerter stimmen nicht ueberein.",
"failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}",
"azureNotConfigured": "Azure AD ist nicht konfiguriert.",
"azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}",
"actionFailed": "{action} fehlgeschlagen: {error}"
},
"messages": {
"systemSettingsSaved": "Systemeinstellungen gespeichert.",
"npmSettingsSaved": "NPM Einstellungen gespeichert.",
"imageSettingsSaved": "Image Einstellungen gespeichert.",
"brandingNameSaved": "Branding-Einstellungen gespeichert.",
"logoUploaded": "Logo erfolgreich hochgeladen.",
"logoRemoved": "Logo entfernt.",
"azureSettingsSaved": "Azure AD Einstellungen gespeichert.",
"passwordChanged": "Passwort erfolgreich geaendert.",
"setupUrlCopied": "Setup URL in die Zwischenablage kopiert.",
"copiedToClipboard": "In die Zwischenablage kopiert.",
"userCreated": "Benutzer '{username}' erstellt.",
"userDeleted": "Benutzer '{username}' geloescht.",
"passwordResetFor": "Passwort fuer '{username}' zurueckgesetzt.",
"newPasswordAlert": "Neues Passwort fuer '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.",
"confirmDeleteUser": "Benutzer '{username}' loeschen? Dies kann nicht rueckgaengig gemacht werden.",
"confirmResetPassword": "Passwort fuer '{username}' zuruecksetzen? Ein neues zufaelliges Passwort wird generiert."
}
}

285
static/lang/en.json Normal file
View File

@@ -0,0 +1,285 @@
{
"login": {
"subtitle": "Multi-Tenant Management Platform",
"username": "Username",
"password": "Password",
"signIn": "Sign In",
"signInWithMicrosoft": "Sign in with Microsoft"
},
"nav": {
"newCustomer": "New Customer",
"settings": "Settings",
"monitoring": "Monitoring",
"logout": "Logout"
},
"dashboard": {
"totalCustomers": "Total Customers",
"active": "Active",
"inactive": "Inactive",
"errors": "Errors",
"searchPlaceholder": "Search by name, subdomain, email...",
"allStatuses": "All Statuses",
"statusActive": "Active",
"statusInactive": "Inactive",
"statusDeploying": "Deploying",
"statusError": "Error",
"refresh": "Refresh",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDashboard": "Dashboard",
"thDevices": "Devices",
"thCreated": "Created",
"thActions": "Actions",
"noCustomers": "No customers found. Click \"New Customer\" to create one.",
"showing": "Showing {start}-{end} of {total}",
"showingEmpty": "Showing 0 of 0"
},
"customer": {
"back": "Back",
"customer": "Customer",
"edit": "Edit",
"delete": "Delete",
"tabInfo": "Info",
"tabDeployment": "Deployment",
"tabLogs": "Logs",
"tabHealth": "Health",
"name": "Name:",
"company": "Company:",
"subdomain": "Subdomain:",
"email": "Email:",
"maxDevices": "Max Devices:",
"status": "Status:",
"created": "Created:",
"updated": "Updated:",
"notes": "Notes:",
"deploymentStatus": "Status:",
"relayUdpPort": "Relay UDP Port:",
"dashboardPort": "Dashboard Port:",
"containerPrefix": "Container Prefix:",
"deployed": "Deployed:",
"setupUrl": "Setup URL:",
"copy": "Copy",
"open": "Open",
"netbirdLogin": "NetBird Login",
"notAvailable": "Not available",
"showCredentials": "Show Credentials",
"credEmail": "Email",
"credPassword": "Password",
"showHide": "Show/Hide",
"credentialsNotAvailable": "Credentials not available. Admin must complete setup manually at the Setup URL.",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"reDeploy": "Re-Deploy",
"noDeployment": "No deployment found.",
"deployNow": "Deploy Now",
"containerLogs": "Container Logs",
"noContainerLogs": "No container logs available.",
"noLogsLoaded": "No logs loaded.",
"healthCheck": "Health Check",
"check": "Check",
"clickCheck": "Click \"Check\" to run a health check.",
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"overall": "Overall:",
"thContainer": "Container",
"thContainerStatus": "Status",
"thHealth": "Health",
"thImage": "Image",
"lastCheck": "Last check: {time}",
"openDashboard": "Open Dashboard"
},
"customerModal": {
"newCustomer": "New Customer",
"editCustomer": "Edit Customer",
"nameLabel": "Name *",
"companyLabel": "Company",
"subdomainLabel": "Subdomain *",
"subdomainHint": "Lowercase, alphanumeric + hyphens",
"emailLabel": "Email *",
"maxDevicesLabel": "Max Devices",
"notesLabel": "Notes",
"cancel": "Cancel",
"saveAndDeploy": "Save & Deploy",
"saveChanges": "Save Changes"
},
"deleteModal": {
"title": "Confirm Deletion",
"confirmText": "Are you sure you want to delete customer",
"warning": "This will remove all containers, NPM entries, and data. This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete"
},
"settings": {
"title": "System Settings",
"tabSystem": "System Configuration",
"tabNpm": "NPM Integration",
"tabImages": "Docker Images",
"tabBranding": "Branding",
"tabUsers": "Users",
"tabAzure": "Azure AD",
"tabSecurity": "Security",
"baseDomain": "Base Domain",
"baseDomainPlaceholder": "yourdomain.com",
"baseDomainHint": "Customers get subdomains: customer.yourdomain.com",
"adminEmail": "Admin Email",
"adminEmailPlaceholder": "admin@yourdomain.com",
"dataDir": "Data Directory",
"dataDirPlaceholder": "/opt/netbird-instances",
"dockerNetwork": "Docker Network",
"dockerNetworkPlaceholder": "npm-network",
"relayBasePort": "Relay Base Port",
"relayBasePortHint": "First UDP port for relay. Range: base to base+99",
"dashboardBasePort": "Dashboard Base Port",
"dashboardBasePortHint": "Base port for customer dashboards. Customer N gets base+N",
"saveSystemSettings": "Save System Settings",
"npmDescription": "NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.",
"npmApiUrl": "NPM API URL",
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
"npmApiUrlHint": "http:// or https:// - must include /api at the end",
"npmLoginEmail": "NPM Login Email",
"npmLoginEmailPlaceholder": "Leave empty to keep current",
"npmLoginPassword": "NPM Login Password",
"npmLoginPasswordPlaceholder": "Leave empty to keep current",
"credentialsSet": "Credentials are set (leave empty to keep current)",
"noCredentials": "No NPM credentials configured",
"saveNpmSettings": "Save NPM Settings",
"testConnection": "Test Connection",
"managementImage": "Management Image",
"managementImagePlaceholder": "netbirdio/management:latest",
"signalImage": "Signal Image",
"signalImagePlaceholder": "netbirdio/signal:latest",
"relayImage": "Relay Image",
"relayImagePlaceholder": "netbirdio/relay:latest",
"dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Save Image Settings",
"brandingTitle": "Branding Settings",
"companyName": "Company / Application Name",
"companyNamePlaceholder": "NetBird MSP Appliance",
"companyNameHint": "Displayed on login page and navbar",
"logoPreview": "Logo Preview",
"defaultIcon": "Default icon (no logo uploaded)",
"uploadLogo": "Upload Logo (PNG, JPG, SVG, max 500KB)",
"uploadBtn": "Upload",
"removeLogo": "Remove Logo",
"brandingSubtitle": "Subtitle",
"brandingSubtitlePlaceholder": "Multi-Tenant Management Platform",
"brandingSubtitleHint": "Shown below the title on the login page",
"defaultLanguage": "Default Language",
"defaultLanguageHint": "Default language for users without a preference",
"systemDefault": "System Default",
"saveBranding": "Save Branding",
"userManagement": "User Management",
"newUser": "New User",
"thId": "ID",
"thUsername": "Username",
"thEmail": "Email",
"thRole": "Role",
"thAuth": "Auth",
"thLanguage": "Language",
"thStatus": "Status",
"thActions": "Actions",
"azureTitle": "Azure AD / Entra ID Integration",
"enableAzureSso": "Enable Azure AD SSO",
"tenantId": "Tenant ID",
"clientId": "Client ID (Application ID)",
"clientSecret": "Client Secret",
"clientSecretPlaceholder": "Leave empty to keep current",
"secretSet": "Secret is set (leave empty to keep current)",
"noSecret": "No client secret configured",
"saveAzureSettings": "Save Azure AD Settings",
"securityTitle": "Change Admin Password",
"currentPassword": "Current Password",
"newPassword": "New Password (min 12 chars)",
"confirmPassword": "Confirm New Password",
"changePassword": "Change Password"
},
"monitoring": {
"title": "System Monitoring",
"refresh": "Refresh",
"hostResources": "Host Resources",
"hostname": "Hostname",
"cpu": "CPU ({count} cores)",
"memory": "Memory ({used}/{total} GB)",
"disk": "Disk ({used}/{total} GB)",
"allCustomerDeployments": "All Customer Deployments",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDeployment": "Deployment",
"thDashboard": "Dashboard",
"thRelayPort": "Relay Port",
"thContainers": "Containers",
"noCustomers": "No customers."
},
"userModal": {
"title": "New User",
"usernameLabel": "Username *",
"passwordLabel": "Password * (min 8 chars)",
"emailLabel": "Email",
"languageLabel": "Default Language",
"cancel": "Cancel",
"createUser": "Create User"
},
"common": {
"loading": "Loading...",
"back": "Back",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"view": "View",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"disable": "Disable",
"enable": "Enable",
"resetPassword": "Reset Password",
"open": "Open",
"active": "Active",
"disabled": "Disabled"
},
"errors": {
"networkError": "Network error \u2014 server not reachable.",
"sessionExpired": "Session expired.",
"requestFailed": "Request failed.",
"serverError": "Server error (HTTP {status}).",
"unknownError": "An unknown error occurred.",
"uploadFailed": "Upload failed.",
"deleteFailed": "Delete failed: {error}",
"failedToLoadSettings": "Failed to load settings: {error}",
"failed": "Failed: {error}",
"logoUploadFailed": "Logo upload failed: {error}",
"failedToRemoveLogo": "Failed to remove logo: {error}",
"updateFailed": "Update failed: {error}",
"passwordResetFailed": "Password reset failed: {error}",
"selectFileFirst": "Please select a file first.",
"passwordsDoNotMatch": "Passwords do not match.",
"failedToLoadCredentials": "Failed to load credentials: {error}",
"azureNotConfigured": "Azure AD is not configured.",
"azureLoginFailed": "Azure AD login failed: {error}",
"actionFailed": "{action} failed: {error}"
},
"messages": {
"systemSettingsSaved": "System settings saved.",
"npmSettingsSaved": "NPM settings saved.",
"imageSettingsSaved": "Image settings saved.",
"brandingNameSaved": "Branding settings saved.",
"logoUploaded": "Logo uploaded successfully.",
"logoRemoved": "Logo removed.",
"azureSettingsSaved": "Azure AD settings saved.",
"passwordChanged": "Password changed successfully.",
"setupUrlCopied": "Setup URL copied to clipboard.",
"copiedToClipboard": "Copied to clipboard.",
"userCreated": "User '{username}' created.",
"userDeleted": "User '{username}' deleted.",
"passwordResetFor": "Password reset for '{username}'.",
"newPasswordAlert": "New password for '{username}':\n\n{password}\n\nPlease save this password now. It will not be shown again.",
"confirmDeleteUser": "Delete user '{username}'? This cannot be undone.",
"confirmResetPassword": "Reset password for '{username}'? A new random password will be generated."
}
}

36
templates/Caddyfile.j2 Normal file
View File

@@ -0,0 +1,36 @@
{
auto_https off
}
:80 {
# Embedded IdP OAuth2/OIDC endpoints
handle /oauth2/* {
reverse_proxy netbird-kunde{{ customer_id }}-management:80
}
# NetBird Management API + gRPC
handle /api/* {
reverse_proxy netbird-kunde{{ customer_id }}-management:80
}
handle /management.ManagementService/* {
reverse_proxy netbird-kunde{{ customer_id }}-management:80 {
transport http {
versions h2c
}
}
}
# NetBird Signal gRPC
handle /signalexchange.SignalExchange/* {
reverse_proxy netbird-kunde{{ customer_id }}-signal:80 {
transport http {
versions h2c
}
}
}
# Default: NetBird Dashboard
handle {
reverse_proxy netbird-kunde{{ customer_id }}-dashboard:80
}
}

View File

@@ -0,0 +1,13 @@
# NetBird Dashboard - Customer {{ customer_id }}
NETBIRD_MGMT_API_ENDPOINT={{ external_url }}
NETBIRD_MGMT_GRPC_API_ENDPOINT={{ external_url }}
AUTH_AUTHORITY={{ external_url }}/oauth2
AUTH_CLIENT_ID=netbird-dashboard
AUTH_AUDIENCE=netbird-dashboard
AUTH_SUPPORTED_SCOPES=openid profile email groups offline_access
AUTH_REDIRECT_URI=/nb-auth
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
USE_AUTH0=false
NETBIRD_TOKEN_SOURCE=accessToken
LETSENCRYPT_DOMAIN=none
NGINX_SSL_PORT=443

View File

@@ -1,10 +1,21 @@
version: '3.8'
networks:
{{ docker_network }}:
external: true
services:
# --- Caddy Reverse Proxy (entry point) ---
netbird-caddy:
image: caddy:2-alpine
container_name: netbird-kunde{{ customer_id }}-caddy
restart: unless-stopped
networks:
- {{ docker_network }}
ports:
- "{{ dashboard_port }}:80"
volumes:
- {{ instance_dir }}/Caddyfile:/etc/caddy/Caddyfile:ro
# --- NetBird Management (with embedded IdP) ---
netbird-management:
image: {{ netbird_management_image }}
container_name: netbird-kunde{{ customer_id }}-management
@@ -21,15 +32,11 @@ services:
- "console"
- "--log-level"
- "info"
- "--single-account-mode-domain={{ subdomain }}.{{ base_domain }}"
- "--dns-domain={{ subdomain }}.{{ base_domain }}"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/api/accounts"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
- "--single-account-mode-domain={{ netbird_domain }}"
- "--dns-domain={{ netbird_domain }}"
- "--idp-sign-key-refresh-enabled"
# --- NetBird Signal ---
netbird-signal:
image: {{ netbird_signal_image }}
container_name: netbird-kunde{{ customer_id }}-signal
@@ -39,6 +46,7 @@ services:
volumes:
- {{ instance_dir }}/data/signal:/var/lib/netbird
# --- NetBird Relay ---
netbird-relay:
image: {{ netbird_relay_image }}
container_name: netbird-kunde{{ customer_id }}-relay
@@ -49,19 +57,13 @@ services:
- "{{ relay_udp_port }}:3478/udp"
env_file:
- {{ instance_dir }}/relay.env
environment:
- NB_ENABLE_STUN=true
- NB_STUN_PORTS=3478
- NB_LISTEN_ADDRESS=:80
- NB_EXPOSED_ADDRESS=rels://{{ subdomain }}.{{ base_domain }}:443
- NB_AUTH_SECRET={{ relay_secret }}
# --- NetBird Dashboard ---
netbird-dashboard:
image: {{ netbird_dashboard_image }}
container_name: netbird-kunde{{ customer_id }}-dashboard
restart: unless-stopped
networks:
- {{ docker_network }}
environment:
- NETBIRD_MGMT_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}
env_file:
- {{ instance_dir }}/dashboard.env

View File

@@ -2,7 +2,7 @@
"Stuns": [
{
"Proto": "udp",
"URI": "stun:{{ subdomain }}.{{ base_domain }}:{{ relay_udp_port }}",
"URI": "stun:{{ netbird_domain }}:{{ relay_udp_port }}",
"Username": "",
"Password": null
}
@@ -11,7 +11,7 @@
"Turns": [
{
"Proto": "udp",
"URI": "turn:{{ subdomain }}.{{ base_domain }}:{{ relay_udp_port }}",
"URI": "turn:{{ netbird_domain }}:{{ relay_udp_port }}",
"Username": "netbird",
"Password": "{{ relay_secret }}"
}
@@ -22,42 +22,35 @@
},
"Relay": {
"Addresses": [
"rels://{{ subdomain }}.{{ base_domain }}:443"
"rels://{{ netbird_domain }}:443"
],
"CredentialsTTL": "12h",
"CredentialsTTL": "24h",
"Secret": "{{ relay_secret }}"
},
"Signal": {
"Proto": "https",
"URI": "{{ subdomain }}.{{ base_domain }}:443",
"Proto": "{{ netbird_protocol }}",
"URI": "{{ netbird_domain }}:{{ netbird_port }}",
"Username": "",
"Password": null
},
"HttpConfig": {
"AuthIssuer": "https://{{ subdomain }}.{{ base_domain }}",
"AuthAudience": "{{ subdomain }}.{{ base_domain }}",
"OIDCConfigEndpoint": ""
"AuthIssuer": "{{ external_url }}/oauth2",
"AuthAudience": "netbird-dashboard",
"OIDCConfigEndpoint": "{{ external_url }}/oauth2/.well-known/openid-configuration"
},
"IdpManagerConfig": {
"ManagerType": "none"
"EmbeddedIdP": {
"Enabled": true,
"Issuer": "{{ external_url }}/oauth2",
"LocalAddress": "http://127.0.0.1:80",
"DashboardRedirectURIs": [
"{{ external_url }}/nb-auth",
"{{ external_url }}/nb-silent-auth"
],
"CLIRedirectURIs": [
"http://localhost:53000/",
"http://localhost:54000/"
],
"SignKeyRefreshEnabled": true
},
"DeviceAuthorizationFlow": {
"Provider": "none"
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"Audience": "{{ subdomain }}.{{ base_domain }}",
"ClientID": "",
"ClientSecret": "",
"Domain": "",
"AuthorizationEndpoint": "",
"TokenEndpoint": "",
"Scope": "openid profile email",
"RedirectURLs": [
"https://{{ subdomain }}.{{ base_domain }}/auth/callback"
],
"UseIDToken": false
}
},
"DataStoreEncryptionKey": "{{ relay_secret }}"
"DataStoreEncryptionKey": "{{ datastore_encryption_key }}"
}